
这两天在做这个空洞骑士风格的Unity 2D Demo玩家控制、基础地图、摄像机跟随都跑通了第三步做了第一只敌人。说实话当时那个敌人实在有点简陋——一个矩形碰撞体左右来回走碰到玩家就扣血。到了第四集要开始制作更多的敌人时我突然意识到一个很现实的问题如果每加一种敌人都要把移动、检测、攻击、受伤、死亡整套逻辑从头写一遍这个项目做到敌人十几种的时候代码量会失控维护成本会直接把我劝退。这一集我从架构入手先搭了一套可复用的敌人框架再用这个框架做了两种完全不同的敌人一种是贴地巡逻、看到玩家就追击的近战怪参考空洞骑士里的Husk壳虫另一种是悬浮在空中的飞行怪会追踪玩家、靠近后自爆参考Belfly爆炸蚊。整个过程走下来代码复用的价值体现得非常明显。如果你也在用Unity学习2D平台游戏或者正想模仿空洞骑士这种银河恶魔城玩法这篇内容应该能帮你少踩不少坑。1. 做更多敌人之前先把敌人抽成一个类1.1 空洞骑士的敌人为什么耐打空洞骑士里的小怪设计和很多同类型游戏不太一样。它不是单纯给你一个移动靶子而是每个敌人都有明确的行为套路——攻击前有前摇攻击后有后摇玩家可以通过观察节奏做出反应。比如Husk在靠近玩家后会停顿一下再挥刀Belfly会先闪烁然后才自爆。这种敌人给玩家读招空间的设计思路才是空洞骑士战斗手感好的核心原因。这意味着什么意味着我们在做Unity Demo时敌人不能只是一个会往玩家身上撞的移动方块。即使是小怪也得有巡逻时的规律移动、发现玩家后的状态切换、攻击之前的提示性动作、受伤后的硬直、死亡后的表现。状态管理是所有敌人的共同需求天然适合抽到同一个基类里。1.2 敌人基类把通用能力收进父类我的第一个决定是写一个EnemyBase抽象类把血量、受伤、死亡、击退、受击闪白这些所有敌人都需要的东西放到里面。子类只需要实现移动模式、攻击方式和动画响应。using System.Collections; using UnityEngine; public abstract class EnemyBase : MonoBehaviour { [Header(生命值)] public int maxHealth 3; protected int currentHealth; [Header(受击反馈)] public float hitFlashDuration 0.15f; public float hurtStunDuration 0.2f; public float knockbackForce 8f; protected Rigidbody2D rb; protected SpriteRenderer spriteRenderer; protected Collider2D enemyCollider; protected bool isDead false; protected bool isHurt false; protected virtual void Awake() { rb GetComponentRigidbody2D(); spriteRenderer GetComponentInChildrenSpriteRenderer(); enemyCollider GetComponentCollider2D(); currentHealth maxHealth; } public virtual void TakeDamage(int damage, Vector2 hitDirection) { if (isDead || isHurt) return; currentHealth - damage; isHurt true; StartCoroutine(HurtFlash()); ApplyKnockback(hitDirection); if (currentHealth 0) Die(); else StartCoroutine(HurtStunRecover()); } private IEnumerator HurtFlash() { spriteRenderer.color new Color(1f, 0.6f, 0.6f, 1f); yield return new WaitForSeconds(hitFlashDuration); spriteRenderer.color Color.white; } private IEnumerator HurtStunRecover() { yield return new WaitForSeconds(hurtStunDuration); isHurt false; } protected virtual void ApplyKnockback(Vector2 hitDirection) { if (rb null) return; Vector2 dir hitDirection.normalized; rb.velocity new Vector2(dir.x * knockbackForce, 2f); } protected abstract void Die(); }说一下我为什么把TakeDamage写成 public virtual。因为后续敌人可能会有不同的受击逻辑比如飞行怪被击中后会直接进入愤怒状态加速追踪这种特殊情况由子类 override 掉TakeDamage就好不用去动基类。同时isHurt的设计很关键它让敌人在受击硬直期间不会继续攻击玩家打起来会感觉每一击都是有意义的不会出现你打敌人的同时敌人还照样反击的情况。1.3 状态机才是敌人AI的地基移动、攻击这些具体表现依靠的核心结构是状态机。很多新手用Animator里的State来驱动物体运动我不是很推荐把逻辑放进Animator——动画状态太多之后很难调试而且逻辑和表现耦在一起后续扩展敌人类型时会非常痛苦。我的做法是在代码里维护一个简单的枚举状态用 Update 里的 switch 分发。public enum EnemyState { Patrol, Chase, Attack, Hurt, Dead } protected EnemyState currentState EnemyState.Patrol; protected virtual void Update() { if (isDead) return; switch (currentState) { case EnemyState.Patrol: PatrolUpdate(); break; case EnemyState.Chase: ChaseUpdate(); break; case EnemyState.Attack: AttackUpdate(); break; case EnemyState.Hurt: HurtUpdate(); break; } } protected abstract void PatrolUpdate(); protected abstract void ChaseUpdate(); protected abstract void AttackUpdate(); protected virtual void HurtUpdate() { }这个状态机没有用插件也没有用复杂的嵌套状态机。对于我们现在这种敌人数量不多的2D Demo来说一个枚举加 switch 已经完全够用而且逻辑一目了然。等以后确实需要更复杂AI的时候再引入状态模式或者 Behavior Tree 也不迟。做项目最怕的就是过度设计先解决眼前的问题别一开始就上重型架构。2. 贴地近战敌人Husk式守卫的完整实现2.1 状态怎么切每个状态都要有明确的出口第一个具体敌人我用HuskEnemy来做。它的行为大致是这样巡逻状态在地面来回走走到平台边缘就转身不追人追击状态玩家进入检测半径后朝玩家方向移动攻击状态距离玩家足够近时先做一个短暂的停顿前摇然后挥刀受伤状态被击中后停顿零点几秒继续追击死亡状态倒地消失这个流程我用一张表来记录状态切换条件写代码前先想清楚能避免很多逻辑漏洞。当前状态切换目标触发条件PatrolChase玩家在检测半径内且与玩家之间没有阻挡ChaseAttack与玩家距离小于攻击半径ChasePatrol玩家超出脱战半径或者玩家消失AttackChase攻击动画播完且玩家仍在检测范围内HurtChase受击硬直结束且玩家仍在检测范围内状态切换的核心是两个半径检测半径和攻击半径。检测半径用于判断这个敌人是否注意到玩家攻击半径用于判断它要不要挥刀。我习惯把这两个值做成 public 字段在 Inspector 里调反复试手感。2.2 移动与转向别在Update里直接改position巡逻和追击的移动方式非常类似区别只是目标方向不同所以我写到一个移动方法里传入方向就行。public class HuskEnemy : EnemyBase { public float patrolSpeed 2f; public float chaseSpeed 3.2f; public float detectRadius 4f; public float attackRadius 1.2f; public float loseRadius 6f; public LayerMask playerLayer; public Transform player; private int facingDirection -1; private float attackCooldown 0f; private bool canFlip true; protected override void PatrolUpdate() { MoveHorizontal(facingDirection * patrolSpeed); } protected override void ChaseUpdate() { float distance Vector2.Distance(transform.position, player.position); if (distance loseRadius) { SwitchState(EnemyState.Patrol); return; } if (distance attackRadius attackCooldown 0) { SwitchState(EnemyState.Attack); return; } int dir player.position.x transform.position.x ? 1 : -1; if (dir ! facingDirection canFlip) Flip(); MoveHorizontal(dir * chaseSpeed); } private void MoveHorizontal(float speed) { rb.velocity new Vector2(speed, rb.velocity.y); } private void Flip() { facingDirection * -1; transform.localScale new Vector3(-transform.localScale.x, transform.localScale.y, transform.localScale.z); } }这里有个细节移动我通过设置rb.velocity的 x 分量而不是直接改transform.position。因为敌人需要和地图碰撞体交互如果直接改坐标很容易穿墙或者卡进地面。用刚体速度Unity的物理引擎会帮我们处理地形阻挡。还有一个容易踩坑的地方是巡逻状态的转向。我用的方法是每一帧往当前方向移动如果前面是悬崖比如平台边缘敌人就往下掉。空洞骑士里很多小怪在平台边缘会掉下去再爬上来但如果我们想做一个走到平台边缘自动转身的敌人需要额外加一条向下射线检测private bool IsNearEdge() { Vector2 origin new Vector2(transform.position.x facingDirection * 0.5f, transform.position.y); RaycastHit2D hit Physics2D.Raycast(origin, Vector2.down, 1f, groundLayer); return hit.collider null; }这条射线从敌人前方底部往下打如果打不到地面说明走到了边缘就执行转身。这个检测在2D平台游戏里非常常用建议直接写进工具类里后面所有贴地怪都能用。2.3 玩家检测少用Trigger多用Overlap检测玩家我用的是Physics2D.OverlapCircle每帧调用一次检查以敌人为圆心、 detectRadius 为半径的圆里是否有 Player 层的碰撞体。不要用 OnTriggerEnter2D 来做持续检测。原因有两个第一玩家移动速度很快的时候可能会一帧之内穿透触发器的检测Unity物理用的是离散碰撞检测快速小物体会穿模第二OnTriggerEnter 是一瞬间的事件你还要额外纪录玩家是否还在范围内状态保持非常麻烦。而 OverlapCircle 每帧都在做现在有没有玩家逻辑更直观。LayerMask 一定要选对记得在 Project Settings 里把 Player 层单独设置。检测的层如果写成 Default可能会把敌方单位甚至地形都检测进去后面排查很痛苦。3. 飞行敌人Belfly式追踪、浮动和自爆攻击3.1 飞行移动不能用Lerp硬拉地面敌人做完了来做第二种类型飞行敌人。参考空洞骑士里的Belfly这种敌人的特点是不接触地面悬空缓慢移动发现玩家后会闪烁加速冲过来冲到面前砰一声自爆。它的威胁不是持续伤害而是一次性的区域爆发伤害。飞行敌人刚做的时候我直接用了Vector2.Lerp让它朝玩家平滑移动。结果发现敌人像是在太空里滑冰到目标点后不会停反而一直在漂。问题出在哪Lerp 的 t 值如果只用 Time.deltaTime 去乘一个固定系数它会造成接近速度越来越慢的运动曲线飞行怪应该是一个恒定速度追踪的单位而不是阻尼逼近。正确做法是用Vector2.MoveTowards每次移动一段固定距离速度恒定到了目标点就停。public class BelflyEnemy : EnemyBase { public float patrolSpeed 1f; public float chaseSpeed 5f; public float detectRadius 5f; public float explodeRadius 0.8f; public float explosionDamageRadius 1.5f; public int explosionDamage 1; private float startY; private float floatAmplitude 0.3f; private float floatFrequency 3f; private bool isExploded false; protected override void Awake() { base.Awake(); startY transform.position.y; } protected override void PatrolUpdate() { Vector2 pos transform.position; float offset Mathf.Sin(Time.time * floatFrequency) * floatAmplitude; pos.y startY offset; pos.x patrolSpeed * Time.deltaTime * -facingDirection; transform.position pos; } protected override void ChaseUpdate() { if (player null) return; float distance Vector2.Distance(transform.position, player.position); if (distance loseRadius) { SwitchState(EnemyState.Patrol); startY transform.position.y; return; } Vector2 dir (player.position - transform.position).normalized; transform.position Vector2.MoveTowards( transform.position, player.position, chaseSpeed * Time.deltaTime ); if (distance explodeRadius) { SwitchState(EnemyState.Attack); } } }为了让飞行怪飞得不那么死板我在 X 轴按固定速度巡逻的同时给 Y 轴叠加了一个正弦波。这样它看起来就是上下轻微飘动很符合昆虫翅膀扇动时的悬浮感。正弦波的相位是 Time.time 驱动的所有同类型的飞行怪会以同样的频率浮动看起来反而非常统一。3.2 自爆攻击带提示的前摇比伤害更重要Belfly 的自爆如果直接撞到玩家就触发玩家完全没有反应时间会显得很不公平。空洞骑士的做法是Belfly 在接近玩家时有一个明显的闪烁加速过程身体颜色变红给玩家足够时间躲开然后才爆炸。我也在代码里加了同样的设计。protected override void AttackUpdate() { if (!isExploded) { isExploded true; StartCoroutine(ExplodeSequence()); } } private IEnumerator ExplodeSequence() { // 闪烁三次提示玩家 for (int i 0; i 3; i) { spriteRenderer.color new Color(1f, 0.3f, 0.3f, 1f); yield return new WaitForSeconds(0.12f); spriteRenderer.color Color.white; yield return new WaitForSeconds(0.12f); } // 检测爆炸半径内的玩家 Collider2D[] hits Physics2D.OverlapCircleAll(transform.position, explosionDamageRadius, playerLayer); foreach (var hit in hits) { var playerHealth hit.GetComponentPlayerHealth(); if (playerHealth ! null) playerHealth.TakeDamage(explosionDamage, transform.position); } Die(); }这里爆炸判定用的是OverlapCircleAll可以同时命中爆炸范围内的多个目标。以后如果场景里同时有两个玩家或者有可破坏的物品这个写法可以直接复用。闪烁三次每次0.12秒加上中间间隔总共给玩家预留了大约0.7秒的闪避时间。这个时长是我多次试玩后调的太短玩家来不及反应太长又会让人觉得敌人很蠢。3.3 飞行敌人的碰撞身体是触发器还是碰撞体Belfly 的自爆伤害范围比它自己的身体大一圈。也就是说玩家不一定要被它撞到才受伤只要在爆炸半径内就会中招。那么飞行怪自身应该怎么设置我的做法是飞行怪的 Collider2D 保持为非 Trigger保证它能和地形碰撞否则它会穿墙同时给它的身体加一个独立的 Trigger 子物体一旦碰到玩家就当接触伤害处理。但这样会有一个问题飞行怪接触玩家瞬间会触发一次伤害接着马上自爆又会触发一次爆炸伤害玩家会被蹭一下再加炸一下两段伤害叠加起来太疼。解决方式是在自爆前加一个短暂的isCharging状态标记。在闪烁提示期间即使碰到玩家也不造成接触伤害等爆炸动画出手才结算伤害。这个细节你在第一次实现时很容易忽略等试玩时发现怎么一下就没血了才会意识到是被连续结算了。4. 战斗手感血量、击退、受击闪白与死亡表现4.1 血量和伤害数值简单才能调平衡这次Demo我用的是玩家攻击一次固定1点伤害的模式敌人血量放在 Inspector 里单独配。第三集最开始我把敌人血量设成10打完一只怪要挥十刀手感拖沓得不行。空洞骑士里的小怪基本都是两三刀解决玩家才能在探索中保持节奏感。敌人血量速度伤害特点Husk贴地怪3巡逻2 / 追击3.21有近战挥刀前摇地面巡逻Belfly飞行怪1巡逻1 / 追击5自爆1闪烁三次后爆炸范围伤害血量1的Belfly是为了鼓励玩家主动用远程手段或者引诱它撞墙来解。它速度快、威胁高但一刀就能解决这种高风险高回报的配置会让战斗有层次感。而贴地怪设成3点血方便玩家练习打一下-闪避-再打的基本节奏。4.2 击退让每一刀都有推人的感觉空洞骑士的击退手感非常细腻敌人受击后会往攻击方向弹开一小段距离期间不能行动。我在基类的ApplyKnockback里直接改rb.velocity给一个水平的力加一个固定的2米/秒抬头。为什么不统一用AddForce因为刚体身上的阻尼、质量会影响最终推开距离不同敌人的质量如果不同打起来的手感会不一致。直接给速度更可控击退方向确定、距离基本一致玩家在任何敌人身上获得的手感都是统一的。protected virtual void ApplyKnockback(Vector2 hitDirection) { if (rb null) return; Vector2 dir hitDirection.normalized; rb.velocity new Vector2(dir.x * knockbackForce, 2f); rb.gravityScale gravityScaleAtStart; // 保留原先重力 }唯一要小心的是刚体重力。如果敌人初始gravityScale是1击退时往上抬的2米/秒很快会被重力拉下来属于正常现象但如果飞行怪的 gravityScale 是0悬浮你打死它的时候它不会落地会停在空中。所以我给飞行怪单独 override 了 ApplyKnockback在击退期间临时把 gravityScale 设为1让它受击后能往下落死亡动画播完掉到地上消失视觉上舒服很多。4.3 死亡表现动画播完、碰撞体关掉、再回收死亡流程看起来简单但处理的顺序很关键。正确顺序是标记isDead true防止后续继续受伤关闭 Collider2D防止死掉的敌人还挡着玩家或者造成接触伤害播放死亡动画动画播完后把敌人归还对象池或 Destroy需要掉落物品就在第3步和第4步之间生成protected override void Die() { isDead true; enemyCollider.enabled false; animator.SetTrigger(Die); // 由动画事件调用 OnDeathAnimationFinished() } private void OnDeathAnimationFinished() { EnemyPool.Instance.ReleaseEnemy(gameObject); }这里不得不提一个经验教训不要让死亡逻辑放在OnDestroy里。2D游戏里如果你在死亡瞬间调用Destroy(gameObject)碰撞体、刚体、Animator 都会立即失效动画根本来不及播放怪物会原地消失。一定要通过动画事件或者协程等死亡动画播放完成再销毁。关于unity脚本控制逐渐消失的热搜问题我的做法是在死亡动画后期加一个淡出事件调用一个简单的协程把 SpriteRenderer 的 alpha 随时间降到0再执行回收。这样画面不会突然少一个东西观感会好很多。5. 场景里的敌人多了之后对象池与渲染剔除5.1 预制体嵌套敌人身上挂几个脚本才合理当我准备把第二种敌人放进场景时发现直接把脚本挂在一个空物体上是不够的。每个敌人实际上是一个多层结构根物体挂 EnemyBase 子类、Rigidbody2D、Collider2D子物体 Body挂 SpriteRenderer、Animator子物体 AttackCheck挂在攻击范围处用来检测攻击命中子物体 AlertingCircle纯视觉指示器用于显示检测范围这种根物体表现子物体的嵌套结构很重要。你不用每次切到敌人身上去找 SpriteRenderer因为根物体上就有一个引用Animator 挂在子物体上翻转父节点的 Scale 时子物体也会跟着翻转很自然地实现了左右朝向。关于 unity renderer的包围盒 的相关问题也在这个结构里体现SpriteRenderer 的包围盒取决于子物体上 sprite 的实际尺寸。如果你在敌人身上挂了一大堆空的子物体它们的坐标离得很远会导致整个敌人的包围盒异常变大渲染裁剪就会出问题。我自己就遇到过给飞行怪挂了一个爆炸特效子物体在很远的坐标结果它超出了屏幕边缘还在渲染性能白白浪费。做预制体时把所有子物体都摆在原点附近特效通过ParticleSystem动态播放就行不要让常驻子物体把包围盒撑大。5.2 对象池别让敌人一个接一个地生成销毁我在前期测试时场景里放了8个飞行怪每次被玩家打死四个同时新刷出四个帧率没太大问题。但当我尝试把敌人数量扩张到50个战斗过程中帧率肉眼可见地掉到了二十几帧。用 Profiler 一查Instantiate 和 Destroy 相关的 GC Alloc 高得吓人。解决办法是对象池。先创建一个 EnemyPool在场景加载时预生成一批敌人实例藏在一个空物体下面需要的时候激活用完了回收。public class EnemyPool : MonoBehaviour { public static EnemyPool Instance; [SerializeField] private GameObject huskPrefab; [SerializeField] private GameObject belflyPrefab; [SerializeField] private int poolSizePerType 20; private Dictionarystring, QueueGameObject poolDict; private void Awake() { Instance this; poolDict new Dictionarystring, QueueGameObject(); PrewarmPool(huskPrefab, poolSizePerType); PrewarmPool(belflyPrefab, poolSizePerType); } private void PrewarmPool(GameObject prefab, int size) { string key prefab.name; QueueGameObject queue new QueueGameObject(); for (int i 0; i size; i) { GameObject obj Instantiate(prefab, transform); obj.SetActive(false); queue.Enqueue(obj); } poolDict[key] queue; } public GameObject GetEnemy(string key, Vector3 position, Quaternion rotation) { if (poolDict.TryGetValue(key, out var queue) queue.Count 0) { GameObject obj queue.Dequeue(); obj.transform.SetPositionAndRotation(position, rotation); obj.SetActive(true); return obj; } return null; } public void ReleaseEnemy(GameObject enemy) { enemy.SetActive(false); string key enemy.name; if (!poolDict.ContainsKey(key)) poolDict[key] new QueueGameObject(); poolDict[key].Enqueue(enemy); } }对象池的使用要注意几个点取对象时 key 要用预制体的名字不要用gameObject.name因为实例名会被 Unity 自动加上 (Clone) 后缀ReleaseEnemy前必须先确保敌人的所有协程已经停掉否则对象复用时旧的协程还在跑敌人被回收后血量要重置回 maxHealth状态要重置回 Patrol必要的话在 Awake 里保存一个初始状态快照把 Instantiate/Destroy 改成 Get/Release 之后50个敌人同屏战斗的帧率稳定在60帧GC 也大幅下降。这个优化对任何需要大量生成敌人的2D游戏都适用。5.3 屏幕外的敌人别让它们傻傻地跑逻辑对象池解决了生成销毁的问题但屏幕上同时有50个敌人每个都在执行 Update 状态机即使没被渲染CPU 依然在空耗。这里就用到了渲染包围盒剔除的概念。Unity 的渲染系统默认就会根据 Renderer 的包围盒做视锥剔除不在相机范围内的物体不会被绘制。但 Update 里的逻辑仍然会执行。所以我在敌人基类里加了一组特别简单的事件响应。private void OnBecameVisible() { isVisible true; canUpdateAI true; } private void OnBecameInvisible() { isVisible false; // 延迟一小段时间再暂停AI防止来回抖动 pendingPauseTime Time.time 0.5f; } protected virtual void Update() { if (isDead) return; if (!isVisible Time.time pendingPauseTime) return; // 正常状态机更新 }当一个敌人进入相机的包围盒范围时Unity 会调用它身上所有 Renderer 的OnBecameVisible离开时调用OnBecameInvisible。我用这两个方法控制 AI 是否更新屏幕外的敌人直接跳过状态机逻辑。加上这个小优化之后哪怕场景里摆了100个敌人实际每帧只需要计算屏幕内那十几个CPU 开销基本可以忽略不计。注意OnBecameVisible必须在敌人身上有 Renderer 时才会触发。如果敌人的 Renderer 在子物体上记得在基类中获取GetComponentInChildrenRenderer()或者直接在检测存活时用transform.position与摄像机视口坐标做手动判断。对于纯逻辑物体用这两招都不适用需要自己写视口判断。最后再分享一点实际操作中的心得这一集把敌人系统从基本架构到两种具体敌人跑通之后最大的感受是2D平台游戏里的敌人真正重要的不是伤害数值而是行为是否可读。玩家看到一只Husk能猜到它会在靠近时挥刀看到Belfly闪红会下意识后撤。这些看似简单的敌人靠的是明确的状态切换和稳定的动画提示。代码层面EnemyBase这个基类目前只有不到200行但下一集我已经可以直接用它扩展远程射击敌人、会飞且不追人的装饰性小生物、甚至Boss的第一阶段。每次加新敌人只需要实现 Patrol、Chase、Attack 三个方法就行不需要再碰血量、击退、死亡这些通用逻辑。如果你做到这一步你的Demo已经初步有点游戏的样子了。下一集我打算做玩家的攻击派生和灵魂碎片系统也就是让玩家开始有成长维度的内容等我做出来再继续分享。