Hello,这里是一个新人菜鸟想通过教别人的方式提升自己,共勉!Second day:第二关:预制体初步,数据模板,攻击与敌方行为(AI树)
同学们,我们上节课只是一个简单的不能再简单的核心玩法循环,那假设我们需要添加其他的东西,比如说100棵树,或者100个敌方角色,那我们应该怎么办,修改的时候一个一个慢慢修嘛?那么接下来,我们将敌方做成预制体,首先也是一样的,我们需要设计一些敌方,并为这个所有敌方的基本模板进行精心的设置。主播要开学了,不更新了,因为时间不够,不过我可能寒假还会再更新,我可能会在寒假讲解对象池,事件中心,以及一些算法
Hello啊,这里是一个菜鸟,叫做自隐喻者。很高兴认识大家!(明天就开学了,悲😇)
事先说明:确实,我就是一个菜鸟,所以说如果我的教程中有什么错误或者做的不好的地方,还请各位指出,谢谢各位大佬的关照。
以下内容,我们依旧是用Unity开发:
相信在同学们玩游戏时,都会遇到很多敌方角色,他们会寻找到主角并且攻击主角,那么,我们希望在我们的游戏第二关中添加这个行为。
学习目标:
- 预制体初步理解(我们将在以后的教程中引入对象池的概念)。
- 为你的角色设置一个数值,例如生命值,攻击力以及许许多多其他成员。
- 为你的角色添加血条等UI显示。
- 设计敌方,让角色能和敌方交互。
- 让敌方“活过来”,能够追着我们跑。
1.预制体的初步理解:
同学们,我们上节课只是一个简单的不能再简单的核心玩法循环,那假设我们需要添加其他的东西,比如说100棵树,或者100个敌方角色,那我们应该怎么办,修改的时候一个一个慢慢修嘛?亦或者说我们需要在其他的场景中调用我们在某个场景中的物体,我们应该怎么办?答案当然是:使用我们的预制体大人!
那么预制体是什么呢?又何德何能,能够简化我们的这么多步骤~预制体相当于一个模板,当我们需要调用的时候,直接将这个模板实例化就行。就像预制菜一样,要用的时候直接加热就行。
那么我们应该怎么让我们精心设计好的某一个物体变成预制体?
- 我们首先应当将我们在左手侧面板中创建好的物体往下拉,将其拖拽到Project面板中(你自己分好的某一个文件夹[例如Prefeb]的栏目下,方便管理)
- 删除面板中的蓝色物体,我们需要在代码中创建的。
- 其次,在代码中控制实例化。
第一步,相信各位简单的已经不用看了,我们直接来看第三步:如何将预制体实例化?
- 创建一个新的脚本,这个脚本将被挂载到一个场景中的空物体,也就是角色生成点上,当然,你要是想挂在某个摄像机上也可以,但这样代码会和我们教的略有不同,你们可以尝试自己摸索。
- 在类中,我们需要重命名两个组件,分别是控制位置的transform,以及游戏物体就是GameObject,这相当于变成了两个变量,因此,我们可以在右手边的那个面板上为两个变量赋值,挂上你需要的东西即可。
- 接下来我们需要一个实例化函数:
- // 1. bulletPrefab: 我们要复制的原始预制体
- // 2. 位置
- // 3. 旋转,暂时2d游戏不用管
- GameObject newBullet = Instantiate(bulletPrefab2D, 你重命名的那个(Transform).position, 你重命名的那个(Transform).rotation);//声明一个变量是为了方便我们后续进行更详细的操作。
- 将上面的那个语句放入你需要放入的地方,例如,在Start函数中,我们可以进行角色的初始化。
- 随后,我们需要在合适的时机销毁,语法如下: Destroy(你要销毁的那个对象实例,从开始到结束需几秒(也就是延迟销毁时间):一个浮点型变量)
但是,这两个操作的成本是十分高昂的,那么我们应该怎么办呢?这里我们卖个关子,等我上完学再回来更新~(一想到要上学,人就死死的😇😇😇)
现在,就让我们按照刚刚所说的方式,来重新写一下我们第一关的代码吧!现在我们就可以生成大量敌方和一个主角了!光看理论肯定不行,让我们开始实际操作(这里我们简单做几个敌方就行了,因为新建一个场景实在是要修改太多代码[在这一瞬间,突然理解了为什么最好还是使用场景名称,而不是使用索引序列进行索引……😭]):
首先让我们来多做几个复杂地形,我们之前已经知道这地面的三个小方块肯定是我们所需要用的最基础的三个小方块,那么我们可以直接将其做成预制体使用。
当我们拖拽之后,看到你的物体变成了这个样子,那么就算成功了,创建一个地面预制体比较简单,因为我们不需要设计实例化,或者说它本身就是一个固定的地形。
那么接下来,我们将敌方做成预制体,首先也是一样的,我们需要设计一些敌方,并为这个所有敌方的基本模板进行精心的设置。
好吧,其实就是瞎画……
如同第一篇所说的添加动画,然后删除面板中原本的物体,只保留Project面板中的预制体。添加一个空物体,方便我们进行实例化。
然后我们双击我们之前放在project面板中的敌方进行调节,这个敌方不仅要有身体,为了能够观测到主角,他应该还要有一个眼睛,因此,我们需要两个碰撞体,一个方形碰撞体就是原本的碰撞体,另一个则是圆形碰撞体,这个碰撞体作为一个触发器使用,这个触发器的半径设置成你想设置的值,然后勾选is trigger,这样它就能作为一个触发器使用。
随后我们来编写用于控制的代码:
首先我们不需要让原型前来干扰我们的代码,我们只希望这个能够作为一个原型使用而已,因此,不要犹豫,直接删除!(不是让你删除预制体!)
添加一个空物体作为敌方生成点,回到上面,我们会发现我们需要一个位置,那么我们应该直接在代码中获取transform。
同时,我们还需要获取game object。
结合以上所学的知识点,我们可以得到这样一个代码:
这样你在运行时,就可以看到人山人海般的敌人了。
2.为角色设置数值:
之前我们所写的所有代码,都是继承自Monobehavior,那么,有没有继承自其他的呢?有的兄弟,有的!这样的类可以为角色设置基础数值,这些基础数值可以通过新建资源文件的方式进行使用。
创建一个这样的类的方式非常简单,我们只需要将代码改写成从继承字m开头的单词,到继承自s开头的单词:ScriptableObject
不过,这里有一些可以便于策划同学观看的小技巧:[Header("你的分类")],这个东西就像一个标签,可以精准的为你的数值分类。
这样的类,我们无法直接挂载到角色身上,而是需要通过其他方式在代码中引用,例如,声明一个这个类型的变量,在右手边面板中给它赋值。我们需要create一个新的文件,如果你已经有了这样一个数值类,你在右键create的时候就会看到你自己的这个类,这时候你只需要新建,角色的数据资产就会出现在你的面前了!这时候我们才将这个资产赋值给那个变量!
例如,我们可以创建一个CharacterData,用来存放角色和攻击相关的所有内容。
这时候我们右键,其实是看不到我们所需要创建的资源文件的。那么我们应该怎么办呢?其实,这个问题的原因是你没有告诉unity你需要在create中创建这样一个资源文件,因此,我们只需要通过一行命令来告诉unity就行了。
· fileName = "New Enemy Data":创建时默认生成的资源文件叫什么名字。
· menuName = "Game Data/Enemy Data":在右键 -> Create 菜单里的路径是什么。这里的意思是会在 Create 菜单下新建一个 Game Data 的子菜单,里面有一个选项叫 Enemy Data。
· order = 1:这个菜单项在列表里的显示顺序,数字越小排得越前。
整合起来那么就是:
这样我们又建新建时就可以看到我们所希望创建的CData了,直接创建并且赋值。
3.添加血条的UI。
- 在 Hierarchy 面板右键 -> UI -> Slider。这会自动创建一个带有 Canvas(画布)和 EventSystem(事件系统)的UI结构。
- 选中新创建的 Slider,在 Inspector 面板,我们可以看到三个物体,作为子物体存在:Background:背景条;Fill Area -> Fill:前景条(即红色的血条部分);Handle Slide Area:滑块区域(用于交互,血条不需要,可以禁用或删除)
- 配置Slider:将 Slider 组件的 Min Value 设为 0,Max Value 设为 1。(或者你有喜欢的数值?),取消勾选 Interactable,因为血条不需要玩家手动去拖拽。调整 Fill 对象的颜色为红色(或其他你喜欢的颜色)。
- 最后我们就可以将血条拖拽到玩家出生点上,作为一个子物体存在,这样血条就会跟随玩家移动了。
接下来,我们需要设计代码部分,毕竟血条不是摆设,不是让你死了还觉得自己是满血的🐮.
using UnityEngine;
using UnityEngine.UI; // 必须引入UI命名空间!
using UnityEngine.SceneManager m;
public class HealthSystem : MonoBehaviour
{
// 通过ScriptableObject获取最大生命值
public CharacterData characterData;
// 当前生命值
private float currentHealth;
// 引用血条Slider组件
public Slider healthSlider;
void Start()
{
// 初始化,当前生命值等于最大生命值
currentHealth = characterData.maxHealth;
// 初始化血条
if (healthSlider != null)
{
healthSlider.maxValue = characterData.maxHealth; // 设置血条最大值
healthSlider.value = currentHealth; // 设置血条当前值
}
}
// 一个受伤的方法,可以被其他脚本(如敌人的攻击脚本)调用
public void TakeDamage(int damageAmount)
{
// 扣血
currentHealth -= damageAmount;
// 更新血条UI
if (healthSlider != null)
{
healthSlider.value = currentHealth;
}
// 检查是否死亡
if (currentHealth <= 0)
{
PlayerDie();
}
}
}
在代码中,你会经常看到,如果什么什么不是空,例如,初始化血条那里,这是很典型的一个错误检查系统,因为如果你所需要调用的东西为空的话,会导致不可预料的后果。
4.让敌我双方能打架
逻辑与第一篇中的胜利判断条件大致相同,所以在此不做赘述,在这里,我们重点讲如何通过预制体的方式来控制子弹:
首先,申你一个攻速变量,这里我们来讲解一下时间:
我们可以通过Time.time来访问当前时间,因此,下一次能射击的时间,也就是上一阵的时间加上CD,这个CD便是攻速变量的倒数,现在想几秒大家就能理解。
然后还是一样的,我们也需要一个用于检测接触的碰撞体,之前我们已经定义了一个受伤的函数,那么当两个碰撞体接触时,我希望这个函数能够起作用,这就是我们大致的逻辑。
或者说那个函数将在敌方碰撞到我方时起效果,而如果我方的子弹碰撞到敌方,会触发敌方的受伤函数。
我方受伤则应当是检测到我方的特殊碰撞体,碰撞到了敌方,自动为我方的生命减去敌方的攻击力。但是这里需要注意,我们应当使用的函数并不是之前我们所使用用于进行视野检测的函数,而是一个专门用于进行碰撞检测的函数:OnCollisionEnter2D
请注意这个函数的参数,一定要是collision 2d,不能是其他的,别的东西如果是其他的,别的东西的话,会出现错误!一定一定一定要注意,我被这玩意儿磨了半小时了!
好了,被这些小错误拖延完进度之后,我们接下来该看如何发射子弹了,总不能一直被敌人追着打,不是嘛?(≧∇≦)
添加一个空物体作为主角的子物体作为喷火口,同理设计一个伤害触发函数,具体可以参考,我们在第一篇中的胜利方法使用。
using UnityEngine;
public class PlayerShoot : MonoBehaviour
{
// 拖拽赋值:子弹预制体、发射点
public GameObject bulletPrefab;
public Transform firePoint;
// 可调整:子弹速度
public float bulletSpeed = 10f;
void Update()
{
// 按空格键发射
if (Input.GetKeyDown(KeyCode.Space))
{
Shoot(); // 调用发射方法
}
}
void Shoot()
{
// 1. 在发射点位置生成子弹
GameObject bullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);
// 2. 给子弹的 Rigidbody2D 加速度(沿发射点的 Right 方向)
Rigidbody2D rb = bullet.GetComponent<Rigidbody2D>();
rb.velocity = firePoint.right * bulletSpeed; // 方向=发射点朝向,速度=自定义值
// 3. (可选)子弹自动销毁(避免场景中堆积)
Destroy(bullet, 2f); // 2秒后销毁子弹
}
}
5. AI和寻路逻辑:让敌人“活过来”
游戏中的AI(人工智能)决定了敌人如何思考和行为。一个简单的追敌AI通常包含以下几个部分:
- 感知:敌人如何发现玩家?(看见?听到?)
- 决策:发现玩家后做什么?(追击?攻击?逃跑?)
- 行动:如何执行决策?(移动?播放动画?)
我们将实现一个最经典的AI模式:追踪(Chase)。
α. AI控制器(状态机)
你可以把AI想象成一个有不同状态的机器,每个状态下它做不同的事,并且满足条件时会在状态间切换。
对于你的小敌人,它可以有两个状态:分别是巡逻状态:未发现玩家时,在原地发呆或沿固定路线走动。还有追击状态:发现玩家后,持续向玩家移动并准备攻击。
β. 感知系统:如何发现玩家?
发现玩家最常用的方法是距离检测和视觉检测(射线检测)。
方法一:距离检测(简单高效) 在敌人身上挂一个圆圈形的触发器(Trigger),如果玩家进入了这个圈,敌人就“看”到了玩家。
1. 设置触发器:
· 选中你的敌人预制体。
· 添加一个组件:Circle Collider 2D。
· 勾选 Is Trigger。
· 调整 Radius 为你希望的“视野范围”。
2. 编写检测代码: 我们需要用到 OnTriggerEnter2D 和 OnTriggerExit2D 函数。
// 挂在敌人身上的脚本,比如叫 EnemyAI.cs
public class EnemyAI : MonoBehaviour
{
public float moveSpeed = 3f; // 敌人的移动速度
private Transform playerTarget; // 存储玩家位置
private bool isChasing = false; // 是否正在追击// 当玩家进入触发范围
void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player")) // 确保碰撞对象是玩家
{
playerTarget = other.transform; // 记住玩家是谁
isChasing = true; // 状态切换为:追击!
Debug.Log("发现玩家!开始追击!");
}
}// 当玩家退出触发范围
void OnTriggerExit2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
isChasing = false; // 状态切换为:不追了。
Debug.Log("玩家跑远了,不追了。");
}
}void Update()
{
if (isChasing && playerTarget != null)
{
// 追击逻辑将在下面实现
}
}
}
γ. 寻路与移动:如何追着玩家跑?
发现玩家后,最关键的一步就是让敌人朝着玩家移动。在2D游戏中,这通常通过计算方向并施加力或直接修改位置来实现。
使用 Vector2.MoveTowards .不要问我为什么,问就是最简单……
playerTarget.position - transform.position:得到一个从敌人指向玩家的向量。
.normalized:将这个向量标准化,即只保留方向,长度变为1。这样做的目的是防止玩家离得远时敌人移动得快,离得近时移动得慢。我们希望敌人速度是恒定的。
Vector2.MoveTowards(当前坐标, 目标坐标, 速度):这是Unity提供的一个非常好用的函数,它会将物体从当前坐标平滑地、以固定速度地移动到目标坐标。Time.deltaTime 是用来保证移动速度在不同性能的电脑上都是一致的。
void Update()
{
if (isChasing && playerTarget != null)
{
// 计算方向:目标位置 - 自身位置 = 方向向量
Vector2 direction = (playerTarget.position - transform.position).normalized;// 方法1:使用MoveTowards(平滑移动)
transform.position = Vector2.MoveTowards(transform.position, playerTarget.position, moveSpeed * Time.deltaTime);// 方法2:使用刚体力(更物理,可能和你之前移动逻辑类似)
// _rb.velocity = direction * moveSpeed;
}
}
void Update()
{
if (isChasing && playerTarget != null)
{
... // 上面的移动代码// 控制面向方向(Flip)
if (direction.x > 0)
{
// 玩家在右边,面朝右
transform.localScale = new Vector3(1, 1, 1); // 或者你的右朝向scale
}
else if (direction.x < 0)
{
// 玩家在左边,面朝左
transform.localScale = new Vector3(-1, 1, 1); // X轴取反即是翻转
}
}
}
完整代码示例
using UnityEngine;public class EnemyAI : MonoBehaviour
{
[Header("移动设置")]
public float moveSpeed = 3f;
[Header("感知设置")]
public float chaseRange = 5f; // 可以把这个和Trigger半径联动private Transform playerTarget;
private bool isChasing = false;
private Rigidbody2D _rb;
private Vector3 originalScale;void Start()
{
_rb = GetComponent<Rigidbody2D>();
originalScale = transform.localScale; // 记录原始的朝向
}void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
playerTarget = other.transform;
isChasing = true;
}
}void OnTriggerExit2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
isChasing = false;
// 可选:停止移动
// _rb.velocity = Vector2.zero;
}
}void Update()
{
if (isChasing && playerTarget != null)
{
// 1. 计算方向
Vector2 direction = (playerTarget.position - transform.position).normalized;// 2. 移动(选择一种方式)
// 方式A:直接修改位置(简单直接)
transform.position = Vector2.MoveTowards(transform.position, playerTarget.position, moveSpeed * Time.deltaTime);// 方式B:使用刚体速度(更物理)
// _rb.velocity = direction * moveSpeed;// 3. 控制面向
if (direction.x > 0.1f)
{
transform.localScale = new Vector3(originalScale.x, originalScale.y, originalScale.z);
}
else if (direction.x < -0.1f)
{
transform.localScale = new Vector3(-originalScale.x, originalScale.y, originalScale.z);
}
}
}// 可选:在Scene视图中绘制感知范围,便于调试
void OnDrawGizmosSelected()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, chaseRange);
}
}
主播要开学了,不更新了,因为时间不够,不过我可能寒假还会再更新,我可能会在寒假讲解对象池,事件中心,以及一些算法。
告辞,喜欢记得点点收藏和点赞,如果有不懂的可以在评论区提问,如果我有时间,我会努力回答的!
更多推荐
所有评论(0)