
简介这份 unity射击游戏.zip 是一套基于 Unity3Dunity5.0 环境的初级射击小游戏完整工程面向刚接触 Unity、希望把 C# 脚本、键盘输入、场景搭建与视觉效果串起来练手的开发者。压缩包共 28 个文件以 dll、config、assets、exe 及资源/配置类文件为主整体约 33.73MB既包含可运行程序也保留了工程资源与依赖便于直接体验和拆解。已有 1585 人学习下载。内容围绕射击数与消灭数计数、子弹发射与音效触发、WASD/QE/ZC/HN 等键位按下释放监听、场景切换、光照与材质调节展开覆盖脚本编程、粒子系统、音频管理、Input 事件驱动、Scene Management 及光照材质等知识点读者可对照工程理解变量更新、条件判断、粒子火花烟雾轨迹、音频源加载播放停止、对象 Transform 控制及资源目录组织也能梳理从场景搭建到脚本联动的完整流程适合作为 Unity 入门到小项目实践的参考样本。1. 从一份能直接打开的 Unity 射击工程说起拿到 unity射击游戏.zip 的时候我以为又是一个拖进引擎就满屏报错的老工程。解压之后打开 Assets 目录场景、预制体、脚本、材质都在原位进入 Play 模式准星能动、枪能开火、敌人会从刷怪点追过来命中之后血条往下掉。这是一套用 unity3D工程按 unity5.0 时代的 API 写成搭起来的射击小游戏完整工程包含玩家移动与相机跟随、武器射击、命中判定、敌人 AI 与状态机、刷怪器、计分 UI 和简单的关卡流程。它适合两类人手上有引擎但没做过完整射击玩法的初学者拿它当跑通的底子以及想直接拿走「射击手感 命中判定 敌人行为」这套骨架、换成自己美术资源的开发者。往下按「它是什么 → 怎么跑 → 核心代码长什么样 → 怎么改」的顺序拆。2. 工程结构从 Assets 目录看这套射击 Demo 的骨架2.1 目录分层脚本按职责切不按场景切打开工程第一件事不是点运行是看目录。这套工程的 Scripts 是按职责分层的不是按场景分文件夹这一点很关键——按场景分的工程做到第三个关卡就会出现大量复制粘贴的脚本改一个移动速度要改三处。按职责分一个 PlayerController 服务所有关卡改一次全局生效。ShooterGame/ ├── Assets/ │ ├── Scenes/ # MainMenu、Level_01、Level_02 │ ├── Scripts/ │ │ ├── Core/ # GameManager、PoolManager、AudioManager │ │ ├── Player/ # PlayerController、PlayerHealth、CameraFollow │ │ ├── Weapon/ # WeaponRaycast、WeaponSwitcher、ImpactFx │ │ ├── Enemy/ # EnemyBrain、EnemyHealth、EnemySpawner │ │ └── UI/ # HUDController、MenuController │ ├── Prefabs/ │ │ ├── Player/ │ │ ├── Weapons/ │ │ ├── Enemies/ │ │ └── FX/ # 弹孔、枪口火焰、命中火花 │ ├── Materials/ │ ├── Audio/ │ ├── Resources/ # 运行时 Resources.Load 的配置、图标 │ └── Editor/ # 打包脚本、工程自检脚本 ├── ProjectSettings/ # TagManager、InputManager、PlayerSettings └── Library/ # 首次打开自动生成可以不打包进压缩包看这张图能读出三个设计判断一是 Core 层的 GameManager 是单例全局唯一负责分数、暂停和关卡切换二是武器和敌人各自成包武器换弹、敌人受伤互不感知靠接口通信三是 Editor 目录单独放着工具脚本这类脚本运行时会被剥离不会打进包里。Prefabs 按类型再分一层是为了让场景文件保持干净——场景里只放预制体实例和刷怪点美术资源和逻辑都在预制体内部改一个敌人只要改预制体两个关卡同时生效。2.2 核心脚本清单与调用关系脚本数量不多大概十来个但职责边界划得比较清楚。我按调用方向列一张表看表比翻文件快脚本所在层职责被谁调用GameManagerCore分数、暂停、关卡切换、游戏状态HUD、EnemyHealth、菜单PoolManagerCore子弹孔、命中特效的对象池WeaponRaycast、ImpactFxPlayerControllerPlayer移动、跳跃、朝向输入系统PlayerHealthPlayer玩家血量、受击、死亡EnemyBrainCameraFollowPlayer第三人称跟随、鼠标视角主相机WeaponRaycastWeapon开火、射线检测、伤害派发输入系统WeaponSwitcherWeapon换枪、切动画输入系统EnemyBrainEnemy状态机待机/追击/攻击/死亡每帧 UpdateEnemyHealthEnemy承伤、死亡、掉落分数WeaponRaycastEnemySpawnerEnemy定时刷怪、数量上限GameManagerHUDControllerUI血条、弹药、分数、准星GameManager这套结构里最容易踩的一个认知误区是把「谁扣血」写反。正确的方向是武器主动调用受击方受击方自己决定扣多少血、什么时候死武器不需要知道对面是敌人还是木箱。工程里用了一个接口来保证这个方向具体实现放在第 4 章讲。2.3 预制体与数据驱动改数值不改代码这套工程的数值全部挂在预制体的 Inspector 上而不是硬编码在脚本里。武器预制体上挂着 WeaponRaycast单发伤害、射速、射程、后坐力都是 public 字段敌人预制体上挂着 EnemyBrain 和 EnemyHealth移动速度、视野距离、血量是同样的做法。这种「预制体即配置」的方式在 Unity 5.0 时代是主流好处是策划能自己调坏处是数值一多就没有全局视图改平衡性得一个个打开预制体。我一般的做法是把它往 ScriptableObject 挪一步既不破坏原有预制体结构又能集中管理using UnityEngine; // 武器数值配置从预制体 Inspector 里抽出来变成独立资源 [CreateAssetMenu(fileName WeaponConfig, menuName Shooter/Weapon Config)] public class WeaponConfig : ScriptableObject { public string weaponName Rifle; public float damage 25f; // 单发伤害 public float fireRate 8f; // 每秒射速8 表示每 0.125s 一发 public float range 100f; // 射线射程单位米 public int magazineSize 30; // 弹匣容量 public float reloadTime 1.8f; // 换弹耗时 public AudioClip fireSfx; // 开火音效 }逻辑说明[CreateAssetMenu]让这个类出现在右键菜单里可以在 Project 窗口直接创建 .asset 资源文件所有字段都是 publicInspector 里可编辑并随资源序列化保存武器脚本持有的是一个WeaponConfig引用而不是一堆散字段。参数说明fireRate用的是「每秒发数」而不是「两发间隔」因为在代码里算1f / fireRate更直观改射速的时候不用做倒数心算range单位统一用米Unity 一个单位默认一米写 100 就是一百米别写成 1000 之后在场景里发现子弹打不到人还以为射线写错了。3. 跑通流程从工程导入到第一枪打响3.1 版本选择与打开工程工程是按 unity5.0 的 API 写的用新版编辑器打开会触发一次自动升级大部分脚本能过少数几个会报错报错集中在旧版 GUI、旧的 NavMesh 烘焙接口和Application.LoadLevel这几个地方。稳妥的做法是先用接近的旧版本打开确认能跑再决定要不要升级。如果手头只有新版编辑器直接打开也行但要先把控制台里的红色报错清干净再点运行否则你会以为是玩法逻辑有问题实际上是脚本根本没编译过。打开步骤启动引擎 → 选择 Open Project → 指向解压出来的 ShooterGame 根目录注意是包含 Assets 和 ProjectSettings 的那一层不是 Assets 里面→ 等待资源导入第一次导入会比较久因为要生成 Library 里的缓存。导入完成后在 Project 窗口双击Assets/Scenes/Level_01.unity场景视图里应该能看到地面、玩家角色、几个敌人和刷怪点。提示Library 目录可以删删掉之后重开工程会重新生成体积能小一大截。但别删 ProjectSettings删了 Tag、Layer、输入轴全丢射线检测会直接失效这是个很隐蔽的坑。3.2 三个看不见的前置条件Tag、Layer、输入轴很多人第一次跑这类工程会卡在「准星能转但打不中」八成不是射线代码写错是 Layer 或 Tag 丢了。这套工程的射线检测用 LayerMask 过滤玩家层、敌人层、环境层分开射线只对敌人层和环境层生效。Layer 的编号存在 ProjectSettings/TagManager.asset 里如果换版本或者手动合并过工程编号可能整体错位LayerMask 的 inspector 值对不上射线就打不到东西。第二个是 Tag。敌人预制体上挂着Enemy这个 Tag武器命中之后靠CompareTag(Enemy)或者GetComponentIDamageable()来判断是不是可伤害目标。Tag 丢了命中判定直接返回表现就是「打上去没反应」。第三个是输入轴。工程用的是Input.GetAxis(Horizontal)、Input.GetAxis(Vertical)和Input.GetButton(Fire1)这几个名字是引擎内置的默认轴正常情况下不会丢但如果有人改过 InputManager或者从别的工程合并过来轴名可能被覆盖。表现是角色原地不动、按左键不开枪但代码一行没错。与其一个个去 Inspector 里翻不如写个编辑器自检脚本一键把这三样扫一遍#if UNITY_EDITOR using UnityEditor; using UnityEngine; public static class ProjectSanityCheck { [MenuItem(Tools/工程自检)] public static void Run() { int problem 0; // 1. 检查关键 Tag 是否存在于 TagManager string[] requiredTags { Player, Enemy }; foreach (var tag in requiredTags) { if (System.Array.IndexOf(UnityEditorInternal.InternalEditorUtility.tags, tag) 0) { Debug.LogError($[自检] 缺少 Tag: {tag}); problem; } } // 2. 检查关键 Layer注意 Layer 名不能为空 string[] requiredLayers { Player, Enemy, Environment }; foreach (var layer in requiredLayers) { if (LayerMask.NameToLayer(layer) 0) { Debug.LogError($[自检] 缺少 Layer: {layer}); problem; } } // 3. 检查输入轴是否可用取不到会抛异常 try { Input.GetAxis(Horizontal); Input.GetButton(Fire1); } catch (System.ArgumentException e) { Debug.LogError($[自检] 输入轴缺失: {e.Message}); problem; } Debug.Log(problem 0 ? [自检] 全部通过可以运行 : $[自检] 发现 {problem} 处问题); } } #endif逻辑说明InternalEditorUtility.tags拿到的是当前工程的全部 Tag 列表LayerMask.NameToLayer返回层号返回 -1 说明层不存在。输入轴那段用 try/catch 是因为Input.GetAxis传了不存在的轴名会抛ArgumentException这是最容易定位的一个信号。参数说明MenuItem的路径决定菜单出现在哪Tools/工程自检就是在顶部菜单栏 Tools 下加一项这个脚本放在Assets/Editor/目录下才会被识别为编辑器脚本放错位置会编译失败。3.3 运行、调试与打包点 Play 之后建议先做三件事把 Game 视图切到16:9或者1920x1080这套 UI 是按固定分辨率锚点摆的窗口比例不对会看到血条跑到屏幕外面打开 Console 面板的 Collapse 开关射击游戏每帧都在打日志的话会刷屏在 Scene 视图里选中玩家把 Gizmos 打开方便看射线方向。打包走 File → Build Settings → 选 PC 平台 → Add Open Scenes 把三个场景按顺序加进去MainMenu 放索引 0然后 Build。要注意 PlayerSettings 里的 Company Name 和 Product Name旧的 PlayerPrefs 存档按这两个名字分目录改了名字等于存档清零。命令行打包对做自动化的团队更有用# 用批处理模式打包-quit 表示执行完退出-logFile 把日志写到文件 # UNITY_EXE 换成你本机编辑器的实际可执行文件路径 $UNITY_EXE -quit -batchmode \ -projectPath D:/Projects/ShooterGame \ -executeMethod BuildTool.BuildWindows \ -logFile build.log逻辑说明-batchmode不开图形界面适合放在持续集成的流水线里-executeMethod指定一个静态方法执行打包逻辑这个方法要放在 Editor 目录下里面调用BuildPipeline.BuildPlayer。参数说明-projectPath是工程根目录路径里有空格要加引号-logFile指定日志输出位置打包失败的时候第一件事就是看这个文件里面会写清楚是哪个资源引用丢了还是哪个脚本编译不过。4. 核心模块拆解一发子弹从按下左键到敌人掉血的完整链路4.1 玩家控制与相机跟随玩家用的是 CharacterController 而不是 Rigidbody这是一个明确的选择射击游戏要的是「精确、可预测」的位移Rigidbody 在斜坡和台阶上容易被物理引擎的求解器推出一些奇怪的速度加上一个 Capsule Collider 还会和场景里的小物体互相顶。CharacterController 自己处理斜坡、台阶和碰撞滑动手感更接近传统射击游戏。using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerController : MonoBehaviour { public float moveSpeed 6f; // 行走速度米/秒 public float sprintSpeed 10f; // 冲刺速度 public float gravity -20f; // 自定义重力比默认 -9.8 更实 public float rotateSpeed 12f; // 转身插值速度 public Transform cameraRoot; // 相机根节点决定移动方向 private CharacterController cc; private Vector3 velocity; // 垂直速度只用于重力累加 void Awake() { cc GetComponentCharacterController(); } void Update() { // 输入是相对相机的按 W 永远朝屏幕前方走这是第三人称的惯例 float h Input.GetAxisRaw(Horizontal); float v Input.GetAxisRaw(Vertical); Vector3 input new Vector3(h, 0f, v).normalized; Vector3 moveDir Vector3.zero; if (input.sqrMagnitude 0.001f) { // 用相机的水平朝向把输入转到世界空间 Vector3 camForward Vector3.Scale(cameraRoot.forward, new Vector3(1, 0, 1)).normalized; Vector3 camRight cameraRoot.right; moveDir camForward * input.z camRight * input.x; // 角色朝向平滑转向移动方向 Quaternion targetRot Quaternion.LookRotation(moveDir); transform.rotation Quaternion.Slerp(transform.rotation, targetRot, rotateSpeed * Time.deltaTime); } float speed Input.GetKey(KeyCode.LeftShift) ? sprintSpeed : moveSpeed; // 重力贴地时给一个小的向下速度防止 CharacterController 悬空 if (cc.isGrounded velocity.y 0f) velocity.y -2f; velocity.y gravity * Time.deltaTime; cc.Move((moveDir * speed velocity) * Time.deltaTime); } }逻辑说明GetAxisRaw返回的是 -1/0/1 的离散值没有平滑过渡这样做是为了让「急停」干脆手感更硬如果想要轻微加速的柔和感把 Raw 去掉换成GetAxis。移动方向不是直接取transform.forward而是取相机的水平朝向这样鼠标转视角之后按 W 还是往屏幕里走符合第三人称射击的直觉。参数说明gravity设为 -20 而不是 -9.8是因为游戏里的时间尺度和真实世界不一样跳跃高度和下落速度要按手感调cc.isGrounded在角色刚落地那一帧可能是 false所以速度给 -2 而不是 0能避免角色在地面上小幅弹跳。4.2 射击实现射线检测和物理弹道怎么选这套工程的主武器用的是射线检测Raycast不是实例化子弹。原因很直接射线是瞬时的不需要处理子弹飞行时间、下落和碰撞命中判定就是一次数学计算性能好且结果确定。代价是没有「子弹飞过去」的视觉表现所以工程在枪口加了一个枪口火焰特效、在命中点生成一个弹孔用视觉补偿这种「瞬间命中」的感觉。using UnityEngine; public class WeaponRaycast : MonoBehaviour { public WeaponConfig config; // 数值配置见 2.3 public Transform muzzle; // 枪口位置射线起点 public LayerMask hitMask; // 只对这些层生效玩家层必须排除 public GameObject impactPrefab; // 弹孔/火花预制体 public ParticleSystem muzzleFlash; // 枪口火焰 private float nextFireTime 0f; void Update() { // GetButton 是按住GetButtonDown 是按下一瞬间 if (Input.GetButton(Fire1) Time.time nextFireTime) { nextFireTime Time.time 1f / config.fireRate; Fire(); } } void Fire() { if (muzzleFlash ! null) muzzleFlash.Play(); // 从枪口沿枪管方向发出射线 Ray ray new Ray(muzzle.position, muzzle.forward); RaycastHit hit; // 最后一个参数忽略触发器避免打到刷怪点的 Trigger 区域 if (Physics.Raycast(ray, out hit, config.range, hitMask, QueryTriggerInteraction.Ignore)) { Debug.DrawLine(muzzle.position, hit.point, Color.red, 0.2f); // 命中的物体上如果有伤害接口就派发伤害 var target hit.collider.GetComponentIDamageable(); if (target ! null) { target.TakeDamage(new DamageInfo { amount config.damage, hitPoint hit.point, hitNormal hit.normal, source gameObject }); } // 弹孔朝命中面的法线方向贴否则会穿模 if (impactPrefab ! null) { var fx PoolManager.Instance.Spawn(impactPrefab, hit.point, Quaternion.LookRotation(hit.normal)); PoolManager.Instance.Despawn(fx, 2f); // 2 秒后回收 } } } }逻辑说明Time.time nextFireTime是射速控制的标准写法用时间戳而不是协程避免协程嵌套和打断muzzle.forward而不是transform.forward因为枪口节点的朝向才是枪管方向。参数说明hitMask一定要在 Inspector 里勾选默认值如果是 Everything射线会先打到自己身上玩家和枪的碰撞体在射线起点附近表现是第一枪就掉自己血range传的是配置里的射程射不到的地方不会生成弹孔这也是一种隐式的射程验证方式。如果想要更强的表现力——比如狙击枪的子弹飞行、霰弹枪的散布——可以把射线换成实例化子弹加 Rigidbody但要注意子弹速度太快会「穿墙」因为物理引擎是按固定步长离散计算的一帧移动 50 米以上就可能直接跳过墙体。常见做法是把子弹的碰撞检测改成Continuous Dynamic或者在子弹上挂一个每帧射线检测的脚本用射线初始化位置到当前位置把这一帧的路径扫一遍。4.3 命中判定与伤害结算用接口把武器和受击方解耦伤害派发这一段是整个工程里最值得学的设计。如果武器直接GetComponentEnemyHealth().hp - 25那木箱、油桶、可破坏墙体就全得写一遍武器脚本会越来越长。工程用了一个IDamageable接口把「谁能被打」这件事从武器里摘出去。using UnityEngine; // 伤害信息一次命中携带的全部上下文 public struct DamageInfo { public float amount; // 伤害数值 public Vector3 hitPoint; // 命中点 public Vector3 hitNormal; // 命中面法线 public GameObject source; // 伤害来源用于计分和仇恨转移 } // 所有可受伤的物体实现这个接口 public interface IDamageable { void TakeDamage(DamageInfo info); }using UnityEngine; public class EnemyHealth : MonoBehaviour, IDamageable { public float maxHp 100f; public GameObject deathFx; public int scoreValue 10; private float hp; private bool dead; void Awake() { hp maxHp; } public void TakeDamage(DamageInfo info) { if (dead) return; // 防重复死亡连发武器很容易触发 hp - info.amount; // 受击反馈让敌人朝向伤害来源短暂硬直这个细节对打击感影响很大 if (info.source ! null hp 0f) { var brain GetComponentEnemyBrain(); if (brain ! null) brain.OnHit(info.source.transform.position); } if (hp 0f) Die(info); } void Die(DamageInfo info) { dead true; if (deathFx ! null) Instantiate(deathFx, transform.position, Quaternion.identity); if (GameManager.Instance ! null) GameManager.Instance.AddScore(scoreValue); Destroy(gameObject, 0.1f); } }逻辑说明dead标记是必须的因为hp 0之后如果同一帧还有第二发子弹命中霰弹枪一枪多颗弹丸Die会被调用多次分数会重复加。受击硬直那一段是手感的关键敌人被打中会短暂转向伤害来源玩家能明确感知到「打中了」。参数说明scoreValue放在敌人身上而不是 GameManager 里是为了让不同敌人有不同分值改的时候只改预制体Destroy(gameObject, 0.1f)延迟销毁是为了让死亡特效有时间播完第一帧如果直接 Destroy特效父子关系断了会出现闪烁。4.4 敌人 AI 状态机与刷怪器敌人用的是最朴素的四状态机待机、追击、攻击、死亡。没有用行为树也没有用导航网格自动寻路就是朝目标点走 距离判定。对这样一个关卡制的射击 Demo 来说这个复杂度刚好敌人不会穿墙但也不会出现「卡在门口过不来」这种更难受的表现。using UnityEngine; public enum EnemyState { Idle, Chase, Attack, Dead } public class EnemyBrain : MonoBehaviour { public float detectRange 25f; // 视野距离 public float attackRange 2f; // 近战攻击距离 public float moveSpeed 3.5f; public float attackCooldown 1.2f; public float damagePerHit 10f; private Transform player; private EnemyState state EnemyState.Idle; private float lastAttackTime 0f; void Start() { // 按 Tag 找玩家比 FindObjectOfType 稳定 var go GameObject.FindGameObjectWithTag(Player); if (go ! null) player go.transform; } void Update() { if (player null || state EnemyState.Dead) return; float dist Vector3.Distance(transform.position, player.position); // 状态转移距离决定行为简单但可控 if (dist detectRange) state EnemyState.Idle; else if (dist attackRange) state EnemyState.Chase; else state EnemyState.Attack; switch (state) { case EnemyState.Chase: Vector3 dir (player.position - transform.position).normalized; dir.y 0f; // 锁住 Y 轴防止上坡时敌人贴地或飞天 transform.position dir * moveSpeed * Time.deltaTime; transform.rotation Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 8f * Time.deltaTime); break; case EnemyState.Attack: TryAttack(); break; } } void TryAttack() { if (Time.time lastAttackTime attackCooldown) return; lastAttackTime Time.time; var target player.GetComponentIDamageable(); if (target ! null) { target.TakeDamage(new DamageInfo { amount damagePerHit, hitPoint player.position, source gameObject }); } } // 被击中时朝来源方向做一个短硬直由 EnemyHealth 调用 public void OnHit(Vector3 sourcePos) { /* 播放受击动画 短暂停顿 */ } }逻辑说明状态机没有用协程和定时器每次 Update 重新算距离再定状态好处是逻辑可预测坏处是状态切换那一帧可能抖动在 detectRange 边界来回切实际做法是给进入和退出各一个不同的阈值做迟滞。dir.y 0这一行是血泪经验敌人朝玩家走的时候如果玩家站在高处或者站在斜坡上方向向量的 Y 分量会让敌人往上飘或者往地里钻。刷怪器负责在几个固定点生成敌人同时限制场上存活数量避免帧率崩掉using UnityEngine; using System.Collections.Generic; public class EnemySpawner : MonoBehaviour { public GameObject enemyPrefab; public Transform[] spawnPoints; public float interval 3f; // 每隔几秒刷一个 public int maxAlive 8; // 场上最多同时存在几个 private readonly ListGameObject alive new ListGameObject(); private float timer; void Update() { // 清理已经销毁的引用防止列表无限增长 alive.RemoveAll(e e null); timer Time.deltaTime; if (timer interval || alive.Count maxAlive) return; timer 0f; var point spawnPoints[Random.Range(0, spawnPoints.Length)]; var enemy Instantiate(enemyPrefab, point.position, point.rotation); alive.Add(enemy); } }逻辑说明alive.RemoveAll(e e null)是这套刷怪器的核心敌人被销毁之后列表里留下的是「伪空引用」Unity 重载了让e null对已销毁对象返回 true这样清理才能生效如果用e.Equals(null)就清不掉内存会缓慢泄漏。参数说明interval和maxAlive是控制难度的两个旋钮想加压就把 interval 调小、maxAlive 调大但要注意移动端上敌人数量超过十几个、每个都带 Animator 的话帧率会明显掉。4.5 UI 与游戏流程GameManager 该管什么、不该管什么GameManager 是一个单例管分数、暂停、关卡加载和游戏结束判定。它不该管具体某个敌人的血量也不该管某把枪的弹药那些属于各自模块。这种「薄的全局层」在项目变大之后能省很多事。using UnityEngine; using UnityEngine.SceneManagement; public class GameManager : MonoBehaviour { public static GameManager Instance; public int targetScore 100; // 通关所需分数 private int score; void Awake() { // 单例场景切换时不销毁但只允许存在一个 if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public void AddScore(int value) { score value; if (HUDController.Instance ! null) HUDController.Instance.RefreshScore(score); if (score targetScore) LoadNextLevel(); } void LoadNextLevel() { int next SceneManager.GetActiveScene().buildIndex 1; // 索引越界就回主菜单避免最后关卡通关后报错 if (next SceneManager.sceneCountInBuildSettings) SceneManager.LoadScene(next); else SceneManager.LoadScene(0); } }逻辑说明DontDestroyOnLoad让 GameManager 跨场景存活分数才能从上一关带到下一关Instance ! null Instance ! this是单例的标准防重复写法切场景时如果新场景里也摆了一个 GameManager会自己销毁掉。参数说明targetScore是通关门槛和敌人的scoreValue配合使用SceneManager.LoadScene需要场景已经加进 Build Settings如果没加运行时会报场景找不到这也是很常见的一个报错来源。5. 排查与避坑这套老工程最容易翻车的五个地方5.1 现象打开工程一片红脚本编译不过原因基本锁定在版本差异上。unity5.0 时代的 API 有几处已经废弃Application.LoadLevel换成了SceneManager.LoadScene旧的UnityEngine.UI里有些组件改名NavMeshAgent的烘焙接口从运行时挪到了编辑器。如果工程里还用了旧版的 OnGUI 做 HUD新版会警告但不报错。解决方式是先看报错的第一条从第一条改起因为后面的报错很多是前面失败的连带结果。LoadLevel这类改起来很快全局搜索替换成SceneManager.LoadScene即可注意要加using UnityEngine.SceneManagement;。如果报错量在几十条以上先别急着逐条改回到旧版本跑通、确认玩法没问题再考虑要不要升级——升级这件事本身不产生任何玩法价值。5.2 现象能开火、有枪口火焰但敌人打不死先排查三件事按命中概率从高到低第一hitMask在 Inspector 里被设成了 Nothing 或者漏勾了 Enemy 层射线什么都没打到第二敌人的 Collider 被删了或者设成了 Trigger而代码里传了QueryTriggerInteraction.Ignore触发器会被直接忽略第三敌人身上没有挂实现IDamageable的脚本射中了但GetComponent返回 null。定位方式很直接在 Fire 方法的Physics.Raycast那一行下面加一句Debug.Log(hit.collider.name)打一枪看控制台有没有输出。有输出说明射线打到了问题在伤害派发没输出说明射线就没命中问题在 Layer 和 Collider。这一步能省掉大量瞎猜。5.3 现象场景里一堆 Missing (Mono Script)预制体引用全空这种情况几乎都发生在工程从一台机器挪到另一台、或者从压缩包里解压出来但目录结构被改过的时候。Unity 的脚本引用不是按文件名是按 GUID 记录的GUID 存在每个脚本旁边的 .meta 文件里。如果压缩包打包时把 .meta 文件过滤掉了或者解压时被杀毒软件删了一部分所有引用都会指向不存在的东西。解决方式先确认压缩包里 Assets 目录下每个文件和文件夹都有对应的 .meta。如果确实缺了只能重新导入原始资源手动重挂脚本。这是个没有后悔药的问题所以我在给别人传 Unity 工程之前一定会先打包成 unitypackage 或者确认 .meta 完整再压缩。5.4 现象敌人卡在墙角原地抖动或者走上斜坡之后悬空前者是移动逻辑里没有做碰撞处理——代码直接transform.position dir * speed * dt没有 CharacterController 也没有 Rigidbody敌人当然会穿过墙体撞到墙角的碰撞体之后位置被反复修正看起来就是抖。后者是移动方向没有锁 Y 轴玩家站在高处的时候方向向量带 Y 分量敌人会朝着斜上方「飞」。改法有两个层次快速修是把移动改成CharacterController.Move或者给敌人加 Rigidbody 并把旋转冻结彻底修是引入导航网格用NavMeshAgent.SetDestination让引擎自己算路径代价是要烘焙 NavMesh场景改动之后要重烘。对于关卡固定的射击 Demo我倾向于直接上 NavMesh前期多花半小时后期省掉一堆卡位调试。5.5 现象编辑器里跑得好好的打包出来黑屏或者没声音编辑器里能跑、打包后黑屏八成是 Resources 加载路径的问题。Resources.Load(Config/WeaponConfig)这种写法路径是相对于任意一个 Resources 文件夹的且不带扩展名。如果打包时资源被放进了 AssetBundle 或者被裁剪掉运行时返回 null后续代码空引用画面就停在加载界面。排查顺序先看 Player.logWindows 在%USERPROFILE%\AppData\LocalLow\公司名\产品名\下里面有完整的异常堆栈再检查PlayerSettings里的 Strip Engine Code 有没有把用到的组件裁掉最后确认打包时 Add Open Scenes 有没有把三个场景都加进去。没声音这一类多半是 AudioListener 重复场景里相机和玩家各挂了一个控制台会提示There are 2 audio listeners in the scene删掉一个就好。6. 二次开发把 Demo 改成自己玩法的三个切入点这套工程的骨架是「玩家射击 敌人靠近」改动空间其实很大我给你三个我自己试过的方向按改动成本从低到高排。第一个切入点换武器手感成本最低只动WeaponConfig。想做霰弹枪把fireRate降到 1.5然后在 Fire 里循环发多条射线每条加一个随机角度偏移// 霰弹一次发 8 条射线每条在圆锥内随机偏转 int pellets 8; for (int i 0; i pellets; i) { // 在 ±5 度范围内随机偏转模拟散布 float yaw Random.Range(-5f, 5f); float pitch Random.Range(-5f, 5f); Quaternion rot muzzle.rotation * Quaternion.Euler(pitch, yaw, 0f); Physics.Raycast(muzzle.position, rot * Vector3.forward, out var hit, config.range, hitMask); // ... 命中处理与单发一致 }想做狙击枪把fireRate调到 0.8、damage拉到 120再给相机加一个短促的 FOV 收缩手感立刻就出来了。改动集中在配置资源和两三行代码不用碰架构。第二个切入点换敌人行为。现在敌人只会直线冲脸想做成「远程射击型 近战冲锋型」两种最省事的做法是保留EnemyBrain的状态机把攻击行为抽成一个策略接口策略实现要点挂载位置MeleeAttack距离小于 2 米时直接派发伤害近战敌人预制体RangedAttack距离 8~20 米时发射投射物冷却 2 秒远程敌人预制体ChargerAttack距离 10 米时加速冲刺撞到玩家造成伤害精英怪预制体策略接口只需要一个Execute(Transform player)方法EnemyBrain在 Attack 状态里调用它。这样加一种敌人就是新建一个脚本 新建一个预制体不改现有代码符合开闭原则。第三个切入点换关卡结构也是改动最大的一块。现在关卡是线性的清完敌人加分分数够了进下一关。想改成「波次防守」需要把EnemySpawner的定时刷怪改成读波次配置——每波指定敌人类型、数量、间隔一波清完才进下一波。这时候GameManager要新增一个「当前波次」状态HUDController要显示剩余敌人数量。我的建议是先在纸上把状态图画出来当前波次、剩余敌人数、波次间隔、玩家存活再动代码否则改到一半会发现状态散落在三个脚本里调起来极其痛苦。验证改动的办法很简单每次改完先跑一遍完整流程——主菜单进关卡、打死三个敌人、分数涨、通关切场景——这条链路走通基本就不会有大的回归问题。再补一条压力测试把maxAlive改成 30看帧率掉不掉掉得厉害就说明哪里在做每帧Find或者GetComponent。从那以后我每次改这类射击工程的数值或者行为都会强制走一遍「编译无报错 → 自检脚本全绿 → 完整流程跑通 → 帧率看一眼」这四步跳过任何一步都会在后面某个意想不到的地方翻车。这套工程的完整文件都在压缩包里从场景资源到脚本都齐全拿去对照着改比看十篇文章快。希望帮到你。本文还有配套的精品资源点击获取