CPT306 Coursework 1 个人游戏作业——坦克大战

文章目录

  • [1. 简介](#1. 简介)
  • [2. 玩法部分](#2. 玩法部分)
    • [2.1 战斗设计](#2.1 战斗设计)
      • [2.1.1 玩家角色设计](#2.1.1 玩家角色设计)
        • [2.1.1.1 移动设计](#2.1.1.1 移动设计)
        • [2.1.1.2 血量设计](#2.1.1.2 血量设计)
        • [2.1.1.3 攻击设计](#2.1.1.3 攻击设计)
      • [2.1.2 敌人设计](#2.1.2 敌人设计)
        • [2.1.2.1 移动设计](#2.1.2.1 移动设计)
        • [2.1.2.2 血量设计](#2.1.2.2 血量设计)
        • [2.1.2.3 攻击设计](#2.1.2.3 攻击设计)
        • [2.1.2.4 生成机制设计](#2.1.2.4 生成机制设计)
    • [2.2 地图设计](#2.2 地图设计)
    • [2.3 道具设计](#2.3 道具设计)
    • [2.4 分数设计](#2.4 分数设计)
      • [2.4.1 击杀分数](#2.4.1 击杀分数)
      • [2.4.2 时间分数](#2.4.2 时间分数)
      • [2.4.3 道具分数](#2.4.3 道具分数)
      • [2.4.4 惩罚分数](#2.4.4 惩罚分数)
  • [3. 系统部分](#3. 系统部分)
    • [3.1 主系统](#3.1 主系统)
    • [3.2 IO 系统](#3.2 IO 系统)
    • [3.3 设置系统](#3.3 设置系统)
  • [4. 美术设计](#4. 美术设计)
    • [4.1 角色移动动画](#4.1 角色移动动画)
    • [4.2 地图美术细节](#4.2 地图美术细节)
    • [4.3 道具设计](#4.3 道具设计)
  • [5. 总结与反思](#5. 总结与反思)
  • [6. 分享](#6. 分享)

1. 简介

我们先介绍一下这次作业的要求,然后再详细介绍作业是怎么设计的,我又是如何实现我的设计的,后面我会说出我的一些问题,在最后我还分享了自己的项目和游戏,希望大家能设计出更好的游戏。

这次作业的要求是设计一个类似于坦克大战的游戏,整个作业的自由性其实很高,对于美术和游戏的细节都可以是任何形式。只对玩法进行了一个大致框架的确定,对于游戏的地图有明确的要求,必须是 23x23 的网格但是最外层必须是不可破坏的障碍物,然后包含随机生成的不可破坏和可破坏的障碍物。还要求必须包含两关,第一关比较基础,教会玩家基本的规则后,玩家消灭所有的敌人就可以进入第二关。第二关的话难度更高,这可以体现在敌人的速度和伤害上。然后敌人的数量也更多,类型也更多,而且要求不是一次展开这些敌人,而是有相应的生成机制,敌人会维持在一个上限,不会无限生成,并且数量是固定的,这意味着敌人也是可以被消灭光的,所以和第一关一样,玩家消灭完所有敌人就可以通关。同时,第二关还需要引入道具系统,这个部分也没有做出详细的要求。玩家每次重生的位置需要固定,而且玩家控制的角色如果生命消耗完就会死亡。

对于控制方面,要包含以下的操作:

控制方式 功能
鼠标左键 攻击
W / A / S / D 移动英雄(上、左、下、右)
左 Shift 键 冲刺 / 加速跑
空格键 暂停 / 继续整个游戏

这部分的要求是操作上的要求,但同样除了基础的控制,它这里规定了我们的游戏系统需要有暂停的功能。

还有一些细节要求,比如碰撞需要有伤害,这部分可以就理解为近战伤害。冲刺提升速度但是需要有冷却时间,同样攻击也是需要有冷却时间,防止鼠标点的越快,火力越猛。

还需要有清晰的 UI 界面,游玩时候包含时间、分数、玩家的状态,而在主菜单要有开始、设置、排行榜、退出的功能,在设置里可以控制背景音乐和音效,在暂停界面有继续、重开、返回主菜单的选项,在关卡结束后有对应的成功、失败信息,并由对应的选项(继续、重开、返回主菜单)。排行榜需要包含玩家名、分数、时间,而且排行榜的信息需要保存在电脑里(持久存在,不会因为游戏关闭而清空)。

这就是整体的要求,或许说的太多太乱,但其实老师对这里详细的规定可以让一些对于游戏经验不足的人可以知道一个游戏有哪些部分组成。

我将其分为了三个部分:

  • 玩法部分:玩法是一个游戏的核心,老师确定了大方向------坦克大战,动作类游戏,固定的地图大小但是随机的地图和敌人,不同难度的两关。玩家的角色怎么操作攻击,但是一些细节还是有我们设计的空间,比如数值设计,如果我们未来从事游戏开发,关于策划就会分为玩法策划、任务策划、数值策划等等,但在这个个人作业,这些当然都是我们自己一个人做。由于我们确定了大方向------我们操作角色移动并远程攻击,消灭所有的敌人就可以通关,所以我们现在只要进行一些细节的设计,比如数值上我们怎么设计这里的角色大小、血量、伤害、速度等,当然我们也可以直接套用老师推荐的值。要求我们第二关有更多类型的敌人,这个类型怎么设计。还要求我们得有道具,这一部分也要进行设计。排行榜得有分数,所以我们得设计分数怎么计算。
  • 系统部分:游戏系统在我这里主要的是抛开了游戏具体玩法的一些辅助性内容,这部分其实可以理解为多个辅助性的模块,它们在一起无法构成游戏,但是它们在每个游戏上都是必不可少的,而且它们可以复用,所以在作业 2 中,这一部分可以少一些,因为作业 2 主要是展示 demo,demo 主要展现玩法,系统可以少一些基础功能,例如设置、暂停等。而老师对于这些功能的描述非常细节,设置包含哪些功能,暂停需要注意游戏场景的确暂停(时间不动),通关或者失败都需要给出清晰的提示以及功能。
  • 美术部分:这一部分其实不是我们这个课程的重点,因此老师对这个的要求最低,虽然我们的课程涉及大量有关贴图、光影的介绍,但是我们的课程更偏向于设计玩法、实现玩法的框架设计,美术虽然并不是很重要,但是作为作业,一个美观的场景设计或者能够吸引人的动画毫无疑问是作业的加分项。

看过我之前 CPT205 作业 作业1介绍 作业2介绍 的应该知道我对星之卡比的喜爱,以及星之卡比这种简单可爱形象非常适合我们这种学生作业。

因此我们这次依然选择星之卡比作为美术核心,我们接下来的设计 会围绕星之卡比展开设计。

我在这里先展示以下我的最后成品大概样子,后面我会详细进行介绍:

关于 Unity 2D 游戏的制作细节也可以参考这个视频:Unity 2D 游戏制作展示

2. 玩法部分

2.1 战斗设计

2.1.1 玩家角色设计

我们先从玩家角色设计开始,这毫无疑问是游戏的核心,然后我们后面的敌人设计只需要根据这一部分进行修改即可,所以我们在设计实现的时候可以考虑这里的拓展性和复用性。

2.1.1.1 移动设计

首先战斗部分我们要思考这里的移动设计,首先是最基础的移动操作,我们要考虑的事情有很多,我们可以从简单的开始,我们就按下wasd进行移动操作,我们可以让我们的角色不像现实世界那种需要加速度然后先加速缓慢移动,这样虽然更接近显示世界,但是在大部分游戏场景里来说这样的操作会给用户不好的游戏体验,因为他会觉得玩家控制的角色很笨重,难以控制,只有到了赛车游戏等才需要这样的真实感,这也是因为游戏类型不同而造成的侧重点不同。所以玩家现在按下一个建,立马会获得这个速度然后进行移动。

我们还需要给角色添加一个碰撞体,如果没有碰撞体那么就碰上东西他不会被阻挡,我们可以修改碰撞体到我们想要的形状。

下一点我们要考虑的是一个我们在代码角度需要考虑的事情,那就是 Unity 里更新的方式并非唯一的,如果我们就简单的使用 Update 就会出现一个问题,同样的游戏在不同的电脑上移动的速度不同,这样操作当然是不行的,我们后面都要考虑这样的问题,当然你也可以使用时间速度公式去计算,当然这样做感觉有点过于麻烦,我们可以直接使用 FixedUpdate 就好。

相关代码如下。

csharp 复制代码
void Update()
    {
        horizontal = Input.GetAxis("Horizontal");
        vertical = Input.GetAxis("Vertical");

        if ((horizontal > 0 && transform.localScale.x < 0) || (horizontal < 0 && transform.localScale.x > 0))
        {
            Flip();
        }

        anim.SetFloat("horizontal", Mathf.Abs(horizontal));
        anim.SetFloat("vertical", Mathf.Abs(vertical));

        if (Input.GetKeyDown(KeyCode.LeftShift) && !isSprinting && !isCoolingDown)
        {
            StartSprint();
        }

        if (isSprinting)
        {
            sprintTimer -= Time.deltaTime;

            if (sprintTimer <= 0f)
            {
                EndSprint();
            }
        }

        if (isCoolingDown)
        {
            cooldownTimer += Time.deltaTime;

            if (cooldownTimer >= sprintCooldown)
            {
                cooldownTimer = sprintCooldown;
                isCoolingDown = false;
            }

            UpdateAPText();
        }
        else if (!isSprinting)
        {
            UpdateAPText();
        }
    }

    void FixedUpdate()
    {
        Vector2 move = new Vector2(horizontal, vertical);

        if (move.magnitude > 1f)
        {
            move = move.normalized;
        }

        rb.velocity = move * currentSpeed;
    }

我们在每一帧读取操作,然后这样的操作放到固定帧上进行刷新,从而保证每个玩家体验的游戏是一样的。

我们还要考虑到如果玩家进行按住水平和竖直方向的移动那么这时候这两个速度方向上都进行了速度的移动,这样玩家斜着走的速度其实是超出了我们给出的上限的,因为我们在水平和竖直方向都进行了移动,所以最后斜着方向移动了 1.414 倍的距离,所以对应的速度也是这样正常我们设置的速度的 1.414 倍,因此我们这里应该使用 normalized 归一化从而防止这样的 bug。

接着我们作业还要求我们有个冲刺功能,这个按键一般是 shift,然后你可以考虑以下几种方案,角色翻滚以位移一段距离或者角色以一个更快的速度进行移动,很明显前者会对美术资源有更高的要求,为了方便我们选择后者。后者也很容易实现,只要用户使用冲刺键就直接将速度修改到更高的值就好了,但是如果一直允许用户使用冲刺并非好的游戏设计。因为这就会带来一个问题,既然我可以一直很强,我为什么不一直这样呢?这样用户就会一直按着 shift 键,然后觉得厌烦,因为我需要一直按着额外一个键进行操作,这样很累。

所以我们需要对这个操作进行一定的限制,或者说把它理解成技能,它需要进入 cd(冷却,禁止玩家使用一段时间防止玩家一直使用)。我们后面还需要设计一个 UI 信息从而告诉玩家自己的状态,因此这样的一个限制操作可以理解为体力条,如果体力条没有满,那么玩家就不能这么操作,体力满了才可以冲刺。

然后我们还要考虑一个事情,那就是我们的角色向右移动是可以理解为正着走的,但是如果我们按下 A 键,如果角色不将自己的朝向改过来,那么这样的话那就是倒着走,这样肯定是有问题的,所以我们需要不断修改角色的朝向,这种操作也很简单,我们可以使用transform轻松实现,相关的代码如下,在玩家按下相反方向的键调用这个方法即可。

csharp 复制代码
void Flip()
    {
        facingDirection *= -1;
        transform.localScale = new Vector3(
            transform.localScale.x * -1,
            transform.localScale.y,
            transform.localScale.z
        );
    }

我们为了移动流畅我们还要添加对应的移动动画,我准备了四张图片以完成这个移动动画。

但其实三张图也可以,一个静止不动,一个迈左脚,一个迈右脚,也可以。但是在做动画的时候要注意将第一个静止帧复制到最后这样动画播放起来才更加连贯流畅,如果不相信的话你可以试一试,你也可以调整这里的间隔从而决定动画的快慢,这些都是需要去尝试的,我无法给出一个准确的依据,我只能给出经验与参考。

我们可以去 Animator 里对这里动画切换进行一个详细的控制。这里的界面就是一个状态机,当满足某些条件的时候它就会从某个状态切换到另一个状态了。

其实我们就是两个状态,一个纯静止的状态,这样不播放任何动画的静止状态,一个会移动的跑步状态,

静止的时候如果玩家输入了任何水平或者竖直方向的方向,那么它就会切换到跑步状态,否则依然保持静止状态。

但是反过来就不是 or 的关系而是 and,两者需要同时满足,所以前者是两个条件分开的,这样只要一个满足条件就会切换状态,而下面是在一起的。

所以这就是为什么我们代码里使用了绝对值。

csharp 复制代码
anim.SetFloat("horizontal", Mathf.Abs(horizontal));
anim.SetFloat("vertical", Mathf.Abs(vertical));

因为不这么做的话,我们在输入 A 或者 S 的时候这个值永远小于 0,无法实现我们需要的条件判断。

我们现在就要思考数值的问题了,这里我的建议是去尝试,你可以试着将空白的地图上用计时的脚本去测试你走遍整个地图要花多少时间,或者更详细的建议是让角色从地图的左下角走到右下角不要花太久的时间,这个时间在我的设计里是 4s,你也可以进行详细的计算得出你的数值,我将速度设置为 5 的时候可以做到这一点,然后我将冲刺时候的速度设置为了常规速度的两倍,然后冲刺时间设置为了 2s,这个数字方便计算,不要尝试 2.3 之类的数字不方便自己进行数值上的设计,这同样也会让你的代码不方便处理。冷却时间设置为 4s,如果也设置成 2s,感觉就会比较脱离显示,我们一般跑步冲刺的时候休息的时间肯定要比我们冲刺的时间从长一点的。这样的设计我认为比较好,2s 既不太短能让用户体验到一个较长的高速时期从而躲避一些敌人的攻击,而 4s 的冷却期也不太长,不会有太长的真空期以让用户没有反制手段,如果设置的短一点也会降低游戏的难度,使用户觉得无聊,这里说的 4s,指的是从使用后就开始进入冷却时间计算,所以代码里是在冲刺持续时间解释后才开始计算,所以是 2s。

这里还需要注意的是我们刚刚说的我们将这里的冷却设定当作了体力条,那么当然跑步的时候我们是不能回复体力的,只有停止跑步了它才应该开始计时。

这一部分完整的代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PlayerMovement : MonoBehaviour
{
    [Header("Move")]
    public float normalSpeed = 5f;
    public float sprintSpeed = 10f;

    [Header("Sprint")]
    public float sprintDuration = 2.5f;
    public float sprintCooldown = 5f;

    [Header("References")]
    public Rigidbody2D rb;
    public Animator anim;
    public Text apText;

    public int facingDirection = 1;

    private float currentSpeed;
    private float horizontal;
    private float vertical;

    private bool isSprinting = false;
    private bool isCoolingDown = false;

    private float sprintTimer = 0f;
    private float cooldownTimer = 0f;

    void Start()
    {
        currentSpeed = normalSpeed;
        UpdateAPText();
    }

    void Update()
    {
        horizontal = Input.GetAxis("Horizontal");
        vertical = Input.GetAxis("Vertical");

        if ((horizontal > 0 && transform.localScale.x < 0) || (horizontal < 0 && transform.localScale.x > 0))
        {
            Flip();
        }

        anim.SetFloat("horizontal", Mathf.Abs(horizontal));
        anim.SetFloat("vertical", Mathf.Abs(vertical));

        if (Input.GetKeyDown(KeyCode.LeftShift) && !isSprinting && !isCoolingDown)
        {
            StartSprint();
        }

        if (isSprinting)
        {
            sprintTimer -= Time.deltaTime;

            if (sprintTimer <= 0f)
            {
                EndSprint();
            }
        }

        if (isCoolingDown)
        {
            cooldownTimer += Time.deltaTime;

            if (cooldownTimer >= sprintCooldown)
            {
                cooldownTimer = sprintCooldown;
                isCoolingDown = false;
            }

            UpdateAPText();
        }
        else if (!isSprinting)
        {
            UpdateAPText();
        }
    }

    void FixedUpdate()
    {
        Vector2 move = new Vector2(horizontal, vertical);

        if (move.magnitude > 1f)
        {
            move = move.normalized;
        }

        rb.velocity = move * currentSpeed;
    }

    void StartSprint()
    {
        isSprinting = true;
        sprintTimer = sprintDuration;
        currentSpeed = sprintSpeed;

        UpdateAPText();
    }

    void EndSprint()
    {
        isSprinting = false;
        currentSpeed = normalSpeed;

        isCoolingDown = true;
        cooldownTimer = 0f;

        UpdateAPText();
    }

    void UpdateAPText()
    {
        if (apText == null) return;

        int maxAP = Mathf.RoundToInt(sprintCooldown);

        if (isSprinting)
        {
            apText.text = $"AP:  0/{maxAP}";
            return;
        }

        if (isCoolingDown)
        {
            int currentAP = Mathf.FloorToInt(cooldownTimer);

            if (currentAP > maxAP)
            {
                currentAP = maxAP;
            }

            apText.text = $"AP:  {currentAP}/{maxAP}";
        }
        else
        {
            apText.text = $"AP:  {maxAP}/{maxAP}";
        }
    }

    void Flip()
    {
        facingDirection *= -1;
        transform.localScale = new Vector3(
            transform.localScale.x * -1,
            transform.localScale.y,
            transform.localScale.z
        );
    }
}

到这里我们完成了基础的移动操作。

2.1.1.2 血量设计

我们这里第二部分完成一个比较简单的部分,血量设计。我觉得数字 10 挺好的,血量 100,理解起来成本有点高。10 是最小的两位数。如果我们的血量数字跌倒了一位数,会让别人感觉自己很脆弱,比较危险,所以最好从 10 开始,不能比这个数字更小了,也可以将这个数字翻倍变为 20。我觉得这两个数字都可以。就比如《我的世界》中玩家就是 10 颗红心,20点生命值进行游戏。

我们只需要注意以下几点:

  1. 生命值不能为负数,变为负数的时候需要将其变为 0。
  2. 生命为 0 及以下的时候角色阵亡,需要进入对应的操作。
  3. 我们的关卡有 2 关。我们需要决定是否保存当前血量进入下一关还是直接满血开启下一关。我们这里会自动重置生命值,如果你选择保存当前生命值记得用一个变量存储然后带到下一关。

相关的代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PlayerHealth : MonoBehaviour
{
    public int currentHealth = 10;
    public int maxHealth = 10;

    public Text healthText;
    public Animator healthTextAnim;

    public GameManager gameManager;

    private bool isDead = false;

    private void Start()
    {
        UpdateHealthUI();
    }

    public void ChangeHealth(int amount)
    {
        if (isDead) return;

        currentHealth += amount;

        if (currentHealth > maxHealth)
        {
            currentHealth = maxHealth;
        }
        else if (currentHealth <= 0)
        {
            currentHealth = 0;
        }

        if (healthTextAnim != null)
        {
            healthTextAnim.Play("TextUpdate");
        }

        UpdateHealthUI();

        if (currentHealth <= 0)
        {
            Die();
        }
    }

    private void UpdateHealthUI()
    {
        if (healthText != null)
        {
            healthText.text = "HP: " + currentHealth + "/" + maxHealth;
        }
    }
    
    public void RestoreFullHealth()
    {
        currentHealth = maxHealth;

        if (healthTextAnim != null)
        {
            healthTextAnim.Play("TextUpdate");
        }

        UpdateHealthUI();
    }

    private void Die()
    {
        isDead = true;

        if (gameManager != null)
        {
            gameManager.GameOver();
        }

        gameObject.SetActive(false);
    }
    
    public void ResetHealth()
    {
        currentHealth = maxHealth;
        isDead = false;
        gameObject.SetActive(true);
        UpdateHealthUI();
    }
}

相关的 UI 部分以及死亡失败等处理放到后面系统部分再进行阐述。

2.1.1.3 攻击设计

攻击这部分该怎么设计呢,我觉得还是以美术为主,如果我们设置成近战攻击就需要搭配对应的动画,这样的话的难度过大。

再考虑到卡比这个角色的特点,大部分情况下是将东西吸入,然后吐出星星以攻击敌人。我打算也这么处理,然后将敌人的进攻设计成接触就会造成伤害,这样对美术的要求就比较低,方便我们制作。但是这么操作还需要思考是否支持屏幕上出现多个子弹,如果出现多个子弹对象该怎么处理,因此我们限制只会有一个子弹对象,而且和前面冲刺一样做了冷却时间的设计。但是我们没法完全照搬一样的设计过来,因为实测发现这样游戏会变得比较难(玩家遇到敌人无法立刻消灭掉它,而是需要稍微迂回超过冷却时间才行,这样游戏难度较大),所以我将这里的条件设计成了子弹消失并且冷却时间过 1s,这样做当然也有缺点,因为攻击之间的间隔变得不固定了,因为有的子弹可能飞行时间超过了 1s,当然只要速度够快,让子弹几乎都能在 1s 内消失就能避免这个问题,所以我们可以把子弹速度设计大一点,但是不能太快,这样用户就看不到子弹了,因为它也是一个对象,我们要让玩家有足够的信息让玩家知道自己怎么在攻击。

子弹也是一个对象,它有体积,我们需要设置其的速度,移动方式。

首先我们要考虑几个问题:

  1. 子弹怎么判断碰到了敌人?
  2. 子弹怎么判断碰到了障碍物?

为什么单独把这两个问题拉出来,因为这里两者并不是一样的情况。敌人是一种对象,而障碍物是地图的一部分。我们作业要求障碍物要分为可破坏的与不可破坏的,当然你也可以单独做成对象进行处理,但我这里将其作为地图的一部分,因为单独作为对象处理我手头的美术资源不太方便解决,但是如果美术资源丰富的情况下,这样做的话写代码就会方便。障碍物这样的地图设计放在了 2.2节阐述。

我们先说敌人怎么处理,这里比较简单,就是简单的碰撞体检测,这里子弹记得用 isTrigger,不然子弹碰到敌人会造成一个物理的力,改变敌人的速度,这不是我们想要发生的事情。

对于障碍物,我们主要是要判断其到底是碰到了哪一块障碍物,然后从而进行对应的操作,因为这里并不是直接坐标关系,因为我的地图其实是一个 23x23 的网格,里面的障碍物是在这些网格内的,因此有时候碰撞点是在一些障碍物交界处等情况,因此需要去判断到底是哪一块格子上的障碍物碰到了子弹,这里我采用将这附近的网格都拿出来计算距离,以判断。

那么现在就是考虑怎么判定碰撞到了什么,由于这里涉及多个实体,我们一般使用 Tag 去进行判断,我们将敌人对象都放在敌人 Tag下,将最外围的障碍物单独用一个 Tag,我们还可以将我们的角色单独放一个标签,以防止角色追上子弹(但应该不可能,这么做是为了潜在的bug)。

同意为了防止子弹一直飞行存在,这里还设置了子弹存在的时间,当超过这个上限子弹就会自动消失,以防止之前的子弹, 这个时间被设置为了 3 s,但应该几乎不会触发。

我们再用一个 bool 状态变量 来判断子弹当前是不是处于飞行状态,从而完成我们前面所说的开火设置。

我们还要决定子弹怎么移动,其实就是按照鼠标位置和卡比的位置计算出对应的方向然后沿着这个方向飞行就好。

我们现在还要考虑具体的数值,这可以以我们的速度作为参考,我们角色移动速度是 5,冲刺速度是 10,所以我们子弹速度最好比这个还快,例如 15,这样的话就算子弹飞最远的斜角也不要 2s。而如果设置成 20,用户很难看清,这里也可以多次尝试从而选择合适的值。但这里为了强调视觉效果,使用的是 8。

我们前文就提到了这里要有足够的提示让玩家知道自己在怎么攻击,我们除了有这种视觉上的反馈还可以有听觉上的反馈,比如子弹发射的声音以及命中的声音。

这就是有关攻击的全部设计,所以关于子弹对象的代码如下所示。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class PlayerBullet : MonoBehaviour
{
    [Header("Bullet Settings")]
    public float speed = 15f;
    public float maxLifeTime = 3f;
    public int damage = 2;

    [Header("Tilemap Settings")]
    public Tilemap destructibleTilemap;

    [Header("Sound Effect")]
    public AudioClip hitClip;

    private Rigidbody2D rb;
    private float lifeTimer;

    public bool IsFlying { get; private set; }

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        if (!IsFlying) return;

        lifeTimer += Time.deltaTime;

        if (lifeTimer >= maxLifeTime)
        {
            HideBullet();
        }
    }

    public void Fire(Vector2 startPosition, Vector2 direction)
    {
        transform.position = startPosition;

        if (direction != Vector2.zero)
        {
            transform.right = direction;
        }

        gameObject.SetActive(true);
        IsFlying = true;
        lifeTimer = 0f;

        if (rb != null)
        {
            rb.velocity = direction.normalized * speed;
        }
    }

    private void HideBullet()
    {
        IsFlying = false;
        lifeTimer = 0f;

        if (rb != null)
        {
            rb.velocity = Vector2.zero;
        }

        gameObject.SetActive(false);
    }

    private void PlayHitSound()
    {
        if (hitClip != null && AudioManager.Instance != null)
        {
            AudioManager.Instance.PlaySFX(hitClip);
        }
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!IsFlying) return;
        if (other.CompareTag("Player")) return;
        
        if (other.CompareTag("Enemy"))
        {
            EnemyHealth enemyHealth = other.GetComponent<EnemyHealth>();
            if (enemyHealth != null)
            {
                enemyHealth.ChangeHealth(-damage);
            }
            PlayHitSound();
            HideBullet();
            return;
        }
        
        Tilemap hitTilemap = other.GetComponent<Tilemap>();
        if (hitTilemap == null)
        {
            hitTilemap = other.GetComponentInParent<Tilemap>();
        }

        if (hitTilemap != null && hitTilemap == destructibleTilemap)
        {
            Vector2 hitPoint = other.ClosestPoint(transform.position);
            Vector3Int targetCell = FindNearestOccupiedCell(hitPoint);

            if (targetCell.x != int.MinValue)
            {
                destructibleTilemap.SetTile(targetCell, null);
            }
            PlayHitSound();
            HideBullet();
            return;
        }
        
        if (other.CompareTag("AirWall") || other.CompareTag("IndestructibleWall"))
        {
            PlayHitSound();
            HideBullet();
            return;
        }
    }

    private Vector3Int FindNearestOccupiedCell(Vector2 hitPoint)
    {
        Vector3Int centerCell = destructibleTilemap.WorldToCell(hitPoint);

        float minDistance = float.MaxValue;
        Vector3Int bestCell = new Vector3Int(int.MinValue, int.MinValue, 0);

        for (int x = centerCell.x - 1; x <= centerCell.x + 1; x++)
        {
            for (int y = centerCell.y - 1; y <= centerCell.y + 1; y++)
            {
                Vector3Int checkCell = new Vector3Int(x, y, 0);

                if (!destructibleTilemap.HasTile(checkCell))
                    continue;

                Vector3 cellCenter = destructibleTilemap.GetCellCenterWorld(checkCell);
                float distance = Vector2.Distance(hitPoint, cellCenter);

                if (distance < minDistance)
                {
                    minDistance = distance;
                    bestCell = checkCell;
                }
            }
        }

        return bestCell;
    }
    
    public void DoubleDamage()
    {
        damage *= 2;
    }
}

这里包含有关子弹的方法(子弹对象碰撞情况、伤害情况、怎么消失),我们将有关玩家的操作部分(是否按鼠标,开火cd情况)单独作为另一个脚本,这样就符合我们软件工程的单一职责,也方便我们后续复用或者扩展。

操作部分代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PlayerShoot : MonoBehaviour
{
    [Header("References")]
    public PlayerBullet playerBullet;
    public Transform firePoint;
    public Text cdText;

    [Header("Shoot Settings")]
    public float reloadTime = 2f;

    [Header("Sound Effect")]
    public AudioClip shootClip;
    
    private float cooldownTimer;

    private void Start()
    {
        cooldownTimer = reloadTime;

        if (playerBullet != null)
        {
            playerBullet.gameObject.SetActive(false);
        }

        UpdateCDText();
    }

    private void Update()
    {
        if (cooldownTimer < reloadTime)
        {
            cooldownTimer += Time.deltaTime;

            if (cooldownTimer > reloadTime)
            {
                cooldownTimer = reloadTime;
            }

            UpdateCDText();
        }
        
        if (Input.GetMouseButtonDown(0))
        {
            TryShoot();
        }
    }

    private void TryShoot()
    {
        if (playerBullet == null || firePoint == null) return;
        
        if (cooldownTimer < reloadTime) return;
        
        if (playerBullet.IsFlying) return;
        
        Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        mouseWorldPos.z = 0f;

        Vector2 shootDirection = mouseWorldPos - firePoint.position;

        playerBullet.Fire(firePoint.position, shootDirection);

        cooldownTimer = 0f;
        UpdateCDText();

        if (shootClip != null && AudioManager.Instance != null)
        {
            AudioManager.Instance.PlaySFX(shootClip);
        }
    }

    private void UpdateCDText()
    {
        if (cdText == null) return;

        int current = Mathf.FloorToInt(cooldownTimer);

        if (cooldownTimer >= reloadTime)
        {
            current = Mathf.RoundToInt(reloadTime);
        }

        cdText.text = $"CD:  {current}/{Mathf.RoundToInt(reloadTime)}";
    }
}

这就是有关玩家操作的角色卡比的全部设计,我们接下来介绍敌人的设计,这部分其实与这里的玩家角色相似,比如移动方式的处理,以及血量的处理,甚至美术资源它们也有相似的部分可以复用。

2.1.2 敌人设计

我们敌人有 2 种,一种普通的敌人,一种精英的敌人(也就是普通的升级版),我不打算让他们有太多区别,我只将精英的敌人设计了不同的外观与更高的数值。当然这里我本来打算设计的更复杂,比如让精英怪有远程攻击的手段,但是这样牵扯到的数值设计就会更复杂,应该将敌人的远程攻击的飞行速度设计为玩家用普通速度难以躲开用冲刺比较容易躲开比较好,当然这样也会牵扯到更多的子弹碰撞情况,这当然可以用 Tag 来解决。包括这里牵扯到可能存在多个对象同时发射子弹瞄准卡比,这里这些对象都是对应的精英怪创建就不用担心同一对象被反复使用的问题了。我没有这样设计主要还是因为美术资源等问题。如果让敌人的子弹也是一个星星就感觉有点怪,感觉很难找到一个较为简单然后又不大的东西作为子弹。当然你们有其他的点子也可以自己发挥,我这里的处理都比较基础简单。

2.1.2.1 移动设计

关于敌人的移动设计这里建议参考著名的游戏《吃豆人》里的游戏设计,这一点在课程中的 AI 系统介绍过。我们要是直接将所有敌人都使用完全相同的追踪逻辑,玩家很快会预测它们的行为,游戏就会变得单调。

我这里使用的是有限状态机,我定义了三种状态,待机状态、巡逻状态、追击状态。

首先追击状态很好理解,敌人检测到周围有玩家出现就会开始追击玩家,这个动作很好理解,但是我们可以将一些细节优化一下。我们可以有一个视野,即敌人看到你才会追击,也就是如果玩家在敌人身后,应该只有足够近,敌人才会发现你而不像玩家在敌人身前那样,敌人可以更早发现你,所以我这里使用了一个圆形碰撞体但是圆心并非在敌人的位置处,如下图所示。

记得将这里勾上 isTrigger,否则这个圈会像我们现实世界一样有实体,这只是一个探测圈,所以不应该影响周围的物理情况。

当然敌人不会一直追逐你,想象一下敌人追着追着看不到卡比了,那么敌人还应该一直追吗?当然不应该,否则敌人似乎有些"全知全能"了,这会让玩家觉得不公平以及不真实。所以我们需要一个判定,当敌人在追击状态时如果离玩家太远就将视为找不到玩家了,从而进入巡逻状态,在周围尝试找一找玩家。

当然平时也可以是巡逻,就像士兵巡逻一样,由于我们地图上同时显示 10 名敌人,如果有多名敌人都在巡逻的话,玩家就会发现他们的移动一致就会觉得一些诡异,这一点会在我们这种有上帝视角的2D俯视角游戏中就会更加明显。因此我们可以让他们每个人巡逻的速度不一样,我们让其的巡逻速度是随机生成的,这样我们就可以发现他们是有差异的,所以会觉得更加真实。

但我们这个游戏的地图是有障碍物的,而我们这里的追击直接采用的是以敌人和玩家的位置计算方向的方式进行操作,这样他们就会追上障碍物,这一点就比较麻烦了,我们的解决方案可以是写一个更高级的寻路算法去解决,但这样比较复杂。我这里做的并不是最好的解决方案,因为这部分其实可以按设定解决,那就是瓦豆鲁迪(敌人)比较笨,所以经常就会撞墙,但是如果一直让它们卡墙也不好,所以这里我加入了一个空闲状态。它们如果在追击状态或者巡逻状态时候卡在墙上了就会进入空闲状态,这可以理解为瓦豆鲁迪(敌人)发现自己过不去发呆一会,然后过一会就会回到巡逻状态。这就是我设计的由三个状态组成的移动系统。

因此这里需要定义一个枚举的数据类型,因为我们这里需要 3 个值用于表示待机状态、巡逻状态、追击状态。

还有一点需要注意的是巡逻不能是一直以一个方向的,这样也不是真正意义上的巡逻,所以这里敌人本来就是会在一个固定范围内移动,到达边界自动调头。或者可能是我们刚刚提到的那种障碍物情况,还没到达边界但是碰到了障碍物,这里我们需要写额外的脚本用于检测前方是否有障碍物,这里我们让它沿着自己的正方形发射一条射线,如果检测到了障碍物那就执行掉头。同样我们还可能因为敌人刚好撞在了一起,如果我们让他们真的撞在一起那就会不自然,所以还需要检测是否发生碰撞,如果发生那也掉头。

我们现在思考一下移动数值问题。我们前面也提到了关于如果敌人是远程攻击的移动数值,那么敌人现在是近战的也可以按照我说的这样设计,普通敌人我们玩家可以直接逃脱开,但是精英敌人需要用冲刺才能跑开。我们还需要注意的是不能让敌人的速度太慢,这样就不会让玩家感到危机感,所以这个速度得稍微比玩家的速度低一点,而精英敌人的速度也不能很快,这样会让玩家感到游戏过难,而突然的高速也会惊吓到玩家。当然这种数值上的设计都离不开我们的测试,不能一直是理论,所以我本来设计的普通敌人移动速度是 4,精英敌人移动速度是 6。但我发现这样精英敌人移动的有点太快了,所以我还是改为了 5。

而巡逻速度可以比追击速度慢一点,所以最高的巡逻速度比追击速度小 1,差不多,然后最低的巡逻速度是最高的巡逻速度的一半,然后巡逻距离设置的短一点,我这里是以巡逻的起点为中心,两边各一段巡逻距离,所以这个距离我选择了 3,这样的话比如普通敌人巡逻一趟的时间大概是1.5s,就会往另一个方向巡逻,这样不会太短,而太长的话很容易遇到障碍物,就会显得有些奇怪,当然由于我们前面有对障碍物有规避机制,这个问题也没特别严重。对于这里规避障碍物使用的射线距离我们可以稍微给的大一点,不要选择 0.01 这样的数字,这样会让它们撞到墙上才转弯,可以是 0.2 或者更大一点的。

相关的代码如下。

csharp 复制代码
using UnityEngine;

public class EnemyMovement : MonoBehaviour
{
    [Header("Move")]
    public float minPatrolSpeed = 1.5f;
    public float maxPatrolSpeed = 3f;
    public float chaseSpeed = 4f;
    
    private float patrolSpeed;
    private float blockedByEnemyTimer = 0f;
    public float blockedTurnTime = 1f;

    [Header("Patrol")]
    public float patrolDistance = 3f;
    public float patrolWaitTime = 1f;

    [Header("Detection")]
    public Transform wallCheckPoint;
    public float wallCheckDistance = 0.2f;
    public LayerMask obstacleLayer;
    public LayerMask enemyLayer;

    [Header("Shoot")]
    public EnemyBullet[] bulletPool;
    public Transform firePoint;
    public float shootDelay = 2f;
    public float shootCooldown = 6f;
    
    [Header("Sound Effect")]
    public AudioClip shootClip;
    
    [Header("Stuck Detection")]
    public float noMoveCheckTime = 2f;
    public float moveThreshold = 0.05f;
    
    [Header("Idle")]
    public float idleDuration = 1.5f;

    private float idleTimer = 0f;

    private Vector3 lastPosition;
    private float noMoveTimer = 0f;

    private int facingDirection = 1;
    private EnemyState enemyState;

    private Rigidbody2D rb;
    private Transform player;
    private Animator anim;

    private bool playerInRange = false;
    private float stayTimer = 0f;
    private float shootCooldownTimer = 0f;

    private float patrolCenterX;
    private int patrolDirection = 1;
    private float patrolWaitTimer = 0f;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
        
        if (Random.value < 0.5f)
        {
            facingDirection = -1;
        }
        else
        {
            facingDirection = 1;
        }
        
        Vector3 scale = transform.localScale;
        scale.x = Mathf.Abs(scale.x) * facingDirection;
        transform.localScale = scale;

        patrolCenterX = transform.position.x;
        patrolDirection = facingDirection;
        patrolSpeed = Random.Range(minPatrolSpeed, maxPatrolSpeed);
        
        lastPosition = transform.position;
        
        ChangeState(EnemyState.Patrolling);
    }

    void Update()
    {
        if (shootCooldownTimer > 0f)
        {
            shootCooldownTimer -= Time.deltaTime;
        }
        
        if (playerInRange && player != null)
        {
            idleTimer = 0f;
            ChasePlayer();
        }
        else if (enemyState == EnemyState.Idle)
        {
            HandleIdle();
        }
        else
        {
            Patrol();
        }

        CheckIfStuckAndGoIdle();
    }
    
    void CheckIfStuckAndGoIdle()
    {
        float movedDistance = Vector2.Distance(transform.position, lastPosition);

        if (enemyState == EnemyState.Patrolling || enemyState == EnemyState.Chasing)
        {
            if (movedDistance <= moveThreshold)
            {
                noMoveTimer += Time.deltaTime;

                if (noMoveTimer >= noMoveCheckTime)
                {
                    rb.velocity = Vector2.zero;
                    noMoveTimer = 0f;
                    idleTimer = 0f;
                    ChangeState(EnemyState.Idle);
                }
            }
            else
            {
                noMoveTimer = 0f;
            }
        }
        else
        {
            noMoveTimer = 0f;
        }

        lastPosition = transform.position;
    }

    void ChasePlayer()
    {
        if (enemyState != EnemyState.Chasing)
        {
            ChangeState(EnemyState.Chasing);
        }

        if ((player.position.x > transform.position.x && facingDirection == -1) ||
            (player.position.x < transform.position.x && facingDirection == 1))
        {
            Flip();
        }

        Vector2 direction = (player.position - transform.position).normalized;
        rb.velocity = direction * chaseSpeed;

        stayTimer += Time.deltaTime;

        if (stayTimer >= shootDelay && shootCooldownTimer <= 0f)
        {
            ShootAtPlayer();
            shootCooldownTimer = shootCooldown;
        }
    }

    void Patrol()
    {
        if (enemyState == EnemyState.Idle)
        {
            rb.velocity = Vector2.zero;
            return;
        }

        if (enemyState != EnemyState.Patrolling)
        {
            ChangeState(EnemyState.Patrolling);
        }

        if (patrolWaitTimer > 0f)
        {
            patrolWaitTimer -= Time.deltaTime;
            rb.velocity = Vector2.zero;
            return;
        }

        float leftLimit = patrolCenterX - patrolDistance;
        float rightLimit = patrolCenterX + patrolDistance;

        bool hitWall = IsWallAhead();
        bool hitEnemy = IsEnemyAhead();

        if (patrolDirection == 1 && transform.position.x >= rightLimit)
        {
            TurnAround(-1);
            return;
        }
        else if (patrolDirection == -1 && transform.position.x <= leftLimit)
        {
            TurnAround(1);
            return;
        }
        else if (hitWall)
        {
            TurnAround(-patrolDirection);
            return;
        }
        else if (hitEnemy)
        {
            rb.velocity = Vector2.zero;
            blockedByEnemyTimer += Time.deltaTime;

            if (blockedByEnemyTimer >= blockedTurnTime)
            {
                TurnAround(-patrolDirection);
                blockedByEnemyTimer = 0f;
            }

            return;
        }

        blockedByEnemyTimer = 0f;
        rb.velocity = new Vector2(patrolDirection * patrolSpeed, rb.velocity.y);
    }
    
    void HandleIdle()
    {
        rb.velocity = Vector2.zero;
        idleTimer += Time.deltaTime;

        if (idleTimer >= idleDuration)
        {
            idleTimer = 0f;
            
            patrolCenterX = transform.position.x;
            patrolDirection = facingDirection;
            patrolWaitTimer = 0f;
            blockedByEnemyTimer = 0f;

            ChangeState(EnemyState.Patrolling);
        }
    }

    void TurnAround(int newDirection)
    {
        patrolDirection = newDirection;
        patrolWaitTimer = patrolWaitTime;
        rb.velocity = Vector2.zero;

        if (facingDirection != patrolDirection)
        {
            Flip();
        }
    }

    bool IsWallAhead()
    {
        if (wallCheckPoint == null) return false;

        RaycastHit2D hit = Physics2D.Raycast(
            wallCheckPoint.position,
            Vector2.right * facingDirection,
            wallCheckDistance,
            obstacleLayer
        );

        return hit.collider != null;
    }

    bool IsEnemyAhead()
    {
        if (wallCheckPoint == null) return false;

        RaycastHit2D hit = Physics2D.Raycast(
            wallCheckPoint.position,
            Vector2.right * facingDirection,
            wallCheckDistance,
            enemyLayer
        );

        if (hit.collider == null)
            return false;

        if (hit.collider.transform.root == transform.root)
            return false;

        return true;
    }

    void Flip()
    {
        facingDirection *= -1;

        transform.localScale = new Vector3(
            transform.localScale.x * -1f,
            transform.localScale.y,
            transform.localScale.z
        );
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            player = other.transform;
            playerInRange = true;
            stayTimer = 0f;
            ChangeState(EnemyState.Chasing);
        }
    }

    private void OnTriggerExit2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            playerInRange = false;
            player = null;
            stayTimer = 0f;
            rb.velocity = Vector2.zero;
            
            patrolCenterX = transform.position.x;
            patrolDirection = facingDirection;
            patrolWaitTimer = 0f;
            blockedByEnemyTimer = 0f;
            
            ChangeState(EnemyState.Patrolling);
        }
    }

    void ShootAtPlayer()
    {
        if (firePoint == null || player == null) return;

        EnemyBullet bullet = GetAvailableBullet();
        if (bullet == null) return;

        Vector2 shootDirection = (player.position - firePoint.position).normalized;
        bullet.Fire(firePoint.position, shootDirection);
        
        if (shootClip != null && AudioManager.Instance != null)
        {
            AudioManager.Instance.PlaySFX(shootClip);
        }
    }

    EnemyBullet GetAvailableBullet()
    {
        for (int i = 0; i < bulletPool.Length; i++)
        {
            if (!bulletPool[i].gameObject.activeInHierarchy)
            {
                return bulletPool[i];
            }
        }
        return null;
    }

    void ChangeState(EnemyState newState)
    {
        if (enemyState == EnemyState.Idle)
        {
            anim.SetBool("isIdle", false);
        }
        else if (enemyState == EnemyState.Patrolling || enemyState == EnemyState.Chasing)
        {
            anim.SetBool("isMoving", false);
        }

        enemyState = newState;

        if (enemyState == EnemyState.Idle)
        {
            anim.SetBool("isIdle", true);
        }
        else if (enemyState == EnemyState.Patrolling || enemyState == EnemyState.Chasing)
        {
            anim.SetBool("isMoving", true);
        }
    }

    private void OnDrawGizmosSelected()
    {
        if (wallCheckPoint != null)
        {
            Gizmos.color = Color.red;

            Vector3 dir = Vector3.right;
            if (Application.isPlaying)
            {
                dir = Vector3.right * facingDirection;
            }

            Gizmos.DrawLine(
                wallCheckPoint.position,
                wallCheckPoint.position + dir * wallCheckDistance
            );
        }
    }
}

public enum EnemyState
{
    Idle,
    Patrolling,
    Chasing,
}

这里的代码其实还有我之前用于测试的部分代码,以及我没有使用的远程攻击的废案,并不是很精简。

我这里对于检测障碍物使用的不是前面提到的 Tag 方法,而是将这些东西放在了对应的图层(Layer)上,然后进行检测。

这两者大部分情况比较类似,我们一般就按照其的意思来进行区分使用, Tag 用于区分对象是谁,而 Layer 用于区分我们是否让其参与特定的检测/物理碰撞。所以这里当然也可以将敌人用 Tag 进行区分,因为它们是敌人,但这里是用于检测是否碰到东西,所以也可以使用 Layer,我们这里使用 Tag 的还有一个原因是可以进行代码的复用,比较方便。

2.1.2.2 血量设计

这与前面玩家血量同理,只是不需要血量的 UI,当然也可以做,我只是觉得这里将敌人头上顶着血量 UI 会有些丑,可以考虑怪物不是满血的时候才展示血条。

还需要注意的是这里敌人在血量为 0 的时候的对应处理和玩家不一样,我们应该让敌人消失而不是直接游戏结束之类的。

这一部分我们主要思考的是敌人血量的数值设计。这其实跟我们前一部分玩家的攻击数值是有关的,所以上一部分我也没有提到这一点。其实我们这里主要需要思考的是敌人应该被几枪击败,我认为首先这里不应该是 1 次,这样这个游戏就太简单了,所以最起码敌人能抗住 1 次攻击,但也不能直接是 10 次,这样大的数字,因为这个地图太小,没有那么大的空间去让玩家能安全攻击那么多次。所以我将普通敌人所需要的射击次数是 2 次,那么精英敌人就可以是 3 次或者 4 次。正如我前文所说,我们需要测试,测试感受下来 3 次更好, 4 次有点难了,每一只都可能让玩家受伤,那么游戏难度就有点过高了。

所以我们攻击力玩家的攻击伤害可以设计为 1,那么敌人的血量就是 2 和 3。 当然这里你可能会想问,按照我们刚刚的设计只要抗住 2 次和 3 次攻击就像,那么为什么血量不选择 1.1 和 2.1 呢。我们本来就可以用 int 去存储这样的数值为什么要使用一个浮点型去计算呢,我们目前的游戏很小,但是如果我们未来开发一个大型游戏呢?很明显这不是一个好的习惯。这么做我们以后也很难理解,1、2、3非常直观,这里使用小数很明显就会让这里变得没必要的复杂。

相关的代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyHealth : MonoBehaviour
{
    public int currentHealth = 4;
    public int maxHealth = 4;
    public GameManager gameManager;
    public EnemySpawnerPlus spawner;

    public void SetSpawner(EnemySpawnerPlus enemySpawner)
    {
        spawner = enemySpawner;
    }

    public void ChangeHealth(int amount)
    {
        currentHealth += amount;

        if (currentHealth > maxHealth)
        {
            currentHealth = maxHealth;
        }
        else if (currentHealth <= 0)
        {
            currentHealth = 0;
            
            if (ScoreTimerManager.Instance != null)
            {
                ScoreTimerManager.Instance.ChangeScore(maxHealth * 50);
            }
            
            if (spawner != null)
            {
                spawner.OnEnemyRemoved();
            }

            if (gameManager != null)
            {
                gameManager.EnemyDefeated();
            }
            
            gameObject.SetActive(false);
        }
    }
}
2.1.2.3 攻击设计

我们前面已经对敌人的攻击方式进行了设计,这里也是注重强调数值方面的考量。

但其实我这里有一个缺点,那就是敌人作为近战碰到玩家就会造成伤害,但是没有足够的信息提示,一般来说可以让玩家变成透明或者闪烁的样子表示被攻击,这里没有做。

而且需要注意的是敌人如果一直贴着玩家,玩家不应该反复受到伤害,否则玩家就会突然暴毙。这一般叫做给玩家无敌帧,这与上面提到的是一个组合,我这里的处理是只有第一次碰撞会造成伤害,所以这时候不操作的话,敌人贴在玩家身上并不会造成,这并不是一个好的解决方案。这些都是可以改进的点。可以将这里攻击的判定加一个冷却判定,每隔几秒要是碰到玩家就会造成伤害。

攻击数值当然也可以用刚刚的方式考虑,而且我们有了一个基础数值作为参考,那就是我们玩家的生命值是 10,我们大概允许普通敌人打我们几下呢? 10 下肯定太多了,伤害是 1 有点显得不痛不痒,2 就很好了,因为再大的话,我们还有精英敌人的存在,这样我们的玩家就像敌人一样 2 下或者 3 下就死就会体验很差了。我们要让玩家感觉他与众不同。所以现在我们玩家能抗住普通敌人 5 下攻击。

那么精英敌人的攻击选择 3 还是 4 或者更大呢,首先肯定不考虑更大的值了,这样就回到我们刚刚提到的攻击力太高显得我们玩家很脆弱。我们可以考虑更复杂的情况,如果精英敌人和普通敌人同时发起攻击,或者玩家本来就不是满血的情况呢,那么如果选择 4 的话,那样就可能要少抗 1 - 2 下的攻击,或者我们进行一些测试。所以最后我选择了 3。

相关的代码如下。

csharp 复制代码
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyCombat : MonoBehaviour
{
    public int damage = 1;

    private void OnCollisionEnter2D(Collision2D other)
    {
        if (other.gameObject.tag == "Player")
        {
            other.gameObject.GetComponent<PlayerHealth>().ChangeHealth(-damage);
        }
    }
}

这里就比玩家操作的角色简单多了,因为没有使用远程攻击。

2.1.2.4 生成机制设计

我们现在要思考怎么在地图上随机生成。

我们这里需要思考以下几个问题:

  1. 我们不能让敌人出生的离玩家太近,所以我们规定的生成区域应该排除玩家出生点附近的区域。
  2. 敌人也不能出生在障碍物里,这样就直接卡住了,障碍物存在的区域应该排除。
  3. 敌人也不能出生在敌人已经存在的区域,这样两只就重叠了。

所以我想的是直接在随机生成的时候确定要生成的位置,而不是将随机生成的程序运行我们要生成的数量次数,这样我们就需要多一个关于前面第三个问题的判断。我们直接将可以生成位置的坐标记录下来,然后在这些区域选择就好,这样就解决第三个问题了。

我们还需要注意的是这里生成使用的是用 Prefab(预设体)和 Instantiate() 方法,而不能直接是用单一 Prefab(预设体)直接进行操作,因为它是一个固定对象,我们这里需要 10 个对象,我们需要创建新的对象,它们都是独立的对象和个体,所以需要 Instantiate 方法,你可以理解为制造了一个对应 Prefab(预设体)的复制。那为什么不使用 new 呢?因为我们每个对象都需要很多的组件,比如我们刚刚写的那些有关移动之类的脚本,甚至 new 出来的对象没有生命周期(Start()、Update()等方法),所以它们压根不会更新。

我们只会在数据对象的时候使用 new给关键字。对于有实体的对象我们一般使用 Instantiate() 方法。

相关的代码如下。

csharp 复制代码
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class EnemySpawner : MonoBehaviour
{
    [Header("References")]
    public GameObject enemyPrefab;
    public Transform player;
    public Tilemap groundTilemap;
    public Tilemap destructibleTilemap;
    public Tilemap indestructibleTilemap;

    [Header("Spawn Settings")]
    public int enemyCount = 10;
    public float minDistanceFromPlayer = 5f;
    
    public int minX = 10;
    public int maxX = 10;
    public int minY = -11;
    public int maxY = 9;

    void Start()
    {
        SpawnEnemies();
    }

    public void SpawnEnemies()
    {
        if (enemyPrefab == null || player == null || groundTilemap == null ||
            destructibleTilemap == null || indestructibleTilemap == null)
        {
            return;
        }

        List<Vector3Int> validCells = new List<Vector3Int>();
        
        for (int x = minX; x <= maxX; x++)
        {
            for (int y = minY; y <= maxY; y++)
            {
                Vector3Int cellPos = new Vector3Int(x, y, 0);
                
                if (destructibleTilemap.HasTile(cellPos))
                    continue;
                
                if (indestructibleTilemap.HasTile(cellPos))
                    continue;
                
                Vector3 worldPos = groundTilemap.GetCellCenterWorld(cellPos);

                if (Vector2.Distance(worldPos, player.position) < minDistanceFromPlayer)
                    continue;

                validCells.Add(cellPos);
            }
        }
        
        if (validCells.Count < enemyCount)
        {
            Debug.LogWarning($"Not enough");
        }

        int spawnAmount = Mathf.Min(enemyCount, validCells.Count);
        
        for (int i = 0; i < spawnAmount; i++)
        {
            int randomIndex = Random.Range(0, validCells.Count);
            Vector3Int chosenCell = validCells[randomIndex];
            validCells.RemoveAt(randomIndex);

            Vector3 spawnWorldPos = groundTilemap.GetCellCenterWorld(chosenCell);

            Instantiate(enemyPrefab, spawnWorldPos, Quaternion.identity);
        }
    }
}

这是第一关的随机敌人生成代码,我们在第二关还要考虑有精英敌人需要生成,而且我想将这个代码扩展一下,让它不是一次性将所有的敌人都生成出来,而是当敌人减少了就考虑重新生成,然后当然不是立马更新,而是等待一定的时间。

我们这里还需要注意敌人的数量是有上限的,我们需要用一个变量去记住已经生成了多少,不要超过上限值。

至于生成什么样的敌人,这里就是用简单的随机去处理了。

这里再生成的时候还需要注意这里得更新可以生成的坐标位置,不能像前面那样固定的了,因为场景会破坏,玩家会移动,敌人也会移动,我这里是固定的,这是一个问题。但其实我现在想了下这里还有缺点,那比如出现子弹的地方也不应该生成,所以还是可以再改进。

相关代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class EnemySpawnerPlus : MonoBehaviour
{
    [Header("References")]
    public GameObject[] enemyPrefabs;   
    public Transform player;
    public Tilemap groundTilemap;
    public Tilemap destructibleTilemap;
    public Tilemap indestructibleTilemap;

    [Header("Spawn Settings")]
    public int maxEnemiesOnField = 10;   
    public int totalEnemyCount = 20;    
    public float minDistanceFromPlayer = 5f;
    public float refillInterval = 2f;  

    [Header("Spawn Range")]
    public int minX = -10;
    public int maxX = 10;
    public int minY = -11;
    public int maxY = 9;

    private List<Vector3Int> validCells = new List<Vector3Int>();

    private int currentAliveEnemies = 0;
    private int totalSpawnedEnemies = 0;

    private bool isRefilling = false;

    void Start()
    {
        BuildValidCellList();
        SpawnInitialEnemies();
    }

    void BuildValidCellList()
    {
        validCells.Clear();

        if (player == null || groundTilemap == null ||
            destructibleTilemap == null || indestructibleTilemap == null)
        {
            Debug.LogWarning("False reference");
            return;
        }

        for (int x = minX; x <= maxX; x++)
        {
            for (int y = minY; y <= maxY; y++)
            {
                Vector3Int cellPos = new Vector3Int(x, y, 0);

                if (destructibleTilemap.HasTile(cellPos))
                    continue;

                if (indestructibleTilemap.HasTile(cellPos))
                    continue;

                Vector3 worldPos = groundTilemap.GetCellCenterWorld(cellPos);

                if (Vector2.Distance(worldPos, player.position) < minDistanceFromPlayer)
                    continue;

                validCells.Add(cellPos);
            }
        }
    }
    
    public void SpawnEnemies()
    {
        BuildValidCellList();
        SpawnInitialEnemies();
    }

    void SpawnInitialEnemies()
    {
        int amount = Mathf.Min(maxEnemiesOnField, totalEnemyCount);

        for (int i = 0; i < amount; i++)
        {
            bool success = SpawnOneEnemy();
            if (!success)
            {
                Debug.LogWarning("Not enough");
                break;
            }
        }
    }

    bool SpawnOneEnemy()
    {
        if (enemyPrefabs == null || enemyPrefabs.Length == 0)
            return false;

        if (totalSpawnedEnemies >= totalEnemyCount)
            return false;

        List<Vector3Int> availableCells = new List<Vector3Int>();

        
        for (int i = 0; i < validCells.Count; i++)
        {
            Vector3 worldPos = groundTilemap.GetCellCenterWorld(validCells[i]);
            
            if (Vector2.Distance(worldPos, player.position) < minDistanceFromPlayer)
                continue;
            
            Collider2D hit = Physics2D.OverlapCircle(worldPos, 0.2f);
            if (hit != null && hit.CompareTag("Enemy"))
                continue;

            availableCells.Add(validCells[i]);
        }

        if (availableCells.Count == 0)
            return false;

        int cellIndex = Random.Range(0, availableCells.Count);
        Vector3Int chosenCell = availableCells[cellIndex];
        Vector3 spawnWorldPos = groundTilemap.GetCellCenterWorld(chosenCell);

        int prefabIndex = Random.Range(0, enemyPrefabs.Length);
        GameObject selectedPrefab = enemyPrefabs[prefabIndex];

        GameObject enemy = Instantiate(selectedPrefab, spawnWorldPos, Quaternion.identity);

        EnemyHealth enemyHealth = enemy.GetComponent<EnemyHealth>();
        if (enemyHealth != null)
        {
            enemyHealth.spawner = this;
        }

        currentAliveEnemies++;
        totalSpawnedEnemies++;

        return true;
    }

    public void OnEnemyRemoved()
    {
        currentAliveEnemies--;

        if (currentAliveEnemies < 0)
            currentAliveEnemies = 0;

        TryStartRefill();
    }

    void TryStartRefill()
    {
        if (isRefilling) return;
        if (totalSpawnedEnemies >= totalEnemyCount) return;
        if (currentAliveEnemies >= maxEnemiesOnField) return;

        StartCoroutine(RefillEnemiesCoroutine());
    }

    IEnumerator RefillEnemiesCoroutine()
    {
        isRefilling = true;

        while (currentAliveEnemies < maxEnemiesOnField &&
               totalSpawnedEnemies < totalEnemyCount)
        {
            yield return new WaitForSeconds(2f);

            bool success = SpawnOneEnemy();
            if (!success)
            {
                break;
            }
        }

        isRefilling = false;
    }
}

这里等待用的是协程,协程跟传统用 Update() 计算相比更加方便,更方便的主要是在多个任务需要等待先后顺序的时候,比如在游戏数据结算的时候就可以用上,所以这个希望大家学会。

但这里的等待时间直接使用了 2s 而不是用一个变量存储这个值,这么做当然不够好,如果用变量,那么我们方便后期直接在 Inspector 里修改,这样才符合软件工程的要求。

这就是有关游戏的核心战斗部分的全部设计。

2.2 地图设计

地图上作业要求了其的大小以及最外围是不可破坏的障碍物,这样可以避免我们加入空气墙以防止角色离开地图,其实这样有固定的墙规定地图的边界会显得游戏更真实自然。

地图要求大小是 23x23 的网格,最外围是不可破坏的障碍物,所以实际可行动的空间是 21x21。如果我们以坐标系去理解的话,最中间是(0,0),然后往每个方向延申 10 个格子构成我们游玩的区域。

因此我们可以怎么设计呢?

为了搭配我们的核心星之卡比,我们的世界应该保持简约可爱,比如一个草地,然后这个草地先铺在最下面有 23x23 那么大。然后在最外围放上一些假山或者石头,它搭配上正方形的碰撞体之类的都可以,尽量是正方形的,这样就刚好是封闭的一个墙,到时候人物撞上去可以刚好卡住,而不会转圈之类的。

当然我们不可能让我们的屏幕都是这个 23x23 的地图,因为我们还要加入一些 UI 展现角色的生命值或者分数等信息,如果我们的屏幕被这个 23x23 的地图填满肯定会导致这些信息遮挡地图,不仅用户无法看清 UI 信息, UI 信息也会遮挡地图英雄玩家体验。刚好我们的屏幕也是长方形,所以我们让地图保持正方形是最好的,然后占据最中间几乎和屏幕的宽度一致,也可以占满,然后两边放上我们刚刚说的这些 UI 信息,所以这里如果 UI 信息下面是黑的,当然也会觉得很丑,因此也可以再加一个背景在最下面。那么我们既然不使用黑色作为背景了,当然上面的 UI 信息虽然是文字信息但是可以不使用我们常规文本使用的黑色,一般来说 UI 信息使用白色会更加清晰,因此这也是为什么一般 UI 信息使用白色,因为别的游戏可能没有对这里的黑色背景进行处理,鉴于我们刚刚将游玩区域地图设计为了绿色的草地加上外围是不可破坏的障碍物,因此这里可以理解成一座海上的孤岛,那么我们黑色的背景就自然可以改成浅蓝色为基调,这样的话搭配白色整体上都是浅色的,不会给玩家一种突兀的感觉,可以让玩家加注意力集中在屏幕的中间,也就是我们的游玩区域地图上。

因此我们完整的地图应该是最下面是一层水,然后水的上一层是草地,草地的再上一层是障碍物(也可以水和草地在同一个图层),那为什么需要两个图层呢,因为我们的障碍物很可能不是正方形的,所以如果障碍物和草地是一个涂层的话,那么就会出现缝隙,如下图所示。

如图所示,我这里演示了将一个障碍物和草地放在了一个图层,中间的障碍物不是正方形就会和草地中间有缝隙,就会让人觉得障碍物并不是在草地上的,这很明显与现实不符合。当然如果你的障碍物下面有对应的地面你当然可以将其放在一个图层。但是绝大部分 Tilemap 的设计应该是两者分离的。而且这张图也解释了为什么我将水和草地分为了两层,因为我的草地不是一个纯方形,而是一个圆角矩形,这样会显得更像是一个真实的海上岛屿,当然这是美术上的细节,我们会在后面的美术部分再详细讨论。

那我们现在地图设计上还剩下一个随机生成的障碍物没有实现,对于随机生成的障碍物要考虑以下几个问题,首先确定障碍物生成的位置,我们当然也可以直接将除了外面障碍物的 21x21 大小的游玩区域全部设定为生成的区域,我们关键要考虑的是第二个问题------不能把路全都封死,因为这样会导致卡关(无法通关),我认为这里还有一个问题,这个问题是关于游戏设计方面的,好的游戏应该要保持随机性的同时尽量保持游戏难度的稳定。对于这一点我们要先去理解为什么游戏需要随机性,随机性是帮助游戏提升可玩性和游玩周期的有效方式,它可以让玩家不会因为关卡的单调而觉得无聊。但是如果完全都是随机很容易导致一个问题,那就是玩家体验的难度是完全随机的,我们应该尽量平衡一下这里的难度,这样可以避免不同玩家因为不同的体验而影响所有玩家对游戏的感觉,同样游戏难度不一也会影响个人玩家的体验,玩家可能会突然连续遇到几次高难度的随机情况而感到挫折。

如果我们抛开第三个问题的话只单纯考虑前两个,我们可以先随机生成一个情况,然后用路径检测,检测玩家是否可以到达某个地方,如果不行,那么就重新随机生成,这是一种解决我们作业的一个基础思路。

我这里想顺带考虑第三个问题。因此我将其设计成了固定+随机的结合障碍物地图设计。

我先设计了一个固定的障碍物模板,如下图所示。

我找了一个木头栅栏作为可破坏的障碍物的 Tilemap,刚好在不同的拐弯处具体的贴图不一样,通过这样固定模板的设计也可以展示不同的情况,从而丰富我们的美术设计。而随机生成的部分可以不这么细节,就是用最单独的木桩就可以,这样随机生成出来的就算在一起但是不连起来也会给用户一种障碍物丰富多样的感觉。

随机部分,我们扫描整个区域,如果这个区域没有障碍物,那么就进行一个判定,如果判定通过就生成障碍物,否则就不生成。通过这样的方式来随机生成。相关代码如下:

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class MapGenerator : MonoBehaviour
{
    [Header("Map Range")]
    public int minCellX = -10;
    public int maxCellX = 10;
    public int minCellY = -11;
    public int maxCellY = 9;

    [Header("Tilemaps")]
    public Tilemap destructibleTilemap;
    public Tilemap indestructibleTilemap;

    [Header("Tiles")]
    public TileBase destructibleTile;
    public TileBase indestructibleTile;

    [Header("Obstacle Chance")]
    [Range(0f, 1f)] public float destructibleChance = 0.25f;
    [Range(0f, 1f)] public float indestructibleChance = 0.15f;

    [Header("Player Safe Area")]
    public Transform playerTransform;
    public int safeRadius = 3;

    private void Start()
    {
        GenerateMapWithoutOverwriting();
    }

    public void GenerateMapWithoutOverwriting()
    {
        Vector3Int playerCell = destructibleTilemap.WorldToCell(playerTransform.position);

        for (int x = minCellX; x <= maxCellX; x++)
        {
            for (int y = minCellY; y <= maxCellY; y++)
            {
                Vector3Int cellPos = new Vector3Int(x, y, 0);
                
                if (Mathf.Abs(x - playerCell.x) <= safeRadius &&
                    Mathf.Abs(y - playerCell.y) <= safeRadius)
                {
                    continue;
                }
                
                if (destructibleTilemap.HasTile(cellPos) || indestructibleTilemap.HasTile(cellPos))
                {
                    continue;
                }

                float randomValue = Random.value;

                if (randomValue < indestructibleChance)
                {
                    indestructibleTilemap.SetTile(cellPos, indestructibleTile);
                }
                else if (randomValue < indestructibleChance + destructibleChance)
                {
                    destructibleTilemap.SetTile(cellPos, destructibleTile);
                }
            }
        }
    }
}

我们可以通过填写判定中的随机生成概率来决定随机生成的多与少,过多当然就有更高的概率我们会遇到死局,当然我们可以提高这里的可破坏障碍物的概率,因为这种障碍物就算把地图堵住也可以通关。我这里的填写数字当然还是会存在死局的情况,但是我经过多次测试才会出现一例, 所以可以暂时忽略不计,当然也可以再调低一点以防止出现意外。

下面的图展现了几次随机生成的结果。

所以整体一上看地图像都是随机生成的,但其实都是以一个固定框架加上随机的障碍物解决的。

2.3 道具设计

道具是作业明确要求的,需要我们在第二关需要加入道具系统。像我们这样的一款游戏可以有什么道具呢,我们当然要尽量加两个道具进去,可以考虑的道具有:医疗箱(回血)、武器包(加攻击的伤害)、鞋子(加速度)。这些都是可以直接修改数值的道具比较简单,当然你也可以加一些额外的消耗类道具,比如血瓶(回血)、炸弹(清空附近障碍)。我想让这里的操作比较简单,因此我这里使用的是前者数值类道具。当然比如后者消耗类道具可以变成自动使用,比如血量耗尽自动回血这样的,或者是 +1 命这样的道具,但是这种道具可能给玩家的反馈不如数值类明显。而且比如我们之前说的,我把这里的障碍物破坏设计为了子弹就可以破坏,因此也不需要炸弹这样的道具。

我设计的两个都是数值类道具,这样比较简单,反馈也比较清晰。一个回血一个加伤害。

这些都是直接修改玩家数值就好,但是需要注意的是这些碰到敌人不会触发,而且贴图应该置于草地之上,可以与障碍物一个图层。因为在生成的时候可以检测到没有障碍物和敌人的地方再生成。

而我们需要碰触到就触发道具的效果并消失,但是没有实体,如果有实体的话就算接触后立即消失,玩家控制的角色也会因为撞上而失去速度产生停顿感。所以这里是 Trigger Collider,我们需要把这里的 Collide 模块的 Is Trigger 勾上,如下图所示。

相应的回血道具代码如下:

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FullHealPickup : MonoBehaviour
{
    public int bonusScore = 2000;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;

        PlayerHealth playerHealth = other.GetComponent<PlayerHealth>();
        if (playerHealth != null)
        {
            playerHealth.RestoreFullHealth();
        }

        if (ScoreTimerManager.Instance != null)
        {
            ScoreTimerManager.Instance.ChangeScore(bonusScore);
        }

        Destroy(gameObject);
    }
}

如果这里碰撞到的不是玩家控制的卡比那就不做任何事,如果是的话那就调用对应的回血和加分函数,加分是因为作业要求排行榜上有分数,因此这里将道具系统也纳入到了分数系统的考量之中。我们会在下一部分中进行分数系统的设计。

另一个增加伤害的代码类似:

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DoubleDamagePickup : MonoBehaviour
{
    public int bonusScore = 2000;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;

        PlayerShoot playerShoot = other.GetComponent<PlayerShoot>();
        if (playerShoot != null && playerShoot.playerBullet != null)
        {
            playerShoot.playerBullet.DoubleDamage();
        }

        if (ScoreTimerManager.Instance != null)
        {
            ScoreTimerManager.Instance.ChangeScore(bonusScore);
        }

        Destroy(gameObject);
    }
}

我们生成道具可以借用前面障碍生成的代码只是加上了对应道具的判定而已。

所以当然存在可能性道具生成在无法到达的地方,但这也是低概率罢了。

因此这个代码只是在原来的代码下改一下就好了,复用代码可以提升我们的开发效率。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class MapGeneratorPlus : MonoBehaviour
{
    [Header("Map Range")]
    public int minCellX = -10;
    public int maxCellX = 10;
    public int minCellY = -11;
    public int maxCellY = 9;

    [Header("Tilemaps")]
    public Tilemap destructibleTilemap;
    public Tilemap indestructibleTilemap;

    [Header("Tiles")]
    public TileBase destructibleTile;
    public TileBase indestructibleTile;

    [Header("Obstacle Chance")]
    [Range(0f, 1f)] public float destructibleChance = 0.25f;
    [Range(0f, 1f)] public float indestructibleChance = 0.15f;

    [Header("Player Safe Area")]
    public Transform playerTransform;
    public int safeRadius = 3;

    [Header("Pickups")]
    public GameObject cakePrefab;
    public GameObject damagePickupPrefab;

    [Range(0f, 1f)] public float cakeChance = 0.05f;
    [Range(0f, 1f)] public float damagePickupChance = 0.05f;

    public float enemyCheckRadius = 0.5f;
    public LayerMask enemyLayer;

    private void Start()
    {
        GenerateMapWithoutOverwriting();
        GeneratePickups();
    }

    public void GenerateMapWithoutOverwriting()
    {
        if (playerTransform == null) return;

        Vector3Int playerCell = destructibleTilemap.WorldToCell(playerTransform.position);

        for (int x = minCellX; x <= maxCellX; x++)
        {
            for (int y = minCellY; y <= maxCellY; y++)
            {
                Vector3Int cellPos = new Vector3Int(x, y, 0);
                
                if (Mathf.Abs(x - playerCell.x) <= safeRadius &&
                    Mathf.Abs(y - playerCell.y) <= safeRadius)
                {
                    continue;
                }
                
                if (destructibleTilemap.HasTile(cellPos) || indestructibleTilemap.HasTile(cellPos))
                {
                    continue;
                }

                float randomValue = Random.value;

                if (randomValue < indestructibleChance)
                {
                    indestructibleTilemap.SetTile(cellPos, indestructibleTile);
                }
                else if (randomValue < indestructibleChance + destructibleChance)
                {
                    destructibleTilemap.SetTile(cellPos, destructibleTile);
                }
            }
        }
    }

    private void GeneratePickups()
    {
        if (playerTransform == null) return;

        Vector3Int playerCell = destructibleTilemap.WorldToCell(playerTransform.position);

        List<Vector3> availablePositions = new List<Vector3>();
        
        for (int x = minCellX; x <= maxCellX; x++)
        {
            for (int y = minCellY; y <= maxCellY; y++)
            {
                Vector3Int cellPos = new Vector3Int(x, y, 0);
                
                if (Mathf.Abs(x - playerCell.x) <= safeRadius &&
                    Mathf.Abs(y - playerCell.y) <= safeRadius)
                {
                    continue;
                }
                
                if (destructibleTilemap.HasTile(cellPos) || indestructibleTilemap.HasTile(cellPos))
                {
                    continue;
                }

                Vector3 worldPos = destructibleTilemap.GetCellCenterWorld(cellPos);
                
                Collider2D enemy = Physics2D.OverlapCircle(worldPos, enemyCheckRadius, enemyLayer);
                if (enemy != null)
                {
                    continue;
                }

                availablePositions.Add(worldPos);
            }
        }
        
        if (cakePrefab != null && availablePositions.Count > 0 && Random.value < cakeChance)
        {
            int index = Random.Range(0, availablePositions.Count);
            Vector3 spawnPos = availablePositions[index];
            Instantiate(cakePrefab, spawnPos, Quaternion.identity);
            
            availablePositions.RemoveAt(index);
        }
        
        if (damagePickupPrefab != null && availablePositions.Count > 0 && Random.value < damagePickupChance)
        {
            int index = Random.Range(0, availablePositions.Count);
            Vector3 spawnPos = availablePositions[index];
            Instantiate(damagePickupPrefab, spawnPos, Quaternion.identity);
        }
    }
}

2.4 分数设计

2.4.1 击杀分数

我们分数可以由几个部分组成,第一部分当然是玩家玩游戏过程中获得的分数,由于我们的玩法是需要我们不断击杀敌人的,所以我们将击杀每个敌人都设定一个分数奖励。

那么当然击杀不同难度的敌人的分数奖励是不同的,由于我们前面确定了,击杀一个普通敌人需要2下,因此我觉得这里200分是一个合适的奖励数字,而我们的精英怪血量是普通敌人的1.5倍,刚好也是需要3下才能打败的,所以300分比较合适。这样的设计也可以让我们的分数变得和生命值挂钩,方便我们写代码,但是后期维护要注意,不要和其他具体数值的分数弄混淆。因此比如第一关结束,因为第一关是由10个普通敌人组成的,所以这里可以获得2000分的击杀分数奖励,那么这个模块就以2000分为基础。你可能会想我如果把这个分数降低一些合适吗,如果你将分数改为100和150,其实也可以,但是我觉得击杀一个敌人获得的反馈的末尾是50而不是00所给玩家带来的反馈不够强烈,就像你这里如果考虑将分数改为10分、20分可以吗,这些数字都太小了,玩家想从你的游戏获得乐趣,那你应该在玩家做出正确行为时候给出足够的反馈,就像我们网上看到的一些网页广告一样,一刀999,这就是一个好的数值设计,如果数字太小,就没法给玩家足够的爽感。当然如果数字太大,也会显得畸形夸张,因此100这种数字在我这里会比150合适,因为100给人的直观感受比150强烈,我这里200、300的设计仅作为参考。

2.4.2 时间分数

第二部分是时间可以转化为对应的分数,作业要求包含一个积分榜包含用户的分数以及时间等信息,那么我们当然也可以将这一部分时间变为对应的分数加入到我们的分数系统中。这也是这一类游戏常用的方式,玩家通关的速度越快理应获得更高的分数。既然第一部分的分数比如通关第一关的分数会是2000分(200分每个敌人,10个敌人,一共2000分),那么这个时间部分的分数也可以以2000分作为基础。每关的时间分都从2000开始,如果随着时间流逝,这2000分被扣完也不会额外扣除玩家的其他分数,这样可以避免出现负分的情况。而我们将每一部分控制在2000分只是一个比较基础的数值设计,其实并不是很平衡,因为很明显我们这里时间的性价比有些低,但是这样将时间转化为对应的分数可以提高游戏的挑战性,也会让玩家更愿意不断尝试以打出更高的分数,从而提高了游戏的可玩性。

2.4.3 道具分数

第三部分的分数来自于道具,这里是为了增加道具的存在感,拾取道具可以直接添加一定数量的分数,从而加重道具在这个游戏里的地位,我将分数直接设计到了每个道具都是2000分,这样道具所获得的分数奖励刚好可以被理解为一个单独的模块,这样做虽然明显不够平衡,但是这里的数字都较大且直观,可以快速提高用户的游玩体验,当然也可以为了平衡性将这里的时间分数变大,然后道具分数变小。目前这样的涉及就很难存在放弃道具而刷时间的存在,但这同样也可能让社区有专门刷时间的竞争与专门刷分数的挑战,因为现在这样的数值设计这两者可能会有所冲突,所以在排行榜界面这个该怎么排也是需要考虑的设计之一。

2.4.4 惩罚分数

这里作业还要求我们需要允许用户在失败的时候可以选择继续,我认为这里可能是阐述作业要求不清晰,但是没有关系,当然也可以加这种复活机制(失败了还能继续现在的进度游玩),那这样肯定得有所惩罚,就比如分数惩罚,因此这就是第四部分,这个惩罚得重一点,但又不特别严重,考虑到游戏难度其实还好,我认为大部分玩家不会在游戏中死亡次数超过1次,那么死亡1次扣除时间分数的一半比较好,因此这个数值是1000分,又符合我们说的2000分为基础,以这个分数为参考系每个分数都会比较清晰,让玩家方便理解其中的关系。这样玩家就不会为了通关恶意一直送死等,保证了游戏的平衡性。

所以分数部分一共有这四部分组成。下面的表格展示了分数的设计,这样的展示也可以放到作业的报告中,这样呈现比较清晰,方便理解,当然还是需要搭配一定的文字解释。

项目 分数
普通敌人 200
精英敌人 300
工具 2000
时间 2000
死亡 -1000

分数系统的基础都是以时间分为基础的,因此我们这里可以在这里写一个可以修改分数的函数,然后别的地方调用这个函数以修改分数,第二部分代码如下。

csharp 复制代码
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;

public class ScoreTimerManager : MonoBehaviour
{
    public static ScoreTimerManager Instance;

    public Text timeText;
    public Text scoreText;

    public int startScore = 2000;

    private int score;
    private int elapsedSeconds;
    private float timer;

    public int CurrentScore => score;
    public float ClearTime => elapsedSeconds;

    private void Awake()
    {
        if (SceneManager.GetActiveScene().name == "MainMenu")
        {
            Destroy(gameObject);
            return;
        }

        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }
    }

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        if (scene.name == "MainMenu")
        {
            if (Instance == this)
            {
                Instance = null;
            }

            Destroy(gameObject);
            return;
        }

        GameObject timeObj = GameObject.FindWithTag("TimeText");
        GameObject scoreObj = GameObject.FindWithTag("ScoreText");

        if (timeObj != null)
        {
            timeText = timeObj.GetComponent<Text>();
        }

        if (scoreObj != null)
        {
            scoreText = scoreObj.GetComponent<Text>();
        }

        UpdateUI();
    }

    void Start()
    {
        score = startScore;
        elapsedSeconds = 0;
        timer = 0f;
        UpdateUI();
    }

    void Update()
    {
        timer += Time.deltaTime;

        while (timer >= 1f)
        {
            timer -= 1f;
            elapsedSeconds++;
            score = Mathf.Max(0, score - 1);
            UpdateUI();
        }
    }

    public void ChangeScore(int amount)
    {
        score += amount;
        score = Mathf.Max(0, score);
        UpdateUI();
    }

    void UpdateUI()
    {
        if (timeText == null || scoreText == null) return;

        int minutes = elapsedSeconds / 60;
        int seconds = elapsedSeconds % 60;

        timeText.text = $"Time:{minutes:00}'{seconds:00}\"";
        scoreText.text = "Score: " + score;
    }
}

这里使用了 Mathf.Max(0, score - 1) 从而避免了我前面说的出现负分的情况。同时这里在后面的系统设计中考虑到了,这里的分数和时间信息需要呈现在 UI 上以让玩家能够随时随地知道自己的信息,因此需要两者同时更新。

这里还需要注意游戏需要支持暂停和继续等,所以这里的方法封装出来也可以方便调用以及复用。

第一部分代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyHealth : MonoBehaviour
{
    public int currentHealth = 4;
    public int maxHealth = 4;
    public GameManger gameManger;
    public EnemySpawnerPlus spawner;

    public void SetSpawner(EnemySpawnerPlus enemySpawner)
    {
        spawner = enemySpawner;
    }

    public void ChangeHealth(int amount)
    {
        currentHealth += amount;

        if (currentHealth > maxHealth)
        {
            currentHealth = maxHealth;
        }
        else if (currentHealth <= 0)
        {
            currentHealth = 0;
            
            if (ScoreTimerManager.Instance != null)
            {
                ScoreTimerManager.Instance.ChangeScore(maxHealth * 50);
            }
            
            if (spawner != null)
            {
                spawner.OnEnemyRemoved();
            }

            if (gameManger != null)
            {
                gameManger.EnemyDefeated();
            }
            
            gameObject.SetActive(false);
        }
    }
}

这里正如前面所说,现在分数奖励正好与生命值挂钩,但是后期维护时候需要注意。

第三部分代码在前面道具设计就提到过了(所以这里只展示一个,另一个同理),调用修改分数的方法从而互动二分数。

csharp 复制代码
public class DoubleDamagePickup : MonoBehaviour
{
    public int bonusScore = 2000;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;

        PlayerShoot playerShoot = other.GetComponent<PlayerShoot>();
        if (playerShoot != null && playerShoot.playerBullet != null)
        {
            playerShoot.playerBullet.DoubleDamage();
        }

        if (ScoreTimerManager.Instance != null)
        {
            ScoreTimerManager.Instance.ChangeScore(bonusScore);
        }

        Destroy(gameObject);
    }
}

第四部分代码如下。

csharp 复制代码
public void ContinueGame()
    {
        if (player == null || respawnPoint == null || playerHealth == null || ScoreTimerManager.Instance == null)
        {
            return;
        }

        player.gameObject.SetActive(true);
        player.position = respawnPoint.position;

        Rigidbody2D playerRb = player.GetComponent<Rigidbody2D>();
        if (playerRb != null)
        {
            playerRb.velocity = Vector2.zero;
        }

        playerHealth.ResetHealth();

        ScoreTimerManager.Instance.ChangeScore(-continuePenalty);

        losePanel.SetActive(false);
        isGameEnded = false;
        isPaused = false;
        Time.timeScale = 1f;
    }

这里同样使用的是一个变量去存储对应的分数是多少,前面第三部分道具的奖励分也是如此,这正是软件工程教会我们的,这么做可以让我们方便维护代码,我们平时写代码的时候都要注意这样的细节。

3. 系统部分

3.1 主系统

我们现在就是要考虑将这些部分组成在一起,让它成为一个完整的系统,也就是整个游戏。

首先让我们这个游戏的框架做出来吧,游戏正常流程从主菜单点开始游戏就进入第一关,第一关成功过关就进入第二关,第二关要是通关就通关。

我们一般使用 GameManager 这样的脚本去管理这样的切换逻辑。

我们来思考一下这个流程的细节,怎么开始游戏,我们点击开始游戏的按钮,然后运行开始游戏方法,从而开始游戏。然后这里应该从第一关切换到第二关。然后要是所有怪物都被消灭应该切换到胜利画面,然后这里点击继续就应该再切换到下一关。同理对第二关也是这样操作。

所以我们应该先将这不同的场景先分开,我分成了主菜单、第一关、第二关。

我没有将暂停、胜利、失败等画面单独变为一个场景。因为场景切换所有的东西都会重置。例如我们暂停或者失败后再继续游玩的时候应该是从上次的地方继续,如果我们切换了场景那么这些都会被清空重置,除非我们还专门保存这些数据。所以那些被我单独做成了一个 UI 界面去遮挡住关卡,然后将游戏里的时间暂停从而实现。

所以我们先将做好的场景像这样排序,如下图所示。

当我们需要切换到对应场景我们就使用 SceneManager.LoadScene() 方法就好了。

我们要是想点击按钮就进行对应的效果,我们可以使用 UI 里的 button,它可以允许我们在点击等操作后调用特定的方法,这些方法可以是我们自己写的脚本里的方法。

所以我这里单独写了一个开始游戏的脚本,记得将这个脚本挂在游戏里的对象上,这样才能调用。并且这里没必要放到 GameManager 上,这样符合单一职责,而且如果放在 GameManager 上,我们只要跟系统有关的我们可能都要使用这个脚本,然后反复填写数值,或者只用一个,要反复去找,效率不高,难以维护。

这里的单一职责让它的代码很简单。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class StartGame : MonoBehaviour
{
    public void StartMenu()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
    }
}

结束游戏也是类似的操作。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class QuitGame : MonoBehaviour
{
    public void QuitApplication()
    {
        Application.Quit();
    }
}

让我们回到我们前面提到的一个问题上来,我们切换场景会导致场景加载的东西清空,但我们其实有两个东西不能清空,一个是音乐,我们换到下一关音乐重新播放会有些奇怪,其实也可以,但是音量至少得保持的跟玩家设置的一致吧。另一点是我们有分数系统,进入第二关不应该让第一关的分数清空,当然这个问题也可以留到 IO 系统中去解决,但我希望第二关开始的时候玩家就能看到自己第一关积累的分数,这样可以增加玩家的成就感。

为了解决这个问题,我们使用 static 关键字让创建的对象所有脚本都可以访问,然后使用 DontDestroyOnLoad()方法告诉 Unity 在切换场景时不要销毁这个对象,从而让它能一直在游戏内生效。

相关代码如下。

csharp 复制代码
using UnityEngine;
using UnityEngine.SceneManagement;

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance;

    public AudioSource bgmSource;
    public AudioSource sfxSource;

    public AudioClip mainMenuBGM;
    public AudioClip gameBGM;

    [Range(0f, 1f)] public float bgmVolume = 1f;
    [Range(0f, 1f)] public float sfxVolume = 1f;

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }
    }

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void Start()
    {
        bgmVolume = PlayerPrefs.GetFloat("BGMVolume", 1f);
        sfxVolume = PlayerPrefs.GetFloat("SFXVolume", 1f);
        ApplyVolumes();

        PlayBGMForScene(SceneManager.GetActiveScene().name);
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        PlayBGMForScene(scene.name);
    }

    private void PlayBGMForScene(string sceneName)
    {
        if (sceneName == "MainMenu")
        {
            PlayBGM(mainMenuBGM);
        }
        else if (sceneName == "GameScene")
        {
            PlayBGM(gameBGM);
        }
    }

    public void PlayBGM(AudioClip clip)
    {
        if (clip == null || bgmSource == null)
            return;

        if (bgmSource.clip == clip)
            return;

        bgmSource.clip = clip;
        bgmSource.loop = true;
        bgmSource.volume = bgmVolume;
        bgmSource.Play();
    }

    public void SetBGMVolume(float volume)
    {
        bgmVolume = volume;
        if (bgmSource != null)
        {
            bgmSource.volume = bgmVolume;
        }

        PlayerPrefs.SetFloat("BGMVolume", bgmVolume);
        PlayerPrefs.Save();
    }

    public void SetSFXVolume(float volume)
    {
        sfxVolume = volume;

        if (sfxSource != null)
        {
            sfxSource.volume = sfxVolume;
        }

        PlayerPrefs.SetFloat("SFXVolume", sfxVolume);
        PlayerPrefs.Save();
    }

    public void ApplyVolumes()
    {
        if (bgmSource != null)
        {
            bgmSource.volume = bgmVolume;
        }

        if (sfxSource != null)
        {
            sfxSource.volume = sfxVolume;
        }
    }

    public void PlaySFX(AudioClip clip)
    {
        if (clip != null && sfxSource != null)
        {
            sfxSource.PlayOneShot(clip, sfxVolume);
        }
    }
}
csharp 复制代码
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;

public class ScoreTimerManager : MonoBehaviour
{
    public static ScoreTimerManager Instance;

    public Text timeText;
    public Text scoreText;

    public int startScore = 2000;

    private int score;
    private int elapsedSeconds;
    private float timer;

    public int CurrentScore => score;
    public float ClearTime => elapsedSeconds;

    private void Awake()
    {
        if (SceneManager.GetActiveScene().name == "MainMenu")
        {
            Destroy(gameObject);
            return;
        }

        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }
    }

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        if (scene.name == "MainMenu")
        {
            if (Instance == this)
            {
                Instance = null;
            }

            Destroy(gameObject);
            return;
        }

        GameObject timeObj = GameObject.FindWithTag("TimeText");
        GameObject scoreObj = GameObject.FindWithTag("ScoreText");

        if (timeObj != null)
        {
            timeText = timeObj.GetComponent<Text>();
        }

        if (scoreObj != null)
        {
            scoreText = scoreObj.GetComponent<Text>();
        }

        UpdateUI();
    }

    void Start()
    {
        score = startScore;
        elapsedSeconds = 0;
        timer = 0f;
        UpdateUI();
    }

    void Update()
    {
        timer += Time.deltaTime;

        while (timer >= 1f)
        {
            timer -= 1f;
            elapsedSeconds++;
            score = Mathf.Max(0, score - 1);
            UpdateUI();
        }
    }

    public void ChangeScore(int amount)
    {
        score += amount;
        score = Mathf.Max(0, score);
        UpdateUI();
    }

    void UpdateUI()
    {
        if (timeText == null || scoreText == null) return;

        int minutes = elapsedSeconds / 60;
        int seconds = elapsedSeconds % 60;

        timeText.text = $"Time:{minutes:00}'{seconds:00}\"";
        scoreText.text = "Score: " + score;
    }
}

我们在准备好对应的暂停方法、继续游戏方法、返回菜单方法、重新开始方法、胜利结算方法、失败结算方法。整个 GameManager 的任务就差不多了,我们前面所做的这些东西在一起就成为了一个完整的系统,算是一个可以运行的游戏了,但这里我们还有两部分没有完成。一个是我们要提供一个排行榜,上面显示通关的详细信息。所以这牵扯到 IO 系统,我还希望设置可以修改游戏的音乐,这样的设置也是不会因为游戏关闭而消失的,所以这也是关乎到 IO 系统。

3.2 IO 系统

IO 系统在 Unity 里也是一个很重要的部分。我们可以写一个 DataManager 去完成数据持久化目的。

Unity 会根据运行平台、公司名、游戏名自动生成的一个路径,我们只需要在这个路径下编辑我们的信息就好了。我们一般不考虑更改一个指定的地址,因为我们很可能游戏需要在不同平台发行,那么我们要是使用一个固定的地址可能导致在别的系统上无法找到我们的这个地址。

一般 Unity 的 IO 涉及到的关键字如下。

关键字 作用
Application.dataPath 游戏资源目录(Assets)
Application.streamingAssetsPath 只读资源目录
Application.persistentDataPath 玩家存档目录
PlayerPrefs 简单键值保存

所以我们的 DataManger 需要完成的第一件事情是当玩家通关的时候将玩家通关的数据保存到电脑上。

我们先思考一下排行版上的信息要包含什么,用户姓名、分数、时间。因此我们需要先定义一个数据结构存储这些类型,相关代码如下。

csharp 复制代码
using System;

[Serializable]
public class LeaderboardEntry
{
    public string playerName;
    public int score;
    public float clearTime;

    public LeaderboardEntry(string playerName, int score, float clearTime)
    {
        this.playerName = playerName;
        this.score = score;
        this.clearTime = clearTime;
    }
    
}

然后我们的排行版应该是什么,应该是一个这样的数据信息的集合,可以用什么呢,这里选择的是 List,因为我们只要通关了就要增加元素,如果使用比如数组就很麻烦,所以使用 List 方便玩家通关后添加通关信息,我们还需要对数据进行排序然后再展示,所以 List 的另一个优势是方便我们排序,相关代码如下。

csharp 复制代码
using System;
using System.Collections.Generic;

[Serializable]
public class LeaderboardData
{
    public List<LeaderboardEntry> entries = new List<LeaderboardEntry>();
}

因此我们现在还需要一个脚本用于从 DataManager 获取已经保存的排行榜数据,然后把它转换成文字显示在 UI 上。需要注意的是我这里本来犯了一个错误,现在这里我已修复,那就是排行榜每次打开都应该将之前的信息进行刷新,否则就不会是最新的消息,而我这里原来的算法直接添加元素进去,就会导致只要点开一次排行版,就会让已有的信息多次展示。修改后的代码如下。

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class LeadboardText : MonoBehaviour
{
    public Text leaderboardText;
    void OnEnable()
    {
        ShowLeaderboard();
    }
    public void ShowLeaderboard()
    {
        leaderboardText.text = "";

        List<LeaderboardEntry> entries = DataManager.Instance.GetLeaderboard();

        if (entries.Count == 0)
        {
            leaderboardText.text = "No records yet.";
            return;
        }

        int displayCount = Mathf.Min(5, entries.Count);

        for (int i = 0; i < displayCount; i++)
        {
            leaderboardText.text +=
                $"{i + 1}. {entries[i].playerName} | Score: {entries[i].score} | Time: {entries[i].clearTime:F2}s\n";
        }
    }
}

那么既然这里在展示的时候没有考虑排序问题,就需要在存储端思考这个问题。但这里的逻辑应该是我们到底在哪里处理排序更好呢,我们从算法的角度去思考这个问题,因为毕竟排序算法是个比较大的复杂度,所以我们应该让它在一些低负载场景下运行,所以如果我们将其放在展示排行版的时候进行,假设我们现在的数据很多,那么玩家就会等待一会才出现,那这样就会导致显得游戏卡顿。而我们放在存储的时候做,我们游戏通关后,他后台进行存储操作,我们前面还在进行其他操作,这样就不会影响,玩家无法感受到这个地方的卡顿。

其实我们刚刚就已经提到了 DataManager 的第二个功能,在下次打开游戏时将数据读取出来。

所以我们的全部代码如下。

csharp 复制代码
using System.IO;
using UnityEngine;
using System.Collections.Generic;

public class DataManager : MonoBehaviour
{
    public static DataManager Instance;

    private string filePath;
    private LeaderboardData leaderboardData = new LeaderboardData();

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }

        filePath = Path.Combine(Application.persistentDataPath, "leaderboard.json");
        LoadLeaderboard();
    }

    public void SaveResult(int score, float clearTime, bool isWin)
    {
        if (!isWin)
        {
            Debug.Log("Game not won. Result will not be saved.");
            return;
        }

        string playerName = "Player" + (leaderboardData.entries.Count + 1);

        LeaderboardEntry newEntry = new LeaderboardEntry(playerName, score, clearTime);
        leaderboardData.entries.Add(newEntry);

        leaderboardData.entries.Sort((a, b) =>
        {
            int scoreCompare = b.score.CompareTo(a.score);
            if (scoreCompare == 0)
            {
                return a.clearTime.CompareTo(b.clearTime);
            }
            return scoreCompare;
        });

        SaveLeaderboardToFile();
    }

    private void SaveLeaderboardToFile()
    {
        string json = JsonUtility.ToJson(leaderboardData, true);
        File.WriteAllText(filePath, json);
        Debug.Log("Leaderboard saved to: " + filePath);
    }

    public void LoadLeaderboard()
    {
        if (File.Exists(filePath))
        {
            string json = File.ReadAllText(filePath);
            leaderboardData = JsonUtility.FromJson<LeaderboardData>(json);

            if (leaderboardData == null || leaderboardData.entries == null)
            {
                leaderboardData = new LeaderboardData();
            }
        }
        else
        {
            leaderboardData = new LeaderboardData();
        }
    }

    public List<LeaderboardEntry> GetLeaderboard()
    {
        return leaderboardData.entries;
    }

    public void ClearLeaderboard()
    {
        leaderboardData = new LeaderboardData();
        SaveLeaderboardToFile();
    }
}

3.3 设置系统

我们还需要做一个设置系统,设置里可以放什么呢,对于我们这个游戏设计的东西比较少。其实可以做一个游戏难度设置,甚至是游戏难度自定义,对于前者我们准备多个数值进行修改,对于后者我们只要做到前面我说的相关的数值用变量存储就好,这也是代码是否方便维护的体现。

这里做了一个控制背景音乐和音效的设置。

这里使用的是前面提到的 PlayerPrefs 系统。它是一种类似字典的数据存储,也就是保存键值对。

所以对于我们这种只要存储背景音乐和音效大小的情况,它就会很方便。我们不需要像刚才那样还需要排序的。这就是按照自己的需求灵活选择数据结构。

相关的代码如下。

csharp 复制代码
using UnityEngine;
using UnityEngine.SceneManagement;

public class AudioManager : MonoBehaviour
{
    public static AudioManager Instance;

    public AudioSource bgmSource;
    public AudioSource sfxSource;

    public AudioClip mainMenuBGM;
    public AudioClip gameBGM;

    [Range(0f, 1f)] public float bgmVolume = 1f;
    [Range(0f, 1f)] public float sfxVolume = 1f;

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
            return;
        }
    }

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void Start()
    {
        bgmVolume = PlayerPrefs.GetFloat("BGMVolume", 1f);
        sfxVolume = PlayerPrefs.GetFloat("SFXVolume", 1f);
        ApplyVolumes();

        PlayBGMForScene(SceneManager.GetActiveScene().name);
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        PlayBGMForScene(scene.name);
    }

    private void PlayBGMForScene(string sceneName)
    {
        if (sceneName == "MainMenu")
        {
            PlayBGM(mainMenuBGM);
        }
        else if (sceneName == "GameScene")
        {
            PlayBGM(gameBGM);
        }
    }

    public void PlayBGM(AudioClip clip)
    {
        if (clip == null || bgmSource == null)
            return;

        if (bgmSource.clip == clip)
            return;

        bgmSource.clip = clip;
        bgmSource.loop = true;
        bgmSource.volume = bgmVolume;
        bgmSource.Play();
    }

    public void SetBGMVolume(float volume)
    {
        bgmVolume = volume;
        if (bgmSource != null)
        {
            bgmSource.volume = bgmVolume;
        }

        PlayerPrefs.SetFloat("BGMVolume", bgmVolume);
        PlayerPrefs.Save();
    }

    public void SetSFXVolume(float volume)
    {
        sfxVolume = volume;

        if (sfxSource != null)
        {
            sfxSource.volume = sfxVolume;
        }

        PlayerPrefs.SetFloat("SFXVolume", sfxVolume);
        PlayerPrefs.Save();
    }

    public void ApplyVolumes()
    {
        if (bgmSource != null)
        {
            bgmSource.volume = bgmVolume;
        }

        if (sfxSource != null)
        {
            sfxSource.volume = sfxVolume;
        }
    }

    public void PlaySFX(AudioClip clip)
    {
        if (clip != null && sfxSource != null)
        {
            sfxSource.PlayOneShot(clip, sfxVolume);
        }
    }
}
csharp 复制代码
using UnityEngine;
using UnityEngine.UI;

public class SettingsMenu : MonoBehaviour
{
    public Slider bgmSlider;
    public Slider sfxSlider;

    private void OnEnable()
    {
        float bgm = PlayerPrefs.GetFloat("BGMVolume", 0.5f);
        float sfx = PlayerPrefs.GetFloat("SFXVolume", 0.5f);

        bgmSlider.SetValueWithoutNotify(bgm);
        sfxSlider.SetValueWithoutNotify(sfx);

        if (AudioManager.Instance != null)
        {
            AudioManager.Instance.SetBGMVolume(bgm);
            AudioManager.Instance.SetSFXVolume(sfx);
        }
    }

    public void OnBGMChanged(float value)
    {
        Debug.Log("BGM Changed: " + value);

        if (AudioManager.Instance != null)
        {
            AudioManager.Instance.SetBGMVolume(value);
        }
    }

    public void OnSFXChanged(float value)
    {
        Debug.Log("SFX Changed: " + value);

        if (AudioManager.Instance != null)
        {
            AudioManager.Instance.SetSFXVolume(value);
        }
    }
    
    public void ResetAudioSettings()
    {
        float defaultValue = 0.5f;

        bgmSlider.SetValueWithoutNotify(defaultValue);
        sfxSlider.SetValueWithoutNotify(defaultValue);

        if (AudioManager.Instance != null)
        {
            AudioManager.Instance.SetBGMVolume(defaultValue);
            AudioManager.Instance.SetSFXVolume(defaultValue);
        }

        PlayerPrefs.SetFloat("BGMVolume", defaultValue);
        PlayerPrefs.SetFloat("SFXVolume", defaultValue);
        PlayerPrefs.Save();
    }
}

这里用的是滑块,这样更符合使用场景,而第一次我们会有一个默认值,而为了让这里不是只有一个按钮,所以我还加了一个重置到默认值的选项保证了这个界面的美观。

至此我们就有了一个完整的游戏系统了。

4. 美术设计

美术部分主要是想跟大家分享一下我是怎么想的。

4.1 角色移动动画

我们前面提到了一遍,如果我们只是用一个图片作为角色进行移动,那么就会显得非常的不真实。

这个问题在传统坦克大战中就不存在,因为里面都是完全的 2D 平面简单造型,只要旋转坦克方向就好。我们这里的角色是有脚的人物,那么他的脚就应该动起来,所以我们需要单独画一下它左脚和右脚移动的情况,可以像我这样再添加一张作为过渡,这样动画效果更好。当然只有这里的前三张做动画也是可以的。

而我这里选用星之卡比的好处就是敌人也比较好画,可以直接复用资源。

4.2 地图美术细节

如下图所示。

我这里除了前面提到的这种 俯视角 2D 呈献 3D 效果的时候为了更自然、真实,我们应该注意比如栅栏在不同的情况下的链接情况的同时,我们要避免重复的元素过多。

比如这里草地不是使用完全一样的草地,如下图我的地图绘制工具栏所示。

这里的草地的细节还可以有多种纹理,我们可以让它们随机分布在不同地方,不同颜色混合使用就会好很多。就像这里水的纹理也有四种,而不是每一个格子完全一样。这些都避免了重复元素过多而导致的视觉疲劳。而这里水元素在我的测试下非常不明显,但是这种纹理确实会显得水是活的,而如果使用单一蓝色的话就会显得单调,因此我觉得这里我的地图处理还是很好的。

4.3 道具设计

回血很容易设计,一般游戏可能用的是急救包之类的,但是考虑到我们的游戏是以卡比为主角的,那么选择吃的更好,所以我想到的是蛋糕,最好是粉色的这样跟卡比更加般配,于是我让 AI 帮我生成了一个,如下图所示,但其实跟我整体风格不是很般配,这也是 AI 工具的缺点,难以保持统一的风格,目前有的 AI 正尝试解决这样的资产问题。

说到攻击力提升,在任天堂中谁会想不起来马里奥吃了蘑菇就可以变大,从而有双倍的数值呢,那这里也选择马里奥世界里的蘑菇比较好,而不是弹药包之类的传统想法。

5. 总结与反思

其实可以注意到我将这里的很多功能用单独的方法进行了封装,这样的话无论是后续维护还是扩展还是别的地方调用都很清晰,方便我们进行后续操作。越大的项目越需要考虑这一点,我将在作业 2 中更详细地讨论对于更大的项目我是如何思考在更大的项目中安排我的代码规划的,其实这些都是软件工程知识的运用,希望大家尽量能做到这一点,而不是单纯地完成任务就好,作为专业的程序员需要注意代码结构以及项目规划。

我们完成作业的时候也可以用这样的方式对自己的代码进行反思,我自己写这篇文章也是对自己的代码进行整理和反思。正如前面我提到的将方法进行封装,是否做到。你可以试着问自己哪些地方的功能有重复,我是怎么完成这些部分的?

例如我在生成地图的两个方法就有部分代码的重复使用,这一点我们在软件工程中曾经学过。

包括比如这里敌人的移动和玩家控制的角色的移动当然也有其相似之处,甚至他们使用的模型也是一种资源的重复利用。

我们还可以问问自己要是对游戏进行数值上的更改是否方便?(或者说我想对我的代码进行修改是否方便)我是不是要为了修改一个地方而修改多个地方,或者说我修改一个地方需要我去代码去找对应的位置。

例如我这里要是修改蘑菇道具的分数奖励呢,我要改哪里。

csharp 复制代码
public class DoubleDamagePickup : MonoBehaviour
{
    public int bonusScore = 2000;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!other.CompareTag("Player")) return;

        PlayerShoot playerShoot = other.GetComponent<PlayerShoot>();
        if (playerShoot != null && playerShoot.playerBullet != null)
        {
            playerShoot.playerBullet.DoubleDamage();
        }

        if (ScoreTimerManager.Instance != null)
        {
            ScoreTimerManager.Instance.ChangeScore(bonusScore);
        }

        Destroy(gameObject);
    }
}

这里的代码我只需要修改开头 bonusScore 的值即可,甚至我可以在 Unity 编辑器的模块里进行修改不用进代码的,这样非常方便,如果写成固定的数字,我就要找到对应的位置进行修改。而我这里的修改分数有对应的方法所以我这里直接调用就好,这就是我刚刚提到的封装。

多写多思考总会有提升的,作业分数并不是最重要的。

就像我这里还提到了我很多地方做得不好的地方,有些是潜在的 bug,有些是目前解决方案不好还可以更进步。所以我希望大家也能在自己做完作业后经常反思总结一下,就像我写完这篇分享一样,我发现了自己以前的很多问题,这也是对我自己的一种提升。

6. 分享

我将我的完整项目分享在了这里:完整项目

如果想直接体验可以去项目的 Release 进行体验:游戏下载

相关推荐
蒸鱼Yuzheng1 小时前
Unity 与 Unreal 测试工具怎么回答:Profiler、日志、自动化与证据链
测试工具·unity·性能分析·游戏测试·unrealengine
野区捕龙为宠3 小时前
Unity WebGL平台 通过 URL 加载图片(UGUI RawImage / 模型材质)
unity·团结引擎
阿松爱学习1 天前
【Unity开发】FileStream 详解及用法指南
unity·c#·unity开发
想做后端的前端1 天前
Unity-UIGI优化Rebatch与Rebuild
unity
玖玥拾2 天前
Unity Shader 基础与图形学(一)
unity·图形渲染·图形学·shader
百里香酚兰2 天前
【Unity学习笔记】如何把粉色Prefab还原
笔记·学习·unity
xcLeigh2 天前
Unity基础:使用Transform控制物体旋转——Rotate与LookAt详解
unity·教程·transform·rotate·lookat
平行云2 天前
3D应用推流太贵太慢?ImmerShare Lite:利用本地算力实现极简一键分享
unity·ue5·实时云渲染·云桌面·像素流·云游戏·串流
淡海水2 天前
11-04-Unity-Span-foreach-LINQ的分配与热路径边界
unity·c#·linq·foreach·span