Unity第一人称射击源码解析:场景管理、角色控制与敌人AI实现

发布时间:2026/9/14 4:18:49
Unity第一人称射击源码解析:场景管理、角色控制与敌人AI实现
简介这款基于Unity开发的第一人称射击游戏提供了完整的可运行工程与源码适合作为毕业设计或期末大作业的参考项目。游戏内置海岛与沙漠两套地图支持鼠标右键开镜、ASDW移动、敌人动态移动与子弹运动轨迹显示主菜单包含开始游戏、游戏说明、退出三个按钮并配有音效交互流程完整。资源包共148个文件主要为63个DLL运行库、47个XML配置、项目管理与场景资源assets、unitypackage、程序集与可执行文件exe等整体约127.9MB附带运行截图与说明文档便于快速预览与部署。目前已有2016人学习/下载适合需要快速上手Unity FPS项目结构、研究敌人AI与射击反馈机制的开发者参考。1. 这套 Unity 第一人称射击源码比新建空项目起步快在哪很多同学拿到一个 Unity 第一人称射击模板第一反应就是打开场景按 Play 看能不能打枪。但这套资源的价值不在“能打”这三个字而在于它把两个完整地图、菜单流程、敌人移动、音效和子弹弹道串成了一条闭环链路。对要交课程设计或毕业设计的人来说最划算的方式不是急着换模型而是带着“这东西怎么被搭出来的”去拆解。这套基于 Unity 的枪战小项目有海岛和沙漠两个可玩动画场景进入菜单后有开始游戏、游戏说明、退出三个入口角色用 WASD 移动鼠标右键开镜左键射击能看到子弹运动轨迹敌人会在地图内持续移动并且带环境与射击音效。它自带完整源码和一个已经导出的 exe 文件代码全都留在本地工程里你改哪里运行效果就跟着变。下面我从 Scene 管理开始把它拆成能改、能答辩的模块。2. 菜单到双地图的加载Scene 管理是这套工程的最外层状态机对没有整理过的 Unity 工程来说最头疼的不是玩法代码而是 Scene 与 Scene 之间怎么切换。这套项目的运行顺序很明确先看到菜单场景再进入海岛或沙漠地图。它把两个地图拆成了独立 Scene而不是把大世界拼在一张地图里。这样拆分带来的直接好处有两个第一打包时两个地图可以分开做资源剔除第二Lighting 不会被一个场景里的大量地形拖到重新烘焙。2.1 场景列表与 Build Settings 的对应关系在 Unity 中一个 Scene 能不能被SceneManager.LoadScene加载取决于它有没有被放到 File Build Settings 的 Scenes In Build 列表中。这套工程里存在三个可切换场景我整理成表格更好理解Scene 名称场景内容启动方式MainMenu背景、标题、开始游戏/游戏说明/退出按钮默认启动场景IslandMap海岛地图玩家出生点、敌人巡逻点、环境音源主菜单“开始游戏”进入DesertMap沙漠地图地图结构与交互逻辑类似海岛可换成默认关卡或做地图选择Build Settings 中的场景排列顺序会直接影响索引。我一般不建议用SceneManager.LoadScene(0)这种索引载入因为谁在列表第一个是很容易被调整的。更稳定的做法是直接按名字加载比如LoadScene(IslandMap)这样做即使以后在列表里插入一个加载场景代码也不会立刻跑飞。还要注意场景名必须和.unity文件名一致。很多同学把IslandMap重命名为Island后忘记同步脚本运行时就报SceneNotFoundException。拿到的资源里如果出现这个报错第一步永远是看 Build Settings 列表而不是改脚本。2.2 菜单按钮的绑定与参数设置菜单页面是 UI Canvas Button 实现的三个按钮对应三个事件StartGame、ShowHelp、QuitGame。下面是挂在 Canvas 根节点上的主菜单控制脚本精简版using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.UI; public class MainMenuController : MonoBehaviour { public GameObject helpPanel; // 游戏说明面板默认隐藏 public Button startButton; public Button helpButton; public Button quitButton; void Start() { // 把按钮点击事件绑定到方法上避免在 Inspector 里手动拖事件 startButton.onClick.AddListener(StartGame); helpButton.onClick.AddListener(ToggleHelpPanel); quitButton.onClick.AddListener(QuitGame); } public void StartGame() { SceneManager.LoadScene(IslandMap); } public void ToggleHelpPanel() { helpPanel.SetActive(!helpPanel.activeSelf); } public void QuitGame() { Application.Quit(); } }这段代码里的参数有三个地方值得说明。SceneManager.LoadScene(IslandMap)的字符串必须与 Build Settings 里的场景名完全一致大小写敏感。Application.Quit()在 Unity 编辑器里不会退出它只在导出的 exe 里生效测试时容易误判成按钮没绑上。ToggleHelpPanel用SetActive(!helpPanel.activeSelf)做翻转第一次点击是显示第二次点击是隐藏不需要单独维护一个布尔变量。如果你想让“开始游戏”可选进沙漠地图可以给按钮额外挂一个Dropdown或复制一个StartDesertGame方法去切换SceneManager.LoadScene(DesertMap)。这个资源本身没有做地图选择页但基于现有 UI 扩展非常方便。还有一个小技巧与 Unity 按钮点击热区有关如果导出 exe 后按钮区域太小点不准不用改文本字号直接在按钮节点下加一个透明 Image 并开启Raycast Target因为 Unity 的 Button 默认响应范围取决于绑定的 Image 矩形而不是子物体文字大小。这是调整点击热区的常见做法课程设计答辩时提一嘴会显得你更懂 UI 性能边界。3. 第一人称控制移动、开镜瞄准和子弹弹道进入游戏后玩家最先感受到的就是移动和射击手感的同步。这套工程里第一人称移动用的是CharacterController组件而不是Rigidbody物理体。原因很简单CharacterController是纯运动学封装不参与复杂物理模拟处理推墙、上下坡和碰撞边缘时行为更稳定对作业项目来说代码也可控得多。3.1 基于 CharacterController 的移动与鼠标视角FPS 控制脚本一般由两部分组成鼠标输入旋转视角键盘输入驱动角色位移。旋转时要把鼠标的Mouse X作用到角色 y 轴把Mouse Y作用到摄像机 x 轴彼此不能绑反。下面这段脚本是我在这种场景下最常用的结构using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed 5.0f; public float mouseSensitivity 2.0f; private CharacterController controller; private Transform cameraTransform; private float xRotation 0f; void Start() { controller GetComponentCharacterController(); cameraTransform Camera.main.transform; Cursor.lockState CursorLockMode.Locked; } void Update() { float mouseX Input.GetAxis(Mouse X) * mouseSensitivity; transform.Rotate(Vector3.up * mouseX); float mouseY Input.GetAxis(Mouse Y) * mouseSensitivity; xRotation - mouseY; xRotation Mathf.Clamp(xRotation, -80f, 80f); cameraTransform.localRotation Quaternion.Euler(xRotation, 0f, 0f); float x Input.GetAxis(Horizontal); float z Input.GetAxis(Vertical); Vector3 move transform.right * x transform.forward * z; controller.Move(move * moveSpeed * Time.deltaTime); } }这段代码里的moveSpeed和mouseSensitivity是你在 Inspector 里最常调的两个值。在海岛地图的岩石地形上moveSpeed不要超过8f否则CharacterController下坡时因步长不够会抖动默认5f跑动和走路有区分度。mouseSensitivity用2f左右在 1080p 下比较顺手如果追求可配置可以把它存到PlayerPrefs再在暂停菜单里暴露一个滑动条。xRotation的 Clamp 区间-80f到80f是必要的。如果你改成90f垂直视角看向地面时摄像机会穿过角色模型甚至被近裁剪面切进墙体。这里有一个很容易忽略的点CharacterController默认不施加重力只有调用Move时才会移动。假如你发现角色走到斜坡边缘会悬空需要在Update里补一段重力处理if (!controller.isGrounded) { controller.Move(Physics.gravity * Time.deltaTime); }3.2 右键开镜的实现方式摘要里特别提到“鼠标右击开镜”。这个功能在这套项目里不是靠动画剪辑而是靠改变主摄像机的fieldOfView和可能附加的局部偏移模拟的。这种做法的好处是代码量小、不依赖额外动画资源很适合拿来做毕设演示。public class AimDownSight : MonoBehaviour { public Camera fpsCamera; public float adsFov 45f; public float normalFov 60f; public float aimSpeed 10f; void Update() { if (Input.GetMouseButton(1)) { fpsCamera.fieldOfView Mathf.Lerp(fpsCamera.fieldOfView, adsFov, aimSpeed * Time.deltaTime); } else { fpsCamera.fieldOfView Mathf.Lerp(fpsCamera.fieldOfView, normalFov, aimSpeed * Time.deltaTime); } } }adsFov的值决定开镜放大的程度。一般 FPS 中60 是默认视角45 是轻度瞄准20 是狙击镜级别。但你用 20 时屏幕边缘会有明显拉伸需要配合遮罩或贴图覆盖两侧否则视觉上很怪。下面的表可以让你直观感受操作FOV 值效果正常视角60接近人眼视野场景边缘无明显畸变开镜瞄准45轻微放大适合步枪或冲锋枪狙击镜20放大明显但四周畸变强必须加遮罩注意开镜只影响摄像机参数不应影响子弹方向。子弹要从摄像机中心发射不能从角色模型 timerode 发射。如果你看到一个项目开镜后子弹乱飘十有八九是把发射方向写成了transform.forward而不是摄像机的朝向后通过变换得到的屏幕中心射线。3.3 子弹运动路径与射线检测“开枪后可以看到子弹运动路径”是这套项目比较出彩的部分实现方式是Raycast LineRenderer/TrailRenderer。它的好处是命中判定是瞬间的射线视觉上的子弹轨迹则是独立的图像组件二者互不干扰。public class GunShoot : MonoBehaviour { public Camera playerCamera; public Transform firePoint; public LineRenderer bulletLine; public float fireRange 100f; public float lineFadeTime 0.08f; void Update() { if (Input.GetMouseButtonDown(0)) { Shoot(); } } void Shoot() { Ray ray new Ray(playerCamera.transform.position, playerCamera.transform.forward); RaycastHit hit; Vector3 endPoint firePoint.position playerCamera.transform.forward * fireRange; if (Physics.Raycast(ray, out hit, fireRange)) { endPoint hit.point; IDamageable target hit.collider.GetComponentIDamageable(); if (target ! null) { target.TakeDamage(20f); } } // LineRenderer 的两点连线配合协程做短暂显示模拟子弹拖尾 bulletLine.SetPosition(0, firePoint.position); bulletLine.SetPosition(1, endPoint); StartCoroutine(ShowBulletTrail()); } IEnumerator ShowBulletTrail() { bulletLine.enabled true; yield return new WaitForSeconds(lineFadeTime); bulletLine.enabled false; } }这段代码要看懂两个位置。第一Ray的起点是playerCamera.transform.position射线方向是playerCamera.transform.forward这样打中的点就是屏幕正中央看到的点。视觉弹道的起点放在firePoint枪口模型位置两者的偏差正好被LineRenderer的插值掩盖看起来更像真实枪口出膛。第二IDamageable是约定接口不是 Unity 内置类型。敌人脚本必须实现TakeDamage方法这里才能通过GetComponentIDamageable()拿到伤害目标。如果敌人没有实现这个接口命中了也只会显示弹道而不会掉血这就是很多 FPS 新手调试时遇到的“打中了但没反应”。如果你觉得LineRenderer表现太简单可以把它替换成一段短小的TrailRenderer子物体在每帧生成一个移动到终点后销毁的子弹特效。但要注意频繁 Instantiate 和 Destroy 会产生 GC 压力后面第 5 章会讲怎么用对象池优化这里先不展开。4. 可移动敌人 AINavMesh 路径与音效触发的边界条件这套项目的敌人不是固定靶而是会在地图内持续移动实现方式绕不开 Unity 的 AI 导航系统。最稳的方案是NavMeshAgent NavMesh Bake先在 Window AI Navigation 窗口里把地图可行走区域烘焙成 NavMesh敌人就能自动避障不需要自己写寻路算法。烘焙时要注意区别两种地图海岛的岩石高度落差大沙漠的地形起伏平缓两种场景的有效面不能混用否则敌人会卡在低矮障碍物上。4.1 NavMeshAgent 的参数和巡逻逻辑给敌人挂上NavMeshAgent后需要针对巡逻模式设置speed、angularSpeed、stoppingDistance等参数。下面是一个常见的两点巡逻脚本using UnityEngine; using UnityEngine.AI; public class EnemyPatrol : MonoBehaviour { public Transform[] patrolPoints; public float moveSpeed 2f; private NavMeshAgent agent; private int currentIndex 0; void Start() { agent GetComponentNavMeshAgent(); agent.speed moveSpeed; agent.stoppingDistance 0.2f; MoveToNextPoint(); } void Update() { // 只有路径计算完成后remainingDistance 才是可靠数值 if (!agent.pathPending agent.remainingDistance agent.stoppingDistance) { MoveToNextPoint(); } } void MoveToNextPoint() { if (patrolPoints.Length 0) return; currentIndex (currentIndex 1) % patrolPoints.Length; agent.SetDestination(patrolPoints[currentIndex].position); } }NavMeshAgent的remainingDistance在路径尚未计算完成时可能短暂返回 0。如果代码里没有!agent.pathPending这层判断敌人会以为第一段路已经走完立刻瞬移去下一个巡逻点。这就是你看到敌人在两个点之间“闪现”的原因。把stoppingDistance设成 0.2f 是合理的做法设成 0 会让 NPC 在目标点附近不断尝试靠近出现反复推挤的抖动。如果你想让敌人在靠近玩家后主动追击可以再给敌人挂一个检测距离判断比如每帧计算玩家和敌人的距离public float triggerDistance 10f; void Update() { Transform player GameObject.FindGameObjectWithTag(Player).transform; float dist Vector3.Distance(transform.position, player.position); if (dist triggerDistance) { agent.SetDestination(player.position); } else { MoveToNextPoint(); } }这样写在小场景里没问题但注意GameObject.FindGameObjectWithTag(Player)每帧调用在高数量 NPC 下不高效。这套源码的敌人数量不多所以能跑得动如果你要扩展成 50 个敌人一定要在Start()里缓存玩家Transform引用或者用事件驱动而不是每帧搜索。4.2 音效播放时机与切换条件“游戏有音效”不是往场景里拖一个AudioSource就完事音效触发点必须紧贴行为。这套资源的枪声应该放在GunShoot.Shoot()里环境音放在主 Camera 或地图根节点UI 按钮音则挂到Button组件对应的 Image 下。下面是一个我用过的整理表行为播放方式关键参数枪声AudioSource.PlayClipAtPoint(gunshot, firePoint.position)volume 0.6pitch 1.0UI 按钮点击Button 上挂独立 AudioSourceplayOnAwake false敌人移动脚步3D 声源挂在敌人身上间隔调用volume 0.2minDistance 3环境风声音效地图根节点的 AudioSource 循环loop truespatialBlend 1如果你发现枪声和射击动作有明显延迟多半是音频文件格式和解码的问题。建议把枪声导出成 44100Hz、16bit 的 WAV 或 128kbps 的 OGG在 Inspector 里勾选Preload Audio Data让它在场景加载时就进入内存而不是开枪瞬间才解码。对 FPS 项目还有一点容易被忽略开镜时玩家往往希望场景背景声稍微“闷”一点但又不想停止播放。不要在AimDownSight里销毁AudioListener改对AudioMixer做低频衰减更符合常规做法。不过课程设计能实现“开枪有声音菜单有背景音”已经足够更细的音频层次可以放到进阶部分。5. 打包验证阶段阴影距离、鼠标锁和 Unity 安装模块下载这套源码后你可能会遇到第一个门槛Unity 版本不对工程打不开。先看ProjectSettings/ProjectVersion.txt里记录的版本号再确认本机安装的 Unity 主版本是否一致。相差一个主版本时可能出现资源包加载失败或脚本错误。如果还没安装对应版本在 Unity Hub 里勾选该版本和Windows Build SupportMono模块再重新打开工程否则 File Build Settings 里连 Windows 平台都不会出现。地图场景都正常跑起来后我会先检查阴影质量。这套项目的海岛和沙漠场景包含大量地形与岩石如果全部走实时阴影导出 exe 后帧率会明显起伏。常见做法是打开 Window Rendering Lighting 设置把 Shadow Distance 调整到 40~60再对静态物体勾选Contribute GI然后重新烘焙光照贴图。静态地形用烘焙移动敌人继续走实时阴影这两者并行是 FPS 优化里性价比最高的做法。还有一个容易被答辩老师抓到的细节鼠标焦点。源码里Cursor.lockState往往只在Start()中赋值玩家把窗口切走再切回来鼠标光标会从画面中央弹出来导致视角失灵。补一段事件代码可以解决void OnApplicationFocus(bool hasFocus) { if (hasFocus) { Cursor.lockState CursorLockMode.Locked; Cursor.visible false; } }OnApplicationFocus是 Unity 的 MonoBehaviour 回调hasFocus会由引擎自动传入。切回游戏窗口时重新锁定鼠标切换出去的瞬间不处理。注意这个方法在编辑器里有时会因 Play Mode 初始化而被触发所以最终验证要在导出的 exe 里做。如果你需要把项目分发给实验室统一装机可以直接把 exe 和同目录下的_Data文件夹整个拷贝到目标机器。想进一步把程序转成 msi 安装包就要保证转换工具能保留完整文件结构不能只转单文件。Unity 游戏的资源文件分散在_Data目录里少一个resources.assets都可能启动黑屏所以相比折腾转 msi我更建议直接用绿色目录拷给老师运行。最后再回头验证一次整条“开镜—射击—伤害”链路打开海岛或沙漠场景让一个敌人在巡逻范围内移动开镜后朝敌人身体射击看三件事。第一LineRenderer是否显示出一条从枪口到击中点的直线第二敌人是否掉血或播放对应反馈第三命中墙面时弹道是否依然穿过模型。如果穿墙了回到GunShoot检查射线是否从Camera.main.transform.position发射而不是从角色根节点发射。只要这三步验证通过这套 Unity 第一人称射击源码就算真正拿到了能答辩、能扩展的地基。本文还有配套的精品资源点击获取