这是一个Unity的学习笔记,内容来自于以下视频:【Unity教程】从0编程制作类吸血鬼幸存者游戏

3 - 设置和导入

开始项目之前先更改一些设置,把游戏分辨率调成Full HD「1920*1080」,然后在软件编辑器中去除掉一些我们用不到的包——JetBrains Rider Editor和Visaual Scripting。

4 - 创建场景

点击file→New Scene选项,创建一个basic 2D (built in) 场景,点击Crtl+S键保存,命名为Main,保存在 ../Assets/Scenes文件夹下。

可以看到Main Camera的Projection为orthographic「自由视角」,更改其旁的Size设置摄像头的大小为8.5。

使用Unity中的tiles map「瓦片地板」在游戏中添加一些地板。在场景Main中右键,创建一个2D object的Tilemap,形状选择rectangular「矩形」,虽然是矩形但默认情况下地图都是基于正方形地板,可以在右边的选项栏中调整地板的大小。之后点击window→2D→tile palette选项,把他拖到右边的栏中,点击Create New Palette创建一个新的调色板并命名为Main Palette,保存在 ../Assets/Tiles/Main文件夹下。然后把Assets里的的精灵图直接拖到右边的Tile Palette中,这时它会让我们选择瓦片的储存位置,我们选择刚刚创建的Main文件夹,之后就可以像画图软件一样,选择我们要画的地板在场景中拖动。但是这样一格格手动绘制的方法太过繁琐。更快速的方法是切换到盒子模具,在图上拖动就会快速产生一大片地板。

Tips:每次更改后最好都Ctrl+S保存一下,以免丢失数据,当有未保存的改动时,场景名称上会有一个小星号。

5 - 添加玩家

在场景Main下右键,点击 Create Empty,重命名为Player。点击精灵图旁的播放按钮把所有的图像列出来,选中小人的图像拖到场景中。这时你可能看不见小人,因为小人和草现在处在同一平面上,导致小人和草地显示的优先级是随机的。我们先把小人放到Player的子项下,重命名为Sprite,在右侧的选项栏中找到Sprite Renderer→Additional Settings→Sorting Layer/Order in Layer来改变图层的位置,层的数字越高,显示的优先级越高

可以把小人的Order in Layer从0设置为1,这样小人就会出现在草地的前面。

更好的方法是点击Sorting Layer下的Add a Sorting Layer,添加两个图层,Layer1重命名为BG「背景」用来放置草地,Layer2重命名为Player。创建完毕后,把Tilemap和Sprite的Sorting Layer分别设置为对应的图层就能正确显示了。

6 - 创建脚本文件

创建第一个脚本来实现让小人在世界中移动,右键Assets创建C#脚本文件,路径和文件名如下:../Assets/Scripts/PlayerController.cs注意:脚本的名称中不能有空格,并且每个单词的第一个字母都要大写,否则Unity会因无法识别文件类型而报错
脚本文件创建好后会自动生成一些代码,最顶部的是一些使用的系统集合和引擎声明,下面有一个启动函数和一个更新函数,启动函数是在第一帧更新之前调用的,更新函数每一帧会被调用一次。
我们在启动函数之前声明一个公共的速度变量 public float moveSpeed;变量名第一个单词字母小写,后面所有单词第一个字母大写,公共变量的值可以在Unity Inspector中修改,这里把 moveSpeed的值更改为2。

7 - 让玩家通过WASD移动

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public float moveSpeed;     //玩家移动速度变量声明

    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
        Vector3 moveInput = new Vector3(0f, 0f, 0f);
        moveInput.x = Input.GetAxisRaw("Horizontal");
        moveInput.y = Input.GetAxisRaw("Vertical");

        Debug.Log(moveInput);

        transform.position += moveInput * moveSpeed * Time.deltaTime;

    }
}

Vector3 moveInput = new Vector3(0f, 0f, 0f);:在更新函数中声明一个新的三维向量,三维向量Vector3有三个值,对应空间坐标轴上的x,y,z。代码中 0f的含义是浮点数0。

moveInput.x = Input.GetAxisRaw("Horizontal");
moveInput.y = Input.GetAxisRaw("Vertical");:这里的 HrizontalVertical可以在Edit→Project Settings→Input Manager中看到,这是Unity自带的输入设置,它会把WASD键和上下左右四个方向对应起来,而这里的 Raw表示的是获取输入的原始数据量,如果这里替换为 Input.GetAxismoveInput则会从0到一平滑上升,注意这里 GetAxisRaw("")双引号中的单词不能有拼错,拼错的话Unity不会报错,但是这行代码也不起作用

调试函数 Debug.Log(moveInput);:启动游戏后按WASD键可以在console中观察到moveInput的数值变化。

transform.position += moveInput * moveSpeed * Time.deltaTime;:通过改变玩家位置来移动玩家,这里的 Time.deltaTime代表帧率的倒数,如果去除玩家的移动速度将取决于游戏中的帧率。

8 - 限制斜向移动速度

当玩家斜向走时,例如朝左上方向走,这时 moveInput.x为-1,moveInput.y为1,而 moveInput的模为根号2,超过了沿水平和垂直方向时的速度。

moveInput.Normalize();:取三维向量的单位向量,避免使玩家斜向走的速度大于垂直和水平方向的速度。

9 - 镜头跟随

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CameraController : MonoBehaviour
{
    private Transform target;

    // Start is called before the first frame update
    void Start()
    {
        target = FindObjectOfType<PlayerController>().transform;
    }

    // Update is called once per frame
    void LateUpdate()
    {
        transform.position = new Vector3(target.position.x, target.position.y, transform.position.z);
    }
}

这时玩家如果移动超出摄像头范围就看不到了,我们要使摄像头始终跟随玩家。

创建一个新的C#脚本命名为CameraController并把它绑定到摄像头上。

声明一个私有的 Transform变量 target,私有的变量可以通过打开右侧栏的三个小点下的debug来观察。

target = FindObjectOfType<PlayerController>().transform;:绑定 targetPlayertransform类型。FindObjectOfType(),按类型查找对象列表,这里查找的是PlayerController这个脚本所对应的 transform类型。

创建一个 LateUpdate()函数,这个函数会在任何Update函数运行过后运行一次。

transform.position = new Vector3(target.position.x, target.position.y, transform.position.z);:使摄像头位置坐标的x和y跟随玩家,而z坐标保持不变。

10 - 添加玩家动画

给人物添加一段动画来让玩家感觉到操纵的人物在移动。

点击Window→Animation→Animation打开动画窗口,点击Cerate创建一个玩家静止状态下的动画Player_Idle和一个移动状态下的动画Player_Moving,保存在 ../Assets/Animations/Player下。选中Player_Moving的动画点击录制,拖动时间轴录制几个玩家在不同角度时的状态,这样玩家走动时会播放晃动的动画。

点击Window→Animation→Animator,点击Player_Idle添加一个Parameters命名为isMoving,右键Player_Idle点击Make Transition创建一个过渡连接到Player_Moving上,点击中间的连线把过渡时间调为0,反过来同样建立Player_Moving到Player_Idle的连线。

在PlayerController脚本中声明一个公共变量anim, public Animator anim;,在Unity Inspector中把动画绑定到anim变量上。

在更新函数的最后添加一段判断玩家是否移动的代码。

if(moveInput != Vector3.zero)
{
    anim.SetBool("isMoving", true);
} else
{
    anim.SetBool("isMoving", false);
}

其中,Vector3.zero等同于 Vector3(0, 0, 0)

11 - 添加敌人

创建敌人的方法和玩家时相同,把敌人重命名为Sprite作为Enemy 1的子对象,另外创建一个新的图层Enemy,让玩家始终显示在敌人上方。

敌人和玩家添加一个Circle Collider 2D「2D圆形碰撞器」,更改Radius到合适的范围,再给敌人添加一个Rigidbody 2D「2D刚体模型」,并把Gravity Scale设置为0以免受到重力影响从画面掉落。

当两个物体碰撞时可能会导致旋转,勾选Rigidbody 2D→Constrains「约束」→Freeze Rotation→Z可以避免物体在Z轴上的旋转。

12 - 让敌人追逐玩家

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyController : MonoBehaviour
{
    public Rigidbody2D theRB;
    public float moveSpeed;
    private Transform target;
    // Start is called before the first frame update
    void Start()
    {
        target = FindObjectOfType<PlayerController>().transform;
    }

    // Update is called once per frame
    void Update()
    {
        theRB.velocity = (target.position - transform.position).normalized * moveSpeed;
    }
}

创建一个C#脚本命名为EnemyController并绑定到Enemy 1上。把Enemy 1的RigidBody 2D绑定到公共变量 theRB上。

theRB.velocity = (target.position - transform.position).normalized * moveSpeed;

theRB.velocityvelocity是Unity内置的物理速度,基于游戏的固定更新,和游戏的帧率无关,不需要和玩家移动速度一样乘以 Time.deltaTime

为了使敌人永远追逐玩家,需要计算出敌人与玩家的向量关系,即玩家位置减敌人位置,并且取单位向量作为敌人速度的方向。

13- 添加敌人动画

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyAnimation : MonoBehaviour
{   
    public Transform sprite;
    public float speed;
    public float minSize, maxSize;
    private float activeSize;

    // Start is called before the first frame update
    void Start()
    {
        activeSize = maxSize;
    speed = speed * Random.Range(.75f, 1.25f);
    }

    // Update is called once per frame
    void Update()
    {
        sprite.localScale = Vector3.MoveTowards(sprite.localScale, Vector3.one * activeSize, speed * Time.deltaTime);
  
        if(sprite.localScale.x == activeSize)
        {
            if(activeSize == maxSize)
            {
                activeSize = minSize;
            } else
            {
                activeSize = maxSize;
            }
        }
    }
}

如果像之前玩家动画那样给每个不同的敌人添加动画太过于繁琐,不如创建一个C#脚本统一处理敌人的动画。

创建一个C#脚本命名为EnemyAnimation并绑定到Enemy 1上。这个脚本的作用是使敌人1的大小从0.9到1.1之间不断变化。

把Sprite绑定到公共变量sprite上,speedmaxSizeminSize分别设置为0.5,1.1和0.9。

MoveTowards(float current, float target, float maxDelta);:把一个数平滑增大或者减小到某一数值,三个传递参数分别为:当前数值,目标数值,速度。

最后判断当前物体大小是否等同于设定的最大值或者最小值,并且相应更改activeSize的大小。

为了避免画面中所有的敌人的动画相同,在开始函数中给动画的速度乘以一个随机函数 speed = speed * Random.Range(.75f, 1.25f);使敌人动画的速度在0.75和1.25倍速之间浮动。

14 - 设置人物的血量

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerHealthController : MonoBehaviour
{
    public float currentHealth, maxHealth;
    // Start is called before the first frame update
    void Start()
    {
        currentHealth = maxHealth;
    }

    // Update is called once per frame
    void Update()
    {
        if(Input.GetKeyDown(KeyCode.T))
        {
            TakeDamage(10f);
        }
    }

    public void TakeDamage(float damageToTake)
    {
        currentHealth -= damageToTake;

        if(currentHealth <= 0)
        {
            gameObject.SetActive(false);
        }
    }
}

创建一个新的C#脚本命名为PlayerHealthController并绑定到Player上。

maxHealth的值设置为25。

创建一个用于扣血的函数 public void TakeDamage(float damageToTake),当人物的血量小于等于0时,使人物对象失能 gameObject.SetActive(false);gameObject在这里对应的是脚本所绑定的整个对象。

在更新函数中创建一段测试扣血的代码,游戏运行后按下T键就会扣玩家10血。KeyCode的变量表可以在KeyCode - Unity 脚本 API找到。

15 - 敌人对玩家造成伤害

为了能使所有敌人都能访问到玩家的生命值,在这里使用Singleton「单例类:保证每一个类仅有一个实例,并为它提供一个全局访问点」。在PlayerHealthController.cs里声明最大生命之前添加如下代码:

public static PlayerHealthController instance;

private void Awake()
{
    instance = this;
}

注意这里声明的instance是静态变量,虽然是公共的,但是不能在inspector中看到。

Awake():Unity所自带的函数,当一个物体被激活时会运行此函数,并且它的运行早于Start()函数。

this代表当前类的实例对象。

需要通过检测敌人和玩家的碰撞来扣除玩家的生命值,打开EnemyController.cs,声明公共变量damage public float damage;把这个值设置为5,并在更新函数之后添加如下代码:

private void OnCollisionEnter2D(Collision2D collision)
{
    if(collision.gameObject.tag == "Player")
    {
        PlayerHealthController.instance.TakeDamage(damage);
    }
}

OnCollisionEnter2D(Collision2D collision):当传入碰撞体与该对象的碰撞体接触时运行(仅限 2D 物理)。

在inspector内给玩家添加一个Player的tag,当检测到被碰撞物体的tag为Player时,会调用PlayerHealthController.cs中的 TakeDamage()函数扣除玩家生命值。

16 - 敌人攻击间隔

如果有大量的敌人同时碰撞到玩家,玩家的生命值流失会很快。需要给敌人一个攻击时间间隔。

在EnemyController.cs中添加两个新的变量:

public float hitWaitTime = 1f;
private float hitCounter;

hitWaitTime为无敌帧倒计时,hitCounter用于重置计时器。

在更新函数中添加倒计时:

if(hitCounter > 0f)
{
    hitCounter -= Time.deltaTime;
}

更新函数每一帧运行一次的,所以这里每次运行一次更新函数,计时器减去这一帧所用的时间。

在碰撞函数中添加计时器为0的判断 if(collision.gameObject.tag == "Player" && hitCounter <=0f)

计时器为零并且玩家受到伤害后,重置计时器:hitCounter = hitWaitTime;

17 - 显示血条

在场景中右键Player→UI→Canvas创建一个画布重命名为Health Canvas,更改Render Mode「渲染模式」右键为World Space,右键Health Canvas→UI→Slider创建一个滑动条,删除Handle Slide Area「滑动按钮」,调整background和Fill Area的stretch方向为四个方向,把四个方向的数值都改为0,并其更改大小、背景图、位置和颜色让它看起来更像个血条。

18 - 血条实时显示血量

在PlayerHealthController.cs的开头添加 using UnityEngine.UI;让代码能够访问Unity的UI组件。

声明滑动条为血条 public Slider healthSlider;,之后在Inspector内绑定Slider。

healthSlider.maxValue = maxHealth;
healthSlider.value = currentHealth;

在开始函数中设置血条的最大值以及当前血条的值和血量相对应。

TakeDamage()函数中添加 healthSlider.value = currentHealth;使血条和血量保持对应。

取消勾选Slider→Interactable防止玩家拖动血条。

19 - 制作敌人预制件

创建../Assets/Prefabs/Enemies文件夹,把Enemy 1重命名为Green Bee拖到文件夹内,这样就生成了敌人的预制件。

拖动预制件到地图上会直接生成它的复制,预制件可以方便的修改所有同种物体的数值。修改预制件所复制物体的数值后在它的左侧会有一根竖着的蓝线,可以通过修改预制件所复制物体的Overrides内容来修改预制件本身的数值。

注意:当改变复制物体的数值时,之后不会受到预制件基准预设值更改的影响,可以通过右键变量名选择revert来恢复。

20 - 自动生成敌人

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyToSpawn;
    // Start is called before the first frame update

    public float timeToSpawn;
    private float spawnCounter;
    void Start()
    {
        spawnCounter = timeToSpawn;
    }

    // Update is called once per frame
    void Update()
    {
        spawnCounter -= Time.deltaTime;
        if(spawnCounter <= 0)
        {
            spawnCounter = timeToSpawn;

            Instantiate(enemyToSpawn, transform.position, transform.rotation);
        }
    }
}

右键场景生成一个Empty Object重命名为Enemy Spawner,在地图上拖动它到你想生成敌人的位置。创建一个C#脚本命名为EnemySpawner并把它绑定到场景中的Enemy Spawner上。

声明要生成的敌人 public GameObject enemyToSpawn;绑定到Enemy- Green Bee上。

和之前无敌帧的倒计时相同,当倒计时小于等于0时重置倒计时并在当前位置实例化敌人:Instantiate(enemyToSpawn, transform.position, transform.rotation);

Object-Instantiate - Unity 脚本 API

21 - 在屏幕外生成敌人

在场景中的Enemey Spawner上创建两个新的Empty Object分别命名为Min Spawn Point和Max Spawn Point,并把它们呈对角移到摄像头以外的区域,声明变量 public Transform minSpawn, maxSpawn;并绑定。

虚线的区域表示敌人的生成区域

public Vector3 SelectSpawnPoint()
{
    Vector3 spawnPoint = Vector3.zero;

    bool spawnVerticalEdge = Random.Range(0f,1f) > .5f;

    if(spawnVerticalEdge)
    {
        spawnPoint.y = Random.Range(minSpawn.position.y, maxSpawn.position.y);

        if(Random.Range(0f,1f) > .5f)
        {
            spawnPoint.x = maxSpawn.position.x;
        } else
        {
            spawnPoint.x = minSpawn.position.x;
        }
    } else
    {
        spawnPoint.x = Random.Range(minSpawn.position.x, maxSpawn.position.x);

        if(Random.Range(0f,1f) > .5f)
        {
            spawnPoint.y = maxSpawn.position.y;
        } else
        {
            spawnPoint.y = minSpawn.position.y;
        }
    }
    return spawnPoint;
}

创建一个用于随机决定敌人生成区域的函数 SelectSpwanPoint(),这个函数返回值是一个Vector3「三维向量」,首先通过 bool spawnVerticalEdge = Random.Range(0f,1f) > .5f;判断生成在竖直边还是垂直边上,再通过相同的办法判断生成在剩余两条边的哪一侧。

22 - 修复一些小bug

防止敌人遮挡住玩家的血条,新建一个新的Sorting Layer命名为UI并移到其他图层之上。

把EnemyController.cs和CameraController.cs中的 target = FindObjectOfTypeplayercontroller().transform;替换为 target = PlayerHealthController.instance.transform;

另外在EnemySpawner.cs添加声明 private Transform target;,在开始函数中添加 target = PlayerHealthController.instance.transform;,并在更新函数的最后添加 transform.position = target.position;,使敌人生成器跟随玩家移动。

23 - 跟踪敌人

在EnemySpawner.cs中添加一个私有变量 private float despawnDistance;表示敌人距离玩家多远时会被清除。

在开始函数中添加 despawnDistance = Vector3.Distance(transform.position, maxSpawn.position) + 4f;

Vector3.Distance():计算3维空间中两点之间距离。

声明一个类型为GameObject的列表spawnedEnemies,private Listgameobject spawnedEnemies = new Listgameobject();

另外把更新函数中的实例化生成敌人 Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation);更改为

GameObject newEnemy = Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation);
spawnedEnemies.Add(newEnemy);

把生成的敌人添加到之前声明的列表中。

24 - 清除较远处敌人

不断地计算所有的敌人和玩家之间的距离占用了大量的资源,可能会导致游戏跳帧,可以通过每隔一定时间检查部分敌人来进行优化。

打开EnemySpawner.cs,添加声明 public int checkPerFrame;private int enemyToCheck;,设置 checkPerFrame为10,enemyToCheck为具体要检查的敌人编号,checkPerFrame为每帧检查多少个敌人。

在更新函数的最后添加:

int checkTarget = enemyToCheck + checkPerFrame;

while(enemyToCheck < checkTarget)
{
    if(enemyToCheck < spawnedEnemies.Count)
    {
        if(spawnedEnemies[enemyToCheck] != null)
        {
            if(Vector3.Distance(transform.position, spawnedEnemies[enemyToCheck].transform.position) > despawnDistance)
            {
                Destroy(spawnedEnemies[enemyToCheck]);
                spawnedEnemies.RemoveAt(enemyToCheck);
                checkTarget--;
            } else
            {
                enemyToCheck++;
            }
        } else
        {
            spawnedEnemies.RemoveAt(enemyToCheck);
            checkTarget--;
        }
    } else
    {
        enemyToCheck = 0;
        checkTarget = 0;
    }
}

checkTarget为我们要检查的敌人总数,while(enemyToCheck < checkTarget)当要检查的敌人编号小于总数时开始循环。if(enemyToCheck < spawnedEnemies.Count)判断敌人编号是否小于敌人列表长度,如果为否则跳出while循环。

if(spawnedEnemies[enemyToCheck] != null)判断敌人编号所对应的是否为空位,如果为空位,用 RemoveAt()函数删除且敌人编号减一,若不为空位继续判断 if(Vector3.Distance(transform.position, spawnedEnemies[enemyToCheck].transform.position) > despawnDistance),如果敌人距离玩家太远则用 Destroy()函数清除敌人,同时删除空位且敌人编号减一。若敌人距离玩家小于清除距离,则敌人编号加一,继续检查下一个敌人。

25 - 添加武器 - 火球

添加一个可以围绕着玩家旋转的火球来攻击敌人。

../Assets/Scripts/Weapons文件夹下新建一个SpinWeapon.cs,在场景中新建Empty Object,路径如下Player/Weapons/Orbitting Fireball/Fireball Holder,绑定精灵图到其上并重命名为Fireball。

给FireBall添加一个Circle Collider 2D碰撞器,调整它的半径。

Tips:为了增强玩家的游戏体验,武器的伤害范围通常比图片尺寸大一些。

打开SpinWeapon.cs,添加声明 public Transform holder;,把Fireball Holder绑定到上面。另外添加角速度声明 public float rotateSpeed;设置为180,单位为度每秒。

在更新函数中添加 holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime));,这里用欧拉角使火球旋转。

Unity中的旋转使用四元数或欧拉角,具体可以参考Unity 中的旋转和方向 - Unity 手册

26 - 对敌人造成伤害

在EnemyController.cs中声明玩家的血量,和玩家一样添加一个TakeDamge()函数:

public void TakeDamage(float damageToTake)
{
    health -= damageToTake;
    if(health <= 0)
    {
        Destroy(gameObject);
    }
}

如果敌人的血量小于0则清除敌人。

给Enemy - Green Bee添加一个Enemy的tag。

新建一个EnemyDamager.cs绑定到火球上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyDamager : MonoBehaviour
{
    public float damageAmount;

    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
  
    }
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if(collision.tag == "Enemy")
        {
            collision.GetComponent<EnemyController>().TakeDamage(damageAmount);
        }
    }

}

声明火球的伤害 public float damageAmount;并设置为5。

GameObject.GetComponent(type):如果游戏对象附加了类型为 type 的组件,则将其返回,否则返回 null

27 - 给火球添加粒子效果

右键Fireball创建粒子效果Effects→Particale System。

在Inpector内找到Shape,把形状改为圆形并更改半径为火球的大小,在Emission中更改Rate over Time为25,另外设置Start Delay:0.5,Start Lifetime:0.5,Start Speed:0.1,Start Size:0.5,设置颜色为随机两种颜色,红色和橙色并调整透明度。Simulation Space从Local更改为World,这样可以显示出火焰的轨迹。另外Size Over LifeTime设置Size随着时间变小。Color Over LifeTime设置颜色随着时间逐渐变淡。

效果如图所示

28 - 设置火球的持续和冷却时间

取消激活FireBall Holder并新建一个Holder:

打开SpinWeapon.cs,声明 Holder绑定到Holder,声明 fireballToSpawn绑定到Fireball Holder上。另外声明 timeBetweenSpawnSpawnCounter,为生成的冷却倒计时,其中设置 timeBetweenSpawn为4。

public Transform holder, fireballToSpawn;
public float timeBetweenSpawn;
private float spawnCounter;

在更新函数中添加火球生成的倒计时:

spawnCounter -= Time.deltaTime;
if(spawnCounter <= 0)
{
    spawnCounter = timeBetweenSpawn;

    Instantiate(fireballToSpawn, fireballToSpawn.position, fireballToSpawn.rotation, holder).gameObject.SetActive(true);
}

Instantiate()函数第四个传递参数为,把实例化的对象作为此参数的子对象,这里倒计时结束后实例化生成为 holder的子对象并激活。

打开EnemyDamager.cs添加声明 public float lifeTime;并设置为3,lifeTime代表火球的持续时间。在开始函数中添加 Destroy(gameObject, lifeTime);,当经过 lifeTime后清除火球。

29 - 火球渐入渐出

打开EnemyDamager.cs,添加声明 public float lifeTime, growSpeed = 5f;private Vector3 targetSize;

注释开始函数中的Destroy()函数,添加:

targetSize = transform.localScale;
transform.localScale = Vector3.zero;

使 targetSize先等于当前火球的大小,之后再设置火球初始大小为0。

和敌人放大缩小的动画类似,在更新函数的最后添加:

transform.localScale = Vector3.MoveTowards(transform.localScale, targetSize, growSpeed * Time.deltaTime);

lifeTime -= Time.deltaTime;

if(lifeTime <=0 )
{
    targetSize = Vector3.zero;

    if(transform.localScale.x <= 0 )
    {
        Destroy(gameObject);
    }
}

当火球持续时间结束后删除火球。

30 - 敌人击退

打开EnemyController.cs,声明 public float knockBackTime = .5f;private float knockBackCount;

创建一个新的函数用于判断哪些武器才能击退:

public void TakeDamage(float damageToTake, bool shouldKnockback)
{
    TakeDamage(damageToTake);

    if(shouldKnockback == true)
    {
        knockBackCounter = knockBackTime;
    }
}

这个函数调用了之前的 TakeDamge()函数。

另外在EnemyDamager.cs中添加 public bool shouldKnockback;的声明并勾选设置为真,同时相应更改下方的 TakeDamge()函数。

在EnemyController.cs的更新函数开头添加击退判断:

if(knockBackCounter >0)
{
    knockBackCounter -= Time.deltaTime;

    if(moveSpeed > 0)
    {
        moveSpeed = -moveSpeed * 2f;
    }

    if(knockBackCounter <=0 )
    {
        moveSpeed = Mathf.Abs(moveSpeed * .5f);
    }
}

Mathf.Abs():取绝对值。

31 - 显示伤害数字

在场景中创建Damage Number Contrller,创建画布Damage Number Canvas,和之前显示血量一样设置Render Mode为World Space并放置到世界中央,点击UI→Text创建文字框。结构如下Damage Number Contrller/Damage Number Canvas/Text (TMP)。

右键Fonts/Kenney Fonts点击Create→TextMeshPro→Font Assets生成字体的一个游戏用版本,绑定到Text上。设置字体大小为72,对齐方式选择垂直和水平居中;设置Dilate和Thickness为0.25给文字添加轮廓线。

32 - 编写伤害数字代码

创建DamageNumber.cs,绑定到Text (TMP)上。设置lifetime为2。

使用文本组件要在开头添加 using TMPro;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class DamageNumber : MonoBehaviour
{
    public TMP_Text damageText;

    public float lifetime;
    private float lifeCounter;


    // Update is called once per frame
    void Update()
    {
  

        if(lifeCounter > 0)
        {
            lifeCounter -= Time.deltaTime;

            if(lifeCounter <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

    public void Setup(int damageDisplay)
    {
        lifeCounter = lifetime;

        damageText.text = damageDisplay.ToString();
    }
}

创建DamageNumberController.cs,绑定两个公共变量

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DamageNumberController : MonoBehaviour
{
    public static DamageNumberController instance;

    private void Awake()
    {
        instance = this;
    }

    public DamageNumber numberToSpawn;
    public Transform numberCanvas;
    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {

    }

    public void SpawnDamage(float damageAmount, Vector3 location)
    {
        int rounded = Mathf.RoundToInt(damageAmount);

        DamageNumber newDamage = Instantiate(numberToSpawn, location, Quaternion.identity, numberCanvas);

        newDamage.Setup(rounded);
        newDamage.gameObject.SetActive(true);
    }
}

取消勾选Text (TMP)的激活状态。

Mathf.RoundToInt():数字四舍五入到整数。

打开EnemyController.cs,在TakeDamge()函数最后添加 DamageNumberController.instance.SpawnDamage(damageToTake, transform.position);以生成伤害数字。

33 - 增强显示效果

设置Damage Number Canvas的Sorting Layer为UI以防敌人遮挡。

给伤害数字添加一个上浮的效果:

在DamageNumber.cs中添加 public float floatSpeed = 1f;

在更新函数中增加 transform.position += Vector3.up * floatSpeed * Time.deltaTime;

Vector3.up:x = 1,y = 0,z = 0

34 - 建立伤害数字对象池

建立对象池重复利用伤害数字对象以减少游戏卡顿。

打开DamageNumberController.cs,添加声明 private Listdamagenumber numberPool = new Listdamagenumber();

创建 GetFromPool()函数用来从对象池中取出数字

public DamageNumber GetFromPool()
{
    DamageNumber numberToOutput = null;
  
    if(numberPool.Count ==0)
    {
        numberToOutput = Instantiate(numberToSpawn, numberCanvas);
    } else
    {
        numberToOutput = numberPool[0];
        numberPool.RemoveAt(0);
    }

    return numberToOutput;
}

更改 SpawnDamage()函数中的 DamageNumber newDamage = Instantiate(numberToSpawn, location, Quaternion.identity, numberCanvas);DamageNumber newDamage = GetFromPool();。并在最后添加 newDamage.transform.position = location;设置其位置。

添加一个PlaceInPool()函数用于把伤害数值存放进对象池

public void PlaceInPool(DamageNumber numberToPlace)
{
    numberToPlace.gameObject.SetActive(false);

    numberPool.Add(numberToPlace);
}

在DamageNumber.cs中的更新函数中更改 Destroy(gameObject);DamageNumberController.instance.PlaceInPool(this);

【修复】删除未激活火球的父对象

在EnemyDamager.cs中添加声明 public bool destroyParent;并设置布尔值为true

在更新函数 Destroy(gameObject);后面添加

if(destroyParent)
{
    Destroy(transform.parent.gameObject);
}

35 - 添加更多敌人

在场景中复制GreenBee的预制件,把它的精灵图更改为其他敌人,并相应地修改数值。

可以右键预制件的复制选择Prefab→Unpack解除和预制件的联系,变为一个独立的对象。

重命名新添加的敌人并生成它们的预制件。

36 - 敌人波次

打开EnemySpawner.cs,在 EnemySpawner类中添加声明 public List<waveinfo> waves;

创建一个新的公共类 WaveInfo用于储存不同敌人波次的数据。

注意:由于新的类不在Unity的MonoBehaviour类中,所以无法在Inspector内看到其公共变量的值,并且也不会被检查更新。

[System.Serializable]
public class WaveInfo
{
    public GameObject enemyToSpawn;
    public float waveLength = 10f;
    public float timeBetweenSpawns = 1f;
}

[System.Serializable]:把其后的代码转换为数据,以在Inspector中显示出来。

点击EnemySpawner→Inspector→Waves的加号创建6个波次对应6个不同的敌人,绑定敌人预制件到EnemyToSpawn,设置WaveLength为10,TimeBetweenSpawn为0.5。

37 - 生成不同波次的敌人

打开EnemySpawner.cs在 EnemySpawner类中添加声明:

private int currentWave;

private float waveCounter;

waveCounter:波次计时器。

后面不再依赖之前的敌人生成代码,注释以下内容:

/* spawnCounter -= Time.deltaTime;
if(spawnCounter <= 0)
{
    spawnCounter = timeToSpawn;

    //Instantiate(enemyToSpawn, transform.position, transform.rotation);

    GameObject newEnemy = Instantiate(enemyToSpawn, SelectSpawnPoint(), transform.rotation);

    spawnedEnemies.Add(newEnemy);
} */

在注释的代码后面添加新的敌人生成代码:

if(PlayerHealthController.instance.gameObject.activeSelf)
{
    if(currentWave < waves.Count)
    {
        waveCounter -= Time.deltaTime;
        if(waveCounter <= 0)
        {
            GoToNextWave();
        }

        spawnCounter -= Time.deltaTime;
        if(spawnCounter <= 0)
        {
            spawnCounter = waves[currentWave].timeBetweenSpawns;

            GameObject newEnemy = Instantiate(waves[currentWave].enemyToSpawn, SelectSpawnPoint(), Quaternion.identity);

            spawnedEnemies.Add(newEnemy);
        }
    }
}

增加了玩家是否死亡的判断,添加了波次倒计时以及调用切换波次的函数,其余部分和之前的代码类似。

EnemySpawner类的最后添加 GoToNextWave()函数用以切换波次:

public void GoToNextWave()
{
    currentWave++;

    if(currentWave >= waves.Count)
    {
        currentWave = waves.Count - 1;
    }

    waveCounter = waves[currentWave].waveLength;
    spawnCounter = waves[currentWave].timeBetweenSpawns;
}

当前波次大于波次列表长度时,停留在最后一个波次。

这时运行游戏会发现第一波的蜜蜂并没有生成,而是直接生成了第二波的敌人。这是由于波次倒计时 waveCounter初始值为0,跳过了第一波次的敌人。

修改开始函数为以下代码:

void Start()
{
    //spawnCounter = timeToSpawn;
    target = PlayerHealthController.instance.transform;

    despawnDistance = Vector3.Distance(transform.position, maxSpawn.position) + 4f;

    currentWave = -1;
    GoToNextWave();
}

使游戏一开始的波次为-1并立即跳转到下一波次。

38 - 经验系统

创建ExperienceLevelController.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ExprienceLevelController : MonoBehaviour
{
    public static ExprienceLevelController instance;

    private void Awake()
    {
        instance = this;
    }

    public int currentExperience;

    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
  
    }

    public void GetExp(int amountToGet)
    {
        currentExperience += amountToGet;
    }
}

在场景中创建Experience Level Controller并绑定C#文件到其上;另外创建Experience Pickup并拖动精灵图到上面作为其子对象,再创建一个新的图层Pickups,使掉落物显示在敌人上方玩家下方。

39 - 拾取经验值

为Experience Pickup添加圆形碰撞器并设置大小,勾选Is Trigger,创建一个新的脚本ExpPickup.cs绑定到其上。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ExpPickup : MonoBehaviour
{
    public int expValue;

    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
  
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if(collision.tag == "Player")
        {
            ExperienceLevelController.instance.GetExp(expValue);

            Destroy(gameObject);
        }
    }
}

设置expValue为1。

给玩家添加一个刚体组件,设置Body Type为Kenematic,即则力、碰撞或关节将不再影响刚体。

打开debug模式开始游戏,可以看到玩家碰撞到经验后经验消失,currentExperience增加1。

40- 拾取范围

在PlayerController.cs中添加拾取范围声明 public float pickupRange = 1.5f;

打开ExpPickup,添加以下声明,并设置moveSpeed「经验移动速度」为1:

private bool movingToPlayer;
public float moveSpeed;

public float timeBetweebChecks = .2f;
private float checkCounter;

private PlayerController player;

在开始函数中添加:

player = PlayerHealthController.instance.GetComponent<PlayerController>();

在更新函数中添加:

if(movingToPlayer == true)
{
    transform.position = Vector3.MoveTowards(transform.position, PlayerHealthController.instance.transform.position, moveSpeed * Time.deltaTime);
} else
{
    checkCounter -= Time.deltaTime;
    if(checkCounter <= 0)
    {
        checkCounter = timeBetweenChecks;

        if(Vector3.Distance(transform.position, PlayerHealthController.instance.transform.position) < player.pickupRange)
        {
            movingToPlayer = true;
            moveSpeed += player.moveSpeed;
        }
    }
}

当玩家和经验值的距离小于 pickupRange时,经验值向玩家移动。

41 - 使敌人掉落经验

制作经验值掉落物的预制件。

打开ExperienceLevelController.cs,添加声明 public ExpPickup pickup;并绑定经验值预制件。

创建一个函数用于生成掉落的经验:

public void SpawnExp(Vector3 position, int expValue)
{
    Instantiate(pickup, position, Quaternion.identity).expValue = expValue;
}

打开EnemyController.cs,添加声明 public int expToGive = 1;,即敌人掉落多少经验值;分别设置不同敌人的 expToGive;另外,在 TakeDamage()函数的 Destroy(gameObject);后添加

ExperienceLevelController.instance.SpawnExp(transform.position, expToGive);,在敌人死亡位置实例化经验值掉落物。

42 - 添加等级

打开ExperienceLevelController.cs,添加每级所需经验值,当前等级,等级上限的声明:

public List<int> expLevels;
public int currentLevel = 1, levelCount = 100;

为expLevels列表添加前7级所需的经验值:

在开始函数中添加:

while(expLevels.Count < levelCount)
{
    expLevels.Add(Mathf.CeilToInt(expLevels[expLevels.Count - 1] * 1.1f));
}

自动生成从8级到100级所需的经验值,每一级所需的经验是上一级的1.1倍。

Mathf.CeilToInt():天花板函数,用进一法取整数。

43 - 升级

打开ExperienceLevelController.cs,在ExpGet()函数中添加升级判断:

if(currentExperience >= expLevels[currentLevel])
{
    LevelUp();
}

创建LevelUp()「升级函数」:

void LevelUp()
{
    currentExperience -= expLevels[currentLevel];

    currentLevel++;

    if(currentLevel >= expLevels.Count)
    {
        currentLevel = expLevels.Count - 1;
    }
}

当玩家到达等级上限时,使玩家停留在最高等级。

44 - 显示经验条

右键场景UI→Canvas创建画布,重命名为UI Canvas,设置UI Scale Mode为Scale Width Or Height,设置Reference Resolution,X: 1920,Y: 1080。

在场景中添加经验条和文本框

和之前设置血条和伤害数字类似,详见17 - 显示血条31 - 显示伤害数字

效果如图所示

添加完毕后设置经验条的Slider→Value为0。

45 - 更新经验条的显示

新建UIController.cs绑定到场景的UI Canvas上:

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;


public class UIController : MonoBehaviour
{
    public static UIController instance;
    private void Awake()
    {
        instance = this;
    }

    public Slider expLvlSlider;
    public TMP_Text expLvlText;


    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
  
    }

    public void UpdateExperiece(int currentExp, int levelExp, int currentLvl)
    {
        expLvlSlider.maxValue = levelExp;
        expLvlSlider.value = currentExp;

        expLvlText.text = "Level: " + currentLvl;
    }
}

分别绑定经验条Slider和等级文字Text到公共变量 expLvlSliderexpLvlText上。

打开ExperienceLevelController.cs,在 GetExp()函数的最后添加 UIController.instance.UpdateExperiece(currentExperience, expLevels[currentLevel], currentLevel);调用UIController.cs中更新经验条的函数。

46 - 武器数值表01

新建Weapon.cs,用于储存武器的属性,因为不需要用到开始和更新函数,删除之:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Weapon : MonoBehaviour
{
    public List<WeaponStats> stats;
    public int weaponLevel;
}

[System.Serializable]
public class WeaponStats
{
    public float speed, damage, range, timeBtweenAttacks, amount, duration;
}

打开SpinWeapon.cs,修改 public class SpinWeapon : MonoBehaviourpublic class SpinWeapon : Weapon,使SpinWeapon类继承Weapon.cs的内容,可以直接使用Weapon.cs声明的所有变量。

47 - 武器数值表02

打开SpinWeapon.cs 添加声明 public EnemyDamager damager;并绑定Fireball到其上。

创建 SetStats()函数用以改变武器的属性:

public void SetStats()
{
    damager.damageAmount = stats[weaponLevel].damage;

    transform.localScale = Vector3.one * stats[weaponLevel].range;

    timeBetweenSpawn = stats[weaponLevel].timeBtweenAttacks;

    damager.lifeTime = stats[weaponLevel].duration;

    spawnCounter = 0;
}

并在开始函数中调用此函数。spawnCounter = 0;:升级后立即刷新火球状态。

更新火球的旋转速度,修改更新函数中的 holder.rotationholder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime * stats[weaponLevel].speed));

修改武器的数值并在游戏中进行测试。

48 - 升级武器

为了方便调用玩家,打开PlayerController.cs添加

public static PlayerController instance;
private void Awake()
{
    instance = this;
}

声明当前使用的武器 public Weapon activeWeapon;,绑定Orbiting Fireball到其上。由于Orbiting Fireball的Spin Weapon.cs继承了Weapon.cs,它也拥有Weapon组件。

在Weapon.cs的 Weapon类中添加升级更新声明:

[HideInInspector]
public bool statsUpdated;

另外添加武器武器升级函数:

public void LevelUp()
{
    if (weaponLevel < stats.Count - 1)
    {
        weaponLevel++;

     statsUpdated = true;
    }
}

打开ExperienceLevelController.cs在Levelup()中调用此函数:PlayerController.instance.activeWeapon.LevelUp();

在SpinWeapon.cs中的更新函数中添加升级的判断,升级则更新武器属性状态:

if(statsUpdated ==true)
{
    statsUpdated = false;

    SetStats();
}

多添加几级的武器属性状态,并在游戏中测试武器的升级系统。

49 - 设置升级界面

右键UI Canvas创建面板,再在面板下创建一个按钮,并相应更改其属性如下图所示:

50 - 更新升级按钮的显示

在Weapon.cs的Weapon类中添加升级描述图片的声明 public Sprite icon; ,在 WeaponStats类中添加升级描述文本的声明 public string upgradeText;,并相应的绑定它们。

新建 LevelUpSelectionButton.cs用于更新升级按钮的显示,绑定器到场景中的Level Up Button 1上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class LevelUpSelectionButton : MonoBehaviour
{
    public TMP_Text upgradeDescText, nameLevelText;
    public Image weaponIcon;

    public void UpdateButtonDisplay(Weapon theWeapon)
    {
        upgradeDescText.text = theWeapon.stats[theWeapon.weaponLevel].upgradeText;
        weaponIcon.sprite = theWeapon.icon;

        nameLevelText.text = theWeapon.name + " - Lvl " +theWeapon.weaponLevel;
    }
}

打开UIController.cs添加声明按钮数组 public LevelUpSelectionButton[] levelUpButtons;并绑定:

绑定后如图所示

在SpinWeapon.cs的开始函数中调用之前的更新升级按钮的函数,用于临时测试

void Start()
{
    SetStats();

    UIController.instance.levelUpButtons[0].UpdateButtonDisplay(this);
}

开始游戏后升级,效果如下图所示

51 - 继续给升级按钮添加功能

打开UIController.cs添加升级界面的声明并绑定Level Up Interface:public GameObject levelUpPanel;

再复制两个按钮放到左右两边,停用Level Up Interface:

打开ExperienceLevelController.cs更改升级函数为如下,升级时打开升级界面,游戏暂停,同时更新武器升级按钮的图标和文本:

void LevelUp()
{
    currentExperience -= expLevels[currentLevel];

    currentLevel++;

    if(currentLevel >= expLevels.Count)
    {
        currentLevel = expLevels.Count - 1;
    }

    //PlayerController.instance.activeWeapon.LevelUp();

    UIController.instance.levelUpPanel.SetActive(true);

    Time.timeScale = 0f;

    UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon);
}

Time.timeScale:时间流逝的标度。可用于慢动作效果。当 timeScale 为 1.0 时,时间流逝的速度与实时一样快。 当 timeScale 为 0.5 时,时间流逝的速度比实时慢 2x。当 timeScale 设置为 0 时,如果您的所有函数都是独立于帧率的, 则游戏基本上处于暂停状态。参见 Time-timeScale - Unity 脚本 API

注释掉在50节中开始函数中增加的更新按钮的函数:

//UIController.instance.levelUpButtons[0].UpdateButtonDisplay(this);

由于修改代码为显示升级面板中间的按钮,开始游戏效果如下图所示:

52 - 点击升级按钮后关闭界面

在LevelUpSelectionButton.cs中添加声明private Weapon assignedWeapon;用于储存所选择武器的信息。

添加选择升级的函数,选择升级后,关闭升级界面并恢复游戏:

public void SelectUpgrade()
{
    if(assignedWeapon != null)
    {
        assignedWeapon.LevelUp();

        UIController.instance.levelUpPanel.SetActive(false);
        Time.timeScale = 1f;
    }
}

为每个按钮添加On Click(),点击按钮后运行SelectUpgrade()函数,如下图所示:

进入游戏测试,当升级后弹出升级界面且游戏暂停,点击升级按钮则界面消失,游戏恢复正常,武器属性也得到增强。

53 - 随机初始武器

玩家升级和解锁更多的武器。

注释PlayerController.cs中的//public Weapon activeWeapon;,在其后新增一个未拥有武器列表和一个拥有的武器列表public Listweapon unassignedWeapons, assignedWeapons;

在ExperienceLevelController.cs中注释//UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon);

停用并在场景中复制几个Orbiting Fireball,把它们和unassignedWeapons绑定,如下图:

在PlayerController.cs中添加一个用于解锁武器的函数:

public void AddWeapon(int weaponNumber)
{
    if(weaponNumber < unassignedWeapons.Count)
    {
        assignedWeapons.Add(unassignedWeapons[weaponNumber]);

        unassignedWeapons[weaponNumber].gameObject.SetActive(true);
        unassignedWeapons.RemoveAt(weaponNumber);
    }
}

在开始函数中调用此函数随机分配武器AddWeapon(Random.Range(0, unassignedWeapons.Count));

Random.Range():取两个数字间的随机数,int类型为开区间,float类型为闭区间

此时进行游戏会发现,随机移动一个unassignedWeapons列表中的武器到assignedWeapons列表中,如下图:

在ExperienceLevelController.cs之前注释的位置,添加修改过后的代码:

UIController.instance.levelUpButtons[0].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[0]);
UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[0]);
UIController.instance.levelUpButtons[2].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[1]);

升级后可看到升级按钮正确显示。

54 - 解锁新武器

当升级时检测武器是否解锁,如果没有则按钮显示解锁,在LevelUpSelectionButton.cs的UpdateButtonDisplay()函数中增加判断:

public void UpdateButtonDisplay(Weapon theWeapon)
{
    if(theWeapon.gameObject.activeSelf ==true)
    {
        upgradeDescText.text = theWeapon.stats[theWeapon.weaponLevel].upgradeText;
        weaponIcon.sprite = theWeapon.icon;

        nameLevelText.text = theWeapon.name + " - Lvl " +theWeapon.weaponLevel;
    } else
    {
        upgradeDescText.text = "Unlock " + theWeapon.name;
        weaponIcon.sprite = theWeapon.icon;

        nameLevelText.text = theWeapon.name;
    }


    assignedWeapon = theWeapon;
}d

当点击解锁按钮时,解锁新武器,打开LevelUpSelectionButton.cs,在SelectUpgrade()函数中增加判断:

public void SelectUpgrade()
{
    if(assignedWeapon != null)
    {   
        if(assignedWeapon.gameObject.activeSelf ==true)
        {
            assignedWeapon.LevelUp();
        } else
        {
            PlayerController.instance.AddWeapon(assignedWeapon);
        }
  

        UIController.instance.levelUpPanel.SetActive(false);
        Time.timeScale = 1f;
    }
}

在PlayerController.cs中增加新的添加武器函数:

public void AddWeapon(Weapon weaponToAdd)
{
    weaponToAdd.gameObject.SetActive(true);

    assignedWeapons.Add(weaponToAdd);
    unassignedWeapons.Remove(weaponToAdd);
}

可以发现我们这里拥有两个AddWeapon()函数,具体调用的是哪个函数根据它们的传参类型而定。

55 - 升级时显示解锁新武器选项

注释之前在ExperienceLevelController.cs中所编写的硬编码:

//UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.activeWeapon);
//UIController.instance.levelUpButtons[0].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[0]);
//UIController.instance.levelUpButtons[1].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[0]);
//UIController.instance.levelUpButtons[2].UpdateButtonDisplay(PlayerController.instance.unassignedWeapons[1]);

在PlayerContoller.cs中添加最大武器上限声明public int maxWeapons = 3;

在ExperienceLevelController.cs添加声明public Listweapon weaponsToUpgrade;,用于在屏幕上显示我们要升级哪三种武器。

修改LevelUp()优先显示一个已解锁武器的升级选项,当拥有武器数量小于maxWeapons时,剩下的升级选项用升级武器或解锁其他武器补足:

weaponsToUpgrade.Clear();

List<Weapon> avalibaleWeapons = new List<Weapon>();
avalibaleWeapons.AddRange(PlayerController.instance.assignedWeapons);

if(avalibaleWeapons.Count > 0)
{
    int selected = Random.Range(0, avalibaleWeapons.Count);
    weaponsToUpgrade.Add(avalibaleWeapons[selected]);
    avalibaleWeapons.RemoveAt(selected);
}
if(PlayerController.instance.assignedWeapons.Count < PlayerController.instance.maxWeapons)
{
    avalibaleWeapons.AddRange(PlayerController.instance.unassignedWeapons);
}

for(int i = weaponsToUpgrade.Count; i < 3; i++)
{
    if(avalibaleWeapons.Count > 0)
    {
        int selected = Random.Range(0, avalibaleWeapons.Count);
        weaponsToUpgrade.Add(avalibaleWeapons[selected]);
        avalibaleWeapons.RemoveAt(selected);
    }
}

for(int i = 0; i < weaponsToUpgrade.Count; i++)
{
    UIController.instance.levelUpButtons[i].UpdateButtonDisplay(PlayerController.instance.assignedWeapons[i]);
}

List.AddRange():把一个列表的所有元素添加到列表List中。

注意:从一个列表添加元素到另一个列表时,首先要判断列表长度是否大于0!

56 - 隐藏满级武器的升级选项

在PlayerController.cs中添加满级武器列表声明:

[HideInInspector]
public List<Weapon> fullyLevelledWeapons = new List<Weapon>();

更改Weapon.cs的LevelUp()函数:

public void LevelUp()
{
    if (weaponLevel < stats.Count - 1)
    {
        weaponLevel++;

        statsUpdated = true;

        if(weaponLevel >= stats.Count -1)
        {
            PlayerController.instance.fullyLevelledWeapons.Add(this);
            PlayerController.instance.assignedWeapons.Remove(this);
        }
    }
}

当武器满级时从assignedWeapons列表中移到fullyLevelledWeapons列表中。

在ExperienceContoller.cs中修改LevelUp()函数中的判断if(PlayerController.instance.assignedWeapons.Count < PlayerController.instance.maxWeapons)if(PlayerController.instance.assignedWeapons.Count + PlayerController.instance.fullyLevelledWeapons.Count < PlayerController.instance.maxWeapons)

此时升级界面不会显示满级武器的升级图标和文本描述。

更进一步,使升级界面不显示满级武器的升级按钮,在LevelUp()最后添加:

for(int i = 0; i < UIController.instance.levelUpButtons.Length; i++)
{
    if(i < weaponsToUpgrade.Count)
    {
        UIController.instance.levelUpButtons[i].gameObject.SetActive(true);
    } else
    {
        UIController.instance.levelUpButtons[i].gameObject.SetActive(false);
    }
}

57 - 跳过升级界面

创建一个跳过升级界面的按钮:

在UIController.cs中添加跳过升级界面的函数:

public void SkipLevelUp()
{
    levelUpPanel.SetActive(false);
    Time.timeScale = 1;
}

把按钮的OnClick()绑定到此函数上,如下图:

58 - 火球升级时增加数量

当火球数量增加时位置需要均匀分布,例如玩家操控3个火球时,火球所在角度应分别为0°,120°,240°。

修改SpinWeapon.cs中的更新函数:

void Update()
{
    //holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime));
    holder.rotation = Quaternion.Euler(0f, 0f, holder.rotation.eulerAngles.z + (rotateSpeed * Time.deltaTime * stats[weaponLevel].speed));

    spawnCounter -= Time.deltaTime;
    if(spawnCounter <= 0)
    {
        spawnCounter = timeBetweenSpawn;

        //Instantiate(fireballToSpawn, fireballToSpawn.position, fireballToSpawn.rotation, holder).gameObject.SetActive(true);
        for(int i = 0; i < stats[weaponLevel].amount; i++)
        {
            float rot = 360f / stats[weaponLevel].amount * i;

            Instantiate(fireballToSpawn, fireballToSpawn.position, Quaternion.Euler(0f, 0f, rot), holder).gameObject.SetActive(true);
        }
    }

    if(statsUpdated ==true)
    {
        statsUpdated = false;

        SetStats();
    }
}

修改每个等级中火球属性的amount,可以发现火球升级后均匀分布:

59 - 设置火球的数值

设置火球的每个等级的数据到一个合理的值:

60 - 增加武器 - 光环

在场景中创建范围伤害武器Bright Zone并添加Circle Collider 2D和EnemyDamager.cs组件:

、..

打开EnemyDamager.cs添加新的变量声明:

public bool damageOverTime;
public float timeBetweenDamage;
private float damageCounter;

private List<EnemyController> enemiesInRange = new List<EnemyController>();

damageOverTime:是否持续伤害。

timeBetweenDamge:攻击时间间隔。

damageCounter:用于攻击时间间隔的倒计时。

enemiesInRange:用于追踪攻击范围内的敌人。

在Inspector内修改它们的数值,如下图所示:

更改OnTriggerEnter2D()函数为如下:

private void OnTriggerEnter2D(Collider2D collision)
{
    if(damageOverTime == false)
    {
        if(collision.tag == "Enemy")
        {
            collision.GetComponent<EnemyController>().TakeDamage(damageAmount, shouldKnockback);
        } 
    } else
    {
        if(collision.tag =="Enemy")
        {
            enemiesInRange.Add(collision.GetComponent<EnemyController>());
        }
    }
}

增加判断是否持续伤害,如果是,则把范围内的敌人增加到enemiesInRange列表中。

添加OnTriggerExit2D()函数,从enemiesInRange列表中移除离开伤害范围的敌人:

private void OnTriggerExit2D(Collider2D collision)
{
    if(damageOverTime == true)
    {
        if(collision.tag == "Enemy")
        {
            enemiesInRange.Remove(collision.GetComponent<EnemyController>());
        }
    }
}

在更新函数中添加对攻击范围内敌人造成伤害的代码。并且当敌人死亡后,移除空位。在更新函数最后添加:

if(damageOverTime == true)
{
    damageCounter -= Time.deltaTime;

    if(damageCounter <= 0)
    {
        damageCounter = timeBetweenDamage;

        for(int i =0; i < enemiesInRange.Count; i++)
        {
            if(enemiesInRange[i] != null)
            {
                enemiesInRange[i].TakeDamage(damageAmount, shouldKnockback);
            } else
            {
                enemiesInRange.RemoveAt(i);
                i--;
            }
        }
    } 
}

效果如下图:

61 - 生效新的武器

在...Assets/Scripts/Weapons下新建ZoneWeapon.cs并绑定其到ZoneWeapon上,绑定Zone Weapon Effect到damager,ZoneWeapons.cs内容如下:

using System.Collections;
using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;

public class ZoneWeapon : Weapon
{
    public EnemyDamager damager;

    private float spawnTime, spawnCounter;
    // Start is called before the first frame update
    void Start()
    {
        SetStats();
    }

    // Update is called once per frame
    void Update()
    {
        if(statsUpdated ==true)
        {
            statsUpdated = false;

            SetStats();
        }

        spawnCounter -= Time.deltaTime;
        if(spawnCounter <= 0f)
        {
            spawnCounter = spawnTime;

            Instantiate(damager, damager.transform.position, Quaternion.identity, transform).gameObject.SetActive(true);
        }
    }

    void SetStats()
    {
        damager.damageAmount = stats[weaponLevel].damage;
        damager.lifeTime = stats[weaponLevel].duration;

        damager.timeBetweenDamage = stats[weaponLevel].speed;

        damager.transform.localScale = Vector3.one * stats[weaponLevel].range;

        spawnTime = stats[weaponLevel].timeBtweenAttacks;

        spawnCounter = 0f;
    }
}

为ZoneWeapon添加几个等级并相应修改数据:

临时修改PlayerController.cs的开始函数用于测试新武器:

void Start()
{
    if(assignedWeapons.Count == 0)
    {
        AddWeapon(Random.Range(0, unassignedWeapons.Count));
    }
}

游戏开始时会使用AddWeapon()实例化生成新武器,停用Zone Weapon Effect防止报错,升级界面可以正确显示升级按钮的图标和文字描述,并且点击升级按钮后武器会提升相应属性,测试如下图:

62 - 添加射击武器 - 匕首

射击类型武器,发射物和敌人碰撞后摧毁自身并对敌人造成伤害。

在EnemyDamager.cs中添加声明public bool destroyOnImpact;

destroyOnImpact:碰撞后是否摧毁自身。

OnTriggerEnter2D()函数中添加destroyOnImpact判断,修改后如下:

private void OnTriggerEnter2D(Collider2D collision)
{
    if(damageOverTime == false)
    {
        if(collision.tag == "Enemy")
        {
            collision.GetComponent<EnemyController>().TakeDamage(damageAmount, shouldKnockback);

            if(destroyOnImpact == true)
            {
                Destroy(gameObject);
            }
        } 
    } else
    {
        if(collision.tag =="Enemy")
        {
            enemiesInRange.Add(collision.GetComponent<EnemyController>());
        }
    }
}

在场景的Weapons下创建Dagger Projectile,绑定匕首的精灵图到其上:

新增一个Sorting Layer,命名为Weapons,把武器显示在敌人和掉落物的上方,修改所有武器的Sorting Layer为Weapons:

为匕首添加2D盒子碰撞器,勾选Is Trigger,添加EnemyDamager.cs组件,勾选destroyOnImpact。

在...Assets/Scripts/Weapons文件夹内新建Projectile.cs,绑定到Dagger Projectile上,Projectile.cs内容如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Projectile : MonoBehaviour
{
    public float moveSpeed;

    // Update is called once per frame
    void Update()
    {
        transform.position += transform.up * moveSpeed * Time.deltaTime;
    }
}

transform.up:朝物体自身所在坐标系上方的向量。

在Inspector内设置moveSpeed为2,进入游戏测试,可发现无论怎样旋转物体方向,物体总是朝着自身的顶部运动。

63 - 匕首自动追踪

在场景中的Weapons下创建Dagger Throw,作为Dagger Projectile的父对象,这里先停用Dagger Projectile,后续通过代码来生成:

创建ProjectileWeapon.cs并绑定到Dagger Throw:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ProjectileWeapon : Weapon
{
    public EnemyDamager damager;
    public Projectile projectile;

    private float shotCounter;

    public float weaponRange;
    public LayerMask whatIsEnemy;

    // Start is called before the first frame update
    void Start()
    {
        SetStats();
    }

    // Update is called once per frame
    void Update()
    {
        if(statsUpdated ==true)
        {
            statsUpdated = false;

            SetStats();
        }
    }

    void SetStats()
    {
        damager.damageAmount = stats[weaponLevel].damage;
        damager.lifeTime = stats[weaponLevel].duration;

        damager.transform.localScale = Vector3.one * stats[weaponLevel].range;

        shotCounter = 0f;

        projectile.moveSpeed = stats[weaponLevel].speed;
    }
}

更改场景中Player的Inspector如下图:

把所有敌人的Layer绑定到一个新的Enemy Layer上:

Layer和Sorting Layer并不相同

对应的绑定ProjectileWeapon.cs中声明的公共变量:

在ProjectileWeapon.cs的更新函数的最后添加实例化远程武器,朝最近的敌人自动发射:

shotCounter -= Time.deltaTime;
if(shotCounter <= 0)
{
    shotCounter = stats[weaponLevel].timeBtweenAttacks;

    Collider2D[] enemies = Physics2D.OverlapCircleAll(transform.position, weaponRange * stats[weaponLevel].range, whatIsEnemy);
    if(enemies.Length > 0)
    {
        for(int i = 0; i < stats[weaponLevel].amount; i++)
        {
            Vector3 targetPosition = enemies[Random.Range(0, enemies.Length)].transform.position;

            Vector3 direction = targetPosition - transform.position;
            float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
            angle -= 90;
            projectile.transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);

            Instantiate(projectile, projectile.transform.position, projectile.transform.rotation).gameObject.SetActive(true);
        }
    }
}

Physics2D.OverlapCircleAll (Vector2 point, float radius, int layerMask= DefaultRaycastLayers, float minDepth= -Mathf.Infinity, float maxDepth= Mathf.Infinity):获取位于某圆形区域内的所有碰撞体的列表。该函数类似于 OverlapCircle,不同之处在于其返回位于该圆形内的所有碰撞体。返回的数组中的碰撞体按 Z 坐标增大的顺序排序。如果该圆形内没有任何碰撞体,则返回一个空数组。

Physics2D-OverlapCircleAll - Unity 脚本 API

Mathf.Atan2(float y, float x):返回其 Tany/x 的角度(以弧度为单位)。返回值是 X 轴与 2D 向量(从零开始,在 (x, y) 处终止)之间的角度。

Mathf.Rad2Deg:弧度到度换算常量(只读)。数值等于360 / (2 * π)

Quaternion.AngleAxis(float angle, Vector3 axis):创建一个围绕 axis 旋转 angle 度的旋转。

假设敌人在B点,玩家在A点,Mathf.Atan2()计算出的角度Angle如下图所示,和Mathf.Rad2Deg相乘弧度转为度,则匕首需要顺时针旋转90°-Angle才能朝向敌人。

计算结果为与X轴夹角的弧度

在旋转后的位置实例化生成匕首并激活,朝向敌人发射。

设置匕首每一级的属性,如下图所示:

在游戏中进行测试:

64 - 添加武器 - 剑

和匕首类似,添加2D盒子碰撞器和EnemyDamager.cs组件,相应修改数值:

在场景中停用Sword,后续通过代码来生成。

65 - 生成剑的戳刺攻击

创建Close Attack Weapons.cs绑定到Giant Sword上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class CloseAttackWeapon : Weapon
{
    public EnemyDamager damager;

    private float attackCounter, direction;
    // Start is called before the first frame update
    void Start()
    {
        SetStats();
    }

    // Update is called once per frame
    void Update()
    {
        if(statsUpdated ==true)
        {
            statsUpdated = false;

            SetStats();
        }

        attackCounter -= Time.deltaTime;
        if(attackCounter <= 0)
        {
            attackCounter = stats[weaponLevel].timeBtweenAttacks;

            direction = Input.GetAxisRaw("Horizontal");

            if(direction != 0)
            {
                if(direction > 0)
                {
                    damager.transform.rotation = Quaternion.identity;
                } else
                {
                    damager.transform.rotation = Quaternion.Euler(0f, 0f, 180f);
                }
            }

            Instantiate(damager, damager.transform.position, damager.transform.rotation, transform).gameObject.SetActive(true);

            for(int i = 1; i < stats[weaponLevel].amount; i++)
            {
                float rot = 360f / stats[weaponLevel].amount * i;

                Instantiate(damager, damager.transform.position, Quaternion.Euler(0f, 0f, damager.transform.rotation.eulerAngles.z + rot), transform).gameObject.SetActive(true);
            }
        }
    }

    void SetStats()
    {
        damager.damageAmount = stats[weaponLevel].damage;
        damager.lifeTime = stats[weaponLevel].duration;

        damager.transform.localScale = Vector3.one * stats[weaponLevel].range;

        attackCounter = 0f;
    }
}

实测如下:

66 - 添加武器 - 斧头

在场景中创建斧子,添加2D盒子碰撞器,EnemyDamager.cs和2D刚体组件:

创建ThrowWeapon.cs绑定到场景中的Axe:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ThrowWeapon : MonoBehaviour
{
    public float throwPower;
    public Rigidbody2D theRB;
    public float rotateSpeed;

    // Start is called before the first frame update
    void Start()
    {
        theRB.velocity = new Vector2(Random.Range(-throwPower, throwPower), throwPower);
    }

    // Update is called once per frame
    void Update()
    {
        transform.rotation = Quaternion.Euler(0f, 0f, transform.rotation.eulerAngles.z - (rotateSpeed * 360f * Mathf.Sign(theRB.velocity.x) * Time.deltaTime));
    }
}

给斧头一个 (-5~5,5) 的速度,并且当斧头朝右时顺时针旋转,当斧头朝左时逆时针旋转。

Math.sign(float f):返回 f 的符号。当 f 为正数或零时,返回值为 1,当 f 为负数时,返回值为 -1。

67 - 生成投掷出的斧头

新建WeaponThrower.cs,和其他武器类似:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WeaponThrower : Weapon
{
    public EnemyDamager damager;

    private float throwCounter;

    // Start is called before the first frame update
    void Start()
    {
        SetStats();
    }

    // Update is called once per frame
    void Update()
    {
        if(statsUpdated ==true)
        {
            statsUpdated = false;

            SetStats();
        }

        throwCounter -= Time.deltaTime;
        if(throwCounter <= 0)
        {
            throwCounter = stats[weaponLevel].timeBtweenAttacks;

            for(int i = 0; i < stats[weaponLevel].amount; i++)
            {
                Instantiate(damager,damager.transform.position, damager.transform.rotation).gameObject.SetActive(true);
            }
        }
    }

        void SetStats()
    {
        damager.damageAmount = stats[weaponLevel].damage;
        damager.lifeTime = stats[weaponLevel].duration;

        damager.transform.localScale = Vector3.one * stats[weaponLevel].range;

        throwCounter = 0f;
    }
}

68 - 死亡后冻结敌人

打开EnemyController.cs在更新函数中增加玩家死亡判断:

void Update()
{
    if(PlayerController.instance.gameObject.activeSelf == true)
    {
    if(knockBackCounter >0)
    {
        knockBackCounter -= Time.deltaTime;

        if(moveSpeed > 0)
        {
            moveSpeed = -moveSpeed * 2f;
        }

        if(knockBackCounter <=0 )
        {
            moveSpeed = Mathf.Abs(moveSpeed * .5f);
        }
    }

    theRB.velocity = (target.position - transform.position).normalized * moveSpeed;

    if(hitCounter > 0f)
    {
        hitCounter -= Time.deltaTime;
    }
    } else
    {
        theRB.velocity = Vector2.zero;
    }

}

当玩家死亡后冻结所有敌人。

69 - 金币系统

在场景中创建Coin Controller,新建CoinController.cs绑定到其上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CoinController : MonoBehaviour
{
    public static CoinController instance;

    private void Awake()
    {
        instance = this;
    }
  
    public int currentCoins;

    public void AddCoins(int coinsToAdd)
    {
        currentCoins += coinsToAdd;
    }
}

把金币的精灵图拖到场景中命名为Coin,添加2D圆形碰撞器,设置Sorting Layer为Pickups,勾选Is Trigger。

创建CoinPickup.cs绑定到Coin上,内容和经验拾取类似:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CoinPickup : MonoBehaviour
{
    public int coinAmount = 1;

    private bool movingToPlayer;
    public float moveSpeed;

    public float timeBetweenChecks = .2f;
    private float checkCounter;

    private PlayerController player;

    // Start is called before the first frame update
    void Start()
    {
        player = PlayerController.instance;
    }

    // Update is called once per frame
    void Update()
    {
        if(movingToPlayer == true)
        {
            transform.position = Vector3.MoveTowards(transform.position, PlayerHealthController.instance.transform.position, moveSpeed * Time.deltaTime);
        } else
        {
            checkCounter -= Time.deltaTime;
            if(checkCounter <= 0)
            {
                checkCounter = timeBetweenChecks;

                if(Vector3.Distance(transform.position, PlayerHealthController.instance.transform.position) < player.pickupRange)
                {
                    movingToPlayer = true;
                    moveSpeed += player.moveSpeed;
                }
            }
        }
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if(collision.tag == "Player")
        {
            CoinController.instance.AddCoins(coinAmount);

            Destroy(gameObject);
        }
    }
}

更改CoinAmount的数值,进入游戏捡起金币,CurrentCoins数值得到增加。

70 - 随机掉落金币

制作金币的预制件。

金币掉落位置略微偏离敌人死亡的位置,并修改掉落金币的价值为value。在CoinController.cs中添加金币声明和金币掉落函数:

public CoinPickup coin;

public void DropCoin(Vector3 position, int value)
{
    CoinPickup newCoin = Instantiate(coin, position + new Vector3(.2f, .1f, 0f), Quaternion.identity);
    newCoin.coinAmount = value;
    newCoin.gameObject.SetActive(true);
}

绑定金币预制件到CoinController的公共变量Coin上。

在EnemyController.cs中添加掉落金币数量和金币掉落率的声明:

public int coinValue = 1;
public float coinDropRate = .5f;

TakeDamge()函数的敌人死亡掉落经验值后面添加掉落金币:

if(Random.value <= coinDropRate)
{
    CoinController.instance.DropCoin(transform.position, coinValue);
}

Random.value:随机返回一个 0.0 到 1.0 之间的浮点数。

进入游戏测试,击杀敌人50%概率掉落金币:

71 - 在UI中显示金币数量

在UI Canvas下创建CoinText和CoinImage,放到右上角:

打开UIController.cs,添加金币文本声明public TMP_Text coinText;并绑定,添加更新金币文本的函数:

public void UpdateCoins()
{
    coinText.text = "Coins: " + CoinController.instance.currentCoins;
}

在CoinController.cs的AddCoins()函数中调用此函数:

public void AddCoins(int coinsToAdd)
{
    currentCoins += coinsToAdd;
    UIController.instance.UpdateCoins();
}

进入游戏测试,右上角实时显示金币数量的变化。

72 - 玩家数值表

在场景中创建Player Stat Controller,新建PlayerStatController.cs和其绑定:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerStatController : MonoBehaviour
{
    public static PlayerStatController instance;

    private void Awake()
    {
        instance = this;
    }

    public List<PlayerStatValue> moveSpeed, health, pickupRange, maxWeapon;

    // Start is called before the first frame update
    void Start()
    {
  
    }

    // Update is called once per frame
    void Update()
    {
  
    }
}

[System.Serializable]
public class PlayerStatValue
{
    public int cost;
    public float value;
}

升级后除了最大武器数量限制为5以外,其他数据的变化通过代码来自动生成,先设置玩家属性表如下图所示:

添加各个属性的最大等级声明public int moveSpeedLevelCount, healthLevelCount, pickupRangeLevelCount;,在Inspector都设置为50。

添加每个属性的当前等级声明public int moveSpeedLevel, healthLevel, pickupRangeLevel, maxWeaponLevel;

在开始函数中添加自动生成每级属性:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerStatController : MonoBehaviour
{
    public static PlayerStatController instance;

    private void Awake()
    {
        instance = this;
    }

    public List<PlayerStatValue> moveSpeed, health, pickupRange, maxWeapon;
    public int moveSpeedLevelCount, healthLevelCount, pickupRangeLevelCount;

    public int moveSpeedLevel, healthLevel, pickupRangeLevel, maxWeaponLevel;

    // Start is called before the first frame update
    void Start()
    {
        for(int i = moveSpeed.Count - 1; i < moveSpeedLevelCount; i++)
        {
            moveSpeed.Add(new PlayerStatValue(moveSpeed[i].cost + moveSpeed[1].cost, moveSpeed[i].value + (moveSpeed[1].value - moveSpeed[0].value)));
        }

        for(int i =health.Count - 1; i < healthLevelCount; i++)
        {
            health.Add(new PlayerStatValue(health[i].cost + health[1].cost, health[i].value + (health[1].value - health[0].value)));
        }
  
        for(int i =pickupRange.Count - 1; i < pickupRangeLevelCount; i++)
        {
            pickupRange.Add(new PlayerStatValue(pickupRange[i].cost + pickupRange[1].cost, pickupRange[i].value + (pickupRange[1].value - pickupRange[0].value)));
        }
    }

    // Update is called once per frame
    void Update()
    {
  
    }
}

[System.Serializable]
public class PlayerStatValue
{
    public int cost;
    public float value;

    public PlayerStatValue(int newCost, float newValue)
    {
        cost = newCost;
        value = newValue;
    }
}

73 - 设置玩家数值升级UI

创建新的玩家升级界面Panel,按钮和描述文本:

创建PlayerStatUpgradeDisplay.cs绑定到场景中的PlayerStatUpgrade上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class PlayerStatUpgradeDisplay : MonoBehaviour
{
    public TMP_Text valueText, costText;

    public GameObject upgradeButton;
}

使场景中的物体和声明绑定。

同样的制作其他几个属性的升级按钮:

74 - 更新UI显示

一般情况下,不要直接引用在不同层结构的另一个子对象。若被引用的对象在游戏中被移除后,会导致导致bug和报错。

保险起见,这里让Player Stat Controller通过UI Controller分别来访问这些升级按钮。

打开PlayerStatUpgradeDisplay.cs添加更新显示函数:

public void UpdateDisplay(int cost, float oldValue, float newValue)
{
    valueText.text = "Value: " + oldValue.ToString("F1") + "->" +newValue.ToString("F1");
    costText.text = "Cost: " + cost;

    if(cost <= CoinController.instance.currentCoins)
    {
        upgradeButton.SetActive(true);
    } else
    {
        upgradeButton.SetActive(false);
    }

}

在UIController.cs的PlayerStatController类中,添加声明public PlayerStatUpgradeDisplay moveSpeedUpgradeDispaly, healthUpgradeDisplay, pickupRangeUpgradeDisplay, maxWeaponUpgradeDisplay;分别和场景中的四个升级界面绑定

在PlayerStatController.cs添加UpdateDisplay()函数,此函数调用了上文中在PlayerStatUpgradeDisplay.cs中的UpdateDisplay()函数:

public void UpdateDisplay()
{
    UIController.instance.moveSpeedUpgradeDisplay.UpdateDisplay(moveSpeed[moveSpeedLevel+1].cost, moveSpeed[moveSpeedLevel].value, moveSpeed[moveSpeedLevel+1].value);
    UIController.instance.healthUpgradeDisplay.UpdateDisplay(health[healthLevel+1].cost, health[healthLevel].value, health[healthLevel+1].value);
    UIController.instance.pickupRangeUpgradeDisplay.UpdateDisplay(pickupRange[pickupRangeLevel+1].cost, pickupRange[pickupRangeLevel].value, pickupRange[pickupRangeLevel+1].value);
    UIController.instance.maxWeaponUpgradeDisplay.UpdateDisplay(maxWeapon[maxWeaponLevel+1].cost, maxWeapon[maxWeaponLevel].value, maxWeapon[maxWeaponLevel+1].value);
}

在ExperienceLevelController.cs的升级函数LevelUp()最后调用此函数PlayerStatController.instance.UpdateDisplay();

当拥有足够的金币可以升级时,显示升级按钮:

75 - 修复BUG

有一个概率触发的bug:如果同时拾取经验和金币的时候升级,金币数量可能会更新不及时,导致虽然金币足以升级,但是升级界面不显示升级按钮。

在PlayerStatController.cs的更新函数中添加:

if(UIController.instance.levelUpPanel.activeSelf == true)
{
    UpdateDisplay();
}

这样当升级面板打开时,升级按钮可以持续不断地更新。

还有一个bug,当一个属性升级到满级时,PlayerStatController.cs中的UpdateDisplay()函数中会超出玩家数值表长度。

在PlayerStatUpgradeDisplay.cs添加满级后的显示函数:

public void ShowMaxLevel(){
    valueText.text = "Max Level";
    costText.text = "Max Level";
    upgradeButton.SetActive(false);
}

修改PlayerStatController.cs中的UpdateDisplay()函数,添加判断是否超过列表长度:

public void UpdateDisplay()
{
    if(moveSpeedLevel < moveSpeed.Count - 1)
    {
        UIController.instance.moveSpeedUpgradeDisplay.UpdateDisplay(moveSpeed[moveSpeedLevel+1].cost, moveSpeed[moveSpeedLevel].value, moveSpeed[moveSpeedLevel+1].value);
    } else
    {
        UIController.instance.moveSpeedUpgradeDisplay.ShowMaxLevel();
    }
    if(healthLevel < health.Count - 1)
    {
        UIController.instance.healthUpgradeDisplay.UpdateDisplay(health[healthLevel+1].cost, health[healthLevel].value, health[healthLevel+1].value);
    } else
    {
        UIController.instance.healthUpgradeDisplay.ShowMaxLevel();
    }
    if(pickupRangeLevel < pickupRange.Count - 1)
    {
        UIController.instance.pickupRangeUpgradeDisplay.UpdateDisplay(pickupRange[pickupRangeLevel+1].cost, pickupRange[pickupRangeLevel].value, pickupRange[pickupRangeLevel+1].value);
    } else
    {
        UIController.instance.pickupRangeUpgradeDisplay.ShowMaxLevel();
    }
    if(maxWeaponLevel < maxWeapon.Count - 1)
    {
        UIController.instance.maxWeaponUpgradeDisplay.UpdateDisplay(maxWeapon[maxWeaponLevel+1].cost, maxWeapon[maxWeaponLevel].value, maxWeapon[maxWeaponLevel+1].value);
    } else
    {
        UIController.instance.maxWeaponUpgradeDisplay.ShowMaxLevel();
    }
}

76 - 购买升级

和74节前文所强调的问题一样,这里我们依旧使按钮的On Click()分别通过UI Controller来访问Player Stat Contoller中的Purchase()函数

在CoinController.cs中添加花费金币函数,花费金币后并且更新金币显示:

public void SpendCoins(int coinsToSpend)
{
    currentCoins -= coinsToSpend;

    UIController.instance.UpdateCoins();
}

在PlayerStatController.cs的PlayerStatController类中添加四个Purchase()函数,对应四个属性:

public void PurchaseMoveSpeed()
{
    moveSpeedLevel++;
    CoinController.instance.SpendCoins(moveSpeed[moveSpeedLevel].cost);
    UpdateDisplay();

    PlayerController.instance.moveSpeed = moveSpeed[moveSpeedLevel].value;
}

public void PurchaseHealth()
{
    healthLevel++;
    CoinController.instance.SpendCoins(health[healthLevel].cost);
    UpdateDisplay();

    PlayerHealthController.instance.maxHealth = health[healthLevel].value;
    PlayerHealthController.instance.currentHealth += health[healthLevel].value - health[healthLevel - 1].value;
}

public void PurchasePickupRange()
{
    pickupRangeLevel++;
    CoinController.instance.SpendCoins(pickupRange[pickupRangeLevel].cost);
    UpdateDisplay();

    PlayerController.instance.pickupRange = pickupRange[pickupRangeLevel].value;
}

public void PurchaseMaxWeapon()
{
    maxWeaponLevel++;
    CoinController.instance.SpendCoins(maxWeapon[maxWeaponLevel].cost);
    UpdateDisplay();

    PlayerController.instance.maxWeapons = Mathf.RoundToInt(maxWeapon[maxWeaponLevel].value);
}

当生命值上限增加的时候,生命值恢复生命值上限增加的数值。

最大武器上限增加时,由于value是浮点数,而maxWeaponint型整数,不能直接赋值,这里需要用Mathf.RoundToInt()函数转换类型。

在UIController.cs中调用这些函数,购买升级后并且跳过武器升级:

public void PurchaseMoveSpeed()
{
    PlayerStatController.instance.PurchaseMoveSpeed();
    SkipLevelUp();
}

public void PurchaseHealth()
{
    PlayerStatController.instance.PurchaseHealth();
    SkipLevelUp();
}

public void PurchasePickupRange()
{
    PlayerStatController.instance.PurchasePickupRange();
    SkipLevelUp();
}

public void PurchaseMaxWeapon()
{
    PlayerStatController.instance.PurchaseMaxWeapon();
    SkipLevelUp();
}

在PlayerController.cs的开始函数中中设置移动速度,拾取范围,武器数量上限的初始值:

moveSpeed = PlayerStatController.instance.moveSpeed[0].value;
pickupRange = PlayerStatController.instance.pickupRange[0].value;
maxWeapons = Mathf.RoundToInt(PlayerStatController.instance.maxWeapon[0].value);

在PlayerHealthController.cs的开始函数中设置玩家血量上限的初始值:

maxHealth = PlayerStatController.instance.health[0].value;

进入游戏测试,点击购买升级后玩家属性获得提升。

77 - 计时器

计算玩家在一局游戏中存活的时间。

在场景中创建Level Manager,创建LevelManager.cs绑定到其上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LevelManager : MonoBehaviour
{
    public static LevelManager instance;

    private void Awake()
    {
        instance = this;
    }

    private bool gameAvtive;
    public float timer;

    // Start is called before the first frame update
    void Start()
    {
        gameAvtive = true;
    }

    // Update is called once per frame
    void Update()
    {
        if(gameAvtive == true)
        {
            timer += Time.deltaTime;
            UIController.instance.UpdateTimer(timer);
        }
    }
}

当gameActive为真时,timer计时,并更新UI时间的显示。

在场景中的UI Canvas下创建文本显示时间:

打开UIController.cs,添加时间文本声明public TMP_Text timeText;绑定刚刚创建的文本,另外添加更新时间文本的函数:

public void UpdateTimer(float time)
{
    float minates = Mathf.FloorToInt(time / 60f);
    float seconds = Mathf.FloorToInt(time % 60);

    timeText.text = "Time: " + minutes + ":" + seconds.ToString("00");
}

Seconds.ToString("00"):Seconds以两位数的形式显示。

78 - 结束计时

在LevelManager.cs中添加结束计时器函数:

public void EndLevel()
{
    gameAvtive = false;
}

在PlayerHealthController.cs的TakeDamge()函数内调用此函数LevelManager.instance.EndLevel();,当玩家死亡后,停止计时:

public void TakeDamage(float damageToTake)
{
    currentHealth -= damageToTake;

    if(currentHealth <= 0)
    {
        gameObject.SetActive(false);

        LevelManager.instance.EndLevel();
    }

    healthSlider.value = currentHealth;
}

创建玩家死亡的粒子特效Player Death Effect,设置如下:

效果图

取消勾选Looping,不循环播放,设置Stop Action为Destroy,播放完粒子动画摧毁自身。

制作粒子效果预制件。

在PlayerHealthController.cs中添加声明public GameObject deathEffect;绑定之前制作的预制件。

TakeDamge()函数中的LevelManager.instance.EndLevel();后添加生成玩家死亡粒子效果Instantiate(deathEffect, transform.position, transform.rotation);

79 - 游戏结束界面

在场景中创建Level End Panel作为游戏结束画面,内容如下:

打开UIController.cs,添加声明并和场景中的内容绑定:

public GameObject levelEndScreen;
public TMP_Text endTimeText;

80 - 生效游戏结束界面

在Level Manger中改写EndLevel()函数:

public void EndLevel()
{
    gameAvtive = false;

    float minutes = Mathf.FloorToInt(timer / 60f);
    float seconds = Mathf.FloorToInt(timer % 60);

    UIController.instance.endTimeText.text = minutes.ToString() + " mins " + seconds.ToString("00") + " scs";
    UIController.instance.levelEndScreen.SetActive(true);
}

判定游戏结束后,记录存活时间并展示游戏结束画面。

这里有一个问题,直接弹出结束画面,可能导致玩家不知道自己是怎么死亡的,从而降低了游戏体验。需要在玩家死亡后延迟几秒再弹出结束画面。

添加玩家死亡后延迟时长声明public float waitToShowEndScreen = 1f;

修改EndLevel()函数:

public void EndLevel()
{
    gameAvtive = false;

    StartCoroutine(EndLevelCo());
}

IEnumerator EndLevelCo()
{
    yield return new WaitForSeconds(waitToShowEndScreen);

    float minutes = Mathf.FloorToInt(timer / 60f);
    float seconds = Mathf.FloorToInt(timer % 60);

    UIController.instance.endTimeText.text = minutes.ToString() + " mins " + seconds.ToString("00") + " scs";
    UIController.instance.levelEndScreen.SetActive(true);
}(

这里使用了协程IEnumerator,当执行EndLevel()EndLevelCo()时,EndLevelCo()函数内代码和外步代码会同步执行。

协程:调用函数时,函数将运行到完成状态,然后返回。这实际上意味着在函数中发生的任何动作都必须在单帧更新内发生;函数调用不能用于包含程序性动画或随时间推移的一系列事件。例如,假设需要逐渐减少对象的 Alpha(不透明度)值,直至对象变得完全不可见。

打开UIController.cs在顶部添加使用场景管理器的命名空间using UnityEngine.SceneManagement;

添加返回主菜单和重新开始游戏的函数:

public void GoToMainMenu()
{

}

public void Restart()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

LoadScene():按照 Build Settings 中的名称或索引加载场景。参见 SceneManagement.SceneManager-LoadScene - Unity 脚本 API

GetActiveScene():获取当前活动的场景。参见 SceneManagement.SceneManager-GetActiveScene - Unity 脚本 API

设置场景中的Main Menu和Restart的On Click()为对应的函数。

81 - 主菜单

创建一个新的场景命名为Main Menu,创建画布和游戏的标题。

创建新的像素字体命名为 Kenney Pixel SDF - Title 绑定到Title的字体上。

Title设置如下:

效果图

在Canvas下创建两个按钮分别命名为Start Game和Quit Game:

效果图

更改Main Camera的背景颜色为蓝色。把.../Assets/_Udemy Vampire Survival Assets/Art文件夹下的Gradient拖到场景中并覆盖相机的底边:

效果图

创建MainMenu.cs绑定到Canvas上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class MainMenu : MonoBehaviour
{
    public string firstLevelName;


    public void StartGame()
    {
        SceneManager.LoadScene(firstLevelName);
    }

    public void QuitGame()
    {
        Application.Quit();

        Debug.Log("I'm Quitting");
    }
}

注意:Application.Quit()在Unity编辑器中不起作用,只有在游戏编译完毕后才生效,这里加入控制台输出日志反馈点击了退出游戏按钮。

在Inspector内设置变量firstLevelName的值为Main。分别绑定两个按钮的On Click()为StartGame()QuitGame()

此时进入游戏点击Start会有报错,原因是没有在编译设置中找到Main场景。

点击File→Build Settings,添加Main和Main Menu场景到其中。注意:右边编号为0的场景是玩家打开游戏时首先看到的场景,需要把主菜单放到0的位置:

82 - 暂停菜单

生效结束界面的Main Menu按钮

在UIController.cs中添加主菜单名字声明public string mainMenuName;并在Inspector内设置为Main Menu。

编写GoToMainMenu()函数的内容:

public void GoToMainMenu()
{
    SceneManager.LoadScene(mainMenuName);
}

创建暂停界面

在UI Canvas下创建新的界面命名为Pause Screen,添加四个按钮:

83 - 生效暂停面板

先停用激活暂停面板。

在UIController.cs中添加暂停面板的声明并绑定public GameObject pauseScreen;

当玩家摁下Esc键时,打开或者关闭暂停界面,在更新函数中添加:

void Update()
{
    if(Input.GetKeyDown(KeyCode.Escape))
    {
        PauseUnpause();
    }
}

添加重新开始游戏,退出游戏,打开/关闭暂停界面的函数:

public void Restart()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

public void QuitGame()
{
    Application.Quit();
}

public void PauseUnpause()
{
    if(pauseScreen.activeSelf == false)
    {
        pauseScreen.SetActive(true);
        Time.timeScale = 0f;
    } else
    {
        pauseScreen.SetActive(false);
        if(levelUpPanel.activeSelf == false)
        {
            Time.timeScale = 1f;
        }
    }
}

在Inspector内分别设置4个按钮的On Click()为对应的功能。

此时,暂停游戏后点击Restart或者回到主菜单重新开始游戏后,画面依旧是暂停状态。这是因为Time.timeScale改变的是全局游戏的时间流速,回到主菜单或者刷新场景后时间流速仍然为0。

Restart()GoToMainMenu()函数最后添加Time.timeScale = 1f;使时间流速恢复正常:

public void GoToMainMenu()
{
    SceneManager.LoadScene(mainMenuName);
    Time.timeScale = 1f;
}

public void Restart()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    Time.timeScale = 1f;

84 - 设置音频和混音器

打开Window→Audio→Audio Mixer命名为Main Mixer,在Groups内添加两个子项分别为Music和SFX。略微降低Music的音量。

.../Assets/_Udemy Vampire Survival Assets/Audio/Music文件夹下Assets/_Udemy Vampire Survival Assets/的音乐拖到场景中作为背景音乐,重命名为BGM,勾选Loop,设置OutPut为Music (Main Mixer):

Unity的Mixer「混音器」不包含在特定的某一个场景中,所以可以在不同场景重复使用同一个Mixer。

同样的在Main场景下设置背景音乐。

.../Assets/_Udemy Vampire Survival Assets/Audio/SFX下的所有音效拖到Main场景中,取消勾选Play On Awake「唤醒时播放」,设置Output为SFX (Main Mixer)。

在场景中创建Empty Object命名为SFX Manager,结构如下图所示:

85 - 播放音效

创建SFXManger.cs绑定到SFX Manager上:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SFXManager : MonoBehaviour
{
    public static SFXManager instance;

    private void Awake()
    {
        instance = this;
    }

    public AudioSource[] soundEffects;

    public void PlaySFX(int sfxToPlay)
    {
        soundEffects[sfxToPlay].Stop();
        soundEffects[sfxToPlay].Play();
    }

    public void PlaySFXPitched(int sfxToPlay)
    {
        soundEffects[sfxToPlay].pitch = Random.Range(.0f, 1.2f);

        PlaySFX(sfxToPlay);
    }
}
public void PlaySFX(int sfxToPlay)
{
    soundEffects[sfxToPlay].Stop();    //先停止播放当前音效,以免声音过于杂乱
    soundEffects[sfxToPlay].Play();    //播放音效
}

PlaySFXPitched(int sfxToPlay):随机改变要播放音效的声调,让音效更富有变化。

绑定所有的音效到Sound Effects上:

在之前的C#文件中调用播放音效的函数。

86(最终章) - 编译游戏

打开File→Build Settings→Player Settings:

可以在这里更改游戏的一些设置,例如游戏图标,游戏名字,存档文件夹名称,分辨率设置之类。

设置完毕后,点击Build Settings中的Build按钮即可编译游戏。

注:需要有所有Unity生成的文件以及文件夹才能运行游戏。

最后修改:2024 年 02 月 25 日
你的支持是我最大的动力