第三部分–敌人
这一部分开始前先创建一个父类脚本,后续写对应的脚本时需要使用父类中的一些变量或者函数(公开的变量可直接访问 or 使用,私有变量则不行)
移动部分
先实时获得敌人面朝的方向,再对其施加一个力
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void Update() { faceDir = new Vector3(-transform.localScale.x, 0, 0); }
void FixedUpdate() { Move(); }
public void Move() { rb.velocity = new Vector2(currentSpeed * faceDir.x, rb.velocity.y); }
|
注意一下 Move 那里,如果放到 FixedUpdate 里执行的话是不需要要乘 deltaTime 的,原因是乘了之后需要填一个非常大的值才能移动.
然后在 Inspector 中填一个比较合适的数值,不出意外的话,敌人应该能正常移动了
动画部分
基础动画可以参照第一篇的玩家动画部分
播放部分则略有不同,因为咱在 Enemy.cs 通用脚本里写了一些逻辑,想在某个敌人的专属脚本中复用并重写这些函数
所以,得先把 Move 改成这样
1
| public virtual void Move()
|
接着给 Animator 的变量加个 protected 以让子类访问
然后在脚本中直接调用即可
1 2 3 4 5
| public override void Move() { base.Move(); anim.SetBool("walk",true); }
|
撞墙判定
把之前写好的 PhysicsCheck 挂到敌人身上,调整 bottomOffset 到合适的地方用于判定是否存在地面(如下图)

接下来做撞墙判定,也就是给左右各添加一个检测点,具体实现代码可以翻阅 Part1 的玩家操作–碰撞检测部分,然后调整到合适的位置
以上是手动调整碰撞,下面是自动调整,具体实现代码如下
1 2 3 4 5 6 7 8 9 10
| private void Awake() { coll = GetComponent<CapsuleCollider2D>();
if (!manual) { rightOffset = new Vector2(coll.offset.x + coll.bounds.size.x / 2, coll.offset.y); leftOffset = new Vector2(coll.offset.x - coll.bounds.size.x / 2, coll.offset.y); } }
|
调整完检测区域后运行,会发现敌人没法和墙壁正常碰撞。原因是墙壁的碰撞使用外框线检测,而不是整个物体,这会导致快速移动穿模之后没法正常碰撞。解决办法就是改为下图的设置

自动巡逻
效果:撞墙之后等待 x 秒后转向接着巡逻
实现代码如下,还是用的计数器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| void Update() { if ((physicsCheck.touchLeftWall && faceDir.x < 0) || (physicsCheck.touchRightWall && faceDir.x > 0)) { wait = true; anim.SetBool("walk", false); }
WaitCounter(); }
public void WaitCounter() { if (wait) { waitTimeCounter -= Time.deltaTime; if (waitTimeCounter < 0) { wait = false; waitTimeCounter = waitTime; transform.localScale = new Vector3(faceDir.x, 1, 1); } } }
|
这种方法有一个坏处,那就是在低性能的机器上运行可能会导致敌人仍然在移动。高性能机子是因为 Update 调用次数直接把 FixUpdate 的调用次数盖过去了才没出问题,后续可能会考虑把这一块抽出来写。
敌人死亡动画&逻辑
这回用的素材没有给死亡动画,只能拿受伤动画改改了。
把受伤动画复制一份,然后把最后一帧的透明度改为 0,就能做出一个最简单的死亡动画。其余的按之前的做好就行
以下是受伤击退的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public void OnTakeDamage(Transform attackTrans) { attacker = attackTrans;
if (attackTrans.position.x - transform.position.x > 0) { transform.localScale = new Vector3(-1, 1, 1); } if (attackTrans.position.x - transform.position.x < 0) { transform.localScale = new Vector3(1, 1, 1); }
isHurt = true; anim.SetTrigger("hurt"); Vector2 dir = new Vector2(transform.position.x - attackTrans.position.x, 0).normalized;
rb.AddForce(dir * hurtForce, ForceMode2D.Impulse); isHurt = false; }
|
然后就会发现一个问题,因为执行速度太快了,isHurt 没法变为 false,导致受击后无法正常移动。这时需要用到协程来解决(别的办法也行)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public void OnTakeDamage(Transform attackTrans) {
StartCoroutine(OnHurt(dir)); }
IEnumerator OnHurt(Vector2 dir) { rb.AddForce(dir * hurtForce, ForceMode2D.Impulse); yield return new WaitForSeconds(0.5f); isHurt = false; }
|
(不要贪刀,有个铸币砍了几刀就没效果了,后面才发现是没血了)
死亡逻辑
1 2 3 4 5 6 7 8 9 10
| public void OnDie() { anim.SetBool("dead", true); isDead = true; }
public void DestroyAfterAnimation() { Destroy(this.gameObject); }
|
然后在动画的最后一帧添加 Event,选择 DestroyAfterAnimation 函数即可,然后顺带把碰撞体也关掉(也可以通过更改图层的方式解决)
注:死亡动画记得把 Can Transition to self 去掉,不然会出 bug
状态机
补充一点需要了解的东西
状态机:在某个节点执行某项操作
抽象类:继承了这个类就必须实现里面的方法,和接口比较类似(只有声明,没有实现)
可以先写一个基类,类似于下面这段。然后在子脚本中继承这个基类就可以了
1 2 3 4 5 6 7
| public abstract class BaseState { public abstract void OnEnter(); public abstract void LogicUpdate(); public abstract void PhysicsUpdate(); public abstract void OnExit(); }
|
其他脚本可以直接调用子脚本中的方法执行,比如下面实现的巡逻状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| protected BaseState patrolState; private BaseState currentState;
private void OnEnable() { currentState = patrolState; currentState.OnEnter(); }
protected virtual void Awake() { }
void Update() { faceDir = new Vector3(-transform.localScale.x, 0, 0);
currentState.LogicUpdate(); WaitCounter(); }
void FixedUpdate() { if (!isHurt && !isDead && !wait) { Move(); } currentState.PhysicsUpdate(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public override void OnEnter(Enemy enemy) { currentEnemy = enemy; }
public override void LogicUpdate() { if (!currentEnemy.physicsCheck.isGrounded || (currentEnemy.physicsCheck.touchLeftWall && currentEnemy.faceDir.x < 0) || (currentEnemy.physicsCheck.touchRightWall && currentEnemy.faceDir.x > 0)) { currentEnemy.wait = true; currentEnemy.anim.SetBool("walk", false); } else { currentEnemy.anim.SetBool("walk", true); } }
public override void OnExit() { currentEnemy.anim.SetBool("walk", false); }
|
然后就是怎么把对应敌人的状态机传进去了,需要新建一个脚本 override 一下 Awake,然后直接 new 一个对象即可
1
| patrolState = new BoarPatrolState();
|
接下来就要做玩家检测了,这次选了一个类射线检测的方法
先向右发射一个 Boxcast 来判断有没有玩家,然后再根据情况切换:比如发现玩家,则切换到追击状态并更改速度,如果丢失目标超过 n 秒,则切换回巡逻模式
由于部分代码过长,我会贴一个文件链接方便查阅
Enemy.cs
BoarChaseState.cs
1 2 3 4 5 6
| protected override void Awake() { base.Awake(); patrolState = new BoarPatrolState(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| public override void LogicUpdate() { public override void OnEnter(Enemy enemy) { currentEnemy.currentSpeed = currentEnemy.normalSpeed; }
if (currentEnemy.FoundPlayer()) { currentEnemy.ChangeState(NPCState.Chase); } }
|
第四部分–玩家UI
UI按键部分
先把EventSystem的InputModule更新一下以方便添加InputAction

然后更改如下设置
“Render Mode”–Screen Space (屏幕多大,这个画布就多大)
“UI Scale Mode”–Scale With Screen (根据屏幕缩放)
“Reference Resolution”– (填上参考分辨率,这里是1920x1080)
“Match”– (改为0.5,取一个中间值)
“Reference Pixels Per”– (按素材填写)
搭建UI
如果ui素材不是很好切的话建议上自动,识别不出来的直接用鼠标画个框就行
接着先建一个带Image组件的物体,然后把组件的Image Type改成Filled,Method改为Horizontal,就能获得条状减少的效果。
复制一份改个素材就能得到双色减少的效果(如下图)

做完以上操作后在Inspector中改锚点、对其,方便ui自适应屏幕

按住Shift是对齐锚点,Alt则是物体
下面是代码逻辑
1 2 3 4 5 6 7 8 9 10 11
|
public Image healthImage; public Image healthDelayImage; public Image energyImage;
public void OnHealthChange(float percentage) { healthImage.fillAmount = percentage; }
|
方法写好,现在该传递数值了,为了方便避免跨场景读取等问题,会用到ScriptableObject
先写一个用于创建ScriptableObject的脚本,里面包含数值变化时需要调用的方法,他本身也会充当一个中间件传递和广播数据
1 2 3 4 5 6 7 8 9 10 11 12
|
[CreateAssetMenu(menuName = "Event/CharacterEventSO")] public class CharacterEventSO : ScriptableObject { public UnityAction<Character> OnEventRaised;
public void RaiseEvent(Character character) { OnEventRaised.Invoke(character); } }
|
从Character脚本中也加上调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
public UnityEvent<Character> onHealthChange;
private void Start() { CurrentHealth = MaxHealth; onHealthChange?.Invoke(this); }
public void TakeDamage(Attack attacker) { onHealthChange?.Invoke(this); }
|
再把CharacterEventSO.asset文件扔给挂载Character脚本的物体调用数值变化方法即可
监听部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
public PlayerStatus playerStatus;
[Header("事件监听")] public CharacterEventSO healthEvent;
private void OnEnable() {
healthEvent.OnEventRaised += UpdateHealth; }
private void OnDisable() { healthEvent.OnEventRaised -= UpdateHealth; }
private void UpdateHealth(Character character) { var percentage = character.CurrentHealth / character.MaxHealth; playerStatus.OnHealthChange(percentage); }
|
这个随便建个新物体挂载上去就行
延迟减少的话只需要给状态栏加如下代码
1 2 3 4 5 6 7 8
| private void Update() { if(healthDelayImage.fillAmount > healthImage.fillAmount) { healthDelayImage.fillAmount -= Time.deltaTime; } }
|
以上就是UI部分
第五部分–其他效果的实现
摄像机跟随和攻击抖动
这一块用到了官方的Cinemachine插件,添加一个2D Camera之后可以看到一大堆的设置,比如物体跟随、追踪物体偏移值等等。
其中,Deadzone(死区)则是在画面中画出一块区域,如果物体没有超出这个区域,摄像机则不会跟随。
然后我们针对当前场景还需要添加两个Extensions:
一个是PixelPerfect,防止像素产生畸变
另一个是Confiner 2D,用于限制摄像机移动,以免露出场景中未绘制的区域
对于后者,需要创建一个Polygon Collider2D搭配使用(别忘了勾Is Trigger)
接下来要实现切换场景后自动获取摄像机边界,这里需要提前给边界物体打好Tag
然后给摄像机挂如下脚本即可
1 2 3 4 5 6 7 8 9 10 11
| void GetCameraBounds() { var obj = GameObject.FindGameObjectWithTag("Bounds"); if (obj == null) return;
confiner2D.m_BoundingShape2D = obj.GetComponent<Collider2D>();
confiner2D.InvalidateCache(); }
|
音效和BGM
播放音效可以参考前面的ScriptableObject,实现方式基本差不多。
唯一需要说的就是这块得用自带的AudioMixer创建两个轨道,来让BGM和FX分别输出,以方便后续调整音量
碰到水直接死亡
给水单独建一个图层画上去,挂上Tag和碰撞体。然后在Character脚本中加入如下内容
1 2 3 4 5 6 7 8 9
| private void OnTriggerStay2D(Collider2D other) { if (other.CompareTag("Water")) { CurrentHealth = 0; onHealthChange?.Invoke(this); OnDie?.Invoke(); } }
|
持久化
因为后面会做场景切换,有一部分物体是不需要重复加载的,故需要把这一部分扔到另一个场景中做持久化
按键交互
要实现的效果是:靠近可交互物体时,显示提示按钮,按下按键能完成交互
先搓一下按键动画,依旧是用Animator完成(这里需要新建一个空白的State用于默认状态)
因为是要交互,Collider肯定少不了,这里放在了玩家的中心位置触发
接下来还需要在父物体挂一个脚本用来判断,大致的逻辑还是通过匹配物体的Tag来决定什么时候显示,再通过匹配设备匹配输入设备名称来播放对应动画(物体的Tag要提前改好)
交互的大概思路是:
先写一个接口用来实现方法,然后订阅事件,进入碰撞范围后获取碰撞体上的接口,按下按键后调用接口方法完成交互
按键提示脚本 接口 按键提示脚本
场景管理和切换
这一部分使用的是官方的Addressable插件来完成,好处是打包的时候不会重复打包某些物体和素材,提高复用率。
插件位置在 “Window” -> “Asset Management”,初次使用会创建一些文件。这里先建几个Groups,把Assets分好类扔进去(能打成Prefab的尽量打,方便后续调整)

下一步要把场景封装成ScriptableObject,其中也包含了场景的类型
1 2 3 4 5
| public enum SceneType { Location, Menu }
|
1 2 3 4 5 6 7 8
| [CreateAssetMenu(menuName = "Game Scene/GameSceneSO")] public class GameSceneSO : ScriptableObject { public SceneType SceneType; public AssetReference sceneReference; }
|
然后通过Events事件来异步卸载当前场景并加载新场景
场景加载事件 传送点 场景加载管理
然后就是场景加载完之后需要执行的一些操作
1.在传送的过程中就需要把按键操作给屏蔽掉,防止出现问题
1 2 3 4 5
| private void OnDisable() { canPress = false; }
|
2.将玩家传送到指定位置
大体流程:卸载当前场景->隐藏角色->加载新场景->传送并显示玩家->获取摄像机边界。如果是第一次加载的话,则直接省去前面卸载的步骤
3.加载后获取摄像机边界
场景加载完成后依旧是通过事件订阅的方式来调用
场景加载管理 空返回事件 摄像机脚本
场景切换动画&事件
切换动画大概思路:
- 新建一个Canvas和Image组件,并调整Canvas的Layer和Image的大小,确保能覆盖原本的画面
- 然后通过事件订阅来调用DOTween在Image上实现一个平滑过渡(调用时只需传入过渡时间)
事件传入(封装了一些函数) 本体
切换时需要执行的事件:加载时需要把状态栏隐藏起来,并且在主界面不显示
通过监听加载和加载完成两个事件来控制开关,然后在SceneLoadManager中通过判断当前加载的场景的类型来决定是否开启状态栏
1 2 3 4 5 6
| if (currentLoadScene.SceneType == SceneType.Location) { afterSceneLoad.RaiseEvent(); }
|
注:因为这里的限制只能调用事件执行,所以需要把加载新游戏的逻辑改为事件调用,而不是直接加载
1
| loadEventSO.RaiseLoadRequestEvent(SceneToLoad, firstPosition, true);
|
主界面按钮功能实现
新游戏
这里就需要先让SceneLoadManager在启动游戏时先加载标题菜单场景
1 2 3 4 5 6 7
| public Vector3 menuPosition; public GameSceneSO menuScene;
private void Start() { loadEventSO.RaiseLoadRequestEvent(menuScene, menuPosition, true); }
|
给“新游戏”按钮挂上VoidEvent,通过SceneLoadManager监听并调用NewGame函数即可实现
接下来要解决的问题是场景还没加载完,状态栏就显示出来了
这里就需要新建一个Event,然后在黑屏时呼叫这个Event,让UIManager隐藏掉即可
1 2 3 4 5 6 7
| yield return new WaitForSeconds(fadeDuration);
sceneUnloadedEvent.RaiseLoadRequestEvent(SceneToLoad, positionToGo, true);
yield return currentLoadScene.sceneReference.UnLoadScene();
|
最后,把血量赋值改为事件调用的方式就可以了
按钮自动选中
写个脚本把GameObject传给Event System的First Selected即可
退出游戏
保存点
大体逻辑看宝箱那一部分,只需要在其基础上增加保存数据即可
注:保存数据部分逻辑不会在本文中提及,可以寻找更好的思路来实现