☰
Unity3D FPS工程模板:从射击到敌人AI与对象池复用全解析
2026/10/21 2:54:17 网站建设 项目流程

简介:面向Unity3D开发者的FPS游戏完整工程源码,rar包内含1609个文件,38.91MB。基于C#脚本编写,覆盖场景构建、角色控制、射击物理、网络同步、AI、UI、音频、性能优化等核心模块;文件类型以bin、asset、prefab、cs、dll、meta配置为主,可支撑项目运行与二次开发。实现要点涉及CharacterController第一人称控制、Physics射线射击、NavMesh敌人寻路、状态机设计与场景光影设置。已有2409人学习下载,适合从零搭建多人联机FPS或研究Unity角色控制与网络同步的中高级开发者。对照源码可掌握从角色控制、射击判定到多人状态同步的完整思路,是一份值得参考的实战工程。

1. Unity3D FPS工程:拿到就能跑的射击游戏模板

如果你第一次打开一个Unity3D FPS工程,大多数情况下会被一堆脚本和Prefab劝退——场景里几百个节点,找半天不知道入口在哪。这份工程是个例外:它的Assets/Scenes里放了一个可以直接按Play运行的Main场景,打开就能进入完整的射击循环。从移动、视角、射击、换弹到敌人AI、伤害飘字、对象池回收,一个单机FPS该有的骨架都在,而且脚本分层很干净,玩家、武器、敌人各自独立成目录。

它适合两类人:一类是想拿现成工程快速跑通一个FPS Demo、再换皮改成自己玩法的开发者,另一类是刚学Unity、想弄明白一个第一人称射击游戏到底由哪几个模块组成的新手。我拆完这套工程后最大的感受是:它不算复杂,但把FPS最核心的几条链路都拉通了——射线命中、敌人追踪、对象池复用,逻辑都不绕,直接读代码就能理解设计者的思路。拿来当二次开发的起点,比从零搭一套要省太多时间。

2. 技术栈与架构设计:从场景层级到脚本生命周期

2.1 技术选型:URP加新版Input System的组合为什么适合FPS

这套工程是用Unity 2021.3 LTS及以上版本打开的,渲染管线走的是URP(通用渲染管线),输入处理用的是新版Input System。这两个选择对FPS都很关键。URP的好处是,场景里的光影和后处理都依赖Volume配置,比如开枪时的全屏泛红、瞄准时的景深模糊,这些都是挂在Volume上的Overrides,如果换回内置渲染管线,这套后处理要全部重做。而新版Input System解决了老Input类在自定义键位和触控上的痛点,工程里的WASD移动、鼠标视角、Shift冲刺,都是通过InputAction资产统一管理的。

用InputAction资产管理输入有一个实际好处:玩家要改键位,不需要进代码,直接在Unity编辑器里调整Binding就行。我做过一个移动端的FPS改造项目,当时把InputAction的虚拟摇杆绑定拖进去就能跑,省掉了大量判断屏幕触摸的代码。这套工程里InputManager单例负责读取Action资产的值,GameObject再通过InputManager.Instance去拿输入,而不是直接在Update里调用Input.GetAxis,就是为后期换端做的准备。

2.2 工程目录结构:每个文件夹负责什么

先看整体布局。工程根目录是标准的Unity工程结构,核心代码全在Assets下面,目录划分比很多开源项目清晰,核心代码集中在Player、Weapon、Enemy三层里。目录结构大致如下:

目录负责内容
Assets/ScenesMain场景与启动场景,Main是实际能玩的主入口
Assets/Scripts/Player角色移动、视角控制、生命值组件
Assets/Scripts/Weapon武器参数配置、射击射线、换弹逻辑
Assets/Scripts/Enemy敌人AI状态机、出生点生成、敌人属性
Assets/Scripts/UI准星、血条、弹药显示、伤害飘字
Assets/Scripts/ManagersGameManager、InputManager、对象池管理器
Assets/Prefabs玩家、武器、敌人、子弹特效的预制体
Assets/Art模型、材质、动画、特效资源
Assets/Audio枪声、脚步、环境音效

三层脚本的设计意图很明显:玩家负责“我能做什么”,武器负责“我用什么打”,敌人负责“对方怎么反应”。UI层和Manager层再把这些串起来。这样拆的好处是改武器手感时不需要动玩家控制逻辑,换敌人行为时也不影响武器脚本。很多新手喜欢把射击和移动写在一个脚本里,前期省事,后期加武器、加AI时会很难受,要同时动一个几百行的文件。

2.3 场景启动链路:从Play到第一发子弹

打开Main场景,点Play到第一发子弹打出去,实际经过了一条固定链路:

场景加载后,GameManager先初始化,PlayerSpawner在出生点生成玩家角色,然后摄像机挂到玩家身上,WeaponManager再根据当前槽位挂载武器,同时EnemySpawner开始激活第一批敌人。

这段链路的核心是GameManager,它不参与具体战斗逻辑,只负责调度。简化版的启动流程大概是这样:

private IEnumerator StartGame() { // 等待1秒,让场景上的动态物体完成初始化 yield return new WaitForSeconds(1f); // 生成玩家并激活控制脚本 player = playerSpawner.Spawn(); playerController.SetActive(true); // 给玩家挂载初始武器 weaponManager.AttachTo(player); // 启动敌人生成波次 enemySpawner.StartFirstWave(); }

GameManager用协程串起启动顺序,WaitForSeconds保证场景里其他物体先Awake。如果你打开场景后是黑屏,第一步就要检查场景里有没有GameObject挂载了GameManager,并且GameManager有没有在Inspector里引用PlayerSpawner——这是FPS模板里最常翻车的地方,我之前见过有人把Main场景打开看到黑屏,查了半天发现是GameManager没挂。

2.4 对象的生命周期:Init到Recycle的约定

所有战斗对象都遵循同一条生命周期约定:Init → Active → Update → Deactive → Recycle。玩家生命值归零触发Death事件,GameManager在2秒后调用Respawn。敌人被子弹命中时减血,血量归零后播放死亡动画,身体交还给对象池等下一轮复用。

这套约定不是写在什么框架文档里,而是每个核心脚本都实现了这样的接口。好处是敌人、子弹、飘字这些高频创建销毁的对象,不用频繁Instantiate和Destroy,而是先放入池子。GC压力会小很多。后面第三章我专门拆对象池的实现,因为这是整个工程里对运行性能影响最大的一个设计。

3. 核心模块拆解:一发子弹从膛线到命中的完整链路

3.1 玩家控制:CharacterController与鼠标视角

玩家移动用的是CharacterController组件,而不是直接改Transform.position。CharacterController自带碰撞检测和斜坡处理,角色走到墙边会自然停下,不用手写复杂的物理碰撞逻辑。

public class PlayerController : MonoBehaviour { [SerializeField] private CharacterController controller; [SerializeField] private float moveSpeed = 6f; [SerializeField] private float sprintMultiplier = 1.6f; [SerializeField] private float gravity = -9.81f; [SerializeField] private float jumpHeight = 1.2f; private float verticalVelocity; private Vector2 moveInput; private float mouseX; private void Update() { HandleMouseLook(); HandleMove(); HandleGravity(); } private void HandleMove() { float speed = moveSpeed * (InputManager.Instance.IsSprinting() ? sprintMultiplier : 1f); Vector3 motion = (transform.forward * moveInput.y + transform.right * moveInput.x) * speed; controller.Move(motion * Time.deltaTime); } private void HandleGravity() { if (controller.isGrounded && verticalVelocity < 0f) { verticalVelocity = -2f; } verticalVelocity += gravity * Time.deltaTime; controller.Move(new Vector3(0f, verticalVelocity, 0f) * Time.deltaTime); } }

移动的核心在Controller.Move那一行。moveInput.y表示前后方向,moveInput.x表示左右方向,乘以transform.forward和transform.right,会让玩家永远朝摄像机朝向的方向移动,而不是世界坐标系方向。这里有个新手容易犯的错误:直接用Vector3(x, 0, z)移动,结果角色按W会朝一个固定方向跑,而不是朝视角方向跑。这套工程里没有这个问题,因为它把世界方向转换成了本地方向,然后交给CharacterController处理。

gravity是用累加速度实现的,落地时用controller.isGrounded判断,防止角色飘在空中。数值方面,moveSpeed在5到7之间比较合适,sprintMultiplier设置在1.5到1.8之间,手感差异很大。我一般会先用默认值跑一遍,再微调这两个参数,因为速度感是FPS手感最敏感的部分。

3.2 武器系统:射线命中、后坐力与换弹

武器脚本是整个工程里最值得读的部分。FPS的射击判定大致有两种方案:一种是生成子弹对象,靠物理碰撞检测;另一种是开枪瞬间发射一条射线,直接计算命中。这套工程用的是射线方案,因为它更稳定,不存在子弹飞行延迟的问题,命中判定几乎是即时的。

public class Weapon : MonoBehaviour { [SerializeField] private float fireInterval = 0.15f; [SerializeField] private int magazineCapacity = 30; [SerializeField] private float damage = 25f; [SerializeField] private float maxRange = 100f; [SerializeField] private LayerMask targetMask; private int currentAmmo; private float nextFireTime; public void Fire() { if (currentAmmo <= 0 || Time.time < nextFireTime) return; currentAmmo--; nextFireTime = Time.time + fireInterval; muzzleFlash.Play(); audioSource.PlayOneShot(fireClip); Ray ray = camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)); if (Physics.Raycast(ray, out RaycastHit hit, maxRange, targetMask)) { IDamageable target = hit.collider.GetComponent<IDamageable>(); target?.TakeDamage(damage, hit.point); SpawnImpactEffect(hit.point, hit.normal); } } public void Reload() { currentAmmo = magazineCapacity; } }

Fire方法的逻辑分三步:先检查弹药和射速冷却,再播放开火的视觉音效反馈,最后从屏幕正中心发射一条射线。射线起点用的不是枪口位置,而是摄像机视口中心,也就是准星所在处。这样做的原因是玩家射击的直觉是“准星指哪打哪”,如果从枪口发射射线,近距离时射线方向和准星偏差会很大。

fireInterval控制射速,数值越小打起来越像连发武器;damage直接决定打中敌人时的扣血量。maxRange设置为100到120就足够大多数室内场景用了,超过这个距离命中精度会明显下降。目标是Enemy层,LayerMask会在Physics.Raycast时过滤掉墙、地面等障碍,否则会穿墙命中敌人——这个细节我会在后面避坑章节专门展开。

后坐力方面,实际工程里一般用RecoilProfile来实现,每次开枪往摄像机欧拉角上加一个随机的偏移量,然后在下次瞄准时缓慢回弹。简单版本就是开火时往某个轴向加偏移,自然恢复。

3.3 敌人AI:NavMesh与有限状态机

敌人AI用的是经典的有限状态机,三种状态:Patrol(巡逻)、Chase(追击)、Attack(攻击)。状态切换的条件完全由距离和可见性决定。

public enum EnemyState { Patrol, Chase, Attack } public class EnemyAI : MonoBehaviour { [SerializeField] private EnemyState state = EnemyState.Patrol; [SerializeField] private float attackRange = 8f; [SerializeField] private float chaseRange = 20f; private NavMeshAgent agent; private Transform target; private void Update() { switch (state) { case EnemyState.Patrol: Patrol(); break; case EnemyState.Chase: Chase(); break; case EnemyState.Attack: Attack(); break; } } private void Chase() { agent.isStopped = false; agent.SetDestination(target.position); if (Vector3.Distance(transform.position, target.position) <= attackRange) { state = EnemyState.Attack; } } private void Attack() { agent.isStopped = true; // 播放攻击动画,对玩家造成伤害 } }

敌人的移动依赖NavMeshAgent组件,追踪玩家时只需调用SetDestination,寻路逻辑全部交给Unity的导航网格。这里有一个关键依赖:场景必须提前烘焙NavMesh,否则NavMeshAgent找不到可走的路径,敌人会原地发呆。这也是很多新手第一次打开这个工程发现敌人不动的真正原因。

状态切换时机在Chase里写得很清楚:距离小于attackRange就进入攻击状态,大于chaseRange会退回巡逻。动画控制也是按state来处理的,比如Chase播跑步动画,Attack播开火动画。这个模式扩展性不错,要加一个“投掷型敌人”,只需要在Attack里改一个方法,不用动状态机框架。

3.4 对象池:子弹与伤害飘字的复用

对象池是这套工程在性能上最值得学习的设计。子弹壳、弹孔、飘字都是战斗里高频出现的对象,如果每次命中都Instantiate再Destroy,GC压力会很大,帧率会随战斗强度下降。

public class ObjectPool : MonoBehaviour { private Stack<GameObject> freeList = new Stack<GameObject>(); public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) { if (freeList.Count > 0) { GameObject go = freeList.Pop(); go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } return Instantiate(prefab, position, rotation); } public void Recycle(GameObject go, float delay) { StartCoroutine(DelayRecycle(go, delay)); } private IEnumerator DelayRecycle(GameObject go, float delay) { yield return new WaitForSeconds(delay); go.SetActive(false); freeList.Push(go); } }

Spawn方法先看池子里有没有空闲对象,有就直接复用;没有才走Instantiate。Recycle方法则不是立刻销毁,而是先SetActive(false),扔回栈里等待下次使用。这样做的好处是,一局对战里反复出现的弹孔特效只需要在开局时生成固定数量,之后全靠复用,不产生任何新的内存分配。

这套工程的飘字也走了同一个池子,命中敌人后数字飘起来,1秒后自动回收。想改成暴击数字、连杀提示,只需要在池子注册一个新的Prefab。这个设计对中等规模项目完全够用,我后面做改造时直接在池子上加了按类型分组的字典,方便按不同特效类型取对象。

3.5 UI与游戏循环:准星、弹药显示与伤害反馈

UI层相对简单,准星是一个Image放在屏幕中央,弹药数量是Text每帧刷新,血条用Slider绑定玩家生命值。伤害反馈做得比较轻量——玩家被攻击时屏幕边缘会闪烁一片红色,这是通过修改CanvasGroup的alpha实现的,没有做复杂的后处理。

UI刷新是在UIHandler里统一处理的,它通过事件订阅的方式监听Weapon脚本的AmmoChange事件和玩家HealthChange事件。这样武器和玩家脚本完全不需要引用UI组件,解耦得很干净。如果你想改成击中敌人时准星变红,可以直接在Weapon的Fire方法里发一个OnHitTarget事件,UI那边订阅一下就行。

4. 本地运行与打包:从导入工程到PC端EXE

4.1 环境准备:确认Unity版本与Package依赖

第一步是环境。用Unity Hub安装Unity 2021.3 LTS或更新版本,创建空工程时选URP模板,然后把这份工程的Assets目录合并进来。第一次打开时Unity会重新编译所有脚本并导入Package,Console窗口会滚动输出大量日志,看到任何红色报错都要停下来看原因。

常见的报错有两种:一种是某个Package版本缺失,比如Input System包没装,表现为无法解析InputSystem命名空间;另一种是URP的管线资产没有在工程里激活,表现为场景渲染全黑。对应解法分别是:Window → Package Manager里安装Input System,以及Edit → Project Settings → Graphics把URP管线资产拖进入口。这两个坑几乎是每个FPS工程换Unity版本后必踩的,别问我怎么知道的。

4.2 打开主场景与一键Play验证

环境准备好后,在Project窗口进入Assets/Scenes,双击Main.unity打开主场景。按编辑器顶部的Play键,正常情况应该是第一人称视角站在出生点,左上角能看到血条和弹药数字。

快速验证逻辑链是否通的顺序是:先按WASD走两步,确认移动正常;再开一枪,确认有枪声和弹壳特效;然后朝敌人方向射击,确认有黄色命中反馈和伤害飘字;最后站在原地让敌人打你,确认画面边缘有受击反馈。这几步都通过,说明整个工程的战斗链路没有问题。如果有一步没反应,优先看Console窗口有没有异常日志,而不是怀疑自己操作错了。

4.3 输入配置:Active Input Handling必须从旧版切走

FPS工程的输入配置是一个大坑。打开Edit → Project Settings → Player,找到Active Input Handling,确认它处于Input System Package Only或Both状态。如果选的是旧版Input Manager,InputAction资产不会被激活,WASD和鼠标视角会完全失效,但画面又是正常的,很容易让人以为是脚本问题。

切换之后Unity会提示重新启动编辑器,这一步一定要做,否则新的输入配置不会加载。另外,鼠标锁定也是FPS特有的点——工程里一般在Start方法里调了Cursor.lockState = CursorLockMode.Locked,所以进游戏后鼠标光标会自动隐藏,按Esc会释放光标。这个行为如果不生效,打包后鼠标会滑出窗口,玩家视角会飘。

4.4 Build Settings打包:从编辑器到独立EXE

编辑器里能跑不代表打包后能跑,打包步骤也需要走一遍。打开File → Build Settings,把Main场景拖进Scenes In Build列表,Platform选择Windows,Mac,Linux,点击Player Settings确认Company Name和Product Name不为空,然后直接Build。

打包输出后,exe文件旁边会生成一个同名的Data目录,这两个东西必须放在同一个文件夹里,不能单独拷贝exe。数据集夹包含所有场景、Shader和资源,少了它游戏会直接报错。我一般会把打包输出放到单独文件夹,因为Unity会自动清空目标目录里的老文件,如果你指定到桌面,桌面上的其他文件可能会被清掉。

4.5 运行验证:不是能进画面就算跑通

打包完成后双击exe,重点验证三件事:第一,开场动画和场景能不能正常加载,URP Shader有没有变粉;第二,射击和命中逻辑是否和编辑器里一致,帧率有没有明显下降;第三,退出游戏再重新进入一次,确认存档和场景状态不会被上一次运行污染。

5. 常见问题与避坑指南:新手最容易踩的五个坑

5.1 场景全黑,只能看到UI

现象:进Main场景按Play后镜头是黑的,HUD和血条都能正常显示,但世界环境完全看不见。

原因:这个工程的渲染依赖URP管线资产,如果工程是从旧版Unity迁移过来的,Graphics Settings里的管线资产没关联,场景会走默认的Legacy渲染路径,导致URP Shader全部失效,表现就是全黑或者粉紫色。

解决:打开Edit → Project Settings → Graphics,把URP管线资产拖到Scriptable Render Pipeline Settings入口。如果找不到这个资产,先生成一份:在Project窗口右键Create → Rendering → Universal Render Pipeline Asset。之后场景会恢复正常,这个顺序不能反。

5.2 按WASD完全没反应,鼠标视角却能转

现象:功能都能用,画面也正常,但键盘按WASD角色一步都不走,鼠标转动视角又完全正常。

原因:输入处理配置不对。这套工程用的是新版Input System,但工程设置里Active Input Handling如果被切回了旧的Input Manager状态,键盘输入的事件绑定会找不到对应的Action映射。

解决:去Edit → Project Settings → Player里找到Active Input Handling,强行切成Input System Package Only,重启Unity编辑器和当前场景就好了。这个坑我踩过一次,当时检查脚本查了一个小时,最后发现是配置项的问题,根本和代码无关。

5.3 敌人原地发呆,开枪打它才反应

现象:进入场景后敌人全部站在原地,不开枪不理你,开了枪它才开始追击。

原因:NavMesh没有烘焙。NavMeshAgent找不到可走的路径,就只能停在原地。开枪时EnemyAI收到伤害事件强制切换到了Chase状态,才会勉强走向玩家。

解决:打开Window → AI → Navigation面板,选Bake选项卡,确认场景里的地面和障碍物都勾选了Navigation Static,然后点击Bake。烘焙完成后场景里会显示蓝色区域,那就是敌人可以行走的路径范围。这里有个细节:平台、阶梯、斜坡这类地形,必须单独检查它们的Navigation Area类型,否则敌人会被卡在台阶前。

5.4 隔着墙也能打到人,命中判定完全不对

现象:步枪隔着一堵墙还能打出命中反馈,或者对着敌人射击却打不出伤害。

原因:两道题是同一类问题——Layer和碰撞矩阵没配好。这个工程的射线检测用targetMask过滤目标层,如果墙壁和敌人同属一个Layer,射线会把墙壁当成可命中物体,直接穿过墙体计算命中。反过来,如果敌人的模型没有放在正确Layer上,射线会穿透敌人,只打中后面的背景,伤害就为0。

解决:把敌人预制体和玩家尸体都放到Enemy层,把墙体、地板放到Environment层,然后在Physics.Raycast时把targetMask设成只包含Enemy层。同时打开Edit → Project Settings → Physics的碰撞矩阵,确认Enemy层和Environment层可以正常碰撞,但射线只从Enemy层取命中信息。

5.5 打包后没有声音,编辑器里却一切正常

现象:编辑器里开火、脚步、受击音效都正常,打包成exe后枪声全没了。

原因:音频文件可能是通过Resources.Load动态加载的,如果打包时勾选了剔除未引用资源,Resources里的音频会被误删。另一种可能是音频源挂在武器模型上,而武器模型在打包时被裁剪了。

解决:把音频文件放到Assets/Resources/Common/Audio目录下,并在代码里用Resources.Load ("Common/Audio/...")加载。打包后在Build Settings里确认所有场景都加入了Scenes In Build,并在Project Settings的Player页开启Compute Mesh Colliders选项,这样模型引用不会被裁掉。做完这些再重新打包验证。

6. 二次开发:从模板到可玩Demo的改造路径

6.1 改枪械手感:先动数值再动代码

FPS的第一步改造往往就是调武器手感。这套工程把武器参数都集中在Weapon脚本的SerializeField上,不需要改代码,直接在Inspector里调就能改变射击节奏:

参数作用试调范围
fireInterval射击间隔,决定是点射还是连发0.1到0.3秒
damage单发伤害,影响击杀所需子弹数15到45
magazineCapacity弹匣容量,影响换弹频率20到50
maxRange射线最大射程,决定远程命中率80到150

把这些数值单独打一个武器配置文件,用ScriptableObject管理,是更工程化的做法。改出一把“手感对”的枪,我一般会设定一个固定场景:10米距离打移动靶,20米距离打固定靶,60米距离打远靶,每次调完数值都走一遍这三个距离,记录击中次数和击杀时间,对比参数变化前后的手感差异。

6.2 新增敌人类型:继承EnemyAI基类

要加一个新敌人,不需要复制整段AI逻辑。把EnemyAI的Patrol、Chase、Attack方法改成virtual,然后新建一个类继承它,重写Attack方法就行:

public class RushEnemy : EnemyAI { protected override void Attack() { base.Attack(); // 近战敌人:速度翻倍,直接冲向玩家 agent.speed = 8f; if (Vector3.Distance(transform.position, target.position) < 1.5f) { target.TakeDamage(30f, transform.position); } } }

再配合调整NavMeshAgent的Radius值和碰撞矩阵,就能做出一个比远程敌人更具压迫感的近战兵种。敌人AI的状态机设计决定了扩展点在哪里,这就是架构的价值——改一个行为不影响其他敌人。

6.3 嫁接开局界面与结算面板

默认工程进来就直接开打,但一个可玩Demo通常需要菜单和结算。最简单做法是新建一个Startup场景放按钮和标题,在GameManager的StartGame协程开头加一个条件判断,只有点了开始按钮才进入主场景。结算面板的做法类似,监听玩家死亡事件后,把当前生存时间和击杀数写入EventBus数据,UI层再读取渲染。

我在改造时习惯把事件名全部集中在EventBus.cs里定义,比如OnPlayerDied、OnEnemyKilled。这样新增面板时不用去翻各个脚本的调用关系,只需要注册监听。这套工程里的UI已经这样做了,方向是对的,只是事件数量还不够多,需要按自己的需求扩展。

6.4 改造后的验证方式

改完代码一定要跑完整局验证,不是只测一个点。我的固定验证路径是:开局→移动→开火→击杀→换弹→被攻击→死亡→重生,这条路径覆盖了玩家生命周期全流程。每改一个模块,我都强制自己从语音播报开始跑一遍完整流程,确认没有破坏原有功能。

代码这东西,改起来一小时,验证三小时,但验证省下的排查时间往往更多。有一次我改了EnemyAI的状态切换逻辑,测试时只打了几枪就交差了,结果一整个下午都在查为什么敌人会卡在巡逻状态不追击。从那以后我每次改完AI都要喂一局完整的遭遇战,跑完确认所有状态切换路径都正常,才敢说这次改完了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询