简介:这是一份面向Unity初学者与游戏开发入门者的3D射击小游戏实战项目,帮助开发者快速掌握Unity 3D基础交互、角色控制、射击逻辑与场景构建等核心技能。资源包含完整可运行工程,含源码(181个C#脚本)、3D模型(134个FBX)、预制体(101个Prefab)、材质与着色器(18个Mat、7个Shader)、音效(3个WAV)及已编译的Windows可执行文件(2个EXE),开箱即玩,无需额外配置。压缩包共4655个文件,总大小93.53MB,文件结构体现典型Unity项目组织方式,含Assets、ProjectSettings等标准目录,便于学习工程管理与资源引用规范。已有2166人下载学习,适合用于课堂实训、自学练手或小型游戏原型验证,能直接复用角色移动、射线检测、UI血条、敌人AI等模块代码,显著降低入门门槛。
1. 这不是“Hello World”式Demo:一个能跑通、能调参、能加功能的Unity 3D射击小游戏工程包
你试过在Unity里拖完Player Prefab、挂完Rigidbody、写完Input.GetButton("Fire"),结果子弹飞出去像被磁铁吸住一样拐弯?或者敌人AI刚靠近就原地卡顿、血条不掉、死亡动画永远播不完?这不是你代码写错了——很可能是项目结构没对齐、物理材质没配对、或Time.timeScale被意外改成了0。这个Unity 3D射击小游戏资源包,不是教学视频里的片段拼接,而是一个完整可运行、模块分层清晰、关键参数全部外露的实战工程:含玩家移动/瞄准/射击/后坐力模拟、带状态机的敌人AI(巡逻→警觉→追击→死亡)、可配置弹道偏移与命中判定、以及一套轻量级UI事件驱动系统。它专为想跳过“为什么我的射线检测总返回null”这类玄学问题、直接进入“怎么让敌人在掩体后探头射击”这种真实需求的开发者准备。无论你是刚通过Unity Learn完成《Roll-a-Ball》的新手,还是正卡在跨平台输入适配上的中级开发者,这个包都能让你在20分钟内跑起一个有反馈、有逻辑、有调试入口的射击骨架。
2. 从空场景到可交互射击:核心模块拆解与初始化流程
这个工程不是靠一堆预制件堆出来的黑匣子。它的可复现性,建立在四个强解耦模块之上:PlayerController(玩家输入与运动)、WeaponSystem(武器逻辑与弹道)、EnemyAI(行为树驱动的状态机)、HitDetection(命中判定与伤害传递)。每个模块都遵循“数据驱动+事件通知”原则——所有关键参数(如移动速度、射速、敌人视野半径)都暴露在Inspector中,所有状态变更(如“玩家开火”“敌人被击中”)都通过UnityEvent广播,而非硬编码调用。这意味着你改一个数值就能看到效果,加一个监听器就能扩展逻辑,不用翻十层脚本找入口。
2.1 PlayerController:移动、瞄准与输入抽象层
玩家控制器不直接处理键盘或手柄,而是通过InputManager统一桥接。该管理器预置了三套输入方案:PC键鼠(WASD+鼠标)、移动端虚拟摇杆(Canvas上两个Joystick prefab)、以及Xbox手柄(支持震动反馈)。你只需在PlayerController.cs顶部的SerializedField区域修改inputScheme枚举值,即可切换整套输入逻辑,无需重写移动代码。
// PlayerController.cs 关键片段 public enum InputScheme { KeyboardMouse, MobileTouch, XboxController } [SerializeField] private InputScheme inputScheme = InputScheme.KeyboardMouse; [SerializeField] private float moveSpeed = 5f; [SerializeField] private float lookSensitivity = 2f; private void Update() { HandleMovement(); HandleLook(); } private void HandleMovement() { Vector2 input = InputManager.GetMoveInput(inputScheme); Vector3 moveDir = transform.forward * input.y + transform.right * input.x; rb.velocity = new Vector3(moveDir.x, rb.velocity.y, moveDir.z) * moveSpeed; }提示:
InputManager.GetMoveInput()返回的是归一化向量(-1~1),已自动处理斜向移动的幅度衰减。若你在移动端发现摇杆响应迟钝,检查MobileJoystick.cs中deadZone参数(默认0.2),调小可提升灵敏度,但会增加误触概率。
2.2 WeaponSystem:不只是Instantiate子弹——弹道、后坐力与冷却的协同控制
射击逻辑的核心在于时间精度与状态同步。本工程摒弃了简单的Instantiate(bulletPrefab)模式,转而采用对象池+物理射线+弹道偏移三重机制:
- 对象池:
BulletPool.cs管理100发子弹实例,避免频繁GC; - 物理射线:每发子弹发射时,先执行
Physics.Raycast()检测障碍物,再决定是否触发HitDetection; - 弹道偏移:
RecoilPattern.cs生成随时间衰减的随机偏移量,叠加到枪口方向上,模拟真实后坐力。
// WeaponSystem.cs 射击主逻辑 public void Fire() { if (isReloading || Time.time < nextFireTime) return; // 1. 获取当前枪口位置与方向(已应用后坐力偏移) Transform muzzle = GetMuzzleTransform(); Vector3 fireDir = muzzle.forward + recoilPattern.GetCurrentOffset(); // 2. 执行射线检测(最大距离100m,忽略玩家自身图层) if (Physics.Raycast(muzzle.position, fireDir, out RaycastHit hit, 100f, ~LayerMask.GetMask("Player"))) { HitDetection.ProcessHit(hit, damage, muzzle.position); } // 3. 播放音效、粒子、后坐力动画,并更新冷却 PlayFireEffects(); recoilPattern.TriggerRecoil(); nextFireTime = Time.time + 1f / fireRate; }参数说明:
fireRate(射速,单位:发/秒)和damage(基础伤害)直接暴露在Inspector中;recoilPattern引用一个RecoilPattern脚本组件,其maxRecoilAngle(最大偏移角)和recoverySpeed(恢复速度)同样可调。新手建议从maxRecoilAngle=0.5起步,避免开局就打飞。
2.3 EnemyAI:用有限状态机(FSM)替代if-else嵌套地狱
敌人AI没有用协程写满屏幕的WaitForSeconds(),而是基于EnemyStateMachine.cs构建的四状态FSM:PatrolState→AlertState→ChaseState→DeathState。每个状态都是独立类(如PatrolState.cs),只负责本状态下的行为决策与状态迁移条件。例如,ChaseState只做两件事:朝玩家位置移动、检测是否进入攻击距离;一旦距离<3米,立即触发AttackTransition事件,由状态机调度器切换至AttackState(该状态在工程中预留了扩展接口)。
// PatrolState.cs 片段 public override void OnStateEnter(EnemyAI enemy) { base.OnStateEnter(enemy); enemy.navMeshAgent.SetDestination(GetNextPatrolPoint()); } public override void OnStateUpdate(EnemyAI enemy) { if (Vector3.Distance(enemy.transform.position, enemy.playerTransform.position) < enemy.alertRadius) { enemy.stateMachine.TransitionTo(new AlertState()); // 状态迁移 return; } if (enemy.navMeshAgent.remainingDistance < 0.5f) { enemy.patrolIndex = (enemy.patrolIndex + 1) % enemy.patrolPoints.Length; enemy.navMeshAgent.SetDestination(enemy.patrolPoints[enemy.patrolIndex]); } }注意:
alertRadius(警觉半径)和chaseRadius(追击半径)是敌人行为的关键阈值,必须在Inspector中为每个敌人预制件单独设置。若发现敌人“看不见你”,优先检查该敌人是否被错误分配了LayerMask(确保其Collider未勾选Ignore Raycast图层)。
3. 命中判定与伤害传递:为什么你的敌人不掉血?三个常被忽略的底层链路
很多开发者卡在“子弹明明打中了敌人,但血条纹丝不动”,本质是命中判定链路断裂。本工程的HitDetection系统强制要求三个环节全部闭合,缺一不可:射线命中标记 → 敌人接收伤害事件 → 血条UI响应更新。下面逐层排查,确保你能亲手验证每一环。
3.1 射线检测的物理层:Collider、LayerMask与Raycast参数校验
Physics.Raycast()不是万能的。它只检测带有Collider且未被Raycast Ignore图层屏蔽的对象。本工程中,敌人预制件的CapsuleCollider必须满足:
isTrigger= false(触发器不参与物理射线);- 所在图层(如"Enemy")未包含在
~LayerMask.GetMask("Player")的排除列表中; - Collider的
Center和Radius需匹配模型实际尺寸(常见坑:导入FBX后Collider缩放错位)。
// HitDetection.cs 中的射线调用(对比你自己的代码) // ✅ 正确:明确指定layerMask,排除Player图层,保留Enemy等可击中图层 int layerMask = LayerMask.GetMask("Enemy", "Environment", "Breakable"); if (Physics.Raycast(origin, direction, out hit, maxDistance, layerMask)) { ProcessHit(hit, damage, origin); } // ❌ 错误:使用-1(全图层)或漏掉关键图层,导致射线穿透 // if (Physics.Raycast(origin, direction, out hit, maxDistance)) { ... }验证方法:在Scene视图中选中敌人,按Ctrl+Shift+C打开Collider可视化,确认绿色轮廓完全包裹模型;在Game视图中开启Gizmos,观察射线是否准确指向敌人中心。
3.2 敌人接收伤害:MonoBehaviour事件订阅与生命周期绑定
敌人不是被动挨打的木桩。它必须主动订阅HitDetection.OnHitEvent,并在OnEnable()中注册、OnDisable()中注销,否则会出现“第一次打不死,第二次才掉血”的经典翻车。
// EnemyHealth.cs 关键生命周期管理 private void OnEnable() { HitDetection.OnHitEvent += OnHitReceived; // 订阅事件 } private void OnDisable() { HitDetection.OnHitEvent -= OnHitReceived; // 必须注销!否则对象销毁后事件仍被调用 } private void OnHitReceived(RaycastHit hit, float damage, Vector3 hitPosition) { if (hit.collider.CompareTag("Enemy") && hit.collider.gameObject == gameObject) { currentHealth -= damage; healthBar.UpdateHealth(currentHealth, maxHealth); if (currentHealth <= 0) Die(); } }血泪经验:若敌人被击中后控制台报错
MissingReferenceException,90%是OnDisable()未注销事件。Unity销毁对象时不会自动清理委托链,残留委托会尝试调用已销毁对象的方法。
3.3 血条UI更新:Canvas Render Mode与RectTransform锚点陷阱
血条UI(HealthBar.cs)依赖Canvas的Render Mode。本工程强制使用Screen Space - Overlay模式,因为:
World Space模式下,血条会随摄像机远近缩放,导致RectTransform.sizeDelta失效;Camera Space模式需额外绑定摄像机,增加耦合。
同时,血条的RectTransform锚点(Anchors)必须设为Min=(0,0), Max=(0,0),即左下角锚定。若你手动拖拽过血条位置,可能意外改为Stretch模式,导致sizeDelta.x无法正确映射血量百分比。
// HealthBar.cs 更新逻辑(仅当锚点正确时生效) public void UpdateHealth(float current, float max) { float ratio = Mathf.Clamp01(current / max); // ✅ 正确:直接修改fillAmount(Image组件) healthFill.fillAmount = ratio; // ❌ 错误:试图用sizeDelta控制宽度(仅在Anchor Min=Max时有效) // healthFill.rectTransform.sizeDelta = new Vector2(ratio * baseWidth, healthFill.rectTransform.sizeDelta.y); }避坑:在Inspector中选中血条Image组件,检查
Image Type是否为Filled,Fill Method是否为Horizontal,Fill Origin是否为Left。任意一项错误都会导致血条反向或不显示。
4. 避坑指南:五个让开发者凌晨三点还在Console里翻日志的真实问题
别信“一键运行”的宣传。这个工程经过某高校实验室3轮压力测试(200+敌人同屏、60FPS持续30分钟),以下问题是高频翻车点,按现象→原因→解决给出可执行方案。请务必在首次运行前扫一遍。
4.1 现象:子弹射出后轨迹弯曲,像被无形力场牵引
原因:子弹Prefab的Rigidbody组件勾选了Use Gravity,且Mass值过小(如0.01),导致重力加速度影响被放大。
解决:选中Bullet.prefab→Rigidbody→ 取消勾选Use Gravity;若需模拟抛物线,改用WeaponSystem中的RecoilPattern添加Y轴偏移,而非依赖物理引擎。
4.2 现象:敌人AI在巡逻路径点之间瞬移,不走NavMesh路径
原因:NavMeshAgent的Auto Braking被禁用,或Speed设为0,导致代理拒绝计算路径。
解决:选中敌人预制件 →NavMeshAgent→ 确保Auto Braking为true;Speed设为≥1;在Awake()中添加agent.Warp(transform.position)强制重置位置。
4.3 现象:移动端虚拟摇杆移动时,玩家原地旋转不前进
原因:MobileJoystick.cs中inputVector未归一化,斜向滑动时向量长度>1,导致PlayerController计算出超速位移,被Rigidbody的Max Depenetration Velocity截断。
解决:在MobileJoystick.cs的GetInputDirection()末尾添加return inputVector.normalized;,并确保PlayerController中moveSpeed不超过8(移动端推荐值5~6)。
4.4 现象:开火音效播放一次后消失,后续射击静音
原因:AudioSource组件勾选了Play On Awake,且Loop为false,但未在WeaponSystem.Fire()中调用audioSource.PlayOneShot(clip)。
解决:选中枪械预制件 →AudioSource→ 取消勾选Play On Awake;在WeaponSystem.PlayFireEffects()中明确调用audioSource.PlayOneShot(fireClip)。
4.5 现象:游戏暂停(Time.timeScale=0)后,敌人AI停止更新,但UI倒计时仍在跑
原因:EnemyAI.Update()使用Time.deltaTime,而UI倒计时脚本(如GameTimer.cs)错误地使用了Time.unscaledDeltaTime。
解决:统一时间基准——所有游戏逻辑(AI、移动、射击)用Time.deltaTime;所有UI动画/倒计时用Time.unscaledDeltaTime。检查GameTimer.cs中是否误写了Time.deltaTime。
5. 进阶技巧:如何在30分钟内给敌人加上“掩体探头射击”行为
这是检验你是否真正吃透本工程架构的试金石。不需要重写AI,只需在现有FSM框架上扩展一个CoverShootingState,并复用已有的WeaponSystem和HitDetection。整个过程分三步:定义掩体标记、实现探头逻辑、注入状态机。
5.1 定义掩体:用空GameObject+Tag+Collider构建可识别掩体
在场景中创建空GameObject,命名为CoverPoint_01,添加BoxCollider(isTrigger=true)和Rigidbody(isKinematic=true)。为其分配TagCoverPoint。此Collider不参与物理碰撞,仅作为射线检测的“掩体存在证明”。
// CoverPointDetector.cs (挂载在敌人身上) public class CoverPointDetector : MonoBehaviour { [SerializeField] private float coverCheckDistance = 5f; private Transform coverPoint; public Transform GetClosestCover() { Collider[] hits = Physics.OverlapSphere(transform.position, coverCheckDistance, LayerMask.GetMask("CoverPoint")); if (hits.Length == 0) return null; // 找到最近的掩体点 float minDist = float.MaxValue; foreach (Collider hit in hits) { float dist = Vector3.Distance(transform.position, hit.transform.position); if (dist < minDist) { minDist = dist; coverPoint = hit.transform; } } return coverPoint; } }注意:
CoverPoint图层需在Project Settings > Tags and Layers中新建,并确保LayerMask.GetMask("CoverPoint")能正确解析。若Physics.OverlapSphere返回空数组,请检查Collider的isTrigger是否为true(触发器才能被Overlap检测)。
5.2 实现探头逻辑:在掩体后移动+周期性探出射击
CoverShootingState继承自IEnemyState,核心逻辑是:移动到掩体后方 → 躲藏 → 定时探出 → 射击 → 缩回。关键在于用NavMeshAgent移动到掩体背面,再用transform.LookAt(player)实现探头转向。
// CoverShootingState.cs public class CoverShootingState : IEnemyState { private EnemyAI enemy; private CoverPointDetector coverDetector; private float shootInterval = 2f; private float lastShootTime; public void OnStateEnter(EnemyAI enemyAI) { enemy = enemyAI; coverDetector = enemy.GetComponent<CoverPointDetector>(); lastShootTime = Time.time; // 移动到掩体后方(掩体位置 - 敌人到掩体方向 * 2米) Transform cover = coverDetector.GetClosestCover(); if (cover != null) { Vector3 coverToEnemy = enemy.transform.position - cover.position; Vector3 coverBehind = cover.position - coverToEnemy.normalized * 2f; enemy.navMeshAgent.SetDestination(coverBehind); } } public void OnStateUpdate(EnemyAI enemyAI) { if (Time.time - lastShootTime > shootInterval) { // 探头:临时覆盖朝向,面向玩家 enemy.transform.LookAt(enemy.playerTransform); enemy.weaponSystem.Fire(); // 复用现有射击系统 lastShootTime = Time.time; // 缩回:1秒后恢复原朝向(可加动画) Invoke(nameof(ResetOrientation), 1f); } } private void ResetOrientation() { // 恢复面向掩体或巡逻方向 if (coverDetector.GetClosestCover() != null) { enemy.transform.LookAt(coverDetector.GetClosestCover()); } } }参数说明:
shootInterval控制探头频率,2f表示每2秒探出一次;coverBehind的偏移距离(2f)需根据掩体大小调整,过大则暴露过多,过小则卡进掩体模型。
5.3 注入状态机:在警觉状态下判断是否启用掩体战术
最后一步,修改AlertState.cs,在警觉时检测附近是否有掩体,若有则直接切换至CoverShootingState,否则走常规追击。
// AlertState.cs 修改片段 public override void OnStateUpdate(EnemyAI enemy) { // ... 原有警觉逻辑 // 新增:检测掩体并切换状态 CoverPointDetector coverDetector = enemy.GetComponent<CoverPointDetector>(); if (coverDetector.GetClosestCover() != null && Vector3.Distance(enemy.transform.position, enemy.playerTransform.position) < enemy.chaseRadius) { enemy.stateMachine.TransitionTo(new CoverShootingState()); return; } // 否则继续追击 if (Vector3.Distance(enemy.transform.position, enemy.playerTransform.position) < enemy.chaseRadius) { enemy.stateMachine.TransitionTo(new ChaseState()); } }从那以后我每次给AI加新行为,都强制走一遍“定义数据(掩体Tag)→ 实现逻辑(CoverShootingState)→ 注入状态机(AlertState)”三步闭环。这比在Update里堆if-else可靠十倍——因为状态迁移条件清晰可见,调试时只需看stateMachine.currentState是什么,就能定位问题模块。希望帮到你。
本文还有配套的精品资源,点击获取