这是博主的一篇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");
:这里的 Hrizontal
和 Vertical
可以在Edit→Project Settings→Input Manager中看到,这是Unity自带的输入设置,它会把WASD键和上下左右四个方向对应起来,而这里的 Raw
表示的是获取输入的原始数据量,如果这里替换为 Input.GetAxis
,moveInput
则会从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;
:绑定 target
和 Player
的 transform
类型。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.velocity
:velocity
是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上,speed
,maxSize
,minSize
分别设置为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上。另外声明 timeBetweenSpawn
和 SpawnCounter
,为生成的冷却倒计时,其中设置 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到公共变量 expLvlSlider
和 expLvlText
上。
打开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 : MonoBehaviour
为 public 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.rotation
为 holder.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上:
对应的绑定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)
:返回其 Tan
为 y/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
才能朝向敌人。
在旋转后的位置实例化生成匕首并激活,朝向敌人发射。
设置匕首每一级的属性,如下图所示:
在游戏中进行测试:
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
是浮点数,而maxWeapon
是int
型整数,不能直接赋值,这里需要用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生成的文件以及文件夹才能运行游戏。
24 条评论
真棒!
你的文章让我心情愉悦,每天都要来看一看。 https://www.yonboz.com/video/9785.html
你的才华让人惊叹,你是我的榜样。 http://www.55baobei.com/9cYuhQFLVP.html
《同行第二季》欧美剧高清在线免费观看:https://www.jgz518.com/xingkong/113594.html
博主太厉害了!
真棒!
博主太厉害了!
狂暴复古单职业攻略:https://501h.com/heji/706.html
《布鲁克斯,草地和可爱脸孔》剧情片高清在线免费观看:https://www.jgz518.com/xingkong/72822.html
哈哈哈,写的太好了https://www.cscnn.com/
哈哈哈,写的太好了https://www.cscnn.com/
博主upup,可以加个友链嘛么么~motoshare.cn
不错不错,我喜欢看 www.jiwenlaw.com
不错不错,我喜欢看 www.jiwenlaw.com
想想你的文章写的特别好https://www.237fa.com/
怎么收藏这篇文章?
叼茂SEO.bfbikes.com
叼茂SEO.bfbikes.com
看的我热血沸腾啊https://www.jiwenlaw.com/
叼茂SEO.bfbikes.com
博主真是太厉害了!!!
陈奈祟:文章真不错http://wap.jst-gpmx.cn/news/2404.html
陈液吱:文章真不错http://wap.jst-gpmx.cn/news/25463.html
kzpyxg56137VW-文章确实不错http://xlou.net/