Unity狙击手3D源码解析:弹道、狙击镜与AI实现 简介面向 Unity 移动端射击游戏开发者的完整 3D 狙击手 FPS 工程源码使用 C# 编写支持 Unity 2018.4.35f1 及以上版本。项目以反恐狙击为核心玩法内置 20 个设计好的关卡覆盖近距离遭遇战与中远距离狙击场景包含不同敌人类型与地形障碍操作流畅并集成 AdMob 广告可直接面向 iOS 与 Android 发布。资源包共 2000 个文件大小约 958.6MB除大量 meta 元数据与 cache 索引外主体包括 179 个 C# 脚本玩家控制、敌人 AI、枪械射击与换弹逻辑、166 个材质、138 个 PNG 贴图、80 个 FBX 与 72 个 OBJ 模型、67 个 Shader、45 个 Prefab 预制体以及动画、音频、PDF 文档等目录结构清晰。工程中还包含 DOTween 插件脚本、枪械换弹/开火动画和沙漠场景动作资源方便研究 FPS 战斗反馈、移动端优化和广告接入方式。当前已有 71 人学习下载适合需要快速上手并发布移动 FPS 项目的初中级开发者参考也便于进行二次开发。1. 拿到 Sniper Army 3D 源码后的第一件事场景与脚本分层Sniper Army 3D狙击大军3D是一个把“狙击手射击”整个玩法打包成 Unity C# 源码的典型工程——场景、瞄准镜、弹道、敌人 AI、命中反馈都能在 Assets 目录里直接改。很多第一次打开这种项目的人会先进场景点 Play结果不是枪不响就是瞄准镜黑屏原因通常不是某个脚本写错了而是没搞清这组源码的依赖顺序。先理清三件事场景里挂着哪些相机与 UI 层、预制体和 ScriptableObject 配置是否绑定、入口脚本是挂在玩家身上还是挂在武器身上。把这三层理顺后面的调参数、做二次开发就不会每改一处都连锁报错。下面顺着这个思路按“射击核心 → 狙击镜渲染 → AI 关卡 → 手感验证”四个环节拆解。适合手里已经拿了一套狙击手 3D 源码、正在对照着改需求的人也适合还没开始、打算自己用 C# 在 Unity 里做一个狙击关卡的开发者。所有代码是通用实现思路不是某个特定项目的逐行还原。2. Sniper Army 3D 狙击核心弹道、命中与伤害的 C# 参数设计2.1 用 ScriptableObject 管理狙击枪械参数别把数值写死在 MonoBehaviour 里狙击游戏的武器参数非常多伤害、弹速、射程、穿甲能力、后坐力、开镜时间。如果全部写死在 MonoBehaviour 里玩家换枪就得复制脚本后期调整平衡性时还要在一个几百行的类里翻找字段。常见做法是把一把枪定义成一个 ScriptableObject用数据驱动的方式挂到武器与子弹上这也是源码工程里最值得先看的文件。using UnityEngine; [CreateAssetMenu(fileName SniperWeapon, menuName Sniper/WeaponConfig)] public class SniperWeaponConfig : ScriptableObject { public string weaponName; public float damage 120f; public float range 300f; public float bulletVelocity 200f; public float spreadAngle 0.02f; public int magazineSize 5; public float reloadTime 2.4f; public float recoilUp 15f; public bool hasScope true; }在 Project 面板右键执行 Create → Sniper → WeaponConfig生成资产后直接拖到武器控制器上即可。这样数值调整不用重新编译 C# 代码运行中改完资产再进 Play Mode 就会生效。需要关注的参数是bulletVelocity越大远距离子弹下坠越小但受物理步长影响要配合弹头脚本里的子步进检测spreadAngle是子弹实际飞行的锥形散布角狙击枪应控制在 0.010.03 弧度damage不建议设成固定值最好作为基础值参与距离衰减计算。2.2 从枪口到目标的弹头仿真Raycast 单帧检测与子步进飞行狙击射击有两种常见命中实现。第一种是单帧Physics.Raycast从枪口朝准星方向发射一条射线命中点即伤害点表现干脆、逻辑最简单。第二种是“实体弹头”生成一个轻量 GameObject每帧让它前进一段距离并对这段路径做射线检测能支持抛物线下坠和移动目标命中。狙击手 3D 项目一般选第二种原因是狙击镜里的高倍观察需要能看到落点也让远距离射击的“提前量”成为玩法的一部分。using UnityEngine; public interface IHittable { void OnHit(float damage, Vector3 point, Vector3 dir); } public class BulletProjectile : MonoBehaviour { public float velocity 200f; public LayerMask hitMask; private void FixedUpdate() { float step velocity * Time.fixedDeltaTime; if (Physics.Raycast(transform.position, transform.forward, out RaycastHit hit, step, hitMask)) { transform.position hit.point; IHittable hittable hit.collider.GetComponentInParentIHittable(); if (hittable ! null) hittable.OnHit(120f, hit.point, transform.forward); Destroy(gameObject); return; } transform.position transform.forward * step; } }这段代码的关键是每物理帧只沿着射线移动velocity * Time.fixedDeltaTime的距离并只检测该距离内是否有碰撞避免子弹高速穿过薄墙出现隧穿。这里的hitMask要单独建立 Player、Enemy、Obstacle 三层狙击枪最怕子弹被玩家自己的准星模型挡住。若把velocity调大到 500 以上建议同时检查 Project Settings 里的Physics.autoSyncTransforms否则多人联机时不同客户端看到的命中点会不一致。2.3 伤害模型爆头倍率、距离衰减与命中反馈的 C# 组织方式伤害不能只写成一句HP - value。狙击枪源码里值得单独建两个文件IHittable接口和DamageInfo结构体。头部命中乘 2.5 倍、四肢减伤 30%、距离超过射程的 80% 后开始伤害递减这些都要在伤害计算里集中处理。表格列一组我常用的默认值方便对照调整。部位基础伤害倍率典型弹种头部2.5x所有狙击弹胸部1.0x高伤穿甲弹四肢0.7x普通弹未判定部位0.2x流弹狙击枪因为射速低每一次开火的正反馈都必须非常强。命中标记、镜头轻微停顿、子弹落点特效、敌人的受击后仰动画这些反馈不能散落在子弹脚本里。合理做法是让IHittable.OnHit内部统一触发一个HitMarkerManager由它负责 UI 与特效播放。判断命中部位时可以用headCollider、torsoCollider等子碰撞体的 tag 区分也可以给每个敌人挂一个BodyPartHitbox组件把倍率直接配在 Inspector 上敌人模板越多越省事。这个设计还有一个额外好处联机时只需要同步伤害事件而不是同步子弹坐标。3. Sniper Army 3D 的狙击镜渲染RenderTexture、阴影问题与 WebGL 存档3.1 双相机狙击镜RenderTexture 挂到 RawImage而不是直接切 FOV狙击手游戏的瞄准镜有两种实现一种是直接改主相机 FOV把镜头拉近这种做法最快但镜框、十字线、镜内画质都很难做真实另一种是双相机加 RenderTexture狙击镜相机输出到一张纹理再贴到 UI 的 RawImage 上。第二种是源码项目里最常见的做法因为镜片畸变、渐变黑圈、镀膜反光都可以在 UI 层叠加。using UnityEngine; using UnityEngine.UI; public class ScopeCameraController : MonoBehaviour { public Camera mainCamera; public Camera scopeCamera; public RawImage scopeImage; public RenderTexture scopeTexture; private void Awake() { scopeCamera.targetTexture scopeTexture; scopeImage.texture scopeTexture; } private void LateUpdate() { scopeCamera.transform.SetPositionAndRotation( mainCamera.transform.position, mainCamera.transform.rotation ); } }关键点是LateUpdate同步保证主相机旋转结束后狙击镜相机再对齐不会出现镜内画面滞后。RenderTexture 的分辨率不必一味求高1024×1024 在狙击镜全屏时已经够用。狙击镜开启时记得把主相机画面继续渲染在底层这样镜外场景的跑动、天空盒都能保留。RenderTexture 参数推荐值出问题时的表现分辨率1024×1024低端设备开镜模糊Depth Buffer24 bit镜内远处物体闪烁antiAliasing2x树枝边缘锯齿明显相机 cullingMaskEnemy Scenery GroundRawImage 黑屏或缺少物体如果开镜后 RawImage 黑屏先检查 scopeCamera 的cullingMask是否包含主相机所在的 UI 层如果镜内物件穿透遮挡再把 scopeCamera 的nearClipPlane调大到 0.3避免枪械模型顶到镜头上。3.2 狙击镜里的 Unity 阴影问题阴影抖动与镜内剔除距离狙击镜里最容易看到两类阴影问题远处敌人脚下阴影抖动近处地形阴影撕裂。抖动通常是Shadow Distance设得太大阴影相机在远距离下深度精度不够。不要只调 Quality Settings 里的阴影距离狙击镜相机和主相机应该使用不同的剔除距离。LayerMask scopeMask LayerMask.GetMask(Scenery, Enemy, Ground); scopeCamera.cullingMask scopeMask; scopeCamera.layerCullDistances[LayerMask.NameToLayer(Enemy)] 300f; scopeCamera.layerCullDistances[LayerMask.NameToLayer(Scenery)] 800f; scopeCamera.layerCullDistances[LayerMask.NameToLayer(Ground)] 500f; scopeCamera.ResetCullingMatrix();把主相机Shadow Distance收在 150200 米单独给 scopeCamera 设置更远的剔除距离这样瞄准镜里能看到敌人但阴影计算仍集中在主相机身边性能与观感都能保住。地面阴影撕裂多半是地形高度图与阴影贴图分辨率不匹配可以给地形单独指定低分辨率阴影或关闭地表接收阴影只保留敌人投影。狙击镜里的准星需要用单独的 UGUI Image 放在 RawImage 顶层否则准星会被场景阴影盖住。3.3 WebGL 发布与 IDBFS 写入失败狙击存档的桥接方案部分狙击源码打包 WebGL 后玩家打了几关刷新页面进度丢失控制台报IDBFS相关错误。这是 Unity WebGL 文件系统映射到 IndexedDB 时的常见问题Application.persistentDataPath在浏览器无痕模式或存储配额不足时无法写入。正确处理是先检测文件系统可用性再做本地内存兜底。public static void SaveSnapshot(string json) { #if UNITY_WEBGL PlayerPrefs.SetString(Sniper_Save, json); PlayerPrefs.Save(); #else string path Path.Combine(Application.persistentDataPath, sniper_save.json); File.WriteAllText(path, json); #endif }这段代码只是解决同步写入异常并不能保证浏览器不清理 IndexedDB。更稳的方案是提供一个IFileBridge接口WebGL 侧允许玩家手动导出.sav文件加载时通过 Unity 的TextAsset或浏览器 File API 读回。需要注意Unity WebGL 下的Input.mousePosition受 CSS 缩放影响开镜后屏幕中心点与子弹命中点容易偏移建议固定使用Screen.width / 2f并转成 UI 坐标再判断是否在镜内。注意PlayerPrefs 在 WebGL 里同样依赖 IndexedDB无痕模式照样可能失效狙击玩法进度条较长时首选手动存档导出。4. Sniper Army 3D 的敌人 AI 与关卡推进状态机、NavMesh 与事件驱动4.1 敌人状态机最小骨架巡逻、警戒、战斗的切换条件狙击手 3D 游戏的可玩性一半在枪另一半在敌人行为。源码里最常见的是状态机型 AI巡逻、警戒、搜索、攻击。行为树更灵活但交付型源码项目用 C# 状态机更好改所有状态可见且能在 Inspector 里直接调整阈值。public enum EnemyState { Patrol, Investigate, Combat } public class SniperEnemyAI : MonoBehaviour { public EnemyState state EnemyState.Patrol; public Transform player; public float viewDistance 80f; public float viewAngle 60f; public LayerMask obstacleMask; private void Update() { bool canSeePlayer DetectPlayer(); switch (state) { case EnemyState.Patrol: if (canSeePlayer) state EnemyState.Combat; break; case EnemyState.Combat: if (!canSeePlayer) state EnemyState.Investigate; break; } } private bool DetectPlayer() { Vector3 toPlayer player.position Vector3.up - transform.position; if (Vector3.Angle(transform.forward, toPlayer.normalized) viewAngle) return false; if (toPlayer.magnitude viewDistance) return false; return !Physics.Raycast(transform.position, toPlayer.normalized, out _, toPlayer.magnitude, obstacleMask); } }这段代码最值得调的是viewDistance与viewAngle。狙击关卡讲的是“你先发现他还是他先发现你”所以敌人视野不能太强。我一般把巡逻状态下的viewDistance设置为玩家狙击枪有效射程的 60%viewAngle取 5070 度。比设置数值更重要的是状态切走的时机敌人从 Combat 转 Investigate 后应该记住最后看见玩家的位置而不是立刻恢复巡逻否则玩家一趴下敌人就失去目标。4.2 掩体寻路与 3D 遮挡判断用 NavMeshAgent 实现受击找掩体敌人中弹后要会找掩体否则狙击手只是在打活靶。最常见的工程解是 Unity 的 NavMeshAgent先烘焙场景静态碰撞体的 NavMesh再在敌人生成时缓存几个掩体点位。受击后调用SetDestination走到安全点而不是随机跑。private Vector3 FindBestCover(Vector3 threatPos, Vector3[] coverPoints) { Vector3 selfPos transform.position; Vector3 awayDir (selfPos - threatPos).normalized; float bestScore -1f; Vector3 best selfPos; foreach (Vector3 point in coverPoints) { Vector3 toCover point - selfPos; float dot Vector3.Dot(toCover.normalized, awayDir); if (dot 0f) continue; float distanceCost toCover.magnitude / 100f; float score dot - distanceCost; if (score bestScore) { bestScore score; best point; } } return best; }这段函数的评分逻辑是掩体点相对玩家方向的反向重合度越高越好同时距离越近越好防止敌人为了一个掩体跑半个地图。NavMeshAgent的radius要设小一点狙击掩体场景里掩体之间的窄通道很多默认 0.5 的半径会导致敌人卡墙设成 0.1 并开启AutoTraverseOffMeshLink更稳。还要注意受击找掩体不要每帧调用应该在受击回调中延迟 0.2 秒执行多个敌人同时受击时错开寻路计算。AI 参数常见默认值影响viewDistance80m发现玩家的距离viewAngle60°视野锥形角度arriveDistance1.5m掩体停留点精度rotSpeed8°/s转身速度狙击靶子重要手感4.3 事件驱动关卡从击杀到下一波流程别把敌人脚本写成关卡脚本“狙击大军”意味着同一场景里有多波次敌人。源码里建议用 UnityEvent 做事件总线而不是把波次判断写在每个敌人脚本里。一个简单的 WaveTrigger 可以监听敌人死亡事件并在数量归零后激活下一波。using UnityEngine; using UnityEngine.Events; public class WaveTrigger : MonoBehaviour { public UnityEvent onWaveCompleted; public void RegisterEnemy(SniperEnemyAI enemy) { enemy.onDied.AddListener(CheckWaveState); } private void CheckWaveState() { if (FindObjectsOfTypeSniperEnemyAI().Length 0) onWaveCompleted?.Invoke(); } }注意FindObjectsOfType不能在 Update 中每帧调用它会产生明显的 GC 压力WebGL 下还会让 IDBFS 写入卡顿更明显。正确的做法是每个敌人在OnDestroy时通知 WaveTrigger然后用一个计数器递减。关卡触发器、任务目标、救援人质、定时进攻都应该由关卡管理器订阅而不是让敌人脚本知道当前处于第几波。这样后续新增敌人类型或者修改波次顺序时不需要动 AI 代码。5. Sniper Army 3D 手感验证命中线、滑动条与按钮热区5.1 用 Debug.DrawLine 把子弹命中线画出来狙击枪开完一枪看不到命中点新手第一反应是调伤害。正确做法是先验证子弹是否确实飞到了准星位置。可以用Debug.DrawLine或 LineRenderer 把枪口到命中点的轨迹画出来至少保留 3 秒方便在 Scene 视图里回看。using UnityEngine; public class ScopeDebugDrawer : MonoBehaviour { public Transform muzzle; public LineRenderer line; public void DrawShot(Vector3 target) { Debug.DrawLine(muzzle.position, target, Color.yellow, 3f); line.positionCount 2; line.SetPosition(0, muzzle.position); line.SetPosition(1, target); } }在BulletProjectile命中回调中调用DrawShot(hit.point)就能立刻看出子弹落点与准星的偏差。这个技巧在调狙击镜同步、弹道下坠和散布参数时非常有用。5.2 用滑动条调灵敏度用父节点扩大按钮点击范围狙击镜甩枪的灵敏度需要一个可调滑块。用 UGUI 的 Slider 绑定鼠标灵敏度注意把 01 的滑块值映射到实际旋转速度范围。public Slider sensitivitySlider; public MouseLook mouseLook; private void Start() { sensitivitySlider.onValueChanged.AddListener(v { float mapped Mathf.Lerp(0.5f, 3f, v); mouseLook.xSensitivity mapped; mouseLook.ySensitivity mapped; }); }这里的Mathf.Lerp是为了让滑块低端也有可用灵敏度避免滑到最小时视角完全转不动。另一个和手感直接相关的细节是“开镜按钮”的点击热区。Unity UGUI 的按钮是否可点取决于Image.raycastTarget的矩形范围所以想扩大点击范围不要去改按钮本身的缩放而是在按钮上层放一个透明 Image 并勾选raycastTarget。从 uGUI 源码的角度看这样点击事件会先落在透明 Image 上再通过事件系统冒泡到子按钮玩家就算按偏一点也能正常开关狙击镜。验证方式是在 Scene 视图打开 UI 调试确认热区已经覆盖住目标区域。本文还有配套的精品资源点击获取