6

☀️ 爆肝整整一个周末写一款类似 皇室战争 的 即时战斗类 游戏Demo!两万多字游戏制作...

 2 years ago
source link: https://blog.csdn.net/zhangay1998/article/details/118719811
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

☀️ 爆肝整整一个周末写一款类似 皇室战争 的 即时战斗类 游戏Demo!两万多字游戏制作过程+解析!【建议收藏学习】

专栏收录该内容
7 篇文章 24 订阅 ¥99.00 ¥19.90
  • 📢博客主页:https://blog.csdn.net/zhangay1998
  • 📢欢迎点赞 👍 收藏 ⭐留言 📝 如有错误敬请指正!
  • 📢本文由 呆呆敲代码的小Y 原创,首发于 CSDN🙉
  • 📢未来很长,值得我们全力奔赴更美好的生活✨




  • ❄️苍茫大地一剑尽挽破,何处繁华笙歌落。斜倚云端千壶掩寂寞,纵使他人空笑我。
  • 🐵回头望去云卷云舒,又是一个令人惬意的周末~
  • 👻正准备卷起袖角去王者峡谷酣畅淋漓的战斗一番!
  • 🔔叮咚~ 原来是 憨憨的小云儿 发来了消息~
  • 😐只好停下了去峡谷征战的脚步,熟练地点开微信聊天框。
  • 小云儿👩:小Y哥哥,你这些天在干嘛呢~,怎么也不见你制作游戏了呀!
  • 小Y(博主):害,工作太忙了呗,忙着赚钱买皮肤、充游戏呢~
  • 小云儿👩:(呆滞)…小YY你变了,说好带我学做更多的游戏呢!!!
  • 小Y:Emma…既然你学习的心情那么迫切,那我就满足你的要求!
  • 小云儿👩:好呀好呀~小Y哥哥,我最近又对皇室战争这款游戏玩法非常感兴趣,你能不能…
  • 小Y:皇室战争呀~ 好说,我之前也很喜欢玩!那我就满足你,来制作一款复刻皇室战争玩法的游戏!
  • 小云儿👩:小Y哥哥 你真好~ 那我就期待你做完,再好好学习一波了!
  • 小Y:没得问题,你瞧好了,我这就开始动手做!
  • 小云儿👩:好咧小Y哥哥,老规矩!动手可以,不阔以动脚哦!
在这里插入图片描述

  • 由于最近工作挺忙的,已经很长时间没有写过制作游戏的文章了

  • 趁这个周末有时间,那就现在开始动手复刻一款前几年很火爆【皇室战争】玩法的游戏Demo

在这里插入图片描述


💫制作思路(游戏策划)

  • 既然要开始开发一个项目,按照老规矩,那就是先来理一下思路

  • 既然我们是照着皇室战争的玩法来做一个类似的,那自然要去熟悉一下皇室战争啦

  • 先来想一下皇室战争的游戏玩法,皇室战争的核心玩法就是战斗模块了

  • 两个人有自己的卡牌组,有圣水限制,满足圣水的数量才能召唤出相应的卡牌人物去场景中战斗!

  • 在圣水足够的时候,可以将对应的卡牌拖到场景中,只可以拖动到自己的地盘~

  • 这里要注意的是要在没张卡牌身上赋予一个怪物模型的信息,并在需要的时候进行调用!

  • 然后就是卡牌对应的游戏对象在中间对拼战斗,直到把对方的国王塔给干爆就算完成胜利了!

  • 这样说起来好像挺简单的哈,但是还有抽卡、存卡、联网和数据库等操作…

  • 有些功能其实无关紧要,对我们简单复刻一款游戏并没有很大的用处

  • 所以肯定会阉割一部分功能,只把核心玩法开发出来就好了,毕竟完整开发一个皇室战争这样的游戏还是很麻烦的!

  • 那我们搞一个单机模式的游戏,奔着战斗模块的核心玩法!

  • 先来看一下游戏步骤思维导图,然后就开始动手操作吧!
    在这里插入图片描述


🎉开始制作

  • 首先打开Unity新建一个项目,博主开发这个使用的Unity2018.4.24

  • 如果有小伙伴下载本文中的游戏资源,酌情使用Unity版本哦~

  • 每个细节都写出来肯定是不现实哒,由于制作过程是在是又臭又长

  • 所以文章中只把关键性操作和配置介绍出来,一起加油~


🏳️‍🌈第一步:寻找合适的模型+动画配置

  • 制作之前在网上查了一下皇室战争是用H5开发的,那我们就没办法了,没有合适的资源包可以直接利用

  • 那就正好我们自己适配 模型+动画 吧。这可是一个苦力活呀~

  • 那我们就利用手头上的资源来自己制作合适的模型吧

  • 我从自己的资源中整理出来一些个模型,大家来看一下吧~ 鱼龙混杂的模型!
    在这里插入图片描述

  • 从左到右,我分别称他们为:米娅公主、火焰武士、暗黑巫师、骷髅兵、一拳超人、弓箭手、绿巨人、美国队长和钢铁侠!

  • 哈哈,这名字都是我起的,尤其是最后的"漫威三巨头",要是被斯坦李老爷子看到估计会气的揍我~
    在这里插入图片描述

  • 模型有了,动画就要自己配置了

  • 每一个模型都要写一个动画控制器,用于控制他们的动画,比如行走攻击死亡动画

  • 我们这里就需要简单设置一下就好了~没必要整一些不必要的麻烦了

模型动画配置

这里对动画系统不熟悉的可以去看我之前介绍动画系统的一篇文章,介绍的挺详细!
Unity零基础到进阶 ☀️| 近万字教程 对 Unity 中的 动画系统基础 全面解析+实战演练

下面我们来进行一下动画配置,我们拿 “漫威三巨头” 的动画配置举例
先看钢铁侠的动画配置

  • 先在工程界面右键 Create—>Animator Controller,创建动画控制器
    在这里插入图片描述
  • 再给钢铁侠添加上Animator动画组件,并把刚才创建的动画控制器添加到Animator动画组件上
    在这里插入图片描述

下面看一下我自己给制作的动画吧

🎈钢铁侠攻击动画和死亡动画

20210711150424224.gif20210711150847184.gif

🎈绿巨人攻击动画和死亡动画

20210711151227498.gif20210711151234633.gif

这动画效果~ 个人制作,纯属娱乐哈哈哈隔~ 大家将就看就行😂

然后点开我们创建的动画控制器Animator Controller进行配置

  • 将创建的动画拖上去,然后如下图所示配置

  • 创建三个动画参数,分别是Trigger类型的移动、Bool类型的死亡、Trigger类型的攻击

  • 因为移动和攻击都是持续性的,所以使用Bool值控制比较好

  • 而死亡只会执行一次,使用Trriger类型参数正合适!
    在这里插入图片描述

  • 这三个参数我们点击动画控制器中的箭头进行配置,分别是:

  • 移动指向攻击时添加上对应的条件:Move变为false,Attacking变为true
    在这里插入图片描述
    攻击指向移动时添加上对应的条件:Move变为true,Attacking变为false
    在这里插入图片描述
    指向死亡时,执行Death
    在这里插入图片描述

  • 拿绿巨人的动画控制器配置举例了,其他的模型动画都是一样的参数配置

  • 这里就不挨个介绍了,直接配置好就好啦!

  • 最后把所有模型设置成预制体,用来备用生成时候使用!
    在这里插入图片描述


🏳️‍🌈第二步:战斗场景配置

想要游戏可以顺利的运行,那肯定是需要一个游戏场景

  • 由于皇室战争是一个用2d画面实现3d效果的制作方式

  • 那我自己肯定实现不了这么高级的操作呀,这其中的灯光配置相机处理还不得把我头皮搞发麻呀

  • 所以这里就简单搭建一个游戏场景,让我们可以运行游戏就好啦!

  • 新建两个游戏对象,分别是敌人的场景区域和我们的场景区域

  • 因为我们在战斗的时候,只能把卡牌拖到自己的区域

  • 后边还要设置不同的场景层级来做区别,所以要使用两个游戏对象

  • 然后去网上搜几张皇室战争的场景图片,来添加到材质上,然后拖到场景上面就好了!

  • 有条件的还可以加一些点缀,这里就不添加了,时间有点不够用了…
    在这里插入图片描述

  • 给我们的playerScene场景区域添加上层级layout为:Ground
    在这里插入图片描述

  • 一起来看看的搭建的场景吧,还加上了前面分享过的特效资源包,正好用上!
    在这里插入图片描述

  • 说实话场景确实很lou,但是搭建成这样已经尽力了,对相机视角的把控还是差很多😨

  • 这也不得不让我更加佩服皇室战争的制作了😊

  • 能以一个2d UI制作出这么好的3d效果,对视角和画面渲染的把控确实很强🤪!


🏳️‍🌈第三步:自动寻路设计

  • 在菜单栏点击windows->AI->Navigation

  • 不同的版本可能所在的位置有些出入,但是在Windows菜单下应该都能找到!
    在这里插入图片描述
    然后会跳转到这个页面,这是设置Unity的寻路系统Navigation的面板
    在这里插入图片描述

  • 我们先暂时不去仔细深究这些个Navigation面板属性值到底是干嘛的

  • 其实就是设置寻路的时候可以走的路径配置,有些时候路面高低不平,很复杂的时候会用到

  • 那我们这里就只是一个平面,所以就不用过多设置,直接默认属性值就好!

  • 然后直接选中Bake界面,点击Bake就是自动设置可执行的寻路环境配置!

在这里插入图片描述

  • 当我们点击Bake了之后,可能发现场景中并没有什么变化 ~

  • 正常设置完之后,选中Navigation面板后,场景中会出现一个蓝色的一层可以寻路的图层!

  • 那我们这里可能就是没有设置静态场景 ~

  • 我们选中场景中的场景中如下所示的游戏对象,然后点击右边的Static将选中的物体设置成静态对象

  • 这样点击Bake的时候才能正确的设置导航路线

在这里插入图片描述

  • 设置完静态对象之后,再来点击Bake!就成了以下的模样,就说明导航可行路线设置成功啦:

  • 蓝色区域代表我们寻路可以行走的路线,正好包含了场景中的两个桥!
    在这里插入图片描述

  • 那接下来就给我们所有的模型添加上这个Nav Mesh Agent组件

  • 这样的话就可以使用代码调用nav.SetDestination(target.position),确认寻路目标位置并启用自动寻路

  • 然后使用nav.isStopped = true 就可以暂时停止自动寻路~
    在这里插入图片描述

  • 这样的话自动寻路就设置好了,在什么时候需要启动自动寻路的时候

  • 在代码中设置启动就好了!


🏳️‍🌈第四步:怪物逻辑脚本编写(关键+重要)

  • 模型加动画还有场景都很随意的简单搭配好了,我们只是快速开发一个游戏玩法的游戏Demo
  • 并不是完整的开发一个游戏,实在是时间不允许呀
  • 所以我们就轻装上阵,下面开始进行怪物逻脚本辑编写
  • 由于代码部分内容有很多,所以文章中只介绍关键代码
  • 毕竟大多数人看一下开发过程就好啦,需要完整代码工程的在下面下载源码工程就好啦~

先来看一下写的怪物基本属性
在这里插入图片描述

  • 有一个唯一编号,这个是后面用来生成不同怪物时候用来做标志的
  • 还有怪物名称、血量、生成需要的费用、移动速度、攻击距离、攻击速度、攻击力大小、怪物的卡牌图片、怪物描述、怪物放到场景的生成音效、攻击音效、死亡音效。。。
  • 这样就差不多怪物的属性写完了,下面根据这些属性来编写方法实现!

其中怪物的类型分为两种,分别是近程远程攻击类型,这里用一个枚举表示

/// <summary>
/// 怪物的类型,近战还是投掷
/// </summary>
public enum MonsterType
{
    Infighting,
    Throw
}
  • 其中,怪物自身还有几种状态分别是:寻路状态,攻击状态、静止状态和死亡状态
  • 定义这几种状态是用来进行动画的播放逻辑处理
/// <summary>
/// 怪物的状态
/// </summary>
public enum MonsterState
{
    Finding,
    Attacking,
    Stopping,
    Death
}

还会用到以下属性,怪物状态、攻击范围,导航目标等等,后面都会用到!

    public bool isHome;

    //怪物的当前状态
    public MonsterState monsterCrtstate;

    //怪物的上一个状态
    private MonsterState monsterLastState;

    [HideInInspector]
    public NavMeshAgent nav;

    private Animator ani;

    //攻击范围检测器
    private SphereCollider attackCol;

    //导航目标
    public Transform target;

    [HideInInspector]
    //怪物攻击队列
    public List<Monster> monsterAttackList;

    [HideInInspector]
    //怪物被攻击队列
    public List<Monster> monsterBeAttackList;

    //是否被放到了场景中
    private bool isEndDrag = false;

    //获取当前技能
    [HideInInspector]
    public Skills crtSkill;

    //血条
    private Slider haemalStrand;

    private AudioSource aud;

Awake中找到寻路、动画、声音和一个控制攻击范围的SphereCollider触发器

    private void Awake()
    {     
            nav = GetComponent<NavMeshAgent>();
            ani = GetComponent<Animator>();
            aud = GetComponent<AudioSource>();
            attackCol = GetComponent<SphereCollider>();

        attackCol.isTrigger = true;
        MonsterInit();
    }

还有一个怪物初始化处理MonsterInit方法,将怪物的各种初始属性设置好:

    /// <summary>
    /// 怪物初始化初始化
    /// </summary>
    private void MonsterInit()
    {
        crtSkill = GetComponent<Skills>();

        if (crtSkill == null)
        {
            Debug.Log("Null");
        }
        if(nav != null)
        {
            nav.stoppingDistance = monsterAttackRange;
        }
        monsterCrtstate = MonsterState.Stopping;
        monsterLastState = MonsterState.Stopping;
        attackCol.radius = monsterAttackRange;
        monsterAttackList = new List<Monster>();
        monsterBeAttackList = new List<Monster>();
        monsterCrtHP = monsterHP;
        haemalStrand = transform.Find("Canvas").GetChild(0).GetComponent<Slider>();
        //根据怪物所属阵营选择导航目标

    }
  • 然后在Update中进行判断,当怪物被放到场景中后

  • 如果当前状态和最后的状态不一致,就进行状态判断的方法

  • 如果攻击范围内有敌人就将当前的状态切换到攻击状态,不然就切换成寻路状态

  • 然后下面的 MonsterStateSwitch()方法就是进行不同状态切换的具体方法,下面会讲到。

  • 至于怎样寻路,上一步中已经讲到啦~

    void Update()
    {

        if(!isEndDrag)
            return;

        //持续性技能触发
        if(crtSkill.skilltype == SkillType.Update)
        {
            crtSkill.use();
        }

        //攻击列表中有对象就进入攻击状态
        if(monsterAttackList.Count > 0)
        {
            monsterCrtstate = MonsterState.Attacking;

            if(monsterAttackList[0] != null)
            {
                //转向目标
                MonsterLookAtTarget(monsterAttackList[0].transform);
            }
        }
        else if(monsterAttackList.Count == 0)
        {
            monsterCrtstate = MonsterState.Finding;
        }

        //进行状态判断
        if(monsterCrtstate != monsterLastState)
        {
            MonsterStateSwitch();
            monsterLastState = monsterCrtstate;
        }
        haemalStrand.transform.parent.LookAt(-Camera.main.transform.position);

    }
  • 然后对怪物身上的触发器进行代码编写

  • 触发器范围内检测到敌方怪物或者敌方水晶,就加入到攻击列表中进行攻击

  • 当这个游戏对象从触发器范围消失(被消灭)后,就移除该游戏对象

    private void OnTriggerEnter(Collider other)
    {
        if (other.isTrigger)
            return;

        if(other.CompareTag(GameConst.GAME_TAG_MONSTER) || other.CompareTag(GameConst.GAME_TAG_NAV_END) || other.CompareTag(GameConst.GAME_TAG_NAV_START))
        {
            Monster otherMonster = other.GetComponent<Monster>();

            if (otherMonster == null)
                return;

            if (otherMonster.monsterOwer != this.monsterOwer)
            {
                //设为攻击目标
                monsterAttackList.Add(otherMonster);

                otherMonster.monsterBeAttackList.Add(this);
            }
        }    
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.isTrigger)
            return;

        if (other.CompareTag(GameConst.GAME_TAG_MONSTER) || other.CompareTag(GameConst.GAME_TAG_NAV_END) || other.CompareTag(GameConst.GAME_TAG_NAV_START))
        {
            Monster otherMonster = other.GetComponent<Monster>();

            if (otherMonster != null && otherMonster.monsterOwer != this.monsterOwer)
            {
                //移除攻击队列中的该对象
                monsterAttackList.Remove(otherMonster);
                otherMonster.monsterBeAttackList.Remove(this);
            }
        }
    }
  • 还定义了一个方法,是在拖拽卡牌到场景中后调用的
  • 拖拽到场景中后,对声音和技能进行加载和释放
  • 还对自身的标签tag进行判断,如果是我方的怪物,就把寻路目标改为敌方水晶
  • 如果是敌方的怪物,就将寻路目标改为我方水晶进行寻路
    /// <summary>
    /// 结束拖拽,意思是怪物进入场景作战
    /// </summary>
    public void IsEndDrag()
    {
        this.gameObject.tag = "Monster";

        if(goClip == null)
        {
            goClip = Resources.Load<AudioClip>(GameConst.AUDIO_GO);
        }
        aud.clip = goClip;

        aud.Play();

        //AudioSource.PlayClipAtPoint(goClip, transform.position + Vector3.up * 48 - Vector3.right *40, 10f);

        isEndDrag = true;

        //战吼类技能触发
        if (crtSkill.skilltype == SkillType.Start && crtSkill != null)
        {
            crtSkill.use();
        }
        if (monsterOwer == MonsterOwer.Player)
        {
            target = GameObject.FindGameObjectWithTag(GameConst.GAME_TAG_NAV_END).transform;
        }
        else
        {
            if (GameObject.FindGameObjectWithTag(GameConst.GAME_TAG_NAV_START).transform)
            {
                target = GameObject.FindGameObjectWithTag(GameConst.GAME_TAG_NAV_START).transform;
            }
        }      
    }
  • 还有一个是动画帧中会调用的方法
  • 如果是近战,就执行近战攻击,是远程就进行远程攻击
    /// <summary>
    /// 动画帧事件中调用该方法
    /// </summary>
    public void MonsterAttackOne()
    {
        if (attackingClip == null)
        {
            attackingClip = Resources.Load<AudioClip>(GameConst.AUDIO_ATTACK);
        }
        aud.clip = attackingClip;
        aud.Play();
        //AudioSource.PlayClipAtPoint(attackingClip, transform.position + Vector3.up * 48 - Vector3.right * 40 * 48,10f);      

        if (monsterType == MonsterType.Infighting)
        {
            //近战
            Inflict();
            //通过动画帧事件调用受伤方法
        }
        else
        {
            if (Missile == null)
                return;
           
            if(monsterAttackList.Count >0 && monsterAttackList[0] != null)
            {
                //远程射击,生成 Missile

                GameObject btn = Instantiate(Missile,transform.position +  transform.up, Quaternion.LookRotation(transform.forward));
                btn.GetComponent<Bullet>().target = monsterAttackList[0].transform;
                btn.GetComponent<Bullet>().attackHarm = monsterAttackPower;
            }
        }
    }
  • 最后就是具体 控制怪物动画状态切换 并执行 相应状态操作 的方法。

  • 这个方法在Update中当前状态和最后状态不同的时候就会被调用!

  • 方法中对怪物不同的状态做出不同的相应来实现我们的需求~

  #region 怪物状态
    /// <summary>
    /// 不同的转态切换
    /// </summary>
    private void MonsterStateSwitch()
    {
        switch (monsterCrtstate)
        {
            case MonsterState.Attacking:
                MonsterStateAttacking();
                break;
            case MonsterState.Finding:
                MonsterStateFinding();
                break;
            case MonsterState.Stopping:
                MonsterStateStopping();
                break;
            case MonsterState.Death:
                MonsterStateDeath();
                break;
        }
    }
    /// <summary>
    /// 怪物在寻路状态
    /// </summary>
    private void MonsterStateFinding()
    {
        nav.SetDestination(target.position);
        ani.SetBool(GameConst.MONSTER_ANIPARAM_MOVE, true);
        ani.SetBool(GameConst.MONSTER_ANIPARAM_ATTACK, false);
        nav.isStopped = false;
        FindWay();
    }
    //设置导航目标
    public void FindWay()
    {
        nav.SetDestination(target.position);
    }
    /// <summary>
    /// 怪物在攻击状态
    /// </summary>
    private void MonsterStateAttacking()
    {
        ani.SetBool(GameConst.MONSTER_ANIPARAM_ATTACK, true);
        ani.SetBool(GameConst.MONSTER_ANIPARAM_MOVE, false);
        nav.isStopped = true;
    }
    /// <summary>
    /// 怪物被控制
    /// </summary>
    private void MonsterStateStopping()
    {
        nav.isStopped = true;
    }
    /// <summary>
    /// 怪物死亡
    /// </summary>
    private void MonsterStateDeath()
    {
        if (deathClip == null)
        {
            deathClip = Resources.Load<AudioClip>(GameConst.AUDIO_DEATH);
        }
        aud.clip = deathClip;
        aud.Play();
        //AudioSource.PlayClipAtPoint(deathClip, transform.position + Vector3.up * 48 - Vector3.right * 40 , 10f);
        ani.SetTrigger(GameConst.MONSTER_ANIPARAM_DEATH);
        //亡语类技能触发
        if(crtSkill.skilltype == SkillType.End)
        {
            crtSkill.use();
        }
        //移除被攻击列表中的自己
        for (int i = 0; i < monsterBeAttackList.Count; i++)
        {
            monsterBeAttackList[i].monsterAttackList.Remove(this);
        }
        Destroy(this.gameObject, 2f);
    }
    #endregion

到这里呢,关于怪物身上最重要的脚本方法就实现完毕了!
这一块是实现需求写的最多的,可能代码会有些多,仔细梳理一下就好了~


🏳️‍🌈第五步:配置模型 攻击范围触发器 和 声音处理

攻击范围触发器

  • 看完了上面的怪物脚本中一些乱七八糟的 逻辑和各种方法处理 是不是感觉有些头大呢~
  • 里面不仅包含了怪物的一些基本属性,还包含了怪物的各种逻辑和状态处理等等。
  • 算是一个很重要的脚本了!其中就含有一个攻击范围和攻击设计处理。
  • 那我们就来对这一块进行一个配置说明吧!

在写怪物脚本的时候,大家应该注意到了
我这里是使用触发器来控制一个攻击范围的,所以我们需要给所有的怪物模型身上添加触发器

拿钢铁侠配置举例,一起来看一下:
在这里插入图片描述

  • 选中需要配置的模型

  • 添加上Sphere Collider组件,勾选Is Triggier选项,并设置好一个 默认的范围=攻击范围,让他充当一个触发器

  • Box Collider是模拟一个碰撞效果的,根据模型调整好大小即可~

  • 然后下面给所有的模型都配置好这两个

  • 这里需要注意的是,我们现在配置的一个攻击范围是默认的攻击范围!

  • 可以通过上一步中的Monster脚本进行攻击范围修改~

看一下我配置的最终效果:球形大小是攻击范围,Box盒型是碰撞盒范围
在这里插入图片描述

声音处理

  • Monster脚本中已经对声音的处理都写好了

  • 包括一个怪物出场声音、攻击声音和死亡时候的声音

  • 那我们只需要在每个模型身体添加一个Audio Source组件就好了

  • Audio Source是一个播放声音的组件,还有一些属性配置,这里就不说了
    在这里插入图片描述

  • 我们这里只需要添加上这个组件就好了,声音是通过在脚本中自动加载播放的

  • 还记得Monster最开始怪物属性吗,就已经包括声音的配置了

  • 只需要添加每个怪物不同的声音就好了
    在这里插入图片描述

  • 而且在脚本中已经有了判断了,如果为空的时候就去自己加载声音文件~

  • 这是当怪物拖拽到场景中的时候调用的方法

//拖拽到场景后
 if(goClip == null)
        {
            goClip = Resources.Load<AudioClip>(GameConst.AUDIO_GO);
        }
        aud.clip = goClip;

        aud.Play();

还有攻击和死亡的时候调用的声音处理方法

//攻击
      if (attackingClip == null)
        {
            attackingClip = Resources.Load<AudioClip>(GameConst.AUDIO_ATTACK);
        }
        aud.clip = attackingClip;

        aud.Play();
//死亡
        if (deathClip == null)
        {
            deathClip = Resources.Load<AudioClip>(GameConst.AUDIO_DEATH);
        }

        aud.clip = deathClip;

        aud.Play();

到这里的话对各种模型身上需要添加的组件已经差不多都配置完了~
下一步就是设计怎样通过鼠标将卡牌拖拽到场景中并生成相应的怪物了!


🏳️‍🌈第六步:鼠标拖拽生成怪物 和 卡牌生成(关键+重要)

终于到这一步了~ 这里也是本个游戏中的一个重点核心内容,一起来看吧!

鼠标拖拽生成怪物

定义一个名为CardShow 的脚本,此脚本负责鼠标的拖拽卡牌部分功能实现

  • 首先是实现了几个接口

  • 分别是:IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerEnterHandler

  • 这几个是实现鼠标拖拽的方法,开始拖拽、拖拽中、拖拽结束和鼠标进入等,可以直接拿来用

  • 先来看一下这个脚本的属性定义

  • 首先是有一个编号,用于标记每个怪物的唯一标志,还有随机卡牌生成到卡组~

  • 怪物预制体,卡牌预制体,当前怪物的Monster脚本等等…

  • 还有就是记录一下卡牌的初始位置,方便取消拖拽的时候可以直接回到原位置!

public class CardShow : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerEnterHandler, IPointerExitHandler
{
    [HideInInspector]
    public int showMonsterIndex;//怪物编号

    [HideInInspector]
    public int[] cardShowMonsterRange;//随机卡组

    public GameObject monsterPrefab;//怪物预制体

    [HideInInspector]
    public GameObject crtMonsterObj;//当前的怪物预制体

    [HideInInspector]
    public Monster crtMonster;//获取当前怪物的脚本

    private GameObject showCardPropertyMSG;

    private Vector3 nowPos;//卡牌初始的位置
    private Transform parent;//卡牌初始的父物体
    private Ray ray;
    private RaycastHit hit;
    private int laser;//层
    private bool canMove = false;
}
  • 这部分是显示到每个卡牌上的怪物信息,包括怪物血量、攻击力、攻击范围等等…
   #region UI 显示
    private Text costText;

    private Image monsterImage;

    private Image crtImage;

    private object testImage;

    private GameObject crtCardValue;

    private Text monsterName;

    private Text showAttack;

    private Text showHp;

    private Text showAttackRange;

    private Text showMoveSpeed;

    private Text showSkillName;
    #endregion
  • 然后定义一个卡牌的初始化方法,在Awake中执行
  • 记录一下初始位置,并将卡牌上显示的属性从Monster脚本中拿到并赋值!
    private void Awake()
    {
        UIInit();
    }
    /// <summary>
    /// 初始化
    /// </summary>
    private void UIInit()
    {
        //获取名为Ground的层
        laser = LayerMask.GetMask(GameConst.GAME_TAG_GROUND);
        //记录初始位置
        nowPos = transform.position;

        parent = transform.parent;

        costText = GetComponentInChildren<Text>();

        monsterImage = transform.GetChild(0).GetComponent<Image>();

        crtCardValue = transform.GetChild(1).gameObject;

        monsterName = transform.GetChild(1).GetChild(1).GetComponent<Text>();

        crtImage = GetComponent<Image>();

        showAttack = transform.GetChild(1).GetChild(2).GetChild(0).GetComponent<Text>();
        showHp = transform.GetChild(1).GetChild(3).GetChild(0).GetComponent<Text>();
        showAttackRange = transform.GetChild(1).GetChild(4).GetChild(0).GetComponent<Text>();
        showMoveSpeed = transform.GetChild(1).GetChild(5).GetChild(0).GetComponent<Text>();
        showSkillName = transform.GetChild(1).GetChild(6).GetChild(0).GetComponent<Text>();
    }
  • Start中还有两个方法GetRandomMonsterShowMsg
  • GetRandomMonster用来通过Monster脚本拿到当前怪物身上的各种信息
  • ShowMsg用于显示当前怪物模型的图片信息,是从Monster脚本身上拿到的
  • 该图片信息是在每一个怪物身上赋值到Monster脚本中的!
    void Start()
    {
        crtCardValue.SetActive(false);
        GetRandomMonster();//获取怪物预制体
        ShowMsg();//显示卡牌图片信息
    }
    /// <summary>
    /// 获取怪物预设体
    /// </summary>
    private void GetRandomMonster()
    {
        if (monsterPrefab == null)
        {
            int randomIndex = Random.Range(0, cardShowMonsterRange.Length);
            showMonsterIndex = cardShowMonsterRange[randomIndex];

            //Load Monster Prefabs of index in Resources (get Monster Prefab Path of Json)
            monsterPrefab = JsonAnalysisData.instance.GetMonsterPrefabOfIndex(showMonsterIndex);
        }
        crtMonsterObj = Instantiate(monsterPrefab, Vector3.zero, Quaternion.identity);
        crtMonsterObj.SetActive(false);

        //获取当前怪物的脚本
        crtMonster = crtMonsterObj.GetComponent<Monster>();
        //图片的加载
        testImage = Resources.Load(crtMonster.monsterImagePath, typeof(Sprite));

        #region 數據信息

        string[] msg = crtMonster.ReturnMonsterMsg();
        monsterName.text = msg[0];
        showAttack.text = msg[1];
        showHp.text = msg[2];
        showAttackRange.text = msg[3];
        showMoveSpeed.text = msg[4];
        showSkillName.text = msg[5];
        #endregion
    }

    /// <summary>
    /// 卡牌的图片显示信息
    /// </summary>
    private void ShowMsg()
    {
        costText.text = crtMonster.monsterCost.ToString();
        //显示图片
        Sprite sp = testImage as Sprite;
        monsterImage.sprite = sp;
    }

下面三个方法是鼠标拖拽的时候调用的

分别是开始拖拽拖拽中拖拽结束时候调用

  • 在开始拖拽时,判断当前圣水数量是否大于卡牌所需的数量
  • 如果圣水数大于卡牌所需的,就将canMove改为true,并激活对应的怪物预制体
  • 拖拽中的时候,先判断canMove的状态
  • 然后就将卡牌的位置随着鼠标移动,并启用射线检测鼠标的位置
  • 如果射线检测到的位置是我们可放置的场景,就将卡牌变为对应的模型
  • 不然就隐藏模型,显示卡牌
  • 拖拽结束的时候,也是先判断canMove的状态
  • 然后启用射线检测位置是否合理,属于我们可放置的区域
  • 如果位置合理,那么久在当前位置生成怪物模型,并销毁卡牌。
  • 不然就让卡牌回归用来的位置!
    /// <summary>
    /// 开始进行鼠标拖拽时调用
    /// </summary>
    /// <param name="eventData"></param>
    public void OnBeginDrag(PointerEventData eventData)
    {

        if ( EnergyManger.instance != null && crtMonster.monsterCost < EnergyManger.instance.energyTar.value)
        {
            crtMonsterObj.SetActive(true);
            transform.SetParent(transform.root);
            canMove = true;
        }
        else
        {
            canMove = false;
        }
        
    /// <summary>
    /// 鼠标拖拽中 时调用
    /// </summary>
    /// <param name="eventData"></param>
    public void OnDrag(PointerEventData eventData)
    {
        if (!canMove)
            return;

        crtCardValue.SetActive(false);
        //卡牌随着鼠标移动
        transform.position = Input.mousePosition;
        ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        //鼠标位置在我方可放置的范围内
        if (Physics.Raycast(ray, out hit, 600, laser))
        {
            //卡牌里的怪进入可放置范围后释放本体
            crtMonsterObj.transform.position = hit.point;

            //可优化
            crtMonsterObj.SetActive(true);
            transform.GetChild(0).gameObject.SetActive(false);
        }
        else
        {
            if (crtMonsterObj != null)
            {
                //出了可放置的范围将怪物隐藏,显示卡牌
                crtMonsterObj.SetActive(false);
            }

            transform.GetChild(0).gameObject.SetActive(true);
        }
    }
    /// <summary>
    /// 松开鼠标时调用
    /// </summary>
    /// <param name="eventData"></param>
    public void OnEndDrag(PointerEventData eventData)
    {
        if (!canMove)
            return;

        ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        //GameManager.instance.HideCardPropertyMSG();
        //判断鼠标松开时落点在正确的位置
        if (Physics.Raycast(ray, out hit, 600, laser))
        {
            crtMonsterObj.SetActive(true);

            crtMonster.IsEndDrag();

            if (CardController.instance != null)
            {
                CardController.instance.cardAmount--;
            }

            //销毁卡牌
            Destroy(this.gameObject, 0.1f);

            //扣能量值
            EnergyManger.instance.reduceMoney(crtMonster.monsterCost);
        }
        else
        {
            //回到原来的位置
            crtMonsterObj.SetActive(false);

            transform.SetParent(parent);
        }
    }
    }
  • 还有两个方法是鼠标移入卡牌内部时,会显示卡牌承载的怪物的信息
  • 包括卡牌名字、所需圣水量、攻击力、血量等等。。。
  • 鼠标移除卡牌内部,则卡牌恢复原样!

简单看一下效果:
在这里插入图片描述
代码:

    /// <summary>
    /// 鼠标进入显示信息
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerEnter(PointerEventData eventData)
    {
        if (!crtImage.raycastTarget)
        {
            return;
        }
        crtCardValue.SetActive(true);
    }

    /// <summary>
    /// 鼠标退出隐藏
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerExit(PointerEventData eventData)
    {
        if (!crtImage.raycastTarget)
        {
            return;
        }
        crtCardValue.SetActive(false);
    }

  • 还需定义一个脚本,用于控制圣水的自然增长扣除圣水的方法~
  • 那我们就来新建一个EnergyManger脚本专门用来控制圣水的增加与扣除!
  • 代码很简单,使用的Slider模拟圣水能量槽,直接来看代码吧:
  public static EnergyManger instance;
  
    [Header("能量增加速度")]
    public  float addSpeed = 0.5f;

    [HideInInspector]
    public Slider energyTar;//圣水能量槽
    private Text num_text;//圣水能量数

    private void Awake()
    {
        instance = this;
        energyTar = GetComponent<Slider>();
        num_text = transform.Find("Cost/Num").GetComponent<Text>();
    }
    private void Update()
    {
        addMoneyTar();
    }
    /// <summary>
    /// 增加圣水的方法
    /// </summary>
    private void addMoneyTar()
    {
        //能量槽慢慢增加
        energyTar.value += Time.deltaTime * addSpeed;
        //数值随着能量槽增加而变化
        num_text.text = ((int)energyTar.value).ToString();
    }
    /// <summary>
    /// 扣除圣水的方法
    /// </summary>
    /// <param name="_moneyTar"></param>
    public void reduceMoney(float _moneyTar)
    {
        this.energyTar.value -= _moneyTar;
        num_text.text = string.Format(energyTar.value.ToString("f0"));
    }

简单看一下效果:
在这里插入图片描述

卡牌生成

  • 我们在实际战斗过程中是定义了一个卡组的,里面最多存放四张卡牌
  • 卡组里的内容是从我们默认的卡牌库里随机抽取过来的,所以这里我们通过代码来实现一下
  • 新建一个CardController脚本,在其中控制战斗时的卡牌随机抽取和生成

代码如下:

    public int cardAmount;//当前卡牌数量
    private GameObject cardPrefab;//卡牌预制体

    private void Awake()
    {
        instance = this;
        cardAmount = 0;
        cardPrefab = Resources.Load<GameObject>(GameConst.CARD_PRDFAB_PATH);//从给定的路径加载预制体
    }
    // Start is called before the first frame update
    void Start()
    {
        for (int i = 0; i < 4; i++)
        {
            if(cardAmount >= 4)
            {
                break;
            }
            cardAmount++;
            GameObject card = Instantiate(cardPrefab, transform);
            CardShow show = card.GetComponent<CardShow>();
            show.cardShowMonsterRange = PlayerController.instance.playerCards;
        }          
    }

    // Update is called once per frame
    void Update()
    {
        //每当卡组内的卡牌数减少时,从卡牌库里抽取一张
        if(cardAmount < 4)
        {
            cardAmount++;
            GameObject card = Instantiate(cardPrefab, transform);
            CardShow show = card.GetComponent<CardShow>();
            show.cardShowMonsterRange = PlayerController.instance.playerCards;
        }
    }

🏳️‍🌈第七步:血条设计和远程攻击的投掷物处理

血条设计

  • 血条部分的逻辑处理在之前的Monster脚本中提到了。
  • 是使用一个Canvas 空间画布,其中添加一个Slider制作的血条。
  • 然后在Monster脚本中拿到这个Slider就可以进行扣血处理了!
  • 很简单的处理,来看一下效果吧!
    在这里插入图片描述
    在这里插入图片描述

远程攻击的投掷物处理

因为在这个游戏中,有远程攻击的模型,使用我们需要对这些远程攻击的投掷物进行一个简单的处理

远程攻击的投掷物处理

因为在这个游戏中,有远程攻击的模型,使用我们需要对这些远程攻击投掷物进行一个简单的处理

  • 首先,我们需要配置投掷物,也就是远程攻击对应的预制体
  • 这里我简单设计了几款投掷物模型,大家凑合看吧~
  • 从左到右的投掷物:美国队长、绿巨人、钢铁侠和弓箭手!(不许吐槽我的设计!!!)
    在这里插入图片描述
  • 对应的还需要创建一个投掷物脚本Bullet
  • 脚本内容也很简单,就是处理投掷物的飞行速度造成伤害自身销毁
  • 其中这个投掷物和目标Target是在Monster中进行赋值的
  • 然后将脚本挂载到投掷物身上即可!

一起来看一下代码:

    [HideInInspector]
    public float attackHarm;
    [HideInInspector]
    public Transform target;
    [HideInInspector]
    public float flySpeed;
    
    private void Awake()
    {
        Destroy(this.gameObject, 2f);
    }

    // Update is called once per frame
    void Update()
    {
        if (target == null)
        {
            Destroy(this.gameObject);
            return;
        }
        Flying();
        Reach();
    }

    private void Flying()
    {
        transform.LookAt(new Vector3(target.position.x, target.position.y + 1, target.position.z));
        transform.position += transform.forward * Time.deltaTime * flySpeed;
    }

    private void Reach()
    {
        if(Vector3.Distance(transform.position,target.position + Vector3.up) < 0.5f)
        {
            target.GetComponent<Monster>().MonsterTakeHarm(attackHarm);
            Destroy(this.gameObject);
        }
    }

🏳️‍🌈第八步:游戏控制器编写

终于到了这一步啦~ 到控制器编写就代表游戏马上就要完成了,一起来看看吧!

我这里使用到了两个控制器,一个是PlayerController,一个是AIController

先来说一下PlayerController控制器,主要是控制玩家相关的简单逻辑操作

  • Awake中定义了一个int数组,用来存储玩家的卡牌
  • 该数组在GameManager脚本中被赋值使用,
  • 然后在Start中简单标记了一下该脚本所持对象为玩家,而且是Home
  • Update中简单定义一个玩家水晶血量小于0时候(就是玩家输掉了)的回调,进行结束战斗处理!

来看一下代码:

    [HideInInspector]
    public Monster player;

    public  int[] playerCards = new int[8];

    private void Awake()
    {
        instance = this;
        player = GetComponent<Monster>();
        //测试套牌数组
        playerCards = new int[8] { 1,2,3,4,5,6,7,8 };
    }

    // Start is called before the first frame update
    void Start()
    {
        player.monsterOwer = MonsterOwer.Player;
        player.isHome = true;
    }

    void Update()
    {
        GameOver();
    }
    private void GameOver()
    {
        if(player.monsterCrtHP <= 0)
        {
            //跳出失败界面,时间暂停
            Time.timeScale = 0;

            GameObject.FindGameObjectWithTag("Canvas").GetComponent<ShowGameEnd>().ShowEnd(false);

            Debug.Log("失败");
        }
    }

另一个就是AIController控制器了,主要控制敌人AI的逻辑

  • Awake中标记为AI,并标记为Home

  • 然后在Start中给AI设置卡组,我们可以给他设置固定的卡组,也可以让他跟玩家的卡牌库一样,随机生成怪物!

  • 在设置完AI的卡组之后,启动一个协程,用于生成怪物使用

  • 协程中设置当敌方水晶血量大于0 的时候,就按照卡组中的数据随机生成怪物

  • 随机分配生成的位置,设置每三秒生成一个怪物(我自己测试,每3秒是打不过AI的。。。看你能不能行!)

  • Update中也是一样,对敌方水晶血量进行判定

  • 当敌方的水晶嗝屁了之后就跳出胜利界面!!!

    [HideInInspector]
    public Monster AI;
    public Transform[] points;//AI怪物出生点
    public int[] aiCards;//AI卡组怪物
    private int nextCardID;
    private void Awake()
    {
        AI = GetComponent<Monster>();
        AI.monsterOwer = MonsterOwer.AI;
        AI.isHome = true;
    }

    // Start is called before the first frame update
    void Start()
    {
        if(!GameManager.instance.isNever)
        {
           //加载AI卡组
            aiCards = JsonAIAnalysisData.instance.GetCrtAICards(GameManager.instance.crtLevel);
        }
        else
        {
            int index = JsonAnalysisData.instance.GetCardLibraryCount();
            aiCards = new int[index];
            //TODO 设置AI的套牌为所有牌库的牌
            for (int i = 0; i < index; i++)
            {
                aiCards[i] = i;
            }
        }

        //获取当前关卡的套牌
        nextCardID = Random.Range(0,aiCards.Length);
        StartCoroutine(AIMonsterGo());  
    }
    // Update is called once per frame
    void Update()
    {
        GameOver();
    }
    IEnumerator  AIMonsterGo()
    
        while(AI.monsterHP >0)
        {
            //随机生成怪物
            GameObject crtMoster = Instantiate(JsonAnalysisData.instance.GetMonsterPrefabOfIndex(aiCards[nextCardID]), transform.forward, Quaternion.Euler(0, 180, 0));

            //设置怪物拥有者为AI
            crtMoster.GetComponent<Monster>().monsterOwer = MonsterOwer.AI;

            //TODO:给怪物分配随机位置
            crtMoster.transform.position = points[Random.Range(0, points.Length)].position;

            crtMoster.GetComponent<Monster>().IsEndDrag();

            //随机下张卡牌所处的套牌位置
            nextCardID = Random.Range(0, aiCards.Length);

            //每三秒生成一个怪物
            yield return new WaitForSeconds(3f);
        }
    } 

    /// <summary>
    /// 游戏结束
    /// </summary>
    private void GameOver()
    {
        if(AI.monsterCrtHP <= 0)
        {
            //时间暂停,跳出胜利界面
            Debug.Log("Ai death");
            Time.timeScale = 0;

            GameObject.FindGameObjectWithTag("Canvas").GetComponent<ShowGameEnd>().ShowEnd(true);
        }
    }
  • 还定义了一个常量类,将工程使用的所有的常量存储起来,比如路径、标签、动画参数等等
  • 使用的时候统一调用这个脚本即可
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public static class GameConst
{
    #region Path

    public const string JSON_MONSTER_PATH_TEXT = "JsonData/MonsterData";

    public const string JSON_AI_PATH_TEXT = "JsonData/AIData";

    public const string SQL_DATABASE_PATH = "/Game.sqlite";

    public const string CARD_PRDFAB_PATH = "Cards";

    public const string AUDIO_GO = "Audio/achievement_unlock_01";

    public const string AUDIO_ATTACK = "Audio/barbarian_attack_02";

    public const string AUDIO_DEATH = "Audio/king_mad_02";

    public const string AUDIO_BUTTON = "Audio/UI/button_click_02";

    #endregion

    #region Tag

    public const string GAME_TAG_MONSTER = "Monster";

    public const string GAME_TAG_NAV_START = "Start";

    public const string GAME_TAG_NAV_END = "End";

    public const string GAME_TAG_GROUND = "Ground";

    public const string GAME_TAG_CARDSLOT = "CardSlot";

    public const string GAME_TAG_CARD = "Card";

    public const string GAME_TAG_CARDLIBRARY = "CardLibrary";

    #endregion

    #region 动画参数

    public static int MONSTER_ANIPARAM_MOVE;

    public static int MONSTER_ANIPARAM_ATTACK;

    public static  int MONSTER_ANIPARAM_DEATH;

    #endregion

    static GameConst()
    {
        MONSTER_ANIPARAM_MOVE = Animator.StringToHash("Move");

        MONSTER_ANIPARAM_ATTACK = Animator.StringToHash("Attacking");

        MONSTER_ANIPARAM_DEATH = Animator.StringToHash("Death");
    }
}

  • 好了,到这里一个简单的游戏控制器就算配置完成了
  • 着急忙慌的也算是完工了,累死了,欢呼!!!

一起看一下AI的生成效果吧:
在这里插入图片描述


🔔游戏展示

当当当~ 终于又到了游戏展示阶段了

  • 我准备了两个视角的游戏展示,可以多方位的看一下
  • 因为对相机视角的调节还不够优秀,所以只能做到以下视频所示的效果了
  • 这个有时间的话可以找一个更合适的角度来放置摄像机
  • 我这里就是简单摆放了一下,并没有深度研究,感觉效果还是差了很多~

压缩动图游戏展示:

在这里插入图片描述

完整视频展示玩家视角:

复刻皇室战争玩法的一个自制小游戏的效果展示

完整视频展示旁观者视角:

自制复刻皇室战争小游戏


🎁本篇文章资源下载

  • 文中资源工程下载链接在这,感兴趣的可以自行下载~
  • 想体验一下,但是余额和积分不足的小伙伴私信我也可以!
  • 点击csdn下载链接

  • 小Y:因为时间和素材的原因,这篇教程到这里就算结束啦~ 还有些地方不够完善😅
  • 小云儿👩:是的呢小Y哥哥,做这些就已经够麻烦的了,我都有点看眼花啦🧐
  • 小Y:好咧,那这次就学到这里吧。我在文中提到的每一个步骤和细节都要仔细看哦😝
  • 小云儿👩:嗯呢,这篇文章内容这么多,我可要好好消化一下,那下次再见啦😊~

本篇文章写了近三万字,讲解了这个游戏的基本思路开发过程,但还是有些细节可能没有介绍到

2021071409215642.png20210714090139785.png
  • 文章中只把关键代码展示出来了,其中还有一些脚本没有展示出来

  • 感兴趣的小伙伴可以根据教程拿到工程,然后可以在这个基础上将游戏更完善的塑造一下

  • 有时间的还可以加入联网模式,自己跟小伙伴对打!

  • 因为我这里时间实在是不够用,所以此工程就做到了这一步

  • 还有挺多细节可以优化,以后如果有时间我也会再更新完善一下~


那本篇两万多字的游戏教程就到此结束了,学废了之后还可以看一下我写的其他游戏哦~

这个游戏专栏以后也会继续更新游戏的!

再接再厉,继续加油!

在这里插入图片描述


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK