發(fā)實(shí)戰(zhàn):角色控制器、武器射擊與AI狀態(tài)機(jī)全解析)
簡(jiǎn)介一套可在 Unity3D 中直接運(yùn)行的 FPS 游戲完整工程源碼面向希望掌握第一人稱射擊玩法和聯(lián)機(jī)對(duì)戰(zhàn)實(shí)現(xiàn)的中初級(jí)開(kāi)發(fā)者可用于學(xué)習(xí)項(xiàng)目搭建與功能拆解。壓縮包內(nèi)共 1609 個(gè)文件其中包含大量場(chǎng)景與資源索引用的 bin、info、meta 文件以及核心的 asset、prefab、mat、cs 等工程配置和腳本文件整體約 38.91MB。已有 2409 人瀏覽學(xué)習(xí)。借助這套工程可以系統(tǒng)查看 FPS 項(xiàng)目的基礎(chǔ)目錄結(jié)構(gòu)、角色移動(dòng)與射擊邏輯、資源管理方式并了解 Unity3D 中通過(guò)網(wǎng)絡(luò)庫(kù)同步玩家狀態(tài)的基本思路。對(duì)于正在規(guī)劃自己的多人 FPS 原型、需要一份可運(yùn)行參考代碼的開(kāi)發(fā)者來(lái)說(shuō)具有一定的對(duì)照和借鑒價(jià)值。1. Untiy3D_FPS 不是成品游戲是一套能直接跑的 FPS 框架拿到 Untiy3D_FPS 這個(gè)資源包先別急著想著“導(dǎo)入就能玩”。它不是一個(gè)完整可發(fā)行的 FPS 游戲而是一套把射擊游戲從零搭到可玩狀態(tài)的框架工程角色控制器、鼠標(biāo)視角、武器射擊與換彈、敵人 AI、命中反饋和基礎(chǔ) UI 全都在里面。它最省事的地方是——你不必從空?qǐng)鼍伴_(kāi)始去建角色、寫相機(jī)、調(diào)物理直接打開(kāi)場(chǎng)景就能跑適合正在做 FPS 畢業(yè)設(shè)計(jì)、想快速驗(yàn)證核心玩法的人。更值錢的是它把“怎么組合起來(lái)”演示了一遍控制器怎么掛、相機(jī)放哪一層、武器腳本為什么這么寫照著它的結(jié)構(gòu)能省掉一周的試錯(cuò)。2. 角色控制器Rigidbody 選型、視角分離與手感參數(shù)FPS 里的“走路手感”是第一個(gè)分水嶺。這套工程用的是 Rigidbody 方案而不是 Unity 自帶的 CharacterController很多剛?cè)腴T的開(kāi)發(fā)者會(huì)在這個(gè)選擇上翻車原因值得先講清楚。2.1 為什么不用 CharacterController 而用 RigidbodyCharacterController 自帶 Move 接口用起來(lái)像“推著膠囊走”好處是寫起來(lái)簡(jiǎn)單、不會(huì)穿墻壞處是它不走物理引擎碰上移動(dòng)平臺(tái)、爆炸沖擊波、子彈擊退這些需要物理反饋的場(chǎng)景就要額外寫很多補(bǔ)丁邏輯。Rigidbody 方案讓角色真的“睡”在物理系統(tǒng)里障礙物阻擋、斜坡滑行、被炸彈炸飛都是免費(fèi)送的代價(jià)是會(huì)遇到抖動(dòng)、穿模這些物理調(diào)參問(wèn)題。這套工程的控制器大致長(zhǎng)這樣using UnityEngine; public class FPSController : MonoBehaviour { [Header(移動(dòng)參數(shù))] [SerializeField] private float moveSpeed 6f; // 普通行走速度 [SerializeField] private float sprintSpeed 9f; // 疾跑速度 [SerializeField] private float jumpForce 5f; // 跳躍初速度 [SerializeField] private float groundCheckDistance 0.2f; private Rigidbody rb; private bool isGrounded; void Start() { rb GetComponentRigidbody(); rb.freezeRotation true; // 凍結(jié)旋轉(zhuǎn)防止物理引擎把角色撞倒 rb.interpolation RigidbodyInterpolation.Interpolate; // 減少運(yùn)動(dòng)抖動(dòng) } void FixedUpdate() { // 地面檢測(cè)向腳下射一條短射線比 OnCollisionStay 更穩(wěn)定 isGrounded Physics.Raycast(transform.position, Vector3.down, groundCheckDistance); if (Input.GetButtonDown(Jump) isGrounded) { rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); } float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); Vector3 moveDir transform.right * h transform.forward * v; moveDir Vector3.ClampMagnitude(moveDir, 1f); float targetSpeed Input.GetKey(KeyCode.LeftShift) ? sprintSpeed : moveSpeed; // MovePosition 在物理幀里移動(dòng)受碰撞約束不會(huì)直接瞬移穿墻 rb.MovePosition(transform.position moveDir * targetSpeed * Time.fixedDeltaTime); } }這段代碼有三個(gè)容易被忽略的點(diǎn)。第一rb.interpolation InterpolateRigidbody 默認(rèn)在低幀率下會(huì)有明顯的“一格一格”移動(dòng)開(kāi)了插值后畫面會(huì)順滑很多這是 FPS 手感的第一步。第二ClampMagnitude是必要的因?yàn)殒I盤輸入從斜方向同時(shí)按下時(shí)Vector3的長(zhǎng)度會(huì)變成 1.414 倍不截?cái)嗟脑捫敝軙?huì)比正著跑速度快很多。第三跳躍檢測(cè)放在Update里、物理移動(dòng)放在FixedUpdate里標(biāo)準(zhǔn)做法因?yàn)镕ixedUpdate的調(diào)用頻率固定而跳躍輸入是逐幀的放在Update里響應(yīng)最及時(shí)。2.2 鼠標(biāo)視角Pitch/Yaw 分離消除萬(wàn)向鎖相機(jī)視角是 FPS 最容易做出“暈 3D 感”的部分。很多人直接對(duì)相機(jī)物體做RotateAround結(jié)果抬頭到 90 度后視角開(kāi)始亂轉(zhuǎn)這是歐拉角的萬(wàn)向鎖問(wèn)題。這套工程的處理方式是水平旋轉(zhuǎn)Yaw作用在角色身上垂直旋轉(zhuǎn)Pitch只作用在相機(jī)上兩者徹底分離。using UnityEngine; public class MouseLook : MonoBehaviour { [Header(靈敏度)] [SerializeField] private float mouseSensitivity 2f; [SerializeField] private float pitchMin -85f; // 低頭下限 [SerializeField] private float pitchMax 85f; // 抬頭上限 private float pitch; // 當(dāng)前俯仰角 void Start() { Cursor.lockState CursorLockMode.Locked; // 鎖定鼠標(biāo)光標(biāo)到屏幕中心 } void Update() { float mouseX Input.GetAxis(Mouse X) * mouseSensitivity; float mouseY Input.GetAxis(Mouse Y) * mouseSensitivity; // 水平旋轉(zhuǎn)旋轉(zhuǎn)角色節(jié)點(diǎn) transform.parent.Rotate(0f, mouseX, 0f); // 垂直旋轉(zhuǎn)使用歐拉角累加并夾緊避免萬(wàn)向鎖 pitch - mouseY; pitch Mathf.Clamp(pitch, pitchMin, pitchMax); transform.localRotation Quaternion.Euler(pitch, 0f, 0f); } }掛載方式是相機(jī)作為角色的子物體MouseLook掛在相機(jī)上transform.parent指向角色。這么做的好處是角色模型的轉(zhuǎn)身和相機(jī)視角的左右轉(zhuǎn)動(dòng)完全同步而上下俯仰不會(huì)帶動(dòng)整個(gè)身體旋轉(zhuǎn)——從第三人稱視角看角色是“轉(zhuǎn)頭”而不是整個(gè)身體都跟著低頭。Cursor.lockState用CursorLockMode.Locked而不用Confined因?yàn)?Locked 才能保證射擊游戲里鼠標(biāo)不撞到屏幕邊緣。2.3 手感參數(shù)怎么調(diào)一張表和兩個(gè)檢查點(diǎn)有了代碼還需要參數(shù)這套工程把參數(shù)都暴露在 Inspector 里方便在編輯狀態(tài)下隨時(shí)改。給一組我試過(guò)比較穩(wěn)的初始值作為參考參數(shù)建議范圍默認(rèn)值說(shuō)明moveSpeed486低于 4 像走路高于 8 轉(zhuǎn)向會(huì)漂sprintSpeed8129需要和 FOV 聯(lián)動(dòng)效果更好jumpForce465高于 6 跳躍滯空時(shí)間過(guò)長(zhǎng)mouseSensitivity1.532和鼠標(biāo)設(shè)備 DPI 相關(guān)不是絕對(duì)標(biāo)準(zhǔn)pitchMax / pitchMin8090±85留 5 度余量避免邊緣抖動(dòng)兩個(gè)檢查點(diǎn)第一個(gè)跑起來(lái)時(shí)感覺(jué)“飄”還是“沉”。飄就降移動(dòng)速度或者增大drag值沉就檢查是不是drag設(shè)太高了導(dǎo)致慣性感過(guò)大。第二個(gè)轉(zhuǎn)身快速滑鼠標(biāo)時(shí)有沒(méi)有掉幀感有的話檢查相機(jī)的Field of View默認(rèn) 60 度在高速轉(zhuǎn)向時(shí)容易暈調(diào)到 7080 度會(huì)明顯改善。手感調(diào)試沒(méi)有絕對(duì)公式但先把這兩點(diǎn)檢查完再去動(dòng)其他參數(shù)能減少很多盲目試錯(cuò)。3. 武器系統(tǒng)與射擊邏輯射線檢測(cè)、散布、換彈與彈藥數(shù)據(jù)角色能走了下一步就是能打人。這套工程的武器模塊做了三個(gè)層次射擊模式、彈藥換彈、后坐力與散布分別是“能不能打”“打多少發(fā)”“打得準(zhǔn)不準(zhǔn)”三層問(wèn)題。3.1 單發(fā)/連發(fā)/霰彈三種射擊模式在一套腳本里武器腳本的核心不是子彈而是“開(kāi)火判定”。這套工程默認(rèn)用射線檢測(cè)模擬子彈——從槍口向正前方打一條Ray如果射線碰到了帶IDamageable接口的對(duì)象就執(zhí)行傷害邏輯。射線檢測(cè)比彈道物體物理子彈性能高很多而且不需要處理子彈飛行速度、碰撞反彈這類問(wèn)題適合原型階段。using UnityEngine; public class WeaponController : MonoBehaviour { [Header(武器屬性)] public float damage 25f; public float range 100f; public float fireRate 0.12f; // 連發(fā)間隔秒 public int maxAmmo 30; public int currentAmmo; [Header(射擊模式)] public bool isAutomatic true; // true連發(fā)false單發(fā) public int pellets 1; // 1普通單發(fā)1 為霰彈 [Header(散布)] public float spreadAngle 1.5f; // 準(zhǔn)星擴(kuò)散角度 private float nextFireTime; void Start() currentAmmo maxAmmo; public void TryFire() { bool canShoot isAutomatic ? Input.GetButton(Fire1) : Input.GetButtonDown(Fire1); if (!canShoot || Time.time nextFireTime || currentAmmo 0) return; nextFireTime Time.time fireRate; currentAmmo--; for (int i 0; i pellets; i) { // 每次開(kāi)火給一個(gè)隨機(jī)方向偏轉(zhuǎn)模擬彈道散布 Vector3 dir Camera.main.transform.forward; dir Quaternion.Euler( Random.Range(-spreadAngle, spreadAngle), Random.Range(-spreadAngle, spreadAngle), 0f) * dir; if (Physics.Raycast(Camera.main.transform.position, dir, out RaycastHit hit, range)) { // 命中接口調(diào)用Unity 中常見(jiàn)的是 hit.collider.GetComponentIDamageable() } } } void Update() TryFire(); // 簡(jiǎn)化寫法實(shí)際工程會(huì)放在 Input 事件回調(diào)里 }幾個(gè)參數(shù)是決定手感的關(guān)鍵。spreadAngle的單位是角度不是像素距離1.5 度意味著在 100 米外子彈會(huì)偏離約 2.6 米打遠(yuǎn)程精度要求高的場(chǎng)景要降到 0.5 度以下。fireRate是射擊間隔0.12 秒相當(dāng)于每分鐘 500 發(fā)這是沖鋒槍級(jí)別的射速如果要做步槍手感調(diào)到 0.150.2 更合適。pellets設(shè)為 8 就是霰彈槍但注意每顆彈丸都獨(dú)立計(jì)算散布和傷害所以霰彈槍近距離傷害爆發(fā)極高需要在數(shù)值上做距離衰減。3.2 彈藥與換彈數(shù)值同步比開(kāi)槍邏輯更值得關(guān)注射擊邏輯本身好寫真正容易出 bug 的是彈藥系統(tǒng)和 UI 同步。這套工程里彈藥分兩層彈匣容量currentAmmo和后備彈藥reserveAmmo。換彈不是瞬間完成的要用協(xié)程模擬換彈動(dòng)畫的耗時(shí)。public class AmmoSystem : MonoBehaviour { public int reserveAmmo 90; // 后備彈藥 public int magSize 30; // 彈匣容量 public float reloadTime 2.2f; // 換彈耗時(shí) public int currentAmmo; public bool isReloading; void Start() currentAmmo magSize; public IEnumerator Reload() { if (isReloading || currentAmmo magSize) yield break; isReloading true; yield return new WaitForSeconds(reloadTime); // 等換彈動(dòng)畫播放完 // 從后備彈藥中取出補(bǔ)滿彈匣所需數(shù)量 int need magSize - currentAmmo; int take Mathf.Min(need, reserveAmmo); currentAmmo take; reserveAmmo - take; isReloading false; } }換彈的邊界情況很多如果彈匣是滿的按 R 不應(yīng)該觸發(fā)換彈如果后備彈藥為 0換彈應(yīng)該直接不開(kāi)始并提示玩家角色在換彈空隙死亡協(xié)程卻還在跑會(huì)導(dǎo)致復(fù)活后彈藥被憑空補(bǔ)充。解決后兩個(gè)問(wèn)題的方法是在協(xié)程開(kāi)頭和結(jié)尾都檢查isReloading狀態(tài)、在角色死亡時(shí)停掉所有協(xié)程并重置彈藥狀態(tài)。數(shù)值同步上UI 的彈藥顯示直接用currentAmmo / reserveAmmo不要在 UI 里再做一次加減運(yùn)算否則很容易出現(xiàn)“換完彈顯示 31 發(fā)”這種尷尬的數(shù)字。3.3 后坐力與準(zhǔn)星擴(kuò)散調(diào)手感像調(diào)音后坐力在代碼層很直接就是開(kāi)火時(shí)給相機(jī)加一個(gè)向上的脈沖角度然后讓它慢慢回落。但這套工程里真正的調(diào)節(jié)重點(diǎn)是“準(zhǔn)星擴(kuò)散”——不開(kāi)火時(shí)準(zhǔn)星是收緊的持續(xù)開(kāi)火時(shí)擴(kuò)散變大?;鸷笾饾u恢復(fù)。這個(gè)機(jī)制比單純的屏幕抖動(dòng)更能影響手感。常見(jiàn)的做法是維護(hù)一個(gè)currentSpread變量每次開(kāi)火增加一個(gè)固定量每幀衰減回去public float baseSpread 0.5f; // 靜止時(shí)最小散布 public float maxSpread 5f; // 連續(xù)射擊后最大散布 public float spreadIncrease 0.3f; // 每發(fā)增量 public float spreadRecover 2f; // 每秒恢復(fù)量 private float currentSpread; void UpdateSpread() { if (currentSpread baseSpread) currentSpread - spreadRecover * Time.deltaTime; currentSpread Mathf.Clamp(currentSpread, baseSpread, maxSpread); // 準(zhǔn)星 UI 的寬度和這個(gè)值綁定 }所有槍支手感調(diào)到最后都會(huì)變成一個(gè)“數(shù)值表格”——射速、后坐力、散步恢復(fù)速度三者互為犄角。射速快但回復(fù)慢的槍手感是一路飄上去的射速慢但回復(fù)快的槍適合點(diǎn)射型玩法。這個(gè)參數(shù)組合沒(méi)有唯一解只能照著自己玩起來(lái)的感覺(jué)微調(diào)。我一般會(huì)把連發(fā)武器的spreadIncrease調(diào)到 0.30.4 之間這樣 10 發(fā)左右能明顯感到散布變大而不是前 5 發(fā)就糊成一團(tuán)。4. 敵人 AI 與命中反饋狀態(tài)機(jī)、NavMesh 與傷害數(shù)字打靶場(chǎng)景能走通之后就要面對(duì) FPS 的第二塊硬骨頭敵人。這套工程的敵人做了一個(gè)最基礎(chǔ)但結(jié)構(gòu)正確的狀態(tài)機(jī)加上 NavMesh 尋路沒(méi)有堆復(fù)雜算法但足夠撐起一個(gè)可玩的關(guān)卡。4.1 狀態(tài)機(jī)先寫對(duì)再談智能敵人的 AI 狀態(tài)可以精簡(jiǎn)為四個(gè)巡邏、追擊、攻擊、死亡。不用一開(kāi)始就上行為樹(shù)普通 FPS 的敵人用狀態(tài)機(jī)完全夠用而且調(diào)試起來(lái)更直觀。核心邏輯是每幀根據(jù)和玩家的距離切換狀態(tài)public enum EnemyState { Patrol, Chase, Attack, Dead } public class EnemyAI : MonoBehaviour { public EnemyState state EnemyState.Patrol; public float chaseRange 15f; // 發(fā)現(xiàn)玩家的距離 public float attackRange 8f; // 開(kāi)火距離 public float loseRange 25f; // 丟失玩家的距離大于 chaseRange 才合理 private Transform player; void Update() { if (state EnemyState.Dead) return; float dist Vector3.Distance(transform.position, player.position); if (dist attackRange) state EnemyState.Attack; else if (dist chaseRange) state EnemyState.Chase; else if (dist loseRange) // 用更大距離判斷丟失避免玩家一閃身就回去巡邏 state EnemyState.Patrol; } }這里最容易忽略的細(xì)節(jié)是loseRange要大于chaseRange。如果追擊和丟失用同一個(gè)距離判斷玩家站在 14.5 米處反復(fù)橫跳敵人就會(huì)在巡邏和追擊之間抽搐。這屬于經(jīng)典的“狀態(tài)機(jī)搖擺”問(wèn)題做法就是給進(jìn)入狀態(tài)和退出狀態(tài)設(shè)置不同的閾值形成一個(gè)遲滯區(qū)間。另外狀態(tài)切換不一定要每幀檢測(cè)可以每 0.2 秒執(zhí)行一次省掉大量性能開(kāi)銷敵人肉眼也看不出區(qū)別。4.2 NavMesh 烘焙參數(shù)決定敵人會(huì)不會(huì)卡墻角NavMesh 是 Unity 內(nèi)置的導(dǎo)航網(wǎng)格系統(tǒng)敵人靠它在場(chǎng)景里找路。烘焙之前要檢查三個(gè)參數(shù)Agent Radius代理半徑、Agent Height代理高度、Max Slope最大爬坡角。參數(shù)建議值說(shuō)明Agent Radius0.30.5過(guò)大會(huì)讓敵人走不進(jìn)窄通道過(guò)小會(huì)貼墻Agent Height1.52以敵人的膠囊體高度為準(zhǔn)不要超過(guò)門框Max Slope3045超過(guò) 45 度的樓梯會(huì)被標(biāo)記為不可走Step Height0.3臺(tái)階高度大于此值會(huì)需要繞路實(shí)際踩坑點(diǎn)在于烘焙完之后敵人還是會(huì)走到網(wǎng)格邊緣然后卡住。這種情況多半是目標(biāo)點(diǎn)玩家位置在 NavMesh 外面——比如玩家站在一個(gè)未烘焙的平臺(tái)上NavMesh 找不到通往目標(biāo)點(diǎn)的路徑。處理方式是追擊時(shí)先對(duì)玩家坐標(biāo)做一次NavMesh.SamplePosition把目標(biāo)點(diǎn)拉回最近的可行走位置而不是直接塞給尋路組件。public NavMeshAgent agent; public Transform player; void Chase() { // 將目標(biāo)點(diǎn)投影到 NavMesh 上偏移最大 2 米 if (NavMesh.SamplePosition(player.position, out NavMeshHit hit, 2f, NavMesh.AllAreas)) { agent.SetDestination(hit.position); } }如果不做這一步玩家站上箱子后敵人會(huì)一直在原地打轉(zhuǎn)看起來(lái)像是在“隔空推墻”。調(diào)試這類問(wèn)題的時(shí)候不要直接猜邏輯錯(cuò)誤先在 Scene 視圖把NavMesh和NavMeshAgent的路徑勾選顯示出來(lái)路徑的終點(diǎn)在哪一眼就能看到。4.3 傷害判定與命中反饋為什么子彈“沒(méi)打中”敵人的碰撞體、武器的射線檢測(cè)、傷害接口三者要能對(duì)接上需要提前定好 Layer。這套工程的做法是敵人放在Enemy層玩家放在Player層射線檢測(cè)時(shí)只檢測(cè) Enemy 層避免子彈打到自己和友方單位。[SerializeField] LayerMask enemyLayer; void Fire() { if (Physics.Raycast(ray, out RaycastHit hit, range, enemyLayer)) { if (hit.collider.TryGetComponentIDamageable(out IDamageable target)) target.TakeDamage(damage); } }這里有一個(gè)常見(jiàn)的“沒(méi)打中”假象子彈確實(shí)碰到了敵人的碰撞體但傷害接口掛在敵人根節(jié)點(diǎn)上而射線碰到的碰撞體在子節(jié)點(diǎn)比如手臂GetComponent只能拿到子節(jié)點(diǎn)自己的組件接口自然就調(diào)不到。解決方式有兩個(gè)要么在子節(jié)點(diǎn)上也掛傷害接口并轉(zhuǎn)發(fā)給根節(jié)點(diǎn)要么用GetComponentInParent向上查找。我會(huì)在碰撞體子物體上放一個(gè)空腳本轉(zhuǎn)發(fā)因?yàn)镚etComponentInParent在深層級(jí)的消耗不算小。這層問(wèn)題理解了后面去做爆頭、部位傷害就順理成章——給頭部單獨(dú)掛一個(gè)碰撞體和IDamageable實(shí)現(xiàn)接口里傳入傷害倍率即可。5. 踩坑實(shí)錄五個(gè)讓角色打不出傷害的常見(jiàn)問(wèn)題這套工程我前后跑了三遍每次重裝 Unity 版本都會(huì)踩到新坑。下面的問(wèn)題不分順序全部是從實(shí)際運(yùn)行中一條條記下來(lái)的有現(xiàn)象、有原因、有解法按這幾個(gè)方向排查能省下大量搜論壇的時(shí)間。5.1 運(yùn)行即報(bào) NullReferenceException現(xiàn)象打開(kāi)場(chǎng)景點(diǎn) PlayConsole 面板立刻彈出一堆NullReferenceException提示某個(gè)腳本的組件引用為空。角色站在原地不動(dòng)相機(jī)無(wú)法旋轉(zhuǎn)。原因腳本 Inspector 上拖拽的引用丟失了。常見(jiàn)于 Unity 版本跨大版本升級(jí)比如從 2018 升到 2020序列化字段的m_FileID變化導(dǎo)致引用斷開(kāi)但腳本本身還掛著。解決先把出問(wèn)題的腳本組件移除再重新添加。如果工程里引用很多就用腳本批量掃描為空引用選中所有預(yù)制體后檢查每個(gè)SerializedObject的FindProperty是否有objectReferenceValue null。找到后重新拖拽賦值即可。更省事的方案是這類引用全部改成在Awake里用GetComponent獲取Inspector 只暴露數(shù)據(jù)參數(shù)不暴露對(duì)象引用。5.2 子彈對(duì)著敵人打卻沒(méi)有任何反應(yīng)現(xiàn)象準(zhǔn)星已經(jīng)對(duì)準(zhǔn)敵人開(kāi)槍射擊動(dòng)畫正常播放、彈藥正常消耗但敵人的血條紋絲不動(dòng)。原因射線檢測(cè)沒(méi)有命中敵人。大概率是 Layer 過(guò)濾的問(wèn)題——Physics.Raycast默認(rèn)檢測(cè)所有 Layer 是沒(méi)有問(wèn)題的但如果按 4.3 的做法給射線傳了enemyLayer參數(shù)而敵人的 Collider 被放到了別的 Layer比如 Default那射線就會(huì)直接穿透敵人。解決在 Inspector 里確認(rèn)敵人的碰撞體所在 Layer再檢查代碼里傳入的 LayerMask 是否包含該層。調(diào)試時(shí)先用Debug.DrawRay畫一條射線看看方向和終點(diǎn)命中點(diǎn)如果和敵人位置偏差超過(guò)碰撞體半徑還要檢查射線方向是否確實(shí)是從準(zhǔn)星中心發(fā)出的。這一步排查完順手把敵人所在 Layer 統(tǒng)一改成 Enemy。5.3 換彈動(dòng)畫播完但彈藥沒(méi)補(bǔ)上現(xiàn)象換彈過(guò)程中彈藥顯示 0/90動(dòng)畫結(jié)束后還是 0/90但后備彈藥沒(méi)有減少。原因協(xié)程里的yield return new WaitForSeconds(reloadTime)被中斷了。常見(jiàn)的是角色在換彈期間死亡或切換武器腳本被禁用或者 GameObject 被 SetActive(false)協(xié)程直接終止后續(xù)的補(bǔ)彈代碼根本沒(méi)執(zhí)行。解決換彈邏輯不能用裸協(xié)程收尾要加一個(gè)“完成標(biāo)志位”。我的做法是public IEnumerator ReloadRoutine() { isReloading true; yield return new WaitForSeconds(reloadTime); if (this null || !gameObject.activeInHierarchy) yield break; // 安全校驗(yàn) currentAmmo Mathf.Min(magSize, currentAmmo reserveAmmo); isReloading false; }另外把換彈開(kāi)始和補(bǔ)彈完成都通過(guò)UnityEvent發(fā)給 UI這樣即使協(xié)程中斷UI 也能響應(yīng)狀態(tài)變化去顯示“換彈被打斷”玩家不會(huì)產(chǎn)生“我按了 R 為什么沒(méi)反應(yīng)”的困惑。5.4 角色轉(zhuǎn)身時(shí)相機(jī)抖動(dòng)畫面出現(xiàn)殘影現(xiàn)象快速左右晃動(dòng)鼠標(biāo)時(shí)主相機(jī)的畫面會(huì)出現(xiàn)輕微的“回?cái)[”轉(zhuǎn)到某個(gè)角度時(shí)抖動(dòng)非常明顯。原因相機(jī)旋轉(zhuǎn)沒(méi)有和角色旋轉(zhuǎn)完全同步。如果MouseLook直接掛在主相機(jī)上而主相機(jī)同時(shí)又是某個(gè)槍械子物體的父節(jié)點(diǎn)槍械的后坐力動(dòng)畫有時(shí)會(huì)在同一幀里修改相機(jī)局部旋轉(zhuǎn)兩套旋轉(zhuǎn)邏輯疊加。解決把相機(jī)和武器徹底分離。主相機(jī)只掛 MouseLook 腳本控制旋轉(zhuǎn)武器模型掛在自己的空物體節(jié)點(diǎn)下用相機(jī)旋轉(zhuǎn)后LateUpdate里疊加后坐力偏移。注意LateUpdate的執(zhí)行順序永遠(yuǎn)在Update之后這樣可以保證玩家看到的畫面是先完成視角轉(zhuǎn)動(dòng)、再疊加后坐力而不是反過(guò)來(lái)。5.5 AI 卡墻角或者追出 NavMesh 邊界現(xiàn)象敵人在墻角來(lái)回走機(jī)器人導(dǎo)航路徑在 Scene 視圖里顯示為碎線有時(shí)直接走穿墻體到網(wǎng)格外面。原因NavMesh 烘焙時(shí) Agent Radius 設(shè)置和墻體厚度不匹配。墻體邊緣如果只有 0.2 米厚而 Agent Radius 設(shè)了 0.5烘焙出來(lái)的可行走區(qū)域會(huì)自動(dòng)把墻邊約 0.5 米的范圍劃為不可走敵人只能“隔空碰壁”。解決把墻體做成實(shí)體 Mesh 再烘焙不要用只有 Collider 沒(méi)有 Renderer 的隱形墻Agent Radius 調(diào)整到小于通道寬度的 1/2。如果敵人已經(jīng)出了 NavMesh說(shuō)明目標(biāo)點(diǎn)投影偏差太大參考 4.2 的NavMesh.SamplePosition把目標(biāo)偏移量從 2 米縮小到 0.5 米并檢查場(chǎng)景中是否有懸空平臺(tái)的邊緣沒(méi)被烘焙覆蓋。6. 導(dǎo)入后的第一件事重構(gòu)目錄結(jié)構(gòu)和跑通驗(yàn)證清單拿到這套 Untiy3D_FPS 工程我會(huì)建議你先花半小時(shí)重構(gòu)目錄把資源和代碼分開(kāi)再把驗(yàn)證清單跑一遍。很多工程“能跑”和“能改”是兩回事亂糟糟的目錄里找腳本比寫腳本更浪費(fèi)時(shí)間。推薦的清理方向是四個(gè)頂級(jí)文件夾Assets/ Scripts/ -- 控制器、武器、AI、UI 全部按模塊拆子目錄 Prefabs/ -- 角色、敵人、掉落的彈藥包 Models/ -- 角色模型和武器模型 Scenes/ -- 主場(chǎng)景和測(cè)試場(chǎng)景分開(kāi)搬動(dòng)文件后第一件事是重新保存場(chǎng)景并檢查預(yù)制體的引用有沒(méi)有斷開(kāi)Unity 會(huì)自動(dòng)維護(hù) GUID 引用但偶爾會(huì)漏。然后按下面的清單跑一遍冒煙測(cè)試點(diǎn) Play 后 Console 無(wú)報(bào)錯(cuò)相機(jī)視角能自由旋轉(zhuǎn)角色移動(dòng)方向跟隨視角不會(huì)斜移加速射擊 10 發(fā)彈藥減少 10準(zhǔn)星散射逐漸變大換彈后彈藥正確補(bǔ)充換彈期間無(wú)法繼續(xù)開(kāi)火敵人能發(fā)現(xiàn)玩家、能追擊、進(jìn)入射程后開(kāi)火擊殺敵人后掉落物能正確拾取這套工程最大的價(jià)值不是那幾行代碼而是它的組織方式。從那以后我每次拿到別人的 Unity 工程都會(huì)先干三件事檢查 Layer 規(guī)劃、刪掉場(chǎng)景里的 Missing Script、把腳本掛載方式統(tǒng)一成GetComponent兜底。這三個(gè)習(xí)慣幫我避開(kāi)了九成以上的運(yùn)行時(shí)報(bào)錯(cuò)。這個(gè) FPS 包也一樣——先跑通再看結(jié)構(gòu)改出第一個(gè)屬于自己的武器再動(dòng)那些參數(shù)去調(diào)手感希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取