资讯动态

Unity FPS射击游戏源码拆解:角色控制、射线检测与AI设计

发布时间:2026/10/17 21:25:49 来源:尧图企业网站定制
简介这是一份基于Unity3D开发的初级射击游戏完整项目定位为Unity3D/Unity5.0入门级小游戏适合有意上手游戏开发的初学者用来理解射击类游戏的整体制作流程。项目用C#脚本实现了射击数与消灭数的计数逻辑、WASD角色移动、QE视角切换、ZC与HN特殊动作等键盘输入响应子弹发射时通过粒子系统呈现火花与烟雾并配合音频管理播放对应音效涵盖了脚本编程、事件驱动、粒子效果与音频控制等核心知识点。同时场景中的地形、敌人与道具通过Transform组件组织结合光照与材质调节提升视觉表现便于学习者观察一个简易3D场景的搭建思路。压缩包共28个文件以dll动态库、config配置文件、assets资源文件、exe启动程序等为主整体33.73MB轻量易部署目前已有1584人浏览学习适合Unity初学者对照运行效果逐项拆解调试。1. 一份 Unity 射击小游戏源码包先搞清它替你做了什么解压这个 unity射击游戏.zip你看到的是一套完整的 FPS 小游戏工程骨架场景、角色控制器、敌人 AI、枪支预制体、UI 血条和音频管理全部串了起来。很多同学拿到这类资源第一反应是去找「开枪那几行代码」但真正值得拆的是开枪之外那套反馈网络——从准星命中、敌人掉血到死亡特效每一步都有对应的脚本和预制体在协作。这份 zip 适合两类人一类是刚接触 Unity3D、想完整跑通一个射击小游戏全流程的开发者另一类是手头有项目需要快速复用角色控制、射击判定和敌人 AI 模块的从业者。用 Unity 5.x 及以上的版本都能打开改造空间很大下面我从头到尾拆一遍包括参数怎么调、哪些地方最容易翻车。2. 角色控制与相机把 FPS 手感的底座先立住射击游戏的「射击」是后续所有内容的地基但地基之下还有两层角色移动和相机控制。这两层没做好后面枪做得再漂亮玩家转两圈就开始头晕。这套工程里角色控制用的方案很典型值得先摸透。2.1 CharacterController 还是 Rigidbody这份资源里怎么选小游戏里的玩家角色绝大部分用 CharacterController 而不是 Rigidbody。原因很直接CharacterController 是 Unity 引擎专门为「人形角色」准备的碰撞器它自己处理重力、碰撞和坡度不需要你写额外的物理约束代码而 Rigidbody 更适合汽车、箱子、被爆炸击飞的物体这类需要真实物理交互的对象。FPS 小游戏里角色需要的是「移动灵敏、不易被卡住、不会因为物理引擎的惯性而发飘」CharacterController 天然满足这些。这份工程里玩家控制脚本的结构大致是这样的public class PlayerController : MonoBehaviour { public float moveSpeed 6f; // 移动速度单位米/秒 public float jumpHeight 1.2f; public CharacterController controller; private Vector3 velocity; private float gravity -9.81f; void Update() { float x Input.GetAxis(Horizontal); float z Input.GetAxis(Vertical); // 把输入方向从世界坐标转到玩家朝向 Vector3 move transform.right * x transform.forward * z; controller.Move(move * moveSpeed * Time.deltaTime); // 模拟重力角色下落 if (controller.isGrounded velocity.y 0) velocity.y -2f; velocity.y gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }controller.Move()每次调用都会让角色位移位移量乘了Time.deltaTime保证不同帧率下速度一致。注意transform.right和transform.forward组合出来的移动方向是以角色当前朝向为基准的这样鼠标转视角后按 W 永远是朝屏幕前方走。如果你把这段代码改成 Rigidbody 版本需要额外处理刚体的速度向量物理引擎还会在角色碰到其他碰撞体时产生不可控的反弹手感调试成本明显更高。2.2 主相机跟随直接赋值位置会导致抖动这是第一个坑FPS 相机跟随的常见做法有两种一种是相机作为角色的子物体位置固定死在头部高度另一种是相机独立存在通过脚本来平滑跟随。这套工程用的是第二种并且用了SmoothDamp做平滑处理代码风格大致如下public class CameraFollow : MonoBehaviour { public Transform target; // 要跟随的角色 public Vector3 offset new Vector3(0f, 1.8f, 0f); public float smoothTime 0.15f; // 平滑时长越小跟得越紧 private Vector3 velocity Vector3.zero; void LateUpdate() { Vector3 desiredPos target.position offset; // SmoothDamp 会把位置变化做低通滤波避免帧率波动导致的抖动 transform.position Vector3.SmoothDamp(transform.position, desiredPos, ref velocity, smoothTime); transform.LookAt(target.position Vector3.up * 1.5f); } }这里有两个关键点。第一相机跟随逻辑放在LateUpdate()而不是Update()因为角色的移动在 Update 中完成LateUpdate 保证相机拿到的是角色本帧移动结束后的位置否则会偶尔出现「相机短暂落后半帧」的拉扯感。第二smoothTime这个参数如果设成 0相机完全刚性跟随角色一碰墙壁相机就开始剧烈抖动设成 0.25 以上转身时画面会明显滞后容易头晕。我一般调这个参数从 0.12 起步每 0.02 一档在快速转身场景下测试找到不抖不拖的最低值。2.3 抬头/低头角度限制不做 Clamp 就会「头翻到背后」鼠标控制视角的代码在多数 Unity 小游戏资源里都是标准写法但限制范围这一段值得单独说。如果你把俯仰角Pitch直接赋值而不做限制会出现一个经典翻车现象玩家往上看视角翻到头顶后再继续上移画面整个倒转。限制代码一般长这样public class MouseLook : MonoBehaviour { public float sensitivity 2.5f; // 鼠标灵敏度 public float pitchLimit 80f; // 抬头/低头最大角度 private float pitch 0f; void Update() { float mouseX Input.GetAxis(Mouse X) * sensitivity; float mouseY Input.GetAxis(Mouse Y) * sensitivity; pitch - mouseY; // 鼠标上移时视角应向上注意取反 pitch Mathf.Clamp(pitch, -pitchLimit, pitchLimit); transform.localRotation Quaternion.Euler(pitch, 0f, 0f); // 水平旋转由玩家身体的 Y 轴旋转完成 transform.parent.Rotate(Vector3.up * mouseX); } }pitchLimit设置成 80° 而不是 90°原因是到了 90° 时Quaternion.Euler在边界附近会出现万向锁问题而且玩家垂直看向正上方时视野里几乎全是天空对游戏没有任何帮助。80° 在实践中是一个「够用且不晕」的偏保守值如果你做的是塔防类往下看的视角可以把负角度放到 -50°抬头限制到 30°具体根据摄像机朝向和玩法调整。注意这段代码我没有乘Time.deltaTime是因为Input.GetAxis(Mouse X)返回的已经是「本帧相对于上一帧的偏移量」再乘 deltaTime 反而会导致灵敏度随帧率变化后面避坑章节会展开说。3. 射击系统拆解射速、射线与子弹命中反馈的取舍射击是这套工程的核心模块。开枪的瞬间程序要做的事远比「生成一个子弹物体」多播放枪口火光、播放音效、判断子弹是否命中、计算伤害、通知敌人播放受击动画、刷新 UI 血条。这一整套流程的起点是攻击判定而攻击判定方案决定了整个射击系统的结构。3.1 射线检测 vs 子弹预制体小游戏为什么几乎都选射线射击游戏的命中判定有两种主流方案射线检测Raycast和物理子弹预制体。物理子弹预制体是「生成一个高速移动的刚体让它靠碰撞触发伤害」非常真实弹道还能受到重力影响射线检测则是「从枪口瞬间发射一条数学上的射线命中谁立刻出结果」没有飞行过程也没有弹道。包括这份资源在内的绝大多数 Unity 射击小游戏都采用射线检测原因很务实小游戏场景不大射程通常在 50~150 米物理子弹在这种距离下飞行只需零点几秒肉眼几乎看不出区别但物理子弹要额外处理穿透、反弹、与场景碰撞体的高频碰撞检测性能开销大得多。更重要的是射线检测的命中判定是同步的——开火的同一帧就能拿到命中结果非常适合接伤害、音效、UI 反馈。核心射击逻辑的典型实现如下public class RaycastGun : MonoBehaviour { public Transform shootPoint; // 枪口位置射线从这里出发 public float range 100f; // 射程 public float damage 30f; // 单发伤害 public float fireInterval 0.12f; // 两枪间隔控制射速 public LayerMask hitMask ~0; // 需要命中的层默认全部 private float lastFireTime -999f; public void TryFire() { if (Time.time - lastFireTime fireInterval) return; lastFireTime Time.time; // 从枪口沿前方向发射射线 Ray ray new Ray(shootPoint.position, shootPoint.forward); RaycastHit hit; if (Physics.Raycast(ray, out hit, range, hitMask, QueryTriggerInteraction.Ignore)) { // 命中后找到受击对象身上的 Health 组件 Health health hit.collider.GetComponentInParentHealth(); if (health ! null) health.TakeDamage(damage); } } }这段代码的关键不在Physics.Raycast本身而在hitMask和QueryTriggerInteraction.Ignore这两个参数。hitMask用来排除不该被子弹命中的物体——比如玩家自己的碰撞体、以及某些只做触发器用的区域QueryTriggerInteraction.Ignore确保射线不会打中仅用于触发事件的碰撞体比如安全区域、出生点区域否则会出现子弹打在空气墙上、敌人不掉血的错觉。fireInterval参数直接控制了武器的 DPS每秒伤害 60 / fireInterval × 单发伤害调数值时两个变量要一起算。3.2 射击参数射速、散布、后坐力怎么配不会「玄学」很多新手调射击手感时是「改了数值跑一遍感觉不对劲再改」纯靠体感所以我直接把这类资源里最常出现的一组参数整理成表方便你对照工程里的字段去理解参数名称常见取值作用说明fireInterval0.08 ~ 0.2两枪间隔秒越小射速越快damage20 ~ 50单发伤害配合敌人最大血量决定击杀时间spreadAngle0 ~ 2子弹散布角大于 0 时射线方向加入随机扰动recoilRecovery0.3 ~ 0.8后坐力恢复时间影响连续射击时准星回正速度range50 ~ 150最大射程超过该距离射线直接失效关于散布角的实现常见做法是在发射瞬间给射线方向叠加一个随机偏移Vector3 spreadDir shootPoint.forward; if (spreadAngle 0f) { // 生成一个锥形范围内的随机方向 Vector3 randomOffset Random.insideUnitSphere * Mathf.Tan(spreadAngle * Mathf.Deg2Rad); spreadDir (shootPoint.forward randomOffset).normalized; } Ray ray new Ray(shootPoint.position, spreadDir);这里物理上的逻辑是Random.insideUnitSphere生成一个单位球内的随机向量乘以Mathf.Tan(spreadAngle)后叠加在原方向尾部再 normalized 得到一个偏离原方向一定角度的随机射线方向。角度变大时散布范围不是线性增长而是越来越快——所以调参时从 0.5° 起步每次加 0.1° 去试别一上来就设 5°。后坐力的手感则有两条路一条是「视角后坐」开火瞬间把相机 pitch 往上顶一点再慢慢恢复另一条是「准星扩散」开火后增大 spreadAngle 的值停火后恢复。小游戏资源里第一条更常见因为实现简单且反馈强烈。回正速度不要调得太快否则连续射击时后坐力完全没感觉也不要太慢否则玩家会觉得压不住枪。3.3 伤害与命中反馈的数据流从射线命中到敌人死亡这条数据流在工程里的走向是固定的RaycastGun通过GetComponentInParentHealth()找到受击对象身上的Health组件调用TakeDamage(damage)。Health组件内部做三件事扣减当前血量、判死亡、广播事件。典型实现public class Health : MonoBehaviour { public float maxHealth 100f; public float currentHealth; // 死亡事件其他脚本动画、UI、掉落系统监听 public System.Action onDeath; void Start() { currentHealth maxHealth; } public void TakeDamage(float amount) { if (currentHealth 0f) return; // 已死亡不再重复扣血 currentHealth - amount; // 触发受击反馈 GetComponentAnimator()?.SetTrigger(Hit); AudioSource.PlayClipAtPoint(hurtSound, transform.position); if (currentHealth 0f) { onDeath?.Invoke(); Destroy(gameObject, 0.5f); // 延迟销毁让死亡动画播完 } } }这个设计值得记下来的点是把伤害计算和表现逻辑解耦了。任何来源的伤害——子弹、爆炸、掉落——都只需要拿一个Health引用并调用TakeDamage至于受击动画、音效、死亡后的消失方式全部由Health内部处理。如果你在改造这个工程时要加新的敌人类型唯一要保证的就是你的新敌人身上有一个Health组件其他都不用动。currentHealth初始化的位置在Start()而不是Awake()因为在Awake()阶段所有物体的组件可能还没准备好如果在Awake()里maxHealth还没有被其他脚本设置过就会用默认值覆盖。如果你的资源包里敌人血量配置在 Inspector 上初始化放在Start()是正确习惯。4. 敌人 AI 与状态融合巡逻、追击、攻击之间的切换逻辑敌人模块是这套工程里信息密度最高的部分。一个合格的敌人至少要有三种行为模式日常巡逻、发现玩家后追击、进入攻击范围后开火。这三者之间的切换逻辑决定了敌人是「活」的还是「木桩」。4.1 敌人状态机的四态设计与切换条件我见过很多 Unity 小游戏的敌人脚本把逻辑写成一长串if/else包在 Update 里从头判断到尾代码到后面根本没法维护。这套工程里用的是典型的枚举状态机结构清晰改造空间也大public enum EnemyState { Patrol, // 巡逻 Alert, // 警戒听到声音或受伤后的短暂搜索 Chase, // 追击 Attack // 攻击 } public class EnemyController : MonoBehaviour { public EnemyState state EnemyState.Patrol; public Transform player; public float chaseRange 15f; // 发现玩家的距离 public float attackRange 5f; // 开火距离 public float fieldOfView 60f; // 视野角度 private NavMeshAgent agent; void Update() { switch (state) { case EnemyState.Patrol: PatrolUpdate(); break; case EnemyState.Chase: ChaseUpdate(); break; case EnemyState.Attack: AttackUpdate(); break; } } void PatrolUpdate() { // 未发现玩家时沿巡逻点移动 if (CanSeePlayer()) { state EnemyState.Chase; agent.isStopped false; } } }状态机的核心逻辑是「每个状态在它的 Update 方法里负责判断是否满足切换条件」。Patrol状态只关心「是否看到玩家」Chase状态只关心「是否离玩家足够近」这样每个状态方法体只有 20 到 30 行。切换条件值得注意的点是优先级追击状态每帧都要判断Distance attackRange是否成立一旦成立立刻切到Attack而Attack状态里每帧发射子弹后要反方向判断距离是否拉大到了chaseRange以上如果是就退回Chase。忽略这个反向判断是常见错误会导致敌人追到离玩家很近后开始原地抽搐。4.2 NavMesh 导航敌人卡住的常见原因与烘焙边界敌人移动依赖 Unity 的 NavMesh 导航系统。这套工程里敌人的移动脚本逻辑很简单NavMeshAgent.SetDestination(player.position)但真正决定移动效果的是一系列NavMeshAgent组件的参数。我每次拿到这类资源第一件事是检查NavMeshAgent的三个参数Speed、Stopping Distance和Auto Braking。Speed决定敌人的追击速度一般设置为玩家移动速度的 0.8 倍否则玩家永远跑不掉Stopping Distance是敌人停下开火的距离设置成等于attackRange的一半左右最自然Auto Braking一定要保持开启否则敌人到达目标点后不会减速会滑出很长一段距离再回头。NavMesh 的另一个大坑是场景烘焙。如果你打开工程发现敌人站在原地不动控制台也没有报错统计信息里显示「NavMesh not baked」说明场景里的地面没有烘焙导航网格。在旧版 Unity 中你需要把所有地面、斜坡、障碍物选中在 Inspector 的 Static 下拉菜单里勾选Navigation Static然后打开 Window - AI - Navigation点 Generate 重新烘焙。地板、墙壁这类永不变形的物体勾选静态没问题但不要给敌人预制体勾选 NavMesh Static否则运行时生成的新敌人不会被寻路网格识别。4.3 视野检测距离 夹角 遮挡三层判定敌人「看到」玩家的完整判定逻辑比大多数人以为的要严格。它需要同时满足三个条件玩家在最大发现距离内、玩家在敌人前方的锥形视野范围内、玩家与敌人之间没有墙面遮挡。三缺一都不能发现玩家这也是这套工程里做得比较扎实的地方bool CanSeePlayer() { Vector3 toPlayer player.position - transform.position; float distance toPlayer.magnitude; // 第一层距离判定 if (distance chaseRange) return false; // 第二层夹角判定判断玩家是否在视野锥形内 float angle Vector3.Angle(transform.forward, toPlayer.normalized); if (angle fieldOfView / 2f) return false; // 第三层遮挡判定射线必须无阻挡命中玩家 RaycastHit hit; if (Physics.Raycast(transform.position, toPlayer.normalized, out hit, distance)) { if (!hit.collider.CompareTag(Player)) return false; } return true; }第三层遮挡判定用的是射线检测射线只要碰到了任何非玩家物体就返回 false。这里有个隐藏细节Physics.Raycast的distance参数传的是玩家距离如果中间隔了一堵墙射线会先击中墙并返回 true 且 hit 不是玩家从而正确判定为不可见。但这个方案在敌人和玩家都在移动时每帧都要发一条射线数量多起来性能会有压力该资源中常见的优化是每 0.2 秒检测一次视野而不是每帧检测肉眼完全看不出区别。如果想让敌人对声音有反应——听到枪声后进入Alert状态往声源方向查看——可以在枪的开火方法里广播一个事件敌人监听后把当前Patrol状态切换为Alert。这是很多游戏里的标准做法也让「偷袭敌人」成为可能改造量不大。5. 避坑排查Unity 5.x 项目打开后的报错与行为异常这份资源带着比较明显的 Unity 5.x 时期工程特征用新版 Unity 打开时几乎没有一次是打开就能跑的。下面这五条是我实际拆解这类工程时踩过、也见得最多的坑每一条都按现象、原因、解决两步走的方式记录。5.1 现象打开工程后控制台连片报错场景里一片空白原因Unity 5.0 时代的一些脚本 API 在后续版本中被标记为过时或直接移除。比如 5.x 里常见的transform.rigidbody、animation.Play()这类快捷访问方式在 Unity 2020 以后的版本全部报编译错误导致整个工程的脚本无法编译场景里的对象全部失去行为逻辑。解决先把报错信息第一条里定位到的脚本文件记下来一般是Find References里被引用次数最多的那几个工具脚本。按Edit - Project Settings - Player - Other Settings找到API Compatibility Level如果不是.NET 4.x先切过去。然后逐个把脚本里已弃用的 API 改成现代写法transform.rigidbody改成GetComponentRigidbody()animation.Play()改成GetComponentAnimator().Play()。改完五六个核心工具脚本后整个工程的编译错误一般能消掉 80%。5.2 现象射击时射线打中了敌人但敌人不掉血原因这是射线检测接入到 UI 层时最常见的翻车点。多数资源包的射线是从Camera.main屏幕中心发出的而相机正前方的物体可能是玩家自己的枪械模型、或者敌人的子碰撞体上没有挂Health。如果枪械模型上有 Collider射线会先碰到枪本身如果敌人的血条 UI 绑在敌人身体上且带 Collider也会拦截射线。解决给玩家枪械模型单独设一个层比如Weapon在RaycastGun的hitMask里排除这个层。另外确认敌人身体的碰撞体层级使用GetComponentInParentHealth()能解决子碰撞体与父级对象不同体的问题但如果敌人身上完全没有任何碰撞体射线永远打不到它。检查敌人预制体是否有CapsuleCollider或BoxCollider没有就加一个。5.3 现象敌人寻路时来回抖动或者原地小碎步原因NavMeshAgent的目标点是玩家脚下但玩家的碰撞体和敌人自身的碰撞体发生了物理重叠。两个角色都带有 Character Controller 或 Collider 时如果NavMeshAgent的Stopping Distance设置小于两个碰撞体半径之和敌人会试图不断向目标点挤近但物理碰撞又把它推回去形成肉眼可见的抖动。解决把NavMeshAgent的Stopping Distance调整为大于敌人CapsuleCollider半径 玩家CapsuleCollider半径 0.3 的白给距离。同时确认敌人身上没有额外挂Rigidbody——NavMeshAgent 驱动角色移动时刚体会产生物理引擎层面的位移干扰两者会打架这是敌人抖动的最常见原因。5.4 现象鼠标灵敏度忽快忽慢快速晃鼠标时画面乱跳原因鼠标视角旋转代码里错误地乘了Time.deltaTime。Input.GetAxis(Mouse X)的值表示「本帧检测到的鼠标位移量」大部分引擎里它已经做过了帧间差异处理你再用Time.deltaTime去乘帧率高时旋转量被缩小帧率低时旋转量被放大同一只鼠标在不同帧率下灵敏度天差地别。快速晃鼠标时因为小帧位移波动画面就跟着乱跳。解决把灵敏度相关的代码改成float mouseX Input.GetAxis(Mouse X) * sensitivity;去掉Time.deltaTime。如果你想让灵敏度与帧率完全无关更严谨的方案是Input.GetAxisRaw(Mouse X)配合自定义累积逻辑但对小游戏来说没必要。顺带检查Time.timeScale是否被改为 0暂停菜单里时间刻度设置为 0 时鼠标视角旋转也会完全失效这是另一个容易误判的地方。5.5 现象场景灯光泛白或一片漆黑角色像被晒变色原因Unity 5.x 的默认着色器是旧版内置管线直接在较新的 Unity 版本中打开旧场景光照探针和反射探针的数据格式不兼容加上烘焙光照图没有重新生成场景就会出现泛白、过曝或者发暗。另一个常见来源是旧版粒子系统和新版系统的贴图通道不兼容特效粒子看起来会像半透明色块。解决打开 Window - Rendering - Lighting 面板在 Scene 标签下点击 Generate Lighting让引擎重新烘焙。烘焙前先确认所有地面、建筑、静态物体都勾选了Static属性。如果烘焙完依然过曝检查场景里有没有叠加了多个方向光两个方向光同时为 1 强度时场景一定过曝。粒子特效显示异常的话把粒子材质里的Shader从旧版Particles/Alpha Blended切换到新版Legacy Shaders/Particles/Alpha Blended能兼容绝大多数旧粒子系统。6. 收尾技巧把射击手感做成可调参数模块最后这条是我自己比较受益的做法也算跟这类资源打交道时养成的一个习惯。很多 Unity 射击小游戏把射击相关参数散落在各个脚本里——射速在RaycastGun的fireInterval、后坐力在相机的MouseLook、伤害在Health的TakeDamage——每调一次都要开三个脚本改完还要担心改的是哪个。通常我會把开火相关的所有手感参数集中到一个类里用[System.Serializable]修饰挂在主武器物体上[System.Serializable] public class GunStats { [Header(射速)] public float fireInterval 0.12f; [Header(伤害)] public float damage 30f; [Header(散布)] public float spreadAngle 0.8f; [Header(后坐力)] public float recoilPitch 1.5f; // 开火时相机上抬的角度 public float recoilRecoverySpeed 4f; // 回正速度 }然后在RaycastGun和相机脚本里各自持有一个GunStats引用所有涉及射击参数的读值都从它那里取。这样你要调手感只需要在 Inspector 上选中武器物体把数值作为一个整体去试而不是改一行代码跑一遍。我一般会按「射速 → 伤害 → 散布 → 后坐力」的顺序调先定击杀时间射速 × 伤害再用散布和后坐力修正控枪难度最后调准星反馈。调后坐力时我踩过一次大坑把recoilPitch一次调大了 3°结果连发时 15 发子弹打完视角已经朝向了天花板玩家完全压不住枪。从那以后我每次调器材都强制自己按小步长走每档不超过 ±0.3°并且把每个参数的实际效果和手绑到一张对照表里去理解效果就明显不一样了。这份工程里的手感项目分布在各个脚本里你在拿到手后可以试着按上面的方式把它们收拢到一起。整套 zip 解压直接用 Unity 打开就能跑熟悉这套结构以后改成自己的项目也会顺畅很多希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑