敌人系统:巡逻者、冲撞野猪与追击黄蜂
Echo Orb 2D 俯视角动作解谜游戏 开发日志第 2 篇
作者:青竹丹枫 | 南京邮电大学 数字媒体技术 | 2026年8月
Tips:这是本系列的第二篇正式技术日志啦,这一次主要的内容涉及四种敌人的设计与实现。同样的,本系列介绍的是博主个人的学习路径以及技术记录,包括了博主对一些问题的解决方法,希望对初学者有一定的帮助。本人只是一个目前在做一些自认为有意义的学习的小白,此篇只是我个人在学习第一个练手项目Echo Orb(此为博主的项目名,大家项目命名时自己取名即可)时的一些开发过程和观点,若有不对或不足之处恳请各位大佬指点迷津!
一、路线:先从靶子再到各类敌人
看了博主之前两篇的日志的读者朋友们都知道,没有美术资源的地方先用简单替换法:先将其基本功能做好,之后再另找心仪的美术资源。但在设计制作基本功能时,强烈建议大家在一个测试场地进行(测试场地是在Scene中新建的一个专门用于设计与测试某一模块的场景),这样可以切断与特定场景的联系,从而使做好后适用于所有场景。
博主在做完子弹弹射系统后,目前测试房间有的只是一个标志了方向的白圆点、一颗弹来弹去的青色光珠、四堵灰墙。这时候我就开始了敌人模块的制作:
最初做了一个蓝色方块,作为最基础的测试敌人Target。它只是左右来回走,碰到子弹就消失。后来这个靶子改名叫“巡逻者”(Patrol),成了 Echo Orb 的第一种正式敌人。在“巡逻者”的底层代码都设计好了后,我用选好的美术资源替换了原本的几何方块,只调节了一下碰撞体积后,这个原本只是一个几何体的“巡逻者”摇身一变,变成了一个可爱的史莱姆怪物(虽然现在只是个静止的图片……,之后博主会介绍动画的插入哒)
大家在设计敌人的过程中,敌人的迭代过程恰是理解 Unity 物理系统最合适的一段时间!可以在设计的过程中逐渐体会如Kinematic 和 Dynamic 的区别、射线检测的偏移、碰撞体与 Trigger 的配合等。接下来博主也会在下文对这些过程进行介绍,希望这篇记录能帮你在自己的项目里少走一些弯路呀。
二、巡逻者:最简单的敌人,最折腾的移动方式
2.1 基本逻辑
巡逻者 = 在两个端点之间来回走动。代码非常简单,核心就几行:
void FixedUpdate()
{
Vector2 newPos = Vector2.MoveTowards(rb.position, target, speed * Time.fixedDeltaTime);
rb.MovePosition(newPos);
if (Vector2.Distance(rb.position, target) < 0.15f)
target = (target == pointA) ? pointB : pointA;
}
两个关键点的理解:
- MovePosition 而非直接改 transform.position——前者通知物理引擎"我要动了,帮我处理碰撞",后者是生拉硬拽,碰撞检测可能会漏
- FixedUpdate 而非 Update——物理移动放在物理帧里执行,保持速度均匀稳定,和帧率脱钩
2.2 踩坑:Kinematic 和 Dynamic,到底选哪个?
第一次跑巡逻者的时候,它直直地往右走,碰到墙壁直接卡住,不回头了。
原因是用了 Kinematic 刚体。Kinematic 撞上 Static 物体(墙壁)不会触发碰撞回调——这是 Unity 的默认设计:Kinematic 就是"代码说了算,物理别管我"。代码让它去 X=8,它就闷头去,墙挡不住,但也不会有回调告诉你它撞墙了。
博主换成Dynamic后,巡逻者被玩家碰一下就飞出去,子弹打到也跟着弹开,场面一度很混乱,因为它质量太小了。
最终设计为Dynamic + 大质量(mass=100)+ 冻结旋转(Freeze Z) + 重力关掉(Gravity=0) + 用 MovePosition 移动。
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 0f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.mass = 100f;
rb.linearDamping = 0f;
这样一举多得:
- 物理上能挡住玩家(Dynamic 和 Dynamic 之间会碰撞)
- 不会被推飞(mass=100,相当于玩家在推一堵墙)
- 碰墙有回调(Dynamic 碰 Static 触发 OnCollisionEnter2D)
- 子弹能正常命中(Dynamic 碰 Dynamic 也有回调)
大家如果想让物体自己走来走去但还要能跟别的东西碰撞——Dynamic + 大质量 + MovePosition 是最省心的组合。大家可以去试一试呀。
2.3 受击反馈:打中了就要有反应
后来加了血量系统,巡逻者碰到子弹不是直接消失,而是走 TakeDamage。扣血的同时闪红 0.1 秒再恢复:
IEnumerator FlashRed()
{
if (sr == null) yield break;
sr.color = Color.red;
yield return new WaitForSeconds(0.1f);
sr.color = originalColor;
}
被打了闪一下红,玩家立刻知道"打中了"。这个东西虽然小,对体验非常重要,虽说有点简陋,但可以体现出打击感了。
2.4 小坑:敌人把玩家挤死在墙角了
巡逻者用 Dynamic 挡人是好的,但出了新问题:玩家被敌人推到墙角时,卡在敌人和墙壁之间动不了,只能等死。很憋屈。
修法:敌人碰到玩家时,用 RaycastAll 检测玩家背后有没有墙。有→敌人回头,不再往前挤。
void OnCollisionEnter2D(Collision2D other)
{
if (other.CompareTag("Player"))
{
Vector2 pushDir = (player.transform.position – rb.position).normalized;
RaycastHit2D[] hits = Physics2D.RaycastAll(player.transform.position, pushDir, 1.5f);
foreach (var h in hits)
{
if (!h.collider.CompareTag("Player") && !h.collider.CompareTag("Enemy"))
{
target = (target == pointA) ? pointB : pointA; // 回头!
break;
}
}
}
}
为什么用 RaycastAll 而不是 Raycast?因为普通 Raycast 打到第一个东西就停了——大概率是玩家自己。RaycastAll 能穿透多层,能"看穿"玩家找到背后的墙。
这方面的问题是很多新手都会碰到的!不仅仅是博主的这个游戏涉及到,很多游戏如“是兄弟就下325层”中,都面临着如果玩家被夹住了该去怎么设计。在这一问题上,需要"看穿"某个物体检查后面还有没有东西的时候,直接用 RaycastAll,检测到了之后再根据需求去设计对应的措施,比如虚化,强制遣返之类的。
三、水潭地形:和敌人系统联动的第一块地形
在讲野猪之前,必须先把水潭说清楚。这是 Echo Orb 中第一个要求"外部地形影响敌人行为"的设计——不是单纯一堵墙,而是对不同的角色有不同的规则。这一点的设计也是符合博主第0篇中GDD所描述的:新的敌人要有特色,要让玩家找到对应的应对措施,让玩家感受到见招拆招的快乐,而非简单的杀杀杀。因此,博主就设计了野猪敌人和水潭机制,为游戏增添了一点乐趣。
3.1 需求
要保证“水潭要能帮助玩家应对野猪”,那就得先明确水潭面对不同对象所体现出的不同效果:
|
角色 |
碰到水潭 |
|
玩家 |
进不去,水潭相当于墙 |
|
巡逻者 / 史莱姆 |
进不去,水潭相当于墙 |
|
子弹 |
穿透,该飞继续飞 |
|
野猪(巡逻态) |
进不去,水潭相当于墙 |
|
野猪(冲撞态) |
穿透!冲进去受伤,速度减缓,找最短路径上岸 |
3.2 实现:Layer Collision Matrix + 代码动态绕行
水潭用了 Unity 内置的 Water Layer。在 Physics 2D 的碰撞矩阵里(Edit → Project Settings → Physics 2D → 最底部那个大大的打勾表),Water 行只勾 Default 列——这样 Default 层上的玩家和巡逻者自动被水挡住。
而子弹和冲撞状态的野猪,用代码遍历所有水碰撞体,逐个设置忽略:
int waterLayer = LayerMask.NameToLayer("Water");
Collider2D[] all = FindObjectsByType<Collider2D>();
foreach (Collider2D c in all)
{
if (c.gameObject.layer == waterLayer)
Physics2D.IgnoreCollision(myCollider, c);
}
这个方法虽然需要每颗子弹遍历所有碰撞体(性能上对小型项目可以忽略),但逻辑简单明了,不需要额外维护另一套 Layer 配置。
3.3 矩阵踩坑
踩坑提醒:Physics 2D 的那个碰撞矩阵是一张二维打勾表。行和列交叉的勾决定了对应两个层之间的物体会不会碰撞。博主曾经在这张表上浪费了整整一下午——最后发现是 Water 行的勾反了,Water 行除了 Default 都勾上了,结果水和万物穿。如果你有两个东西就是撞不到一起去,先去矩阵里对一下行和列!
四、野猪冲撞者:第一个充满设计感的敌人,也是第一个完整的状态机
4.1 设计
野猪本质上先是一个敌人,先得对其本身基础逻辑进行设计,再考虑和水潭的交互:
野猪的本身逻辑是 Echo Orb 中敌人行为最复杂的。它有四个状态:巡逻、冲撞、休息、游向岸边。
巡逻 ←──────────┐
│ │
│ (看见玩家) │ (休息 2s 结束)
↓ │
冲撞 (2s) ──────→休息
│ ↑
│ │
├──→碰墙→──────┘
├──→碰玩家→扣 2 血 + 击退,继续冲
└──→碰水→扣 2 血 + 变红 + 继续冲→到终点→0.5 倍速游上岸
代码层面就是一个 enum 加一个 switch,每种状态一个 Update 方法。别把所有逻辑全塞在一个 Update 里,否则三个月后自己回来都看不懂(是的博主刚回头看已经看不懂了……)
public enum BoarState { Patrol, Charge, Rest, SwimToShore }
void FixedUpdate()
{
switch (state)
{
case BoarState.Patrol: PatrolUpdate(); break;
case BoarState.Charge: ChargeUpdate(); break;
case BoarState.Rest: RestUpdate(); break;
case BoarState.SwimToShore: SwimUpdate(); break;
}
}
4.2 踩坑:野猪"看不见"玩家
冲撞条件是"距离在 6 单位内 + 视线无遮挡"。视线检测用一条从野猪到玩家的 Raycast:
bool CanSeePlayer()
{
float dist = Vector2.Distance(transform.position, player.transform.position);
if (dist > sightRange) return false;
Vector2 dir = player.position – transform.position;
RaycastHit2D hit = Physics2D.Raycast(transform.position, dir, dist);
return hit.collider.CompareTag("Player");
}
写好之后,换了更大的美术素材,此时又有新问题出现了:野猪立刻"瞎了",永远看不到玩家。
仅仅是改动了大小,怎么原本的功能就失效了?!博主在上一篇主角的设计中也遇到了相似的问题,大家肯定也反应过来啦,原因非常简单:射线从 transform.position(野猪身体的正中心)发出,第一个命中的东西就是野猪自己的碰撞体。原来素材小时射线勉强能从体内穿出去,素材换成大的之后碰撞体也跟着变大了,射线永远打在自己身上。
所以大家记住不要硬编码偏移数字。用 myCollider.bounds.extents.magnitude 动态计算碰撞体半径,射线从体外侧发出:
float r = myCollider.bounds.extents.magnitude + 0.1f;
Vector2 rayOrigin = rb.position + dir * r;
RaycastHit2D hit = Physics2D.Raycast(rayOrigin, dir, dist – r);
不管素材怎么换、碰撞体怎么变,射线永远自适应地从身体边缘往外发,别写死数字。这个教训适用于任何需要在物体内部发射线的情况。
4.3 冲撞入水与游泳上岸
冲撞状态下动态关闭水碰撞:StartCharge 关,冲到底或碰墙后 EndCharge。如果冲撞结束时在水里,进入 SwimToShore 状态,往冲进来的反方向游(降速),直到离水上岸再恢复水碰撞、进休息。
这里还有个小坑:冲撞结束后立刻恢复水碰撞→野猪被物理引擎弹出去。要等到 IsInWater() 返回 false 了才恢复——时序很重要。
五、黄蜂:定时生崽 + 玩家靠近就追
黄蜂和巡逻者大部分逻辑是复用的——左右巡逻、靠近玩家扣血。多了两个特性:
- 追击:玩家进入 chaseRange 范围时,移动目标从巡逻端点切换到玩家位置,追过去
- 生崽:每 5 秒 spawnCount 只小黄蜂,在周围 spawnRadius 内随机散开出生
小黄蜂和本体使用完全相同的脚本。区别只在 Inspector 里的几个参数:
|
属性 |
大黄蜂 |
小黄蜂 |
|
Scale |
1 |
0.5 |
|
maxHP |
3 |
1 |
|
babyPrefab |
拖入小蜂预制体 |
留空(不生) |
做"弱化版"敌人时优先考虑复用脚本。Inspector 里改几个参数就能出一个新敌人,代码几乎不需要动,方便得很嘞。***但不要滥用,不然重复度太高会消磨玩家的兴趣!!!博主想到自己原价买的生化危机安魂曲就来气,大家懂的都懂……
六、Boss 的设计只是属性不同的同款敌人
Boss 的设计只是属性不同的同款敌人。野猪 Boss 和普通野猪用的是完全一样的 Enemy_Boar 脚本。从头到尾只改了三样:HP 改成 50、换了 Boss 专属的美术素材和动画、加了一个死亡动画。
加死亡动画就是在原来"直接 Destroy"的基础上,先播一段时长 0.8 秒的死亡 clip(用 Animator 的 Trigger 参数驱动),播完再销毁:
void Die()
{
isDead = true;
rb.linearVelocity = Vector2.zero;
GetComponent<Collider2D>().enabled = false;
if (anim != null) anim.SetTrigger("die");
StartCoroutine(DeathDelay());
}
IEnumerator DeathDelay()
{
yield return new WaitForSeconds(0.8f);
Destroy(gameObject);
}
Animator 里用 Trigger 而不是 Bool 来控制死亡。Trigger 是一次性的——播完自动失效,不需要在代码里手动重置。Bool 需要两边(true 和 false)都管,多一个状态就多一个忘掉的概率。
一句话总结:Boss 不一定非要单独写一套脚本。很多时候它只是"属性不同的同款敌人"。脑子里的第一反应应该是"现有脚本能不能复用",实在不能了再考虑写新的。
七、四种敌人教会我的六件事
|
经验 |
为什么重要 |
|
Dynamic + 大质量 + MovePosition |
需要碰撞又不想被推飞时的稳定组合,优先级最高 |
|
射线从碰撞体外侧发出 |
用 bounds.extents 自适应,换素材后不会崩 |
|
状态机用 enum + switch |
每种状态一个方法,逻辑清晰不纠缠 |
|
RaycastAll 穿透多层物体 |
需要"看穿"自身或某物时用,找背后的墙非常方便 |
|
Trigger 管理一次性事件 |
死亡、交互等播完即消,不用手动重置 |
|
小敌人复用脚本 + Inspector 差异化 |
最划算的开发方式,不要复制粘贴代码 |
再次强调!!!
本人只是一个目前在做一些自认为有意义的学习的小白,此篇只是我个人的一些开发路径和观点。发布的目的是渴望得到一些指点,同时也为想迈出游戏开发这一步的读者们一些小的方向。若有不对或不足之处恳请各位大佬指点迷津!
下一篇预告
血量、药水、对话:让游戏变得完整 —— HP 红心 UI 的数据绑定怎么做的、受伤闪烁协程和无敌帧、TMP 中文迟迟不显示到底怎么解决、对话系统首次和后续怎么区分、NPC 的交互图标为什么跟着翻转。
网硕互联帮助中心



评论前必须登录!
注册