公司动态

Unity FPS游戏开发实战:从角色控制到敌人AI的完整指南

📅 2026/8/27 6:47:36
Unity FPS游戏开发实战:从角色控制到敌人AI的完整指南
简介在Unity游戏开发中第一人称射击FPS是入门者最常接触的经典类型它涵盖了角色控制、物理碰撞、射线检测、人工智能等核心机制。本文从基础技术概念出发深入解析Character Controller与Rigidbody在角色移动中的取舍讲解如何通过摄像机射线实现精准的射击判定并利用简单的枚举状态机驱动敌人AI的巡逻、追击与攻击行为。这些原理不仅是FPS游戏的核心更是理解游戏引擎工作方式的钥匙。通过学习这些技术你能够快速搭建一个可玩的射击游戏原型应对期末作业、课程设计或独立游戏开发的实际需求。文章还结合了NavMesh导航、协程换弹等工程实践并提供了常见问题排查思路帮助你避开开发中的典型陷阱。无论你是Unity初学者还是正在准备项目都能从中获得从理论到落地的完整参考。1. 项目整体设计与技术选型1.1 为什么选FPS作为Unity期末作业每到期末Unity课程设计项目就会在各个群里炸开锅。我见过太多人上来就问有没有现成的Unity期末作业源码但说实话第一人称射击游戏FPS这个选题确实是所有Unity期末作业里性价比最高、最容易出效果也最好写报告的方向之一。原因很简单FPS类型的游戏天然自带一套成熟的系统模块——角色控制、摄像机跟随、射击判定、敌人AI、UI交互、游戏状态管理。这套组合拳几乎覆盖了Unity的核心功能面脚本生命周期、物理刚体、碰撞检测、射线投射、动画系统、UI事件、场景管理。等你把这套东西做完Unity的常用API基本都摸过一遍了老师在验收时问什么你都能接住。另外还有一层现实考量——FPS游戏做出来的演示效果足够抓眼球。期末展示环节你一放出第一人称视角举枪射击、敌人倒地的画面比那些只做了个UI界面或者简单物体移动的项目高出好几个档次。但这不等于说FPS就是简单的代名词。相反如果基本功不扎实很容易在几个经典问题上翻车——角色抖动、子弹穿墙、摄像机穿模、敌人卡路点。这些坑我在第4章里会逐一拆解。1.2 期末项目要交付什么按照大多数学校的要求Unity期末作业通常需要交付三样东西可运行的完整工程或者打包后的exe、源码展示、设计报告。有些老师还会要求现场答辩演示。所以在动手之前我强烈建议你先花半天时间把交付标准搞清楚——有没有要求必须打包报告格式是模板还是自由发挥要不要写测试用例文档这些决定了你要在哪儿投入精力。我带的这个项目最终定位如下平台Windows桌面端引擎版本Unity 2021.3 LTS长期支持版本UI Toolkit相关API稳定兼容性好实现语言C#核心玩法第一人称视角场景中有多个敌人玩家通过鼠标瞄准射击消灭敌人计分并限制弹药敌人被击中后有反馈、死亡后会重生附加功能准星设计、剩余血量/弹药UI、简单的开始与结束界面、按键绑定说明交付物包括完整Unity工程文件夹含Assets源码、场景文件、一份约30页的设计报告含流程图、UML类图、核心代码说明、一段3分钟的演示操作视频。1.3 技术路线的几个关键决策先交代我踩过之后认为最省心的技术选型。角色控制器用Character Controller组件而不是Rigidbody刚体原因后面单独说。射击判定采用主摄像机发射射线Raycast不做子弹落点模拟——这是目前市面绝大多数轻量级FPS网课和商店资源包的标准做法期末项目到这个精度完全足够。敌人AI用最简单的状态机巡逻、追击、攻击。不需要上行为树插件也不用动画状态机的复杂过渡一个枚举状态加几个if分支就能跑通关键是逻辑清晰、报告好写。场景模型全部用Unity内置的Cube、Capsule组合搭建这样能避开外部模型导入的各种坑贴图丢失、比例失控、FBX格式不兼容也方便打包。UI部分用Unity的ugui。写这个项目的年代UI Toolkit虽然已经可用但ugui的文档多、教程多、踩坑经验也多期末阶段求稳才是第一原则。正式做之前我建议你确认一下自己手上的Unity版本新版6000.x之后的ugui虽然依然支持但新建UI时会默认走UI Toolkit流程不要在这里浪费时间纠结版本一致跟着官方模板走就行。2. 核心系统逐环节拆解2.1 角色控制器Character Controller与Rigidbody该选谁这是FPS项目第一个分岔路口。用Character Controller还是Rigidbody实现角色移动网上争论能吵出几百楼。我直接给结论期末项目用Character Controller。Character Controller是Unity针对人形角色控制设计的高层封装组件它自带碰撞体胶囊形状和移动逻辑不需要手动处理刚体力学的加速度、摩擦力、反弹。最关键是它的Move()方法做的是受碰撞约束的位置增量——简单说就是你让它往前走它碰墙了会停下来但不会像刚体那样弹开或者卡进墙里。这对第一人称游戏来说是救命级的特性不然你要花大量精力处理碰撞反弹的问题。而Rigidbody方案走的是物理引擎的动力学路线。好处是可以用AddForce做惯性、后坐力、击飞效果物理表现力强。但代价是你必须自己控制碰撞后的运动学行为一个常见的翻车案例是玩家按住W往前顶墙刚体在力的作用下持续压缩碰撞体积最后积累能量一松手人就弹射出去了。这种踩弹簧效应处理起来非常恶心需要加各种摩擦力参数和速度钳制。代码上两种方案最大的区别在这。Character Controller的移动是这样public class PlayerMotor : MonoBehaviour { public float walkSpeed 5f; public float runSpeed 10f; public float jumpHeight 1.5f; public float gravity -9.81f; private CharacterController controller; private float verticalVelocity; private bool isGrounded; void Start() { controller GetComponentCharacterController(); } void Update() { if (controller.isGrounded verticalVelocity 0) { verticalVelocity -2f; } float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 move (transform.right * horizontal transform.forward * vertical) * (Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed); verticalVelocity gravity * Time.deltaTime; move.y verticalVelocity; controller.Move(move * Time.deltaTime); } }注意这里有个容易踩坑的点Character Controller的Move方法传入的已经是经过Time.deltaTime缩放的距离向量不要自己再乘一次。很多人第一版代码写成controller.Move(move * Time.deltaTime * Time.deltaTime)结果角色移动极慢还以为是速度参数没调对。2.2 鼠标视角控制与摄像机设计第一人称视角的本质是鼠标移动控制视角旋转。其中水平方向的偏航角Yaw旋转整个玩家对象包括胶囊体垂直方向的俯仰角Pitch只旋转摄像机。为什么水平旋转要给整个玩家对象而垂直只转摄像机因为AI敌人需要判断玩家朝向如果你把水平旋转也放在摄像机上玩家胶囊体的forward方向就不会变寻敌判定会错乱。这个系统的代码如下public class MouseLook : MonoBehaviour { public float mouseSensitivity 100f; public Transform playerBody; public float maxLookAngle 80f; private float xRotation 0f; void Start() { Cursor.lockState CursorLockMode.Locked; Cursor.visible false; } void Update() { float mouseX Input.GetAxis(Mouse X) * mouseSensitivity * Time.deltaTime; float mouseY Input.GetAxis(Mouse Y) * mouseSensitivity * Time.deltaTime; playerBody.Rotate(Vector3.up * mouseX); xRotation - mouseY; xRotation Mathf.Clamp(xRotation, -maxLookAngle, maxLookAngle); transform.localRotation Quaternion.Euler(xRotation, 0f, 0f); } }Mathf.Clamp那行是关键不限制角度的话玩家把鼠标往上抬到头摄像机会翻转360度视角直接穿模到脑后。maxLookAngle设为80度比较合理不要给到90因为到了90度时万向锁问题可能会让旋转表现得极其诡异。还有一个细节鼠标灵敏度这里乘以了Time.deltaTime。有些教程不乘而是直接给很大的灵敏度值。两种都能用但乘以deltaTime后数值的逻辑更直观——会变成每秒旋转100度而不是每帧旋转100度后者的旋转速度会随帧率波动高刷屏上转得飞快低配电脑上转得跟蜗牛一样。2.3 射击系统射线检测与命中判定射击系统我直接用一个取自Unity官方FPS示例实现思路的射线检测方案。主摄像机作为枪口起点从屏幕中心向正前方发射一条射线射线命中的第一个碰撞体就是目标。这套做法的本质是你看到屏幕中心那个准星位置就是你子弹落地的位置不需要算弹道、下坠和散射。但有一点必须提前说清楚射线检测是从摄像机中心发出的不是从枪口模型发出的。也就是说你看到枪口在屏幕右下角但子弹其实是从屏幕中心射出去的。这属于屏幕空间射击Screen Space ShootingCS和《使命召唤》里都这么干不用觉得违和。核心射击逻辑public class Gun : MonoBehaviour { public Camera playerCamera; public float damage 30f; public float range 100f; public float fireRate 8f; public float impactForce 30f; public int maxAmmo 30; public int currentAmmo; public float reloadTime 1.5f; public ParticleSystem muzzleFlash; private float nextTimeToFire 0f; private bool isReloading false; void Start() { currentAmmo maxAmmo; } void Update() { if (isReloading) return; if (currentAmmo 0) { StartCoroutine(Reload()); return; } if (Input.GetButtonDown(Fire1) Time.time nextTimeToFire) { nextTimeToFire Time.time 1f / fireRate; Shoot(); } } void Shoot() { muzzleFlash.Play(); currentAmmo--; RaycastHit hit; if (Physics.Raycast(playerCamera.transform.position, playerCamera.transform.forward, out hit, range)) { EnemyHealth enemy hit.transform.GetComponentEnemyHealth(); if (enemy ! null) { enemy.TakeDamage(damage, hit.point, transform.forward); } if (hit.rigidbody ! null) { hit.rigidbody.AddForce(-hit.normal * impactForce, ForceMode.Impulse); } } } IEnumerator Reload() { isReloading true; yield return new WaitForSeconds(reloadTime); currentAmmo maxAmmo; isReloading false; } }这套代码藏着三个值得写进报告的细节第一fireRate控制的是射击间隔而不是每秒发射次数。nextTimeToFire Time.time 1f / fireRate这个公式把射速8换算成间隔0.125秒——报告里写清楚这个换算逻辑答辩时老师很容易被这种知其然也知其所以然的描述打动。第二命中判定只有一层。GetComponentEnemyHealth()要求敌人对象直接挂在被射线命中的碰撞体上。如果像某些复杂项目那样模型是子物体、碰撞体挂在子物体上、脚本挂在父物体上那这里就返回null了。期末项目我建议把脚本和碰撞体放同一对象上避免多层结构。第三弹药和换弹用协程实现。StartCoroutine(Reload())里的WaitForSeconds不会阻塞主线程玩家在换弹期间还能自由移动和转视角。顺带说一句如果用了WaitForSeconds但脚本被禁用了比如玩家死亡时禁用整个控制脚本协程不会自动停止需要在OnDisable里加一句StopAllCoroutines()否则敌人能站起来继续打你。2.4 敌人AI用枚举状态机实现巡逻和追击敌人的AI我不想写太复杂的东西。方案是三个状态的枚举状态机Patrol巡逻、Chase追击、Attack攻击。实现思路是每个敌人身上挂一个EnemyAI脚本带一个DetectionRange玩家进入此范围则切换为追击和AttackRange进入攻击距离则发射弹药。public enum EnemyState { Patrol, Chase, Attack } public class EnemyAI : MonoBehaviour { public EnemyState currentState EnemyState.Patrol; public float detectionRange 20f; public float attackRange 5f; public float moveSpeed 3.5f; public Transform player; public GameObject enemyBullet; public float fireRate 1f; private UnityEngine.AI.NavMeshAgent agent; private Vector3 lastKnownPosition; private float nextFireTime; void Start() { agent GetComponentUnityEngine.AI.NavMeshAgent(); agent.speed moveSpeed; } void Update() { float distanceToPlayer Vector3.Distance(transform.position, player.position); switch (currentState) { case EnemyState.Patrol: PatrolBehavior(); if (distanceToPlayer detectionRange) { currentState EnemyState.Chase; } break; case EnemyState.Chase: agent.isStopped false; agent.SetDestination(player.position); lastKnownPosition player.position; if (distanceToPlayer attackRange) { currentState EnemyState.Attack; } else if (distanceToPlayer detectionRange * 1.5f) { currentState EnemyState.Patrol; } break; case EnemyState.Attack: agent.isStopped true; transform.LookAt(new Vector3(player.position.x, transform.position.y, player.position.z)); if (Time.time nextFireTime) { nextFireTime Time.time fireRate; ShootAtPlayer(); } if (distanceToPlayer attackRange) { currentState EnemyState.Chase; } break; } } void ShootAtPlayer() { Vector3 direction (player.position - transform.position).normalized; GameObject bullet Instantiate(enemyBullet, transform.position direction, Quaternion.LookRotation(direction)); bullet.GetComponentRigidbody().velocity direction * 20f; Destroy(bullet, 3f); } }这套方案有个隐藏要求场景烘焙了NavMesh否则agent.SetDestination不工作。我见过太多人卡在这一步——敌人ai写好了运行后敌人纹丝不动结果发现场景地面根本没有烘焙NavMesh。在Window - AI - Navigation窗口里选中地面勾选Navigation Static打开Bake选项卡点击Bake。这个步骤不复杂但漏掉的人比例极高。关于NavMeshAgent还要注意它和Character Controller的区别。Character Controller控制玩家时是自己的逻辑说了算而NavMeshAgent控制敌人时是寻路算法说了算。你不能边用NavMeshAgent的SetDestination又边直接改transform.position两套系统会互相打架结果敌人疯狂抖动或者来回横跳。3. 从零搭建的完整实现流程3.1 场景搭建用Cube拼出可游玩的战斗场地我强烈建议期末场景用Unity内置的Cube、Plane、Capsule来搭不要从网上下载场景模型。原因有三一是模型版权和格式问题容易在答辩时被抓把柄二是Unity内置对象生成的场景文件体积小上传到教务系统不费劲三是所有碰撞体都是现成的Box Collider不需要手动调整。具体搭一个70x70的方形场地地面拉一个Plane四周放一圈Cube做围墙高度2米厚度0.5米。场地内部放四五个不同尺寸的Cube块做掩体不要一字排开要有高有低有错落这样玩家和AI才有绕柱子、卡视角的空间。敌人出生点在场地四角放置空物体Empty GameObject做SpawnPoint方便脚本中配置。光线方面建议用Baked Light烘焙光照而不是实时光源。操作是所有静态物体勾选Contribute GIWindow - Rendering - Lighting切换Mixed/Baked模式点击Generate Lighting。烘焙过的场景在低配电脑上帧率提升明显而且阴影效果更好看。需要注意烘焙会把灯光信息写进场景之后每次调整物体位置都要重新烘焙否则阴影不更新。3.2 玩家控制完整代码与挂载玩家对象的结构是一个空物体Player下面挂Capsule子物体做可视化模型主摄像机Camera作为Player的子物体挂在眼睛高度约1.7米。Player上挂三个脚本PlayerMotor移动、MouseLook视角、PlayerHealth血量。建议不要在主摄像机上也挂MouseLook而是把MouseLook挂在摄像机物体上引用Player作playerBody。我最开始做的时候把摄像机作为场景里的独立对象忘记挂在Player下面。结果玩家向前走摄像机留在原地视角就灵魂出窍了——玩家操控的角色在画面里越走越远但摄像机纹丝不动。这个错误特别低级但出现频率极高。执行顺序的建议先搭Player对象直接把Camera拖成Player的子物体调整localPosition到(0, 1.7f, 0)然后再写代码。给一个检测玩家对象结构是否正确的快捷方法运行游戏按WASD如果屏幕画面里场景的地面在动说明摄像机跟着角色在走如果画面不动角色却能看到自己或者画面变得很奇怪那就是摄像机没有正确挂载为子物体。3.3 敌人死亡与重生逻辑敌人被击中后要有反应。这里我用了EnemyHealth脚本包含血量、死亡动画和重生。死亡动画不做复杂动作就是物体放大倒下或者直接隐藏加粒子特效期末阶段用特效遮丑完全够。public class EnemyHealth : MonoBehaviour { public float maxHealth 100f; private float currentHealth; public Transform spawnPoint; public float respawnTime 5f; public GameObject deathEffect; private void Start() { currentHealth maxHealth; } public void TakeDamage(float amount, Vector3 hitPoint, Vector3 hitDirection) { currentHealth - amount; if (currentHealth 0f) { Die(); } } void Die() { Instantiate(deathEffect, transform.position, Quaternion.identity); gameObject.SetActive(false); StartCoroutine(Respawn()); } IEnumerator Respawn() { yield return new WaitForSeconds(respawnTime); currentHealth maxHealth; transform.position spawnPoint.position; transform.rotation spawnPoint.rotation; gameObject.SetActive(true); } }一个实用细节SetActive(false)比Destroy(gameObject)好。原因一是Destroy会延迟到帧末执行此时如果还有代码在引用这个对象会抛MissingReferenceException原因二是重生时需要用到对象Destroy后再Instantiate新对象会产生大量内存垃圾。SetActive暂停对象的所有更新逻辑重生时再激活即可教科书里把这个叫做对象池的简单版。3.4 UI计分与游戏状态管理UI部分做三个Canvas开始界面、游戏状态、结束界面。开始界面放标题和点击开始按钮游戏状态放血条Slider、弹药数Text、击杀数Text结束界面放你赢了/你输了和重新开始的按钮。Canvas的Scale Mode建议用Screen Space - Overlay或者Screen Space - Camera并指定UI相机。Overlay模式最简单UI永远显示在屏幕最上层不需要处理被3D物体遮挡的问题期末项目直接用Overlay。唯一要注意的是Overlay下UI的分辨率适配如果不在Canvas的CanvasScaler里设置UI Scale Mode为Scale With Screen Size打包后在不同分辨率屏幕上UI会跑偏。游戏状态管理用一个静态类GameManager保存当前状态、击杀数、总敌人数并响应UI按钮事件。public enum GameState { Menu, Playing, GameOver } public class GameManager : MonoBehaviour { public static GameManager Instance; public GameState currentState GameState.Menu; public int killCount 0; public int totalEnemies 4; public GameObject startUI; public GameObject gameUI; public GameObject gameOverUI; void Awake() { Instance this; } public void StartGame() { currentState GameState.Playing; startUI.SetActive(false); gameUI.SetActive(true); Cursor.lockState CursorLockMode.Locked; } public void AddKill() { killCount; if (killCount totalEnemies) { EndGame(true); } } void EndGame(bool win) { currentState GameState.GameOver; gameUI.SetActive(false); gameOverUI.SetActive(true); Cursor.lockState CursorLockMode.None; } public void RestartScene() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } }RestartScene()做场景重载是最稳的重置方式所有变量回到初始值比手动一个个重置干净利落得多。4. 常见问题与避坑指南4.1 子弹穿墙多半是射线起点和方向问题很多人调试时发现明明看到屏幕上准星对准了墙壁但开枪后没反应或者子弹偶尔能打穿一面薄墙。这类问题最典型的原因有三个第一射线起点的位置太靠摄像机内部了。我用的是playerCamera.transform.position作为射线起点正常情况下没问题。但如果摄像机嵌在墙体里贴墙太近射线会直接从墙背面出发自然打不中前面。解决方法是发射前做一次OverlapSphere检测如果玩家离墙过近就禁止射击或者把起点稍微前移0.1米。第二Raycast的maxDistance参数可能太小。像上面代码里设的range 100f意味着射线最多飞行100米如果场景尺寸超过这个范围就会失效。解决方法是检查场景边界确保range大于你最远交战距离的1.5倍。第三射线命中了非目标物体。如果敌人身体是多个Cube拼的每个Cube都有Collider射线可能先打在某个Cube上而那个Cube上又没有挂EnemyHealth。这就是为什么我前面强烈建议脚本直接挂在被碰撞的物体上而不是挂在父物体上。4.2 角色移动时飘或抖动Character Controller移动时最容易出现的观感问题是飘——移动速度感觉不对像踩在冰面上。这通常是因为没有正确使用重力。Character Controller的Move方法传入的move.y必须包含重力分量。我前面代码中先把verticalVelocity初始化为0在地面上时强制设为-2f让角色始终受到向下的力当玩家走到悬崖边时角色不会悬空浮走而是会自然坠落。如果这一步漏了角色会浮在斜坡表面平移毫无重力感。另一种抖动发生在移动方向计算有误时。如果你用transform.forward作为前进方向但摄像机旋转和玩家对象旋转存在细微偏差也就是水平旋转同时应用到了摄像机和Player上就会产生蛇形走位或者高频抖动。检查方式在运行时观察Inspector面板中Player和Camera的Rotation值如果两者同时变化说明代码里把MouseLook挂错了对象。4.3 敌人生成时穿模、寻路失灵一个高频问题是敌人重生点设置在掩体内部或者场景中的地面没有烘焙NavMesh。前者会导致敌人卡在墙里后者会导致NavMeshAgent不寻路。正规做法是重生点不要直接放地面位置而是在制作场景时就把SpawnPoint空物体精确放到地面上方0.5米并确保周围没有Collider阻挡。或者在EnemyHealth的Respawn协程里加重生后的一小段无敌时间并把transform.position做一次碰撞检测修正Vector3 newPos spawnPoint.position; if (Physics.OverlapSphere(newPos, 0.5f).Length 1) // 至少包含自身 { newPos.y 1f; } transform.position newPos;烘焙NavMesh之后还有一个检查项确认NavMeshAgent的Area Mask勾选了Walkable。如果烘焙时不小心把某些区域标记为Not Walkable即便视觉上地面平坦敌人也不会走过去。4.4 性能问题打包后帧率骤降期末项目很容易被忽略的一点是场景性能优化。你的项目在编辑器里跑可能60帧但在笔记本上打包后可能掉到20帧。常见瓶颈烘焙的光照贴图尺寸过大模型面数过高Post-processing特效堆太多。我的建议是场景中物体数量控制在200个以内贴图尺寸不超过1024x1024不要给所有小物体挂实时阴影。如果发现画面明显卡顿可以在Player Settings的Quality等级中把阴影质量、抗锯齿调到Medium。终极方案是关闭场景自带的Post-processing Volume那个胶片颗粒或景深特效在期末答辩的投影仪上根本看不出来还白白吃掉大量性能。4.5 常见问题速查表问题现象排查方向解决方案子弹打不中敌人射线起点/方向/距离确认Camera位置增大range检查命中物体的层级角色无法转身MouseLook脚本挂载错误水平旋转应旋转Player对象垂直旋转只转Camera敌人不动NavMesh未烘焙Window - AI - Navigation选中地面Bake敌人死循环转向LookAt导致的朝向混乱锁定Y轴只让敌人水平转向玩家游戏结束UI不弹GameManager单例被销毁检查GameManager对象是否DontDestroyOnLoad换弹瞬间开火无反应isReloading状态未判断在Update开头增加isReloading检查5. 设计报告怎么写才能拿高分5.1 报告结构和每个章节的写法很多同学把设计报告当成使用说明书大段贴代码老师看完一头雾水。实际上报告的核心价值是展示你的设计思路和问题解决能力不是代码陈列。我推荐一个简单直接的章节结构项目概述一两页写清游戏类型、玩法、开发引擎与版本需求分析列出功能需求和非功能需求性能要求、操作体验系统设计画出客户端模块划分图可以用文字描述模块关系和职责核心实现挑2-3个技术难点重点展开比如射线射击判定、状态机AI、协程换弹测试与调试写你遇到的三五个Bug和排查过程总结与展望项目做完了学到什么还有哪些可以优化其中核心实现环节是得分的重点要把代码逻辑讲清楚而不是贴代码。举个例子你写射击系统时可以这样描述射击系统通过主摄像机发射一条可视化的射线实现。射线起点为摄像机位置方向为摄像机正前方向量。设定最大射程为100米当射线与场景中任意碰撞体相交时系统通过GetComponent判断目标是否携带EnemyHealth组件。若存在调用其TakeDamage方法并传入伤害值实现扣血逻辑。为防止同一目标被连续多帧命中系统通过攻击间隔fireRate限制触发频率每次开火后需要等待1/fireRate秒才可再次发射。这样写即使没贴完整代码老师也能看懂你的设计逻辑。在此基础上再附上一段核心代码片段只贴关键部分说明对应设计。把代码拆散到思路关键代码结果解释的结构里而不是整页整页堆代码报告的可读性会好很多。5.2 截图与演示视频的制作技巧报告里的截图要经过挑选不要直接对着Unity编辑器随便截一张图就放上去。优秀的截图应该包含场景编辑时加了辅助标签的界面、游戏运行时玩家视角的射击瞬间、敌人被命中时的粒子特效帧、UI界面跳转的关键节点。建议在开发过程中就习惯性按PrintScreen或使用Unity内置的Recording窗口把编辑器状态和运行状态分别截图在报告中用红框或箭头标注关键组件位置。用Snipaste或Snipping Tool截图后在报告排版时统一缩放到合适宽度不要出现比例不一致的情况。演示视频建议用OBS录制选择游戏窗口区域格式输出mp4时长控制在3分钟左右。视频内容建议不要从头到尾平铺直叙而是按照开始界面 - 移动射击 - 敌人刷新 - 换弹 - 胜利界面的顺序一边操作一边配上语音解释我现在用鼠标瞄准了这个敌人按下左键发射射线命中后敌人掉血。这样老师不看报告光看视频就能了解项目全貌答辩时相当于你预先铺了一遍路。5.3 答辩时的加分回答思路答辩时老师大概率会问几个固定问题提前把答案想清楚为什么用射线检测而不是子弹预制体碰撞——射线检测计算量极小不需要实例化物体、模拟物理飞行代码简单适合轻量级FPS。子弹预制体虽然可以表现弹道弧线和不同武器特性但在期末项目的规模下是过度设计。为什么用Character Controller而不是Rigidbody——Character Controller自带胶囊碰撞特性能可靠模拟人形角色避开了刚体碰撞反弹的经典问题控制逻辑更直观。Rigidbody适合需要复杂物理交互的角色比如可以被打飞的布娃娃但FPS主角通常不需要这些。敌人的AI是怎么设计的——状态机三个状态。这个答法可以展开说为什么要用状态机而不是决策树或行为树因为状态机对于三个状态来说逻辑最清晰行为树的优势在于大量行为和复杂决策层级期末规模用不到。这种回答方式展示的是我知道还能怎么做也明白为什么不做比背代码给人的印象深得多。写在最后的一点个人经验这个项目从头到尾做完前前后后我花了两周左右每天差不多三四小时。第一版在角色控制上反复调整手感就耗了两天——鼠标灵敏度、移动速度、跳跃力每一项都需要一步步试。Unity就是这样很多参数没有绝对正确值只有合不合适。我最大的体会是期末项目最重要的不是炫技而是体系完整。哪怕敌人AI就是最基础的三个状态只要写清楚了运行逻辑在报告里分析透了就已经完成了课程设计的目标。反倒是那种从网上下载一整包完整FPS商店项目、删删改改交上去的答辩时一问三不知老师随便深挖一个变量名字都能看出来是别人的东西反而不讨好。最后分享一个我在调试过程中觉得最有用的小技巧在场景里放一个文本UI显示调试信息——玩家坐标、当前状态、弹药数量、射线命中物体的名称。每次遇到问题先看这个文本面板再猜原因比看日志快得多。这个调试面板在答辩演示时留着或者删掉都可以但开发阶段它帮你省下的时间绝对值得你花10分钟做出来。本文还有配套的精品资源点击获取