ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:这是一份基于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.GetComponentInParent<Health>(); 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通过GetComponentInParent<Health>()找到受击对象身上的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; // 触发受击反馈 GetComponent<Animator>()?.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改成GetComponent<Rigidbody>(),animation.Play()改成GetComponent<Animator>().Play()。改完五六个核心工具脚本后,整个工程的编译错误一般能消掉 80%。

5.2 现象:射击时射线打中了敌人,但敌人不掉血

原因:这是射线检测接入到 UI 层时最常见的翻车点。多数资源包的射线是从Camera.main屏幕中心发出的,而相机正前方的物体可能是玩家自己的枪械模型、或者敌人的子碰撞体上没有挂Health。如果枪械模型上有 Collider,射线会先碰到枪本身;如果敌人的血条 UI 绑在敌人身体上且带 Collider,也会拦截射线。

解决:给玩家枪械模型单独设一个层(比如Weapon),在RaycastGun的hitMask里排除这个层。另外确认敌人身体的碰撞体层级,使用GetComponentInParent<Health>()能解决子碰撞体与父级对象不同体的问题,但如果敌人身上完全没有任何碰撞体,射线永远打不到它。检查敌人预制体是否有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 打开就能跑,熟悉这套结构以后改成自己的项目也会顺畅很多,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进