☰
Unity实现据点突袭关卡:敌人AI状态机与任务流程实战
2026/10/18 8:41:35 网站建设 项目流程

各位同学大家好,今天这篇教程不聊空泛的游戏策划案,也不做营销号式的"游戏推荐",而是回归技术本身。很多做 Unity 游戏开发的朋友都遇到过一个问题:单机射击游戏里,那些"突袭敌方据点"的高潮关卡到底是怎么做出来的?玩家从门口突入的那一刻,AI 小怪为什么能像真人一样警觉、包抄、压制,而不是站在那里等死?

本文会从游戏开发的角度,完整拆解一个 "Modern Warfare" 风格的突袭任务关卡。我会把"敌人守卫营地的自动巡逻 + 玩家突入后的警觉反击 + 任务目标逐步推进"这套逻辑从前到后实现一遍。整个过程覆盖 Unity 的导航寻路、AI 状态切换、触发器(Trigger)机制、任务流程控制,以及关卡节奏编排。代码基于 C# 编写,全部给出可复制的完整脚本,并按文件路径标清楚。

如果你未来想做一个 FPS 射击游戏,或者想在公司项目里加入"室内清剿""据点攻坚"类型的玩法副本,这篇文章可以直接作为脚手架参考。零基础的同学也能看懂,因为我会从场景搭建和脚本挂载讲起;有经验的同学可以直接跳到第 4 节拿代码。

那我们先从最核心的问题开始:一个"突袭据点"的任务关卡,技术上到底由哪些部分组成?

1. 背景与核心概念

1.1 一个突袭关卡的本质是什么

把"突袭敌方据点"这个玩法拆开看,它本质上是一个带有明确阶段目标的任务型战斗场景。它和我们平时在射击游戏里随便打几个野怪完全不同。突袭关卡讲究的是节奏:玩家在潜入阶段面对的是相对松懈的敌人,在破门或预警后面对的是迅速响应的敌人,而在占领关键区域后,又会触发新的目标,比如肃清残余势力、回收情报。

从程序员的角度来看,这其实就是把"关卡状态"和"敌人行为"两者绑定:

  • 敌人平时处于巡逻状态,按固定路线走动;
  • 当玩家进入某片区域或触发警报后,敌人切换到警觉状态,开始寻找目标;
  • 当玩家被敌人看见,敌人进入战斗状态,呼叫队友包抄;
  • 当玩家完成所有阶段目标,任务结束,播放结算或进入下一个关卡。

这部分逻辑如果用代码硬堆在 Update 里,很快就会变成一个谁都不敢改的巨型方法。所以实际开发中,我们会用一个简单的状态机模式来管理任务,用另一个状态机来管理每个敌人个体。两者之间通过事件机制通信,而不是互相引用满天飞。

1.2 常用技术名词先理清

在进入实战之前,有几个名词需要先搞清楚,后面代码里我会反复使用。

第一,NavMesh(导航网格)。这是 Unity 内置的寻路系统。AI 敌人不会穿墙,也不会原地打转,是因为关卡地面被烘焙成了导航网格,敌人沿着网格上的可行走路径移动。我们用简单的 NavMeshAgent 组件就能控制敌人从一个点移动到另一个点。

第二,触发器(Trigger)。它是 Collider 的一种特殊形态,勾选 Is Trigger 后,不产生物理碰撞,但可以检测物体进入和离开。突袭关卡里的"破门瞬间""进入院子""靠近房间",全都是靠触发器来感知的。很多新手喜欢用距离判断,其实没必要,场景触发器更稳定、更直观。

第三,状态机(Finite State Machine)。敌人不是每一帧都在攻击玩家的。它平时应该巡逻,看到人之后才瞄准射击。这需要一个有限状态机来约束行为,避免出现"隔着墙就在朝你扫射"这种低级 AI 表现。

第四,任务流程控制。关卡目标不是一个固定布尔值,它是有顺序的,第一步做什么、第二步做什么,必须线性推进。我会用一个轻量的 MissionManager 来管理当前任务阶段。

1.3 为什么选择自己写而不是直接买插件

可能有同学会说,Asset Store 上有现成的 AI 插件,比如 Behavior Designer,或者一些 FPS 框架,直接拖进去不就行了吗?

这里需要说明一下。第三方插件确实功能强大,但它在项目里是一个"黑盒"。当我们需要做高度定制化的任务关卡时,插件自带的 AI 行为不一定符合设计要求。而且如果你刚学 Unity,从头到尾没写过状态机,后续排查问题会很痛苦。本文中选择自己实现一套轻量方案,代码量不大、逻辑透明,改起来也快,足以支撑一个小型 FPS 原型项目。等真正商务项目的策划案复杂到一定程度,再考虑引入商业化插件也不迟。

2. 环境准备与版本说明

2.1 开发环境

本文示例以 Unity 2021.3 LTS 或 Unity 2022.3 LTS 为常见环境,语言使用 C#。如果你的项目使用 Unity 6 或更高版本,核心 API 基本兼容,但由于不同版本之间导航系统接口略有差异,请以安装版本的官方文档为准。脚本中所涉及的主要 API 包括:

  • UnityEngine.AI.NavMeshAgent
  • UnityEngine.AI.NavMeshHit
  • OnTriggerEnter
  • Coroutine(协程)
  • GameObject.FindGameObjectWithTag

这些 API 在近几个 LTS 版本中都很稳定,不需要额外导入第三方包。唯一需要注意的是,旧版项目中如果使用的是 Legacy Navigation(即非 NavMesh 的寻路方案),脚本写法会有较大区别,建议升级到新版烘焙方案。

2.2 项目结构规划

为了让大家看得清楚,我们规划一个简单的 Unity 项目结构:

Assets/ ├── Scenes/ │ └── RaidLevel.unity ├── Scripts/ │ ├── MissionManager.cs │ ├── EnemyAI.cs │ ├── EnemySpawner.cs │ ├── TriggerZone.cs │ └── PlayerDetector.cs ├── Prefabs/ │ ├── Enemy.prefab │ └── Player.prefab └── Terrain/ (或直接使用 Plane + 墙壁模型)

如果大家是刚开始学,不要纠结于文件夹命名,Unity 的脚本引用按文件名匹配,只要保证脚本名和类名一致就行。

2.3 准备素材

你不一定需要美术资源。Unity 内置的基本几何体(Cube、Capsule、Plane)就足够本次实验。敌人用 Capsule 代替,玩家用 Capsule 或 ThirdPersonController 代替,墙壁用 Cube 拼起来就行。至于枪械模型和射击特效,不是本节重点,后续实战案例里我们用 Debug.Log 和简单的距离检测代替子弹命中。

下面我们进入核心逻辑拆解环节。

3. 核心逻辑拆解

3.1 任务状态机设计

一个突袭巢穴的关卡,至少分成四个阶段:

阶段一:潜入(Stealth) —— 玩家悄无声息地接近据点外围 阶段二:突破(Breach) —— 玩家进入某个触发区域,敌人进入警觉 阶段三:清剿(Clear) —— 玩家消灭指定数量的敌人 阶段四:完成(Complete)—— 任务目标全部达成

在代码中,我用枚举来表示这四个阶段:

// 文件路径:Assets/Scripts/MissionManager.cs public enum MissionPhase { Stealth, // 潜入阶段 Breach, // 突破阶段 Clear, // 清剿阶段 Complete // 完成阶段 }

这就是一个最简单的任务状态机。MissionManager 持有当前阶段,并提供切换方法。当阶段发生变化时,它广播一个事件,所有监听该事件的系统都会收到通知。这样做的好处是,任务逻辑和敌人逻辑解耦。敌人不需要知道任务进行到哪一步,它只需要接收到"警报触发"这个事件,然后做出反应即可。

3.2 敌人 AI 状态机设计

敌人个体同样使用状态机。我给敌人定义三个基础状态:

public enum EnemyState { Patrol, // 巡逻 Alert, // 警觉 Combat // 战斗 }

巡逻状态下,敌人沿着预设的巡逻点依次移动。警觉状态下,敌人停止移动,朝向玩家位置查看,并且向周围队友发送警报。战斗状态下,敌人朝玩家方向移动并发起攻击。这里我不会真的实现射击系统,而是用一个"攻击距离判断 + 扣血"的简化方案来表达。

3.3 事件驱动的通信机制

在实际项目中,如果每个敌人都直接引用 MissionManager,然后每帧去检查任务阶段,代码会非常混乱。更优雅的方式是事件驱动。

C# 中的 Action 和事件是天然的观察者模式工具。MissionManager 可以定义一个静态事件:

public static event System.Action<MissionPhase> OnPhaseChanged;

当阶段切换时触发事件,所有订阅该事件的组件(比如敌人 AI、UI 提示)在收到消息后各自处理自己的逻辑。这里使用静态事件做全局广播,原型项目里简单直接;如果项目规模较大,可以替换为 ScriptableObject 事件通道,那个更利于多人协作和资源热更新。

那下面我们就正式动手,把整个突袭任务关卡搭出来。

4. 完整实战案例:搭一个据点突袭关卡

4.1 创建项目结构

首先打开 Unity,新建 3D 项目(Universal Render Pipeline 或 Built-in Render Pipeline 均可,本文逻辑与渲染管线无关)。

按前面规划创建文件夹。然后搭建一个简单的场景:

  • 一块大 Plane 作为地面;
  • 若干 Cube 堆出围墙和建筑,围出一个"敌人据点";
  • 在据点外围放一个 3D 物体作为玩家出生点;
  • 在据点门口放一个空物体,命名为 "BreachTrigger",挂上 Box Collider,勾选 Is Trigger,大小设置为覆盖门口区域;
  • 在据点内部放置几个巡逻路点,比如 PatrolPoint1、PatrolPoint2。

场景搭建起来效果大概像下面这个 ASCII 简图:

玩家出生点 | [围墙开口] ← BreachTrigger | [敌人巡逻路线] P1 <----> P2 <----> P3

这里不用做得很好看,重点是逻辑跑通。

4.2 编写 MissionManager

MissionManager 是整个关卡的大脑。它负责几件事:

  • 追踪当前阶段;
  • 在阶段切换时发出事件;
  • 统计敌人数量,当敌人全部清光后,进入完成阶段。

我们来看完整代码:

// 文件路径:Assets/Scripts/MissionManager.cs using System; using UnityEngine; public enum MissionPhase { Stealth, // 潜入阶段 Breach, // 突破阶段 Clear, // 清剿阶段 Complete // 完成阶段 } public class MissionManager : MonoBehaviour { public static MissionManager Instance; // 阶段切换时广播事件 public static event Action<MissionPhase> OnPhaseChanged; [Header("任务参数")] public int totalEnemyCount = 4; // 本关卡敌人总数 public string winMessage = "Mission Complete!"; public MissionPhase CurrentPhase { get; private set; } private int _killedEnemyCount; private void Awake() { if (Instance == null) { Instance = this; } else { Destroy(gameObject); } } private void Start() { SetPhase(MissionPhase.Stealth); _killedEnemyCount = 0; } public void SetPhase(MissionPhase newPhase) { if (CurrentPhase == newPhase) return; CurrentPhase = newPhase; Debug.Log($"[MissionManager] 当前阶段切换为: {newPhase}"); // 触发事件,让所有订阅方收到消息 OnPhaseChanged?.Invoke(newPhase); if (newPhase == MissionPhase.Complete) { Debug.Log(winMessage); } } // 敌人被击杀时调用一次 public void RegisterEnemyKilled() { _killedEnemyCount++; Debug.Log($"[MissionManager] 已消灭敌人: {_killedEnemyCount}/{totalEnemyCount}"); if (_killedEnemyCount >= totalEnemyCount) { SetPhase(MissionPhase.Complete); } } }

这段代码的逻辑非常直接。SetPhase方法在状态切换时触发静态事件。RegisterEnemyKilled负责累加击杀数,当击杀数达到总数后,任务阶段自动切到 Complete。

在 Unity 中,这个脚本不需要挂到某个特定物体上,但为了方便找,建议在场景里创建一个空物体命名为 "MissionManager" 并挂上去。因为我们写了单例,所以场景里只能有一个。

4.3 编写 EnemyAI

接下来是敌人 AI。这是整个教程的核心部分。我先说明需求:

  1. 巡逻:敌人沿 PatrolPoint 数组移动,到点后停顿一会儿。
  2. 警觉:收到"突破"事件后,敌人进入 Alert,停止巡逻,看向玩家。
  3. 战斗:敌人看到玩家后进入 Combat,追向玩家,在一定距离内攻击。

来看代码:

// 文件路径:Assets/Scripts/EnemyAI.cs using UnityEngine; using UnityEngine.AI; public class EnemyAI : MonoBehaviour { public enum EnemyState { Patrol, // 巡逻 Alert, // 警觉 Combat // 战斗 } [Header("巡逻设置")] public Transform[] patrolPoints; // 巡逻点列表 public float patrolWaitTime = 1f; // 到达巡逻点后的等待时间 [Header("警觉设置")] public float alertTurnSpeed = 5f; // 警觉时转身看目标的速率 public float alertSightRange = 10f; // 警觉后能看到玩家的距离 [Header("战斗设置")] public float combatRange = 8f; // 进入攻击的距离 public float attackCooldown = 1.2f; // 攻击冷却时间 public float moveSpeedInCombat = 4f; // 战斗时移动速度 private NavMeshAgent _agent; private EnemyState _state; private int _currentPatrolIndex; private float _waitTimer; private float _attackTimer; private Transform _player; private void Start() { _agent = GetComponent<NavMeshAgent>(); _state = EnemyState.Patrol; _currentPatrolIndex = 0; _waitTimer = 0f; _attackTimer = 0f; GameObject playerObj = GameObject.FindGameObjectWithTag("Player"); if (playerObj != null) { _player = playerObj.transform; } SetNextPatrolPoint(); // 订阅任务事件 MissionManager.OnPhaseChanged += HandlePhaseChanged; } private void OnDestroy() { MissionManager.OnPhaseChanged -= HandlePhaseChanged; } private void HandlePhaseChanged(MissionPhase newPhase) { if (newPhase == MissionPhase.Breach) { // 突破阶段:敌人进入警觉 ChangeState(EnemyState.Alert); } } private void Update() { switch (_state) { case EnemyState.Patrol: UpdatePatrol(); break; case EnemyState.Alert: UpdateAlert(); break; case EnemyState.Combat: UpdateCombat(); break; } } private void UpdatePatrol() { if (_agent == null) return; // 巡逻点到达判断 if (!_agent.pathPending && _agent.remainingDistance < 0.5f) { _waitTimer += Time.deltaTime; if (_waitTimer >= patrolWaitTime) { SetNextPatrolPoint(); _waitTimer = 0f; } } } private void SetNextPatrolPoint() { if (patrolPoints == null || patrolPoints.Length == 0) { Debug.LogWarning("没有配置巡逻点"); return; } _currentPatrolIndex = (_currentPatrolIndex + 1) % patrolPoints.Length; _agent.destination = patrolPoints[_currentPatrolIndex].position; } private void UpdateAlert() { if (_player == null) return; // 面向玩家 Vector3 direction = _player.position - transform.position; direction.y = 0; if (direction.magnitude < 0.01f) return; Quaternion lookRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, alertTurnSpeed * Time.deltaTime); // 如果玩家在攻击距离内,立刻进入战斗 float distance = Vector3.Distance(transform.position, _player.position); if (distance <= combatRange) { ChangeState(EnemyState.Combat); } } private void UpdateCombat() { if (_player == null) return; float distance = Vector3.Distance(transform.position, _player.position); if (distance > combatRange) { // 距离过远,靠近玩家 _agent.isStopped = false; _agent.speed = moveSpeedInCombat; _agent.destination = _player.position; } else { // 距离足够,停下攻击 _agent.isStopped = true; AttackPlayer(); } } private void AttackPlayer() { _attackTimer -= Time.deltaTime; if (_attackTimer <= 0f) { _attackTimer = attackCooldown; // 模拟伤害事件 Debug.Log($"{gameObject.name} 正在射击玩家!"); // 此处可以调用玩家血量的扣血逻辑 // PlayerHealth playerHealth = _player.GetComponent<PlayerHealth>(); // if (playerHealth != null) playerHealth.TakeDamage(10); } } private void ChangeState(EnemyState newState) { if (_state == newState) return; _state = newState; Debug.Log($"{gameObject.name} 状态切换为: {newState}"); // 刚进入警觉时停止移动 if (_state == EnemyState.Alert) { _agent.isStopped = true; } if (_state == EnemyState.Patrol) { _agent.isStopped = false; } } // 敌人被玩家击杀时调用 public void Die() { // 解除订阅,避免场景卸载时报错 MissionManager.OnPhaseChanged -= HandlePhaseChanged; MissionManager.Instance.RegisterEnemyKilled(); Destroy(gameObject); } }

这段代码基本覆盖了一个"会巡逻、会警觉、会打人"的敌人。有两点需要重点解释。

第一,_agent.isStopped的用法。很多新手在切换状态时忘记停掉 NavMeshAgent,结果敌人明明在警觉中还到处乱走。这里在进入 Alert 状态时主动把isStopped设为 true,进入 Patrol 时再设回 false。这是个非常实用的细节。

第二,事件订阅一定要在OnDestroy中退订。MissionManager 是静态事件,如果不退订,敌人销毁后再次触发阶段切换,C# 会尝试调用一个不存在的委托引用,造成 NullReferenceException。

接下来我们需要把敌人和任务系统联动起来。

4.4 编写触发器

现在我们要让"玩家踏入据点门口"这一动作产生意义。前面说过,用触发器实现。

在场景中,BreachTrigger 的职责是:当玩家进入该范围时,通知 MissionManager 切换阶段。

// 文件路径:Assets/Scripts/TriggerZone.cs using UnityEngine; public class TriggerZone : MonoBehaviour { [Header("触发设置")] public string targetTag = "Player"; // 只响应携带该标签的对象 public bool oneTime = true; // 是否只触发一次 public MissionPhase phaseWhenEntered = MissionPhase.Breach; private bool _alreadyTriggered; private void OnTriggerEnter(Collider other) { if (oneTime && _alreadyTriggered) return; if (!other.CompareTag(targetTag)) return; _alreadyTriggered = true; Debug.Log($"[TriggerZone] {gameObject.name} 检测到玩家进入,任务切换到 {phaseWhenEntered}"); MissionManager.Instance.SetPhase(phaseWhenEntered); } }

这里注意,CompareTag比other.tag == "Player"性能更好,而且是 Unity 官方推荐写法。还有一种做法是把 TriggerZone 做成一个基类,不同的触发区域继承它并重写触发逻辑,这样信息架构更清晰。不过对于本文的项目体量,简单使用一个可配置的组件已经够了。

4.5 编写敌人出生管理器

在实际关卡中,敌人可能分批出现。比如玩家破门后,敌人从掩体后逐批刷新,会更有压迫感。我们做一个简单的 EnemySpawner,它监听 MissionPhase 事件,在不同阶段生成预制的敌人。

// 文件路径:Assets/Scripts/EnemySpawner.cs using UnityEngine; public class EnemySpawner : MonoBehaviour { [Header("预制体")] public GameObject enemyPrefab; [Header("第一批敌人(潜入时存在)")] public Transform[] stealthSpawnPoints; [Header("第二批敌人(突破时增援)")] public Transform[] breachSpawnPoints; private void Start() { // 初始生成巡逻敌人 SpawnGroup(stealthSpawnPoints); // 订阅事件,在突破阶段生成增援 MissionManager.OnPhaseChanged += OnMissionPhaseChanged; } private void OnDestroy() { MissionManager.OnPhaseChanged -= OnMissionPhaseChanged; } private void OnMissionPhaseChanged(MissionPhase phase) { if (phase == MissionPhase.Breach) { SpawnGroup(breachSpawnPoints); } } private void SpawnGroup(Transform[] points) { if (enemyPrefab == null || points == null) return; foreach (Transform point in points) { GameObject enemy = Instantiate(enemyPrefab, point.position, point.rotation); enemy.name = $"Enemy_{point.name}"; } } }

4.6 编写玩家检测器

敌人如何发现玩家?在简单原型中,我们直接用距离和角度判断。我给 PlayerDetector 脚本挂到玩家身上,或者挂在敌人身上都行。这里为了演示方便,我把检测逻辑写在敌人身上,但玩家这一侧需要提供一个"受伤接口"。

玩家脚本示例:

// 文件路径:Assets/Scripts/PlayerHealth.cs using UnityEngine; public class PlayerHealth : MonoBehaviour { [Header("生命值")] public float maxHealth = 100f; public float currentHealth { get; private set; } private void Start() { currentHealth = maxHealth; } public void TakeDamage(float amount) { currentHealth -= amount; Debug.Log($"[PlayerHealth] 受到 {amount} 点伤害,剩余 {currentHealth}"); if (currentHealth <= 0f) { Die(); } } private void Die() { Debug.Log("玩家倒下,任务失败!"); // 这里可以调用 UI 显示失败界面,或者重载关卡 } }

4.7 运行与验证

写完这些脚本后,回到 Unity 编辑器,进行以下操作:

  1. 把 MissionManager.cs 挂到名为 MissionManager 的空物体上;
  2. 创建敌人 Prefab,挂上 EnemyAI.cs 和 NavMeshAgent(Unity 添加 NavMeshAgent 组件时会自动处理),把巡逻点数组拖进去;
  3. 把 EnemyAI 中 patrolPoints 数组的长度设为实际巡逻点数量,然后把场景中的 PatrolPoint1、PatrolPoint2 拖到槽位;
  4. 把 EnemySpawner.cs 挂到场景中一个空物体上,指定敌人预制体;
  5. 给玩家的 GameObject 打上 "Player" 标签;
  6. 在 NavMesh 面板中烘焙导航网格,确保地面和墙体的可行走区域正确;
  7. 运行场景,观察 Console 输出。

预期输出流程是:

[MissionManager] 当前阶段切换为: Stealth Enemy_1 状态切换为: Patrol Enemy_2 状态切换为: Patrol [TriggerZone] BreachTrigger 检测到玩家进入,任务切换到 Breach [MissionManager] 当前阶段切换为: Breach Enemy_1 状态切换为: Alert Enemy_2 状态切换为: Alert [EnemySpawner] 生成增援敌人...

当玩家用鼠标点击攻击敌人(或者用触发器模拟击杀)后,调用敌人的Die()方法,控制台会输出击杀计数。当计数达到总数后,任务切换到 Complete。

为了方便测试,你可以在玩家脚本里加一个简单的按键击杀功能:

// 在 PlayerHealth.cs 下面追加一个新脚本 TargetGizmo.cs using UnityEngine; public class TargetGizmo : MonoBehaviour { public float attackRange = 100f; private void Update() { if (Input.GetMouseButtonDown(0)) { // 从摄像机向鼠标位置发射射线 Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, attackRange)) { EnemyAI enemy = hit.collider.GetComponentInParent<EnemyAI>(); if (enemy != null) { enemy.Die(); } } } } }

把 TargetGizmo.cs 挂到玩家物体上,运行后点击敌人即可测试击杀流程。

4.8 结果说明

到这里,一个可以完整运行的据点突袭任务原型已经诞生。你可以看到:

  • 任务阶段正确地从潜入切换到突破;
  • 敌人从巡逻状态转为警觉状态;
  • 增援敌人在指定时间点生成;
  • 击杀数量达到目标后任务结束。

这个项目的核心价值不在于画面,而在于它建立了一套清晰的模块化框架。UI、伤害数值、子弹效果、音效,未来都可以在不破坏现有逻辑的前提下加进来。

5. 常见问题与排查思路

开发这种关卡时,最容易出现下面几个问题。我按经验从高频到低频列一遍。

5.1 敌人不会移动

可能性很多,我遇到过最多的原因是 NavMesh 没有烘焙。Unity 场景中,如果地面没有正确生成导航网格,NavMeshAgent 找不到可行走区域,会原地不动。

排查步骤:

  • 确认场景中的地面和障碍物已设置为 Static;
  • 打开 Window -> AI -> Navigation 面板,选择 Bake Tab,点击 Bake;
  • 在 Scene 视图按下键盘 0 键(场景导航模式)查看蓝色网格覆盖区域;
  • 确认巡逻点确实落在蓝色网格范围内。

另一种可能是敌人 Prefab 上根本没有 NavMeshAgent 组件。NavMeshAgent 不是自动添加的,必须手动添加。

5.2 敌人进入警觉但一直抽搐

"抽搐"是很多 AI 新手期的典型问题。常见原因是 Update 中同时存在两套靠近玩家的逻辑:NavMeshAgent 在移动,同时 transform.position 又被手动赋值,两者互相覆盖。

解决办法是:明确区分移动职责。使用 NavMeshAgent 时,不要直接修改 transform.position,而是设置agent.destination。如果某段逻辑需要手动控制位移,就先把agent.enabled设为 false,或者把agent.isStopped设为 true。

5.3 触发器没反应

触发器没反应的排查顺序通常是:

  1. 确认目标对象有没有刚体(Rigidbody)组件。在 Unity 中,OnTriggerEnter 需要至少一方带有 Rigidbody;
  2. 确认碰撞体是否勾选了 Is Trigger;
  3. 确认触发器和目标的碰撞层没有被 Layer Collision Matrix 忽略。

其中第一点最容易忽略。一个没有 Rigidbody 的玩家角色是永远不会触发 OnTriggerEnter 的。做法很简单:给玩家 GameObject 添加 Rigidbody,并设置interpolation为 Interpolate,约束旋转,避免物理效果干扰。

5.4 静态事件导致内存泄漏或重复触发

如果你发现同一事件被执行了好几次,通常是场景里存在多个 MissionManager,或者没有在 OnDestroy 中退订。可以这样检查:

  • 在场景中搜索 MissionManager,确保只有一个;
  • 所有订阅了MissionManager.OnPhaseChanged的脚本,都必须重写 OnDestroy,在里面做-=退订操作。

5.5 事件驱动导致逻辑顺序不可控

静态事件是广播机制,它不保证订阅方的执行顺序。如果 A 脚本需要在 B 脚本处理完事件后再执行,直接用事件广播就不合适了。这种场景下,建议把"阶段切换"改成显式调用:MissionManager 主动调用某个接口。比如:

Manager.OnPhaseChanged += OrderAwareTrigger; // 不推荐 Manager.ReadyPhaseForOrders(); // 推荐:显式告诉系统

5.6 性能问题:敌人数量多时卡顿

如果敌人特别多,每帧都计算所有敌人到玩家的距离可能会造成不必要的开销。建议把 Update 中的距离检测改为"间隔检测"或使用 Unity 的 Physics.OverlapSphere 手动控制检测频率,或者使用 Job System 做多线程计算。

我这里列一个快速参考表:

问题现象常见原因解决思路
敌人原地不动NavMesh 未烘焙或巡逻点不在导航网格上检查导航网格覆盖范围并重新 Bake
敌人抽搐NavMeshAgent 和手动位移互相冲突不要同时使用两种移动控制
触发器不响应缺少 Rigidbody 或碰撞层配置错误给玩家添加 Rigidbody 并检查碰撞矩阵
事件重复执行场景多个管理器或未退订查看场景对象数量,确认单例唯一
敌人看不见玩家距离判断过快或视线遮挡使用 Physics.Raycast 添加视线检查
任务结束不触发击杀计数与敌人总数不一致检查初始化数量和注册击杀的调用时机

6. 最佳实践与工程建议

在我自己带项目做玩法原型时,下面几条经验比代码本身更值得沉淀。写出来供大家参考。

6.1 状态机的核心原则

状态机不是简单地在 Update 里放一个 switch 就万事大吉。真正稳定的状态机应该具备三个要素:

  • 单一入口:任何状态切换都通过 ChangeState 完成,不要在外面直接修改枚举值;
  • 状态退场逻辑:退出前要重置计时器、停止移动、恢复默认参数;
  • 状态入场逻辑:进入新状态时,明确设置初始速度、目标、动画参数。

本文的 EnemyAI 中,ChangeState 里已经包含了入场逻辑和状态切换日志。如果以后扩展,可以加一个ExitState方法,用于统一清理。

6.2 事件通信比硬引用更适合玩法模块

在学校作业或小型 Demo 里,直接引用其他脚本的实例很常见。但到了稍大一点的项目,脚本 A 改个名字,所有引用它的脚本全部报错,这种维护成本很痛苦。

事件系统天然解决了"谁通知谁"的耦合问题。MissionManager 不知道是哪个敌人触发了警报,也不需要知道;敌人也不需要关心 MissionManager 的内部逻辑。两边只关心"发生了什么"。

在后续项目中,可以用 ScriptableObject 做"游戏事件通道",这样连静态类的全局状态都能省掉,资源热更新更灵活。

6.3 关卡数据尽量可配置

不要在代码里写死"敌人总数是4"这种魔法数字。把这部分数据暴露为 Inspector 中的可调参数,是游戏开发的基本素养。因为策划随时都可能改数值,如果每次改数值都得翻代码然后重编译,开发效率会非常低。

6.4 使用协程处理延迟逻辑

有些玩法需要延迟触发,比如玩家破门后 0.5 秒敌人开始行动。如果直接在 Update 里加计时器,逻辑会散得到处都是。用协程(Coroutine)会让可读性高很多。

IEnumerator DelayEnemyAlert(float delay) { yield return new WaitForSeconds(delay); // 执行警报逻辑 }

但要注意,协程在对象被禁用时不会自动停止。如果对象被 Destroy,协程也会随之终止,这一点通常没问题。但如果你在协程里引用了外部对象,记得做空引用检查。

6.5 安全与工具链建议

在开发 AI 战斗时,特别是涉及玩家受击、敌人伤害、任务判定这些逻辑,一定要在测试环境下验证,不要直接在生产环境改数据。单人开发可能无所谓,但团队开发时,伤害数值、敌人刷新逻辑不应该直接提交到主干分支,建议先用分支或本地测试场景跑通。

另外,避免在 AI 脚本里直接使用硬编码的寻路节点索引。如果偶发调试时发现巡逻点丢失,打印日志时带上巡逻点名字,比输出索引更容易定位问题。

6.6 日志的使用

原型开发阶段不要吝啬 Debug.Log。但到了多人协作阶段,日志应该分级:开发日志、警告日志、错误日志。玩家的伤害和敌人状态切换可以打开发日志,而对象为 null、预制体缺失这类问题必须打错误日志。这样才不会被海量日志淹没关键信息。

7. 总结与学习路线

本文从零开始搭建了一个现代战争风格的据点突袭任务原型。通过这套简单代码,我们实现了任务阶段控制、敌人巡逻与警觉、触发器感知、增援刷新和击杀计数的完整闭环。每个模块都保持了低耦合的通信方式,后续替换美术、音效、UI都相对容易。

如果你想继续深入,可以从下面几个方向扩展:

第一,真正的敌对 AI。目前只是距离判定攻击,没有视线遮挡和弹道计算。接下去你可以引入 Raycast 做视线检测,只有敌人真正"看到"玩家时才进入战斗状态,这样玩法会更有战术纵深。

第二,弹仓与枪械系统。把子弹发射、弹道、命中反馈加进来,玩法就从"逻辑演示"变成了"可用游戏"。注意子弹命中要优先检测可破坏物和掩体,不要直接穿透墙壁。

第三,任务目标和 UI 联动。在屏幕顶部显示当前目标,比如"消灭所有敌人""回收情报文件"。MissionManager 阶段切换时更新 UI,同时播放入场音效和镜头动画。

第四,存档与关卡重试。玩家血量归零后一键重载场景,敌人状态和任务阶段全部重置。重点在于各种单例和静态事件的重置是否彻底。

在真实项目中,我会特别提醒你优先关注两个风险:一是静态事件退订不干净带来的内存泄漏,二是标签匹配错误导致触发器失效。这两类问题非常隐蔽,往往是"运行起来偶尔一切正常,偶尔完全失灵"的幕后元凶。

最后给一点动手建议:你不需要等美术资源再开始。用 Unity 自带几何体搭一个粗糙院子,把这段流程完整跑通一次,再逐步替换成真实模型。很多关卡玩法上的问题,都是在原型阶段暴露出来的,提前解决它们,能省下后面翻工的大量时间。

如果这篇文章对你有帮助,不妨收藏备用。下一篇我们继续聊射击游戏中更硬核的弹道计算和 AI 战术配合,很快见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询