恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Unity实现据点突袭关卡:敌人AI状态机与任务流程实战
首页
资讯中心
/
Unity实现据点突袭关卡:敌人AI状态机与任务流程实战
Unity实现据点突袭关卡:敌人AI状态机与任务流程实战
发布时间:2026/10/18 8:41:43
各位同学大家好今天这篇教程不聊空泛的游戏策划案也不做营销号式的游戏推荐而是回归技术本身。很多做 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.NavMeshAgentUnityEngine.AI.NavMeshHitOnTriggerEnterCoroutine协程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.ActionMissionPhase 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 编写 MissionManagerMissionManager 是整个关卡的大脑。它负责几件事追踪当前阶段在阶段切换时发出事件统计敌人数量当敌人全部清光后进入完成阶段。我们来看完整代码// 文件路径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 ActionMissionPhase 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。这是整个教程的核心部分。我先说明需求巡逻敌人沿 PatrolPoint 数组移动到点后停顿一会儿。警觉收到突破事件后敌人进入 Alert停止巡逻看向玩家。战斗敌人看到玩家后进入 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 GetComponentNavMeshAgent(); _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.GetComponentPlayerHealth(); // 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 编辑器进行以下操作把 MissionManager.cs 挂到名为 MissionManager 的空物体上创建敌人 Prefab挂上 EnemyAI.cs 和 NavMeshAgentUnity 添加 NavMeshAgent 组件时会自动处理把巡逻点数组拖进去把 EnemyAI 中 patrolPoints 数组的长度设为实际巡逻点数量然后把场景中的 PatrolPoint1、PatrolPoint2 拖到槽位把 EnemySpawner.cs 挂到场景中一个空物体上指定敌人预制体给玩家的 GameObject 打上 Player 标签在 NavMesh 面板中烘焙导航网格确保地面和墙体的可行走区域正确运行场景观察 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.GetComponentInParentEnemyAI(); 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 触发器没反应触发器没反应的排查顺序通常是确认目标对象有没有刚体Rigidbody组件。在 Unity 中OnTriggerEnter 需要至少一方带有 Rigidbody确认碰撞体是否勾选了 Is Trigger确认触发器和目标的碰撞层没有被 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 战术配合很快见。