最近顽皮狗官方确认《最后生还者》系列还有更多游戏项目正在开发中,而且这次明确不是电视剧内容。消息一出,玩家社区里关于“艾莉之后的故事会怎么展开”“新作会不会用全新主角”的讨论就没停过。不过作为一个常年关注游戏开发技术的博主,我看到的反而是另一层信息:顽皮狗能够同时推进多个高质量项目,背后那套成熟的 AI 行为设计、电影化叙事流程和技术管线,才是真正值得开发者学习的东西。这篇文章不聊剧情猜测,我们单纯从技术角度拆解《最后生还者》这类第三人称叙事驱动游戏的核心玩法实现。我会结合 Unity 引擎,带大家从零搭建一个包含敌人 AI 感知、潜行、同伴跟随和简单状态机的可运行 Demo。无论你是刚接触游戏 AI 开发的初学者,还是在公司里负责玩法系统设计的开发者,都能在这篇文章里找到可以直接落地的思路和代码。1. 背景与核心概念1.1 《最后生还者》的技术标签:叙事驱动的 AI 设计顽皮狗的游戏之所以能被玩家记住,除了剧情演出之外,还有一个容易被忽略的技术核心:AI 系统。在《最后生还者》中,敌人不会像传统射击游戏那样傻站在掩体后面对射,他们会根据玩家的脚步声、枪声、视线范围和环境互动做出复杂反应。感染者会循着声音搜索玩家位置,人类敌人会相互掩护、包抄侧翼。这些行为的底层,其实就是一套由“感知系统 + 状态机 + 行为选择”组成的 AI 架构。对开发者来说,直接复刻顽皮狗的完整 AI 系统不现实,但我们可以提炼出一套适用于中小型项目的简化模型。这套模型通常包含:感知层:负责收集视觉、听觉信息。决策层:根据感知结果切换行为状态。行为层:执行巡逻、追踪、攻击、搜索等具体动作。动画层:根据行为状态播放对应动画。理解了这个分层结构,后续无论是用 Unity、Unreal 还是 Godot,都能快速迁移思路。1.2 为什么顽皮狗的 AI 会让人觉得“聪明”很多开发者第一次玩《最后生还者》时会觉得敌人 AI 特别“聪明”,但实际上他们并不是真的具备高级推理能力。顽皮狗的做法是用大量的细节规则去模拟真实行为。比如:敌人在听到声音后不会立刻锁定玩家位置,而是先朝声源方向警戒一段时间。同伴 AI 不会主动暴露玩家位置,会尽量保持安静并跟随玩家路径。敌人视线被障碍物遮挡时,会丢失目标,并进入搜索状态。这些设计本质上是对“信息不确定性”的模拟。在实现层面,就是要给 AI 的感知系统加入“置信度”和“遗忘时间”的概念。1.3 本文要动手做什么为了更直观地讲解,我会带大家用 Unity 实现一个简化版“循声者”敌人 AI:敌人拥有“巡逻 / 警觉 / 追踪 / 攻击”四种状态。敌人能听到玩家的脚步声,距离越近声音越大。玩家可以蹲下潜行来降低脚步声,从而避开敌人。一个简单的同伴 NPC 会跟随玩家,并在玩家潜行时也保持安静。最终效果是一个可以运行的小场景:你操控玩家角色穿过有敌人巡逻的走廊,需要利用潜行机制绕过敌人,同伴会自动跟随你的路径。整个过程全部采用 Unity 原生组件实现,不依赖任何付费插件。2. 环境准备与版本说明2.1 引擎与工具版本本文示例以 Unity 开发环境为主。为避免版本差异导致的配置问题,建议使用以下环境:工具版本建议说明Unity Editor2022.3 LTS 或更新长期支持版本,稳定性更好Visual Studio2022 或 VS Code用于 C# 脚本编写操作系统Windows / macOS / LinuxUnity 均支持建模工具Blender(可选)仅用于替换占位模型版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果你使用的是 Unity 2021 或 Unity 6,核心 API 基本通用,个别命名空间可能需要根据官方文档微调。2.2 项目结构规划为了保持代码清晰,我推荐在 Unity 中建立以下目录结构:Assets/ ├── Scripts/ │ ├── AI/ │ │ ├── EnemyController.cs │ │ ├── EnemyPerception.cs │ │ └── EnemyStateMachine.cs │ ├── Player/ │ │ ├── PlayerController.cs │ │ └── PlayerNoiseEmitter.cs │ └── Companion/ │ └── CompanionFollow.cs ├── Scenes/ │ └── DemoScene.unity └── Prefabs/ ├── Enemy.prefab ├── Player.prefab └── Companion.prefab这里的PlayerNoiseEmitter.cs是一个比较关键的设计:玩家移动时不再直接通知敌人,而是通过一个“噪声源”组件广播自己的声音强度,敌人的感知系统再去监听。这是一个低耦合设计,在项目变大之后会很好维护。2.3 场景搭建所需基础对象为了快速跑通 Demo,我们可以用 Unity 内置的几何体代替正式美术资源:玩家:一个 Capsule(胶囊体)+ 一个小方块作为“面向”标识。敌人:一个 Cylinder(圆柱体)+ 圆锥作为“视线方向”标识。同伴:一个 Capsule,使用不同的颜色材质区分。场景:一个 Plane 作为地面,再加上几个 Cube 作为掩体。不需要导入外部素材,所有资源都在 Unity 菜单GameObject > 3D Object里创建。3. 核心机制原理拆解3.1 NPC 感知系统:如何“看到”和“听到”玩家NPC 感知系统是 AI 行为的基础。在《最后生还者》里,敌人的感知分为视觉和听觉两条通道。视觉系统包含视野角度、可视距离和遮挡检测;听觉系统包含声源距离和声源强度。先看视觉感知的核心代码思路。我们需要让敌人只看到“视野范围内 + 没有遮挡 + 距离足够近”的对象。// 文件路径:Assets/Scripts/AI/EnemyPerception.cs using UnityEngine; public class EnemyPerception : MonoBehaviour { [Header("视觉参数")] public float viewRadius = 10f; // 可视半径 public float viewAngle = 110f; // 视野角度 public LayerMask targetMask; // 目标层级 public LayerMask obstacleMask; // 障碍物层级 [Header("听觉参数")] public float hearRadius = 6f; // 可听半径 public float hearingSensitivity = 1f; // 听觉灵敏度 public Vector3 lastKnownTargetPosition { get; private set; } // 检测玩家是否在视野中 public bool CanSeeTarget(Transform target) { if (target == null) return false; Vector3 directionToTarget = target.position - transform.position; float distance = directionToTarget.magnitude; // 距离检测 if (distance > viewRadius) return false; // 角度检测 Vector3 forward = transform.forward; float angle = Vector3.Angle(forward, directionToTarget); if (angle > viewAngle * 0.5f) return false; // 遮挡检测 if (Physics.Raycast(transform.position, directionToTarget.normalized, out RaycastHit hit, distance, obstacleMask)) { return false; } // 额外判断目标身上是否有“可被发现”标记 if (target.TryGetComponent<PlayerNoiseEmitter>(out _)) { lastKnownTargetPosition = target.position; return true; } return false; } // 检测是否听到声源 public bool CanHearTarget(Vector3 targetPosition, float noiseRange) { float distance = Vector3.Distance(transform.position, targetPosition); // noiseRange 是声源的最大传播距离 if (distance > noiseRange) return false; // 距离越近,听得越清楚 float intensity = 1f - (distance / noiseRange); return intensity * hearingSensitivity > 0.3f; } // 供外部调用的获取目标方法(用于 Gizmos 调试) public void OnDrawGizmosSelected() { UnityEditor.Handles.color = Color.yellow; UnityEditor.Handles.DrawWireArc(transform.position, Vector3.up, Quaternion.Euler(0, -viewAngle * 0.5f, 0) * transform.forward, viewAngle, viewRadius); UnityEditor.Handles.color = Color.red; UnityEditor.Handles.DrawWireArc(transform.position, Vector3.up, Vector3.forward, 360f, hearRadius); } }这段代码的关键点有三个:角度检测:使用Vector3.Angle(forward, directionToTarget)判断目标是否在视野角内。遮挡检测:通过Physics.Raycast从 AI 位置向目标发射射线,如果中间有障碍物就视为不可见。听觉判定:用noiseRange表示声源传播范围,AI 与声源距离越近,听到的概率越高。注意:CanSeeTarget中有一步target.TryGetComponent<PlayerNoiseEmitter>(out _),这其实是一个简化处理。在正式项目中,更推荐通过 LayerMask 来区分目标,这样性能更好。3.2 状态机:从“巡逻”到“追踪”的决策逻辑状态机是 NPC 决策层的核心。一个标准的敌人 AI 状态包含:进入状态时执行的操作。状态持续期间每帧更新的操作。切换到其他状态的条件。离开状态时执行的操作。我们用枚举定义状态,然后在EnemyStateMachine中实现状态切换逻辑。为了让代码更易读,这里使用简单的switch结构管理,复杂项目可以改用状态模式或行为树,但原理是一致的。// 文件路径:Assets/Scripts/AI/EnemyStateMachine.cs using UnityEngine; public enum EnemyState { Patrol, // 巡逻 Alert, // 警觉 Chase, // 追踪 Attack // 攻击 } public class EnemyStateMachine : MonoBehaviour { public EnemyState currentState { get; private set; } [Header("行为参数")] public float patrolSpeed = 2f; public float chaseSpeed = 5f; public float alertDuration = 3f; public float attackDistance = 1.5f; [Header("引用")] public Transform player; public Transform[] patrolPoints; public EnemyPerception perception; public UnityEngine.AI.NavMeshAgent agent; public Animator animator; private int currentPatrolIndex = 0; private float alertTimer = 0f; private void Start() { agent = GetComponent<UnityEngine.AI.NavMeshAgent>(); int state = (int)EnemyState.Patrol; EnterState(EnemyState.Patrol); } private void Update() { switch (currentState) { case EnemyState.Patrol: UpdatePatrol(); break; case EnemyState.Alert: UpdateAlert(); break; case EnemyState.Chase: UpdateChase(); break; case EnemyState.Attack: UpdateAttack(); break; } } private void EnterState(EnemyState newState) { // 离开旧状态时的清理逻辑 switch (currentState) { case EnemyState.Chase: agent.isStopped = false; break; } currentState = newState; switch (newState) { case EnemyState.Patrol: agent.speed = patrolSpeed; GoToNextPatrolPoint(); break; case EnemyState.Alert: agent.isStopped = true; alertTimer = 0f; break; case EnemyState.Chase: agent.isStopped = false; agent.speed = chaseSpeed; break; case EnemyState.Attack: agent.isStopped = true; break; } } private void UpdatePatrol() { // 巡逻过程中持续感知 if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } if (player != null && perception.CanHearTarget(player.position, 5f)) { EnterState(EnemyState.Alert); return; } // 到达巡逻点后切换到下一个点 if (agent.remainingDistance < 0.5f) { GoToNextPatrolPoint(); } } private void UpdateAlert() { alertTimer += Time.deltaTime; // 警觉状态下保留视觉检测 if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } // 警觉结束后回到巡逻 if (alertTimer >= alertDuration) { EnterState(EnemyState.Patrol); } } private void UpdateChase() { // 追踪时不断更新目标位置 agent.SetDestination(player.position); if (Vector3.Distance(transform.position, player.position) <= attackDistance) { EnterState(EnemyState.Attack); return; } // 如果丢失目标太久,回到警觉状态 if (!perception.CanSeeTarget(player) && !perception.CanHearTarget(player.position, 5f)) { EnterState(EnemyState.Alert); } } private void UpdateAttack() { if (Vector3.Distance(transform.position, player.position) > attackDistance) { EnterState(EnemyState.Chase); } } private void GoToNextPatrolPoint() { if (patrolPoints.Length == 0) return; agent.SetDestination(patrolPoints[currentPatrolIndex].position); currentPatrolIndex = (currentPatrolIndex + 1) % patrolPoints.Length; } }这段状态机代码虽然简单,但已经覆盖了 AI 开发中最常见的模式:巡逻期间持续感知,优先级是视觉大于听觉;警觉状态是一个缓冲区,用来模拟“好像听到了什么”的效果;追踪状态会持续更新目标位置,直到靠近后进入攻击状态。3.3 玩家的“声音发射器”设计在传统游戏开发中,很多初学者会让 AI 直接监听玩家身上的碰撞体,或者直接读取玩家坐标。这种方式有一个问题:如果后期加入更多 NPC、更多 AI 逻辑,代码会变得极其混乱。更合理的做法是“声音事件广播”。我们来写一个PlayerNoiseEmitter组件。它的职责只有一个:根据玩家的移动速度,计算出当前产生的噪音大小,并发送给周围的 AI 感知器。// 文件路径:Assets/Scripts/Player/PlayerNoiseEmitter.cs using UnityEngine; public class PlayerNoiseEmitter : MonoBehaviour { [Header("噪声参数")] public float walkNoise = 3f; // 行走时的噪声范围 public float runNoise = 8f; // 奔跑时的噪声范围 public float crouchNoise = 1f; // 潜行时的噪声范围 private CharacterController characterController; private PlayerController playerController; // 来自我们后续写的玩家控制脚本 private void Awake() { characterController = GetComponent<CharacterController>(); playerController = GetComponent<PlayerController>(); } // 返回当前噪声强度,供 AI 感知系统查询 public float GetCurrentNoiseRange() { if (playerController == null) return walkNoise; if (playerController.IsCrouching) return crouchNoise; float speed = characterController.velocity.magnitude; if (speed < 0.1f) return 0f; // 站立不动 if (speed < 4f) return walkNoise; return runNoise; } // 可视化调试 private void OnDrawGizmosSelected() { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(transform.position, GetCurrentNoiseRange()); } }这个脚本本身不需要主动调用物理系统,敌人 AI 会在每帧 Update 中主动查询玩家身上的GetCurrentNoiseRange(),然后结合CanHearTarget判断是否听到声音。这种“查询式”的事件驱动方式是 Unity 中比较轻量的一种选择。如果项目规模变大,也可以改用UnityEngine.Events或者消息系统来做事件广播,效果更好,但代码复杂度会上升。3.4 同伴跟随 AI:保持冷静,不暴露玩家在《最后生还者》里,同伴的行为设计非常讲究:他们会跟着主角走,但不会在潜行时乱跑,也不会挡在敌人枪口前面。实现一个像样的同伴 AI 其实不复杂,核心是把“跟随”和“避险”结合起来。// 文件路径:Assets/Scripts/Companion/CompanionFollow.cs using UnityEngine; using UnityEngine.AI; public class CompanionFollow : MonoBehaviour { [Header("跟随参数")] public Transform player; public float followDistance = 2f; public float updateInterval = 0.5f; public float avoidDistance = 1.2f; private NavMeshAgent agent; private PlayerNoiseEmitter playerNoise; private void Start() { agent = GetComponent<NavMeshAgent>(); playerNoise = player.GetComponent<PlayerNoiseEmitter>(); } private void Update() { if (player == null) return; // 如果玩家正在潜行,同伴也保持安静 if (playerNoise != null && playerNoise.GetCurrentNoiseRange() < 1f) { agent.speed = 1.5f; } else { agent.speed = 3f; } // 计算跟随目标位置:玩家背后偏移一点 Vector3 offset = -player.forward * followDistance; Vector3 targetPosition = player.position + offset; // 避让障碍物:如果目标位置被遮挡,直接站到玩家旁边 if (Physics.Raycast(player.position, offset.normalized, out RaycastHit hit, followDistance)) { targetPosition = player.position + (hit.point - player.position).normalized * avoidDistance; } agent.SetDestination(targetPosition); } }这里有一个很实用的技巧:offset = -player.forward * followDistance让同伴始终站在玩家背后方向,而不是直接站在玩家当前位置。这样在过转角时,同伴不会被卡在墙角。3.5 潜行与掩体系统潜行系统本身不复杂,但它连接了玩家控制、动画状态、敌人 AI 等多个模块。我们的简化设计只需要做到两点:玩家按下Ctrl键切换站立 / 蹲下状态。蹲下状态下,PlayerNoiseEmitter返回较小的噪声范围,同时玩家移动速度降低。掩体系统在正式项目中会涉及物理射线检测和相机联动,这里就不展开实现,只说明思路:掩体检测一般是从玩家位置向外发射一圈射线,找到最近的掩体边缘,然后把玩家吸附到掩体边缘位置。Unity 的 Character Controller 组件和Physics.SphereCast是常用实现方案。4. 完整实战案例:搭建一个可运行 Demo4.1 Demo 目标我们最终要实现这样的效果:玩家使用 WASD 移动,鼠标控制朝向。按Ctrl或C键进入潜行状态,移动速度降低。场景中有一个敌人,按设定路线巡逻。当玩家奔跑经过敌人附近时,敌人听到声音并向玩家位置警觉。当敌人看到玩家时,进入追踪状态,靠近后攻击。同伴 NPC 自动跟随玩家,并随玩家一起潜行。4.2 创建场景和基础物体打开 Unity,新建一个 3D 项目,然后按以下步骤操作:在 Hierarchy 窗口右键,创建 Plane 作为地面。创建几个 Cube,随意摆放作为掩体墙。创建 Capsule 并命名 Player,添加 Character Controller 组件。创建 Cylinder 并命名 Enemy,添加 NavMeshAgent 组件。创建 Capsule 并命名 Companion,添加 NavMeshAgent 组件。给 Player 和 Companion 分配不同颜色的材质,方便区分。摆放完成后地面还是无法寻路,因为需要先构建 NavMesh。点击菜单Window > AI > Navigation,在 Navigation 面板里选择 Bake 按钮,就会在地面上生成蓝色寻路区域。注意:掩体 Cube 需要设置为静态物体(Static),否则烘焙结果会不对。4.3 编写玩家控制脚本玩家控制脚本会处理移动、转向和潜行切换。为了让代码更完整,这里给出一个可直接使用的版本:// 文件路径:Assets/Scripts/Player/PlayerController.cs using UnityEngine; [RequireComponent(typeof(CharacterController))] [RequireComponent(typeof(PlayerNoiseEmitter))] public class PlayerController : MonoBehaviour { [Header("移动参数")] public float walkSpeed = 3f; public float runSpeed = 6f; public float crouchSpeed = 1.5f; public float turnSpeed = 15f; [Header("潜行")] public bool IsCrouching { get; private set; } public KeyCode crouchKey = KeyCode.C; private CharacterController controller; private PlayerNoiseEmitter noiseEmitter; private void Awake() { controller = GetComponent<CharacterController>(); noiseEmitter = GetComponent<PlayerNoiseEmitter>(); } private void Update() { // 切换潜行 if (Input.GetKeyDown(crouchKey)) { IsCrouching = !IsCrouching; } // 计算移动方向 float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; // 根据状态选择速度 float speed = runSpeed; if (IsCrouching) speed = crouchSpeed; else if (Input.GetKey(KeyCode.LeftShift)) speed = runSpeed; else speed = walkSpeed; // 应用移动 Vector3 move = direction * speed; controller.Move(move * Time.deltaTime); // 角色朝向移动方向(仅在移动时旋转) if (direction.magnitude > 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(direction, Vector3.up); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, turnSpeed * Time.deltaTime); } } private void OnControllerColliderHit(ControllerColliderHit hit) { // 简单实现:碰撞时不再移动,防止穿过掩体 // 正式项目建议使用 Physics.SphereCast 做移动检测 } }这个脚本用了CharacterController.Move而不是transform.Translate,优点是自带碰撞检测,可以避免玩家穿过掩体。4.4 编写敌人 AI 状态机与感知逻辑敌人的代码分为两个脚本:EnemyPerception.cs负责感知,EnemyStateMachine.cs负责状态决策。这两个脚本在第三节已经给过核心代码,这里做两处补充:EnemyStateMachine的Update中需要补充对PlayerNoiseEmitter的查询。为了能实际看到状态切换,我们在 Enemy 上添加一个简单的颜色变化显示状态。// 在 EnemyStateMachine.Update 的 UpdatePatrol 中替换声音检测部分 private void UpdatePatrol() { if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } // 从玩家身上获取噪声范围 PlayerNoiseEmitter noise = player.GetComponent<PlayerNoiseEmitter>(); if (noise != null && perception.CanHearTarget(player.position, noise.GetCurrentNoiseRange())) { EnterState(EnemyState.Alert); return; } if (agent.remainingDistance < 0.5f) { GoToNextPatrolPoint(); } }4.5 部署到 Unity 并连接引用脚本写好之后,还需要在 Inspector 面板中拖拽引用:选中 Enemy 物体,在 EnemyStateMachine 组件中把player拖到 Player 物体上。在patrolPoints数组中添加 2~3 个空物体作为巡逻点。perception字段拖入 Enemy 身上的 EnemyPerception 组件。agent字段会自动获取,因为我们在代码里使用了GetComponent。跟随玩家:将 CompanionFollow 的player指向 Player。4.6 运行与预期结果点击 Play 运行场景,预期效果如下:玩家站在起点,敌人沿巡逻点来回巡逻。玩家快速按住 Shift 跑向敌人,敌人会在跑动声音进入听阈后进入 Alert 状态(颜色变橙)。玩家继续跑进敌人视野,敌人进入 Chase 状态(颜色变红),开始追击。玩家按下 C 蹲下,并远离敌方视野,敌人会进入 Alert 状态并在几秒后回到 Patrol。如果一切正常,你会在 Console 窗口看到我们写的 Debug.Log 输出(如果需要可视化信息,可以在状态切换处加一行日志)。5. 常见问题与排查思路在实际开发中,AI 系统是 bug 高发区域。这里整理几个高频问题:问题现象常见原因解决思路敌人不移动,原地发呆NavMeshAgent 没有正确烘焙 NavMesh检查 Window > AI > Navigation 是否烘焙完成敌人能“穿墙”看到玩家遮挡检测射线没有检测到障碍物确认掩体物体是否勾选 Static,并配置 obstacleMask玩家蹲下后敌人仍然追杀潜行时噪音范围没有被正确传入敌人检查 PlayerNoiseEmitter 的返回值和 CanHearTarget 逻辑同伴与玩家重叠碰撞跟随目标点被玩家自身阻挡使用-player.forward偏移量,并配合物理测试调整 followDistanceAI 状态切换过于频繁听觉阈值设置太低把 hearingSensitivity 调低,或把 noiseRange 数值改小动画不切换Animator 参数与代码参数不一致在 Unity 中检查 Animator 参数名称,并同步枚举攻击状态下不播放攻击动画缺少攻击动画层简化处理时可以直接播放默认动画或使用 Debug.Log 代替排查 AI 问题时,建议按“感知 -> 决策 -> 行为 -> 表现”的顺序检查。先确认敌人是否能“感知”到玩家,再确认状态是否切换,最后才看表现层动画。很多问题最终都出在感知层数据没传对。6. 最佳实践与工程建议6.1 AI 感知参数不要魔法数字我在示例代码里直接写了很多数值,比如viewRadius = 10f、hearRadius = 6f。在 Demo 阶段没问题,项目变大后就会出现“调了这个数值忘了那个数值”的情况。建议把所有感知参数收集到一个可配置的数据类中,或者使用 Unity 的 ScriptableObject 来管理。// 文件路径:Assets/Scripts/AI/EnemyPerceptionConfig.cs using UnityEngine; [CreateAssetMenu(fileName = "EnemyPerceptionConfig", menuName = "AI/Perception Config")] public class EnemyPerceptionConfig : ScriptableObject { public float viewRadius = 10f; public float viewAngle = 110f; public float hearRadius = 6f; public float hearingSensitivity = 1f; public float alertDuration = 3f; public float attackDistance = 1.5f; }这样做的好处是策划同学可以单独调整参数,不需要改代码。6.2 不要每帧发射大量射线在玩家机器上,黄点的敌人 AI 可能只有几个,每帧射线不会成为瓶颈。但如果你把同样逻辑用到 50 个 NPC 上,性能就会急剧下降。优化思路:降低感知检测频率,比如每 0.2 秒检测一次,而不是每帧检测。使用Physics.OverlapSphereNonAlloc批量获取周围对象,再逐个判断距离和角度。对远处的 AI 使用 LOD 简化,距离远的 AI 不检测视野,只检测听觉。6.3 状态机扩展性:考虑用行为树替代示例中的switch状态机适合状态少、逻辑简单的场景。如果敌人需要结合“逃跑、呼叫同伴、掩体切换、投掷物”等复杂行为,建议学习行为树(Behavior Tree)。Unity 自带的 NavMesh 组件 + 行为树插件(如 Behavior Designer)是目前商业项目常用的组合。6.4 安全与合规:不要设计“必死”陷阱在开发游戏 AI 时,有一个设计原则需要重视:玩家永远有可用的应对策略。如果敌人视距过长、听觉过灵敏,玩家就会陷入“必死”循环。顽皮狗的关卡设计就很注重“试探性失败”,让玩家逐渐摸清 AI 规则后完成反制。在做 Demo 时也要注意,把潜行作为一种正反馈机制来设计,而不是把潜行做成惩罚机制。7. 总结与学习路线这篇文章围绕着顽皮狗《最后生还者》项目开发背后的技术核心,拆解了叙事驱动游戏中常见的 AI 感知、状态机、潜行和同伴跟随系统。我们用 Unity 实现了一个简化版 Demo,核心代码包括:EnemyPerception:视觉角度检测 + 听觉噪声检测 + 遮挡判断。EnemyStateMachine:巡逻 / 警觉 / 追踪 / 攻击四种状态。PlayerNoiseEmitter:根据移动状态生成不同噪声范围。CompanionFollow:简单有效的同伴跟随逻辑。如果你能把这套逻辑完整跑通,下一步可以这样进阶:学习行为树,把状态机替换成可视化行为树。增加掩体切换,让 AI 在受到攻击时主动寻找掩体。加入群体 AI 通信,让附近敌人响应同伴的警报。结合动画状态机,让 AI 行为与最终表现完全匹配。顽皮狗能够同时推进多个《最后生还者》项目,背后是他们积累多年的 AI 行为设计能力与技术管线。我们虽然没法一夜之间复制他们的完整方案,但从感知、决策、执行这个分层模型开始,一步一步搭建属于你自己的 AI 体系,这条路是每个游戏开发者都能走的。如果有不清楚的地方,欢迎在评论区留言交流,也可以把你跑通 Demo 的截图发出来,大家一起讨论优化。
最近顽皮狗官方确认《最后生还者》系列还有更多游戏项目正在开发中,而且这次明确不是电视剧内容。消息一出,玩家社区里关于“艾莉之后的故事会怎么展开”“新作会不会用全新主角”的讨论就没停过。不过作为一个常年关注游戏开发技术的博主,我看到的反而是另一层信息:顽皮狗能够同时推进多个高质量项目,背后那套成熟的 AI 行为设计、电影化叙事流程和技术管线,才是真正值得开发者学习的东西。这篇文章不聊剧情猜测,我们单纯从技术角度拆解《最后生还者》这类第三人称叙事驱动游戏的核心玩法实现。我会结合 Unity 引擎,带大家从零搭建一个包含敌人 AI 感知、潜行、同伴跟随和简单状态机的可运行 Demo。无论你是刚接触游戏 AI 开发的初学者,还是在公司里负责玩法系统设计的开发者,都能在这篇文章里找到可以直接落地的思路和代码。1. 背景与核心概念1.1 《最后生还者》的技术标签:叙事驱动的 AI 设计顽皮狗的游戏之所以能被玩家记住,除了剧情演出之外,还有一个容易被忽略的技术核心:AI 系统。在《最后生还者》中,敌人不会像传统射击游戏那样傻站在掩体后面对射,他们会根据玩家的脚步声、枪声、视线范围和环境互动做出复杂反应。感染者会循着声音搜索玩家位置,人类敌人会相互掩护、包抄侧翼。这些行为的底层,其实就是一套由“感知系统 + 状态机 + 行为选择”组成的 AI 架构。对开发者来说,直接复刻顽皮狗的完整 AI 系统不现实,但我们可以提炼出一套适用于中小型项目的简化模型。这套模型通常包含:感知层:负责收集视觉、听觉信息。决策层:根据感知结果切换行为状态。行为层:执行巡逻、追踪、攻击、搜索等具体动作。动画层:根据行为状态播放对应动画。理解了这个分层结构,后续无论是用 Unity、Unreal 还是 Godot,都能快速迁移思路。1.2 为什么顽皮狗的 AI 会让人觉得“聪明”很多开发者第一次玩《最后生还者》时会觉得敌人 AI 特别“聪明”,但实际上他们并不是真的具备高级推理能力。顽皮狗的做法是用大量的细节规则去模拟真实行为。比如:敌人在听到声音后不会立刻锁定玩家位置,而是先朝声源方向警戒一段时间。同伴 AI 不会主动暴露玩家位置,会尽量保持安静并跟随玩家路径。敌人视线被障碍物遮挡时,会丢失目标,并进入搜索状态。这些设计本质上是对“信息不确定性”的模拟。在实现层面,就是要给 AI 的感知系统加入“置信度”和“遗忘时间”的概念。1.3 本文要动手做什么为了更直观地讲解,我会带大家用 Unity 实现一个简化版“循声者”敌人 AI:敌人拥有“巡逻 / 警觉 / 追踪 / 攻击”四种状态。敌人能听到玩家的脚步声,距离越近声音越大。玩家可以蹲下潜行来降低脚步声,从而避开敌人。一个简单的同伴 NPC 会跟随玩家,并在玩家潜行时也保持安静。最终效果是一个可以运行的小场景:你操控玩家角色穿过有敌人巡逻的走廊,需要利用潜行机制绕过敌人,同伴会自动跟随你的路径。整个过程全部采用 Unity 原生组件实现,不依赖任何付费插件。2. 环境准备与版本说明2.1 引擎与工具版本本文示例以 Unity 开发环境为主。为避免版本差异导致的配置问题,建议使用以下环境:工具版本建议说明Unity Editor2022.3 LTS 或更新长期支持版本,稳定性更好Visual Studio2022 或 VS Code用于 C# 脚本编写操作系统Windows / macOS / LinuxUnity 均支持建模工具Blender(可选)仅用于替换占位模型版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。如果你使用的是 Unity 2021 或 Unity 6,核心 API 基本通用,个别命名空间可能需要根据官方文档微调。2.2 项目结构规划为了保持代码清晰,我推荐在 Unity 中建立以下目录结构:Assets/ ├── Scripts/ │ ├── AI/ │ │ ├── EnemyController.cs │ │ ├── EnemyPerception.cs │ │ └── EnemyStateMachine.cs │ ├── Player/ │ │ ├── PlayerController.cs │ │ └── PlayerNoiseEmitter.cs │ └── Companion/ │ └── CompanionFollow.cs ├── Scenes/ │ └── DemoScene.unity └── Prefabs/ ├── Enemy.prefab ├── Player.prefab └── Companion.prefab这里的PlayerNoiseEmitter.cs是一个比较关键的设计:玩家移动时不再直接通知敌人,而是通过一个“噪声源”组件广播自己的声音强度,敌人的感知系统再去监听。这是一个低耦合设计,在项目变大之后会很好维护。2.3 场景搭建所需基础对象为了快速跑通 Demo,我们可以用 Unity 内置的几何体代替正式美术资源:玩家:一个 Capsule(胶囊体)+ 一个小方块作为“面向”标识。敌人:一个 Cylinder(圆柱体)+ 圆锥作为“视线方向”标识。同伴:一个 Capsule,使用不同的颜色材质区分。场景:一个 Plane 作为地面,再加上几个 Cube 作为掩体。不需要导入外部素材,所有资源都在 Unity 菜单GameObject > 3D Object里创建。3. 核心机制原理拆解3.1 NPC 感知系统:如何“看到”和“听到”玩家NPC 感知系统是 AI 行为的基础。在《最后生还者》里,敌人的感知分为视觉和听觉两条通道。视觉系统包含视野角度、可视距离和遮挡检测;听觉系统包含声源距离和声源强度。先看视觉感知的核心代码思路。我们需要让敌人只看到“视野范围内 + 没有遮挡 + 距离足够近”的对象。// 文件路径:Assets/Scripts/AI/EnemyPerception.cs using UnityEngine; public class EnemyPerception : MonoBehaviour { [Header("视觉参数")] public float viewRadius = 10f; // 可视半径 public float viewAngle = 110f; // 视野角度 public LayerMask targetMask; // 目标层级 public LayerMask obstacleMask; // 障碍物层级 [Header("听觉参数")] public float hearRadius = 6f; // 可听半径 public float hearingSensitivity = 1f; // 听觉灵敏度 public Vector3 lastKnownTargetPosition { get; private set; } // 检测玩家是否在视野中 public bool CanSeeTarget(Transform target) { if (target == null) return false; Vector3 directionToTarget = target.position - transform.position; float distance = directionToTarget.magnitude; // 距离检测 if (distance > viewRadius) return false; // 角度检测 Vector3 forward = transform.forward; float angle = Vector3.Angle(forward, directionToTarget); if (angle > viewAngle * 0.5f) return false; // 遮挡检测 if (Physics.Raycast(transform.position, directionToTarget.normalized, out RaycastHit hit, distance, obstacleMask)) { return false; } // 额外判断目标身上是否有“可被发现”标记 if (target.TryGetComponent<PlayerNoiseEmitter>(out _)) { lastKnownTargetPosition = target.position; return true; } return false; } // 检测是否听到声源 public bool CanHearTarget(Vector3 targetPosition, float noiseRange) { float distance = Vector3.Distance(transform.position, targetPosition); // noiseRange 是声源的最大传播距离 if (distance > noiseRange) return false; // 距离越近,听得越清楚 float intensity = 1f - (distance / noiseRange); return intensity * hearingSensitivity > 0.3f; } // 供外部调用的获取目标方法(用于 Gizmos 调试) public void OnDrawGizmosSelected() { UnityEditor.Handles.color = Color.yellow; UnityEditor.Handles.DrawWireArc(transform.position, Vector3.up, Quaternion.Euler(0, -viewAngle * 0.5f, 0) * transform.forward, viewAngle, viewRadius); UnityEditor.Handles.color = Color.red; UnityEditor.Handles.DrawWireArc(transform.position, Vector3.up, Vector3.forward, 360f, hearRadius); } }这段代码的关键点有三个:角度检测:使用Vector3.Angle(forward, directionToTarget)判断目标是否在视野角内。遮挡检测:通过Physics.Raycast从 AI 位置向目标发射射线,如果中间有障碍物就视为不可见。听觉判定:用noiseRange表示声源传播范围,AI 与声源距离越近,听到的概率越高。注意:CanSeeTarget中有一步target.TryGetComponent<PlayerNoiseEmitter>(out _),这其实是一个简化处理。在正式项目中,更推荐通过 LayerMask 来区分目标,这样性能更好。3.2 状态机:从“巡逻”到“追踪”的决策逻辑状态机是 NPC 决策层的核心。一个标准的敌人 AI 状态包含:进入状态时执行的操作。状态持续期间每帧更新的操作。切换到其他状态的条件。离开状态时执行的操作。我们用枚举定义状态,然后在EnemyStateMachine中实现状态切换逻辑。为了让代码更易读,这里使用简单的switch结构管理,复杂项目可以改用状态模式或行为树,但原理是一致的。// 文件路径:Assets/Scripts/AI/EnemyStateMachine.cs using UnityEngine; public enum EnemyState { Patrol, // 巡逻 Alert, // 警觉 Chase, // 追踪 Attack // 攻击 } public class EnemyStateMachine : MonoBehaviour { public EnemyState currentState { get; private set; } [Header("行为参数")] public float patrolSpeed = 2f; public float chaseSpeed = 5f; public float alertDuration = 3f; public float attackDistance = 1.5f; [Header("引用")] public Transform player; public Transform[] patrolPoints; public EnemyPerception perception; public UnityEngine.AI.NavMeshAgent agent; public Animator animator; private int currentPatrolIndex = 0; private float alertTimer = 0f; private void Start() { agent = GetComponent<UnityEngine.AI.NavMeshAgent>(); int state = (int)EnemyState.Patrol; EnterState(EnemyState.Patrol); } private void Update() { switch (currentState) { case EnemyState.Patrol: UpdatePatrol(); break; case EnemyState.Alert: UpdateAlert(); break; case EnemyState.Chase: UpdateChase(); break; case EnemyState.Attack: UpdateAttack(); break; } } private void EnterState(EnemyState newState) { // 离开旧状态时的清理逻辑 switch (currentState) { case EnemyState.Chase: agent.isStopped = false; break; } currentState = newState; switch (newState) { case EnemyState.Patrol: agent.speed = patrolSpeed; GoToNextPatrolPoint(); break; case EnemyState.Alert: agent.isStopped = true; alertTimer = 0f; break; case EnemyState.Chase: agent.isStopped = false; agent.speed = chaseSpeed; break; case EnemyState.Attack: agent.isStopped = true; break; } } private void UpdatePatrol() { // 巡逻过程中持续感知 if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } if (player != null && perception.CanHearTarget(player.position, 5f)) { EnterState(EnemyState.Alert); return; } // 到达巡逻点后切换到下一个点 if (agent.remainingDistance < 0.5f) { GoToNextPatrolPoint(); } } private void UpdateAlert() { alertTimer += Time.deltaTime; // 警觉状态下保留视觉检测 if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } // 警觉结束后回到巡逻 if (alertTimer >= alertDuration) { EnterState(EnemyState.Patrol); } } private void UpdateChase() { // 追踪时不断更新目标位置 agent.SetDestination(player.position); if (Vector3.Distance(transform.position, player.position) <= attackDistance) { EnterState(EnemyState.Attack); return; } // 如果丢失目标太久,回到警觉状态 if (!perception.CanSeeTarget(player) && !perception.CanHearTarget(player.position, 5f)) { EnterState(EnemyState.Alert); } } private void UpdateAttack() { if (Vector3.Distance(transform.position, player.position) > attackDistance) { EnterState(EnemyState.Chase); } } private void GoToNextPatrolPoint() { if (patrolPoints.Length == 0) return; agent.SetDestination(patrolPoints[currentPatrolIndex].position); currentPatrolIndex = (currentPatrolIndex + 1) % patrolPoints.Length; } }这段状态机代码虽然简单,但已经覆盖了 AI 开发中最常见的模式:巡逻期间持续感知,优先级是视觉大于听觉;警觉状态是一个缓冲区,用来模拟“好像听到了什么”的效果;追踪状态会持续更新目标位置,直到靠近后进入攻击状态。3.3 玩家的“声音发射器”设计在传统游戏开发中,很多初学者会让 AI 直接监听玩家身上的碰撞体,或者直接读取玩家坐标。这种方式有一个问题:如果后期加入更多 NPC、更多 AI 逻辑,代码会变得极其混乱。更合理的做法是“声音事件广播”。我们来写一个PlayerNoiseEmitter组件。它的职责只有一个:根据玩家的移动速度,计算出当前产生的噪音大小,并发送给周围的 AI 感知器。// 文件路径:Assets/Scripts/Player/PlayerNoiseEmitter.cs using UnityEngine; public class PlayerNoiseEmitter : MonoBehaviour { [Header("噪声参数")] public float walkNoise = 3f; // 行走时的噪声范围 public float runNoise = 8f; // 奔跑时的噪声范围 public float crouchNoise = 1f; // 潜行时的噪声范围 private CharacterController characterController; private PlayerController playerController; // 来自我们后续写的玩家控制脚本 private void Awake() { characterController = GetComponent<CharacterController>(); playerController = GetComponent<PlayerController>(); } // 返回当前噪声强度,供 AI 感知系统查询 public float GetCurrentNoiseRange() { if (playerController == null) return walkNoise; if (playerController.IsCrouching) return crouchNoise; float speed = characterController.velocity.magnitude; if (speed < 0.1f) return 0f; // 站立不动 if (speed < 4f) return walkNoise; return runNoise; } // 可视化调试 private void OnDrawGizmosSelected() { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(transform.position, GetCurrentNoiseRange()); } }这个脚本本身不需要主动调用物理系统,敌人 AI 会在每帧 Update 中主动查询玩家身上的GetCurrentNoiseRange(),然后结合CanHearTarget判断是否听到声音。这种“查询式”的事件驱动方式是 Unity 中比较轻量的一种选择。如果项目规模变大,也可以改用UnityEngine.Events或者消息系统来做事件广播,效果更好,但代码复杂度会上升。3.4 同伴跟随 AI:保持冷静,不暴露玩家在《最后生还者》里,同伴的行为设计非常讲究:他们会跟着主角走,但不会在潜行时乱跑,也不会挡在敌人枪口前面。实现一个像样的同伴 AI 其实不复杂,核心是把“跟随”和“避险”结合起来。// 文件路径:Assets/Scripts/Companion/CompanionFollow.cs using UnityEngine; using UnityEngine.AI; public class CompanionFollow : MonoBehaviour { [Header("跟随参数")] public Transform player; public float followDistance = 2f; public float updateInterval = 0.5f; public float avoidDistance = 1.2f; private NavMeshAgent agent; private PlayerNoiseEmitter playerNoise; private void Start() { agent = GetComponent<NavMeshAgent>(); playerNoise = player.GetComponent<PlayerNoiseEmitter>(); } private void Update() { if (player == null) return; // 如果玩家正在潜行,同伴也保持安静 if (playerNoise != null && playerNoise.GetCurrentNoiseRange() < 1f) { agent.speed = 1.5f; } else { agent.speed = 3f; } // 计算跟随目标位置:玩家背后偏移一点 Vector3 offset = -player.forward * followDistance; Vector3 targetPosition = player.position + offset; // 避让障碍物:如果目标位置被遮挡,直接站到玩家旁边 if (Physics.Raycast(player.position, offset.normalized, out RaycastHit hit, followDistance)) { targetPosition = player.position + (hit.point - player.position).normalized * avoidDistance; } agent.SetDestination(targetPosition); } }这里有一个很实用的技巧:offset = -player.forward * followDistance让同伴始终站在玩家背后方向,而不是直接站在玩家当前位置。这样在过转角时,同伴不会被卡在墙角。3.5 潜行与掩体系统潜行系统本身不复杂,但它连接了玩家控制、动画状态、敌人 AI 等多个模块。我们的简化设计只需要做到两点:玩家按下Ctrl键切换站立 / 蹲下状态。蹲下状态下,PlayerNoiseEmitter返回较小的噪声范围,同时玩家移动速度降低。掩体系统在正式项目中会涉及物理射线检测和相机联动,这里就不展开实现,只说明思路:掩体检测一般是从玩家位置向外发射一圈射线,找到最近的掩体边缘,然后把玩家吸附到掩体边缘位置。Unity 的 Character Controller 组件和Physics.SphereCast是常用实现方案。4. 完整实战案例:搭建一个可运行 Demo4.1 Demo 目标我们最终要实现这样的效果:玩家使用 WASD 移动,鼠标控制朝向。按Ctrl或C键进入潜行状态,移动速度降低。场景中有一个敌人,按设定路线巡逻。当玩家奔跑经过敌人附近时,敌人听到声音并向玩家位置警觉。当敌人看到玩家时,进入追踪状态,靠近后攻击。同伴 NPC 自动跟随玩家,并随玩家一起潜行。4.2 创建场景和基础物体打开 Unity,新建一个 3D 项目,然后按以下步骤操作:在 Hierarchy 窗口右键,创建 Plane 作为地面。创建几个 Cube,随意摆放作为掩体墙。创建 Capsule 并命名 Player,添加 Character Controller 组件。创建 Cylinder 并命名 Enemy,添加 NavMeshAgent 组件。创建 Capsule 并命名 Companion,添加 NavMeshAgent 组件。给 Player 和 Companion 分配不同颜色的材质,方便区分。摆放完成后地面还是无法寻路,因为需要先构建 NavMesh。点击菜单Window > AI > Navigation,在 Navigation 面板里选择 Bake 按钮,就会在地面上生成蓝色寻路区域。注意:掩体 Cube 需要设置为静态物体(Static),否则烘焙结果会不对。4.3 编写玩家控制脚本玩家控制脚本会处理移动、转向和潜行切换。为了让代码更完整,这里给出一个可直接使用的版本:// 文件路径:Assets/Scripts/Player/PlayerController.cs using UnityEngine; [RequireComponent(typeof(CharacterController))] [RequireComponent(typeof(PlayerNoiseEmitter))] public class PlayerController : MonoBehaviour { [Header("移动参数")] public float walkSpeed = 3f; public float runSpeed = 6f; public float crouchSpeed = 1.5f; public float turnSpeed = 15f; [Header("潜行")] public bool IsCrouching { get; private set; } public KeyCode crouchKey = KeyCode.C; private CharacterController controller; private PlayerNoiseEmitter noiseEmitter; private void Awake() { controller = GetComponent<CharacterController>(); noiseEmitter = GetComponent<PlayerNoiseEmitter>(); } private void Update() { // 切换潜行 if (Input.GetKeyDown(crouchKey)) { IsCrouching = !IsCrouching; } // 计算移动方向 float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 direction = new Vector3(horizontal, 0f, vertical).normalized; // 根据状态选择速度 float speed = runSpeed; if (IsCrouching) speed = crouchSpeed; else if (Input.GetKey(KeyCode.LeftShift)) speed = runSpeed; else speed = walkSpeed; // 应用移动 Vector3 move = direction * speed; controller.Move(move * Time.deltaTime); // 角色朝向移动方向(仅在移动时旋转) if (direction.magnitude > 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(direction, Vector3.up); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, turnSpeed * Time.deltaTime); } } private void OnControllerColliderHit(ControllerColliderHit hit) { // 简单实现:碰撞时不再移动,防止穿过掩体 // 正式项目建议使用 Physics.SphereCast 做移动检测 } }这个脚本用了CharacterController.Move而不是transform.Translate,优点是自带碰撞检测,可以避免玩家穿过掩体。4.4 编写敌人 AI 状态机与感知逻辑敌人的代码分为两个脚本:EnemyPerception.cs负责感知,EnemyStateMachine.cs负责状态决策。这两个脚本在第三节已经给过核心代码,这里做两处补充:EnemyStateMachine的Update中需要补充对PlayerNoiseEmitter的查询。为了能实际看到状态切换,我们在 Enemy 上添加一个简单的颜色变化显示状态。// 在 EnemyStateMachine.Update 的 UpdatePatrol 中替换声音检测部分 private void UpdatePatrol() { if (perception.CanSeeTarget(player)) { EnterState(EnemyState.Chase); return; } // 从玩家身上获取噪声范围 PlayerNoiseEmitter noise = player.GetComponent<PlayerNoiseEmitter>(); if (noise != null && perception.CanHearTarget(player.position, noise.GetCurrentNoiseRange())) { EnterState(EnemyState.Alert); return; } if (agent.remainingDistance < 0.5f) { GoToNextPatrolPoint(); } }4.5 部署到 Unity 并连接引用脚本写好之后,还需要在 Inspector 面板中拖拽引用:选中 Enemy 物体,在 EnemyStateMachine 组件中把player拖到 Player 物体上。在patrolPoints数组中添加 2~3 个空物体作为巡逻点。perception字段拖入 Enemy 身上的 EnemyPerception 组件。agent字段会自动获取,因为我们在代码里使用了GetComponent。跟随玩家:将 CompanionFollow 的player指向 Player。4.6 运行与预期结果点击 Play 运行场景,预期效果如下:玩家站在起点,敌人沿巡逻点来回巡逻。玩家快速按住 Shift 跑向敌人,敌人会在跑动声音进入听阈后进入 Alert 状态(颜色变橙)。玩家继续跑进敌人视野,敌人进入 Chase 状态(颜色变红),开始追击。玩家按下 C 蹲下,并远离敌方视野,敌人会进入 Alert 状态并在几秒后回到 Patrol。如果一切正常,你会在 Console 窗口看到我们写的 Debug.Log 输出(如果需要可视化信息,可以在状态切换处加一行日志)。5. 常见问题与排查思路在实际开发中,AI 系统是 bug 高发区域。这里整理几个高频问题:问题现象常见原因解决思路敌人不移动,原地发呆NavMeshAgent 没有正确烘焙 NavMesh检查 Window > AI > Navigation 是否烘焙完成敌人能“穿墙”看到玩家遮挡检测射线没有检测到障碍物确认掩体物体是否勾选 Static,并配置 obstacleMask玩家蹲下后敌人仍然追杀潜行时噪音范围没有被正确传入敌人检查 PlayerNoiseEmitter 的返回值和 CanHearTarget 逻辑同伴与玩家重叠碰撞跟随目标点被玩家自身阻挡使用-player.forward偏移量,并配合物理测试调整 followDistanceAI 状态切换过于频繁听觉阈值设置太低把 hearingSensitivity 调低,或把 noiseRange 数值改小动画不切换Animator 参数与代码参数不一致在 Unity 中检查 Animator 参数名称,并同步枚举攻击状态下不播放攻击动画缺少攻击动画层简化处理时可以直接播放默认动画或使用 Debug.Log 代替排查 AI 问题时,建议按“感知 -> 决策 -> 行为 -> 表现”的顺序检查。先确认敌人是否能“感知”到玩家,再确认状态是否切换,最后才看表现层动画。很多问题最终都出在感知层数据没传对。6. 最佳实践与工程建议6.1 AI 感知参数不要魔法数字我在示例代码里直接写了很多数值,比如viewRadius = 10f、hearRadius = 6f。在 Demo 阶段没问题,项目变大后就会出现“调了这个数值忘了那个数值”的情况。建议把所有感知参数收集到一个可配置的数据类中,或者使用 Unity 的 ScriptableObject 来管理。// 文件路径:Assets/Scripts/AI/EnemyPerceptionConfig.cs using UnityEngine; [CreateAssetMenu(fileName = "EnemyPerceptionConfig", menuName = "AI/Perception Config")] public class EnemyPerceptionConfig : ScriptableObject { public float viewRadius = 10f; public float viewAngle = 110f; public float hearRadius = 6f; public float hearingSensitivity = 1f; public float alertDuration = 3f; public float attackDistance = 1.5f; }这样做的好处是策划同学可以单独调整参数,不需要改代码。6.2 不要每帧发射大量射线在玩家机器上,黄点的敌人 AI 可能只有几个,每帧射线不会成为瓶颈。但如果你把同样逻辑用到 50 个 NPC 上,性能就会急剧下降。优化思路:降低感知检测频率,比如每 0.2 秒检测一次,而不是每帧检测。使用Physics.OverlapSphereNonAlloc批量获取周围对象,再逐个判断距离和角度。对远处的 AI 使用 LOD 简化,距离远的 AI 不检测视野,只检测听觉。6.3 状态机扩展性:考虑用行为树替代示例中的switch状态机适合状态少、逻辑简单的场景。如果敌人需要结合“逃跑、呼叫同伴、掩体切换、投掷物”等复杂行为,建议学习行为树(Behavior Tree)。Unity 自带的 NavMesh 组件 + 行为树插件(如 Behavior Designer)是目前商业项目常用的组合。6.4 安全与合规:不要设计“必死”陷阱在开发游戏 AI 时,有一个设计原则需要重视:玩家永远有可用的应对策略。如果敌人视距过长、听觉过灵敏,玩家就会陷入“必死”循环。顽皮狗的关卡设计就很注重“试探性失败”,让玩家逐渐摸清 AI 规则后完成反制。在做 Demo 时也要注意,把潜行作为一种正反馈机制来设计,而不是把潜行做成惩罚机制。7. 总结与学习路线这篇文章围绕着顽皮狗《最后生还者》项目开发背后的技术核心,拆解了叙事驱动游戏中常见的 AI 感知、状态机、潜行和同伴跟随系统。我们用 Unity 实现了一个简化版 Demo,核心代码包括:EnemyPerception:视觉角度检测 + 听觉噪声检测 + 遮挡判断。EnemyStateMachine:巡逻 / 警觉 / 追踪 / 攻击四种状态。PlayerNoiseEmitter:根据移动状态生成不同噪声范围。CompanionFollow:简单有效的同伴跟随逻辑。如果你能把这套逻辑完整跑通,下一步可以这样进阶:学习行为树,把状态机替换成可视化行为树。增加掩体切换,让 AI 在受到攻击时主动寻找掩体。加入群体 AI 通信,让附近敌人响应同伴的警报。结合动画状态机,让 AI 行为与最终表现完全匹配。顽皮狗能够同时推进多个《最后生还者》项目,背后是他们积累多年的 AI 行为设计能力与技术管线。我们虽然没法一夜之间复制他们的完整方案,但从感知、决策、执行这个分层模型开始,一步一步搭建属于你自己的 AI 体系,这条路是每个游戏开发者都能走的。如果有不清楚的地方,欢迎在评论区留言交流,也可以把你跑通 Demo 的截图发出来,大家一起讨论优化。