
简介一份2024年Unity期末大作业“开炮打怪物”小游戏完整工程项目面向计算机相关专业学生与Unity初学者既可作为课程设计与期末项目的参考也适合作为独立游戏开发的练手模板。压缩包约54.97MB共2000个文件其中178个C#脚本承载炮塔射击、怪物生成、物理碰撞、计分与UI等核心玩法逻辑大量meta、asset、prefab、anim、mat文件构成完整Unity场景与资源体系bin、dll、exe为构建与运行相关产物另有md、json、txt等说明文档与配置项目结构和构建结果相对完整便于按模块查阅。目前已有200人学习下载。从这份工程中可同时获得可运行的成品、完整源码和场景资源尤其适合想深入理解Unity游戏整体开发流程、快速定位关键实现或用于二次改造的读者若结合作者博文介绍还能看到项目规划与排错思路参考价值较高。1. 这个“开炮打怪物”的期末作业到底在做什么期末周只剩三天任务书一行字Unity 小游戏题材自拟能跑起来。这是很常见的坑。大部分人在这个节点上做出来的不是游戏是素材堆砌的静态场景——摄像机不动、炮弹穿墙、怪物从出生点一路飘过来。而“开炮打怪物”这个标题恰好指认了一个最小但完整的射击闭环玩家转动炮塔点击开炮炮弹带物理飞行命中怪物后扣血、闪烁、死亡、加分然后继续生成下一只。这套循环能覆盖 Unity 期末评分里几乎所有硬指标场景搭建、组件物理、脚本逻辑、UI 反馈和最终打包演示适合刚装完 Unity 还没有完整项目经历的初学者也适合时间紧到只能抓主干的人。下面按最常见、最可靠的做法把它拆开讲每个环节都给出能直接抄进项目里的代码和参数你在自己的场景里跑通再按导师的评分表慢慢微调。2. 把开炮拆成三个动作炮塔瞄准、发射与弹道开炮在评委肉眼里其实由三个独立的小系统组成炮管朝哪儿、炮弹从哪儿飞出去、飞出去之后怎么跟怪物产生关系。这三步任何一步偷懒就会出现“炮弹穿模、怪物原地不动、炮管拧成麻花”的经典翻车现场。出代码之前先把场景骨架搭对。我习惯在一个空场景里只放地面Plane、主摄像机、炮塔模型和灯光其他装饰后面再说。炮塔用两个嵌套的空物体来组织最外层底座不动只绕 Y 轴旋转底座下挂一个炮管炮管只绕自身的 X 轴俯仰。再把炮管末端对齐一个空物体作为发射点后续的炮弹预制体就从这里生成。这个结构决定了旋转数学的复杂度很多自己做的时候弄成一团根本原因是底座和炮管没有分层。2.1 鼠标瞄准先求落点再做有限速度的旋转炮口的目标点从鼠标点击处取得。这里用射线打在一个看不见的“地面平面”上而不是直接转炮塔看屏幕中心。手感上的关键不是瞬间转到目标角度而是让炮塔以每秒多少度的速度转过去。直接瞬间转头会显得像劣质网页广告对期末演示很吃亏。// TurretController.cs using UnityEngine; public class TurretController : MonoBehaviour { public Transform rotationBase; // 只负责 Y 轴旋转的底座 public Transform barrel; // 只负责 X 轴俯仰的炮管 public float turnSpeed 120f; // 每秒最大转向度数 public float elevationMin -10f; public float elevationMax 45f; private Plane groundPlane new Plane(Vector3.up, Vector3.zero); void Update() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (groundPlane.Raycast(ray, out float enter)) { Vector3 hit ray.GetPoint(enter); Vector3 toHit hit - transform.position; Vector3 flat new Vector3(toHit.x, 0, toHit.z); // 底座只绕 Y 轴转看向地面落点在水平方向上的投影 Quaternion targetRot Quaternion.LookRotation(flat); rotationBase.rotation Quaternion.RotateTowards( rotationBase.rotation, targetRot, turnSpeed * Time.deltaTime); // 炮管在自己的局部空间里只改 X 轴俯仰角 float angle Vector3.Angle(flat, toHit); angle Mathf.Clamp(angle, elevationMin, elevationMax); barrel.localEulerAngles new Vector3(angle, 0, 0); } } }逻辑说明Plane.Raycast用来把“屏幕上的鼠标位置”转换成“地面上一个世界坐标点”这是做瞄准最稳的通用做法不需要自己写射线与平面求交的数学。Quaternion.RotateTowards会限制每次旋转的增量让炮塔以固定角速度转头比直接赋值四元数平滑得多。炮管用localEulerAngles而不是世界旋转是因为它挂在旋转座下世界坐标下的欧拉角顺序容易让人绕晕。参数说明turnSpeed放在 Inspector 里调60 到 150 之间是常规范围。60 太迟钝怪物从侧面跑过去炮筒跟不上150 则接近瞬移少了“重型炮台”的笨重感。elevationMax别超过 60否则近距离的怪物就在炮口底下仰角太高反而打不中。2.2 炮弹为什么用物理驱动而不自己每帧移动很多低质量教程为了让炮弹看起来像子弹直接用transform.Translate每帧往前推结果撞到怪物没有反馈、或者隔着薄墙穿过去。用物理的好处是命中判定交给碰撞系统炮弹触碰地面会反弹打中怪物会触发OnCollisionEnter这些反馈都是评委肉眼可见的。炮弹预制体的核心配置挂一个Rigidbody把collisionDetectionMode设为ContinuousDynamic然后由开炮脚本在出膛瞬间直接把速度赋上去。给初速度比AddForce更好控制因为炮弹不会受到额外推力而飘。// FireController.cs using UnityEngine; public class FireController : MonoBehaviour { public GameObject shellPrefab; public Transform muzzle; public float fireForce 30f; public float cooldown 0.35f; private float lastFireTime; void Update() { if (Input.GetMouseButtonDown(0) Time.time - lastFireTime cooldown) { Fire(); lastFireTime Time.time; } } void Fire() { GameObject shell Instantiate(shellPrefab, muzzle.position, muzzle.rotation); if (shell.TryGetComponentRigidbody(out Rigidbody rb)) { // 直接给速度出膛瞬间就是最终速度 rb.velocity muzzle.forward * fireForce; } } }逻辑说明muzzle.forward是炮管当前朝向的单位向量赋给Rigidbody.velocity后炮弹就沿炮管方向飞出去。这里特意用TryGetComponent而不是GetComponent避免预制体漏挂组件时控制台直接红一片Demo 现场这种错误非常尴尬。参数说明fireForce是炮弹的初速单位不是米每秒而是 Unity 物理世界里的速度值。场景里怪物出生点离炮塔 25 米左右时我一般先给 22飞行一秒出头能到手感刚好给 60 以上就会出现高速穿模需要配合ContinuousDynamic才能救回来。cooldown下限建议 0.2 秒再低就不是“开炮”而是“开枪”失去了大炮该有的节奏。2.3 命中判定把伤害写在炮弹上而不是写在怪物身上炮弹命中后要处理两件事通知怪物扣血然后把自己销毁。按我的习惯把这些逻辑单独放到ProjectileDamage脚本里这样怪物脚本只需要暴露一个TakeDamage的公共方法后续新增火焰弹、冰冻弹都只扩展炮弹本身不动怪物代码。// ProjectileDamage.cs using UnityEngine; public class ProjectileDamage : MonoBehaviour { public int damage 1; public float lifeTime 5f; private void Start() { Destroy(gameObject, lifeTime); } private void OnCollisionEnter(Collision collision) { if (collision.collider.TryGetComponent(out MonsterController monster)) { monster.TakeDamage(damage); Destroy(gameObject); } } }逻辑说明lifeTime是炮弹的兜底生命周期没命中任何东西也会自动销毁避免场景里积攒看不见的死弹造成卡顿。碰撞后先用TryGetComponent检查是不是怪物再决定销毁对象。如果打中地面也销毁那就意味着打偏的炮弹直接消失场景看起来会比较干净。参数说明damage是炮弹对怪物的伤害值要与第 4 章里怪物的maxHp一起调。如果一发炮弹造成 1 伤害、怪物 3 点血正好是三炮一个怪的节奏。lifeTime不需要太长5 秒足够覆盖最远出生点的弹道超过这个时间还没命中基本说明瞄准错了。注意炮弹的碰撞体不要勾isTrigger。勾了之后OnCollisionEnter不会触发得改OnTriggerEnter这是新手最容易无意识埋下的坑。3. 让怪物“围过来”而不是“傻站着”生成、追踪与受击反馈“开炮打怪物”的怪物如果只是站在原地挨打项目也能跑起来但评委能一眼看出 AI 是假的。不需要上什么状态机决策树只要让怪物做三件事被生成器按波次刷出来、朝炮塔方向移动、受伤时有明显反馈整套游戏感就出来了。3.1 协程生成波次比 InvokeRepeating 好维护生成逻辑最忌在Update()里每帧Instantiate。常见做法是用协程加WaitForSeconds控制节奏间隔时间放到 Inspector 里演示现场调整不用重新编译。场景边缘放四到六个空物体作为出生点用数组统一管理。// SpawnManager.cs using UnityEngine; using System.Collections; using System.Collections.Generic; public class SpawnManager : MonoBehaviour { public Transform[] spawnPoints; public GameObject monsterPrefab; public float spawnInterval 2f; public int maxAlive 8; public int wave 1; private readonly ListGameObject alive new ListGameObject(); private IEnumerator Start() { while (true) { if (alive.Count maxAlive) { for (int i 0; i wave; i) { SpawnOne(); } yield return new WaitForSeconds(spawnInterval); } else { // 场上怪物已满先不生成避免瞬间刷出一大堆 yield return new WaitForSeconds(0.5f); } } } private void SpawnOne() { Transform point spawnPoints[Random.Range(0, spawnPoints.Length)]; GameObject monster Instantiate(monsterPrefab, point.position, point.rotation); alive.Add(monster); monster.GetComponentMonsterController().OnDeath () alive.Remove(monster); } }逻辑说明alive列表用来记录当前存活的怪物数量。生成器每秒检查一次满员就等着不占满屏幕。事件OnDeath在怪物死亡时触发用来从列表里移除引用。协程的好处是结构清晰想停波次直接StopAllCoroutines()就行。参数说明spawnInterval初始建议 2 秒wave是每波生成数量从 1 开始。maxAlive控制在 8 到 12 之间机器配置一般跑得动。注意协程挂在生成器对象上脚本被禁用时协程并不会自动停要记得在处理状态改变的逻辑里调用停止。3.2 追踪方案平地场景别上 NavMesh期末考试场景大多是一块平地面没什么障碍物直接用Rigidbody.MovePosition做追踪就够。相比transform.Translate它的好处是能感受到物理碰撞怪物聚集时互相推挤看起来更自然。等导师哪天要求“怪物会绕柱走”再给每个怪物加NavMeshAgent也不迟。// MonsterController.cs using UnityEngine; public class MonsterController : MonoBehaviour { public float moveSpeed 3f; public int maxHp 3; public int score 100; public Transform home; // 炮塔锚点在 Inspector 里拖入 private Rigidbody rb; private int hp; public event System.Action OnDeath; private void Start() { rb GetComponentRigidbody(); hp maxHp; } private void FixedUpdate() { Vector3 dir (home.position - transform.position).normalized; rb.MovePosition(transform.position dir * moveSpeed * Time.fixedDeltaTime); } public void TakeDamage(int amount) { hp - amount; if (hp 0) { OnDeath?.Invoke(); Destroy(gameObject); } } }逻辑说明FixedUpdate是物理更新的固定时间步移动代码放到这里能跟物理引擎同步。每帧重新计算dir所以怪物会持续追着炮塔的最新位置走不会在炮塔移动后继续朝老路线发呆。OnDeath用 C# 事件而不是轮询生成器那边订阅一次就够了。参数说明moveSpeed给 2.5 到 3.5对应第 2 章炮弹初速 22、出生点距离 25 米的场景新手刚好来得及打两三轮。太快会让玩家一直手忙脚乱太慢则没有压迫感。maxHp对应炮弹的damage3 点血正好验证“三炮一个”的节奏。3.3 受击反馈是游戏感和“写完作业”的分界线怪物掉血必须有视觉反馈。最省钱的办法是受击瞬间把材质颜色闪红再快速恢复。这里有个技术点不要直接改material或sharedMaterial用MaterialPropertyBlock在渲染层做临时覆盖。直接改材质会把材质实例复制出来怪物一多就卡改sharedMaterial会污染所有用同一个材质的怪物后期调色会翻车。// 在 MonsterController 内部追加 using UnityEngine; public class MonsterController : MonoBehaviour { // 省略前面的移动和体力代码…… private Renderer meshRenderer; private MaterialPropertyBlock flashBlock; private void Awake() { meshRenderer GetComponentInChildrenRenderer(); flashBlock new MaterialPropertyBlock(); } public void Flash() { flashBlock.SetColor(_BaseColor, Color.red); meshRenderer.SetPropertyBlock(flashBlock); Invoke(nameof(ResetFlash), 0.15f); } private void ResetFlash() { flashBlock.SetColor(_BaseColor, Color.white); meshRenderer.SetPropertyBlock(flashBlock); } }逻辑说明MaterialPropertyBlock只覆盖单个渲染器的属性不修改共享材质所以怪物数量再多也不会产生材质实例碎片。然后把Flash()调用加进TakeDamage受击时立刻变红0.15 秒后恢复。这个细节是“像不像游戏”的分水岭光扣血不掉色手感会显得很干。参数说明属性名要看 Shader 用的是什么。URP 的 Lit 和 Simple Lit 用_BaseColor内置渲染管线的 Standard 用_Color。如果 SetColor 之后没有反应先检查这里不生效多半是属性名对不上而不是脚本没执行。4. 手感、数值与 UI从“能跑”到“能玩”的关键调校代码全部跑通只算完成了 50%。剩下那 50% 是数值和反馈——也就是评委上手玩的第一分钟感受。这一章先说清楚怎么把参数定在合理区间再讲 HUD 和瞄准线这种“技能指示器”的简易实现。4.1 先给自己一张参数表别靠感觉拍脑袋调手感的入门方法是把核心参数做成一张表先跑一轮再逐项改。我初始那版用了这些值参数初始值对应节奏意图第一轮调试感觉炮塔 turnSpeed120°/s能跟上有侧面进攻的怪物流畅但怪物跑到侧后方略迟钝炮弹 fireForce22从炮口飞到 20 米处约一秒合适炮弹 cooldown0.35s怪物三炮击杀时总时间约 1 秒舒坦怪物 moveSpeed3m/s从 25 米出生点到炮塔约 8 秒偏快压力略大怪物 maxHp3对应三发炮弹节奏不错生成 maxAlive8屏幕略挤但不至于满屏10 只时会明显紧张这组参数背后的逻辑是玩家从发现怪物到瞄准再到开炮平均一轮要 1 秒以上怪物从出生点到炮塔的移动时间至少得大于玩家击杀它所需时间的两倍否则怪没机会被击杀就贴脸。25 米距离、怪物 3m/s移动耗时约 8.3 秒单杀一轮约 3 秒勉强能打掉两只这就是一个可玩的压力曲线。期末演示时评委经常会说“难度太低或者太高”不要慌直接停掉运行在 Inspector 里把spawnInterval从 2 拖到 1.5把moveSpeed从 3 拖到 3.5重跑一遍节奏马上就不一样。这个动作在答辩现场非常加分前提是你的所有参数都暴露在 Inspector 里而不是写死在代码中。4.2 HUD 只放三样东西分数、波次、场上存活数UI 部分用一个 Canvas 就够。右上角 Score 文本左上角 Wave 文本底部中间显示当前场上怪物数量。字体不用找什么花哨素材内置的反正够看重点是把“击杀怪物 → 分数跳变 → 剩余怪数变化”这条正反馈做清楚。我给一个最朴素的数字更新方式在GameUIManager里保存对三个Text组件的引用怪物死亡时由事件触发数值刷新。// GameUIManager.cs using UnityEngine; using UnityEngine.UI; public class GameUIManager : MonoBehaviour { public Text scoreText; public Text waveText; public Text aliveText; public SpawnManager spawnManager; private int score; public void AddScore(int value) { score value; scoreText.text Score: score; } private void Update() { aliveText.text Monsters: spawnManager.AliveCount; } }逻辑说明AddScore由怪物死亡事件触发而不是自己在 Update 里轮询省去每帧比对数值的浪费。场上的存活数因为和生成器相关直接在 Update 里从SpawnManager暴露的AliveCount属性读取即可。这里没有做字符串拼接性能优化期末演示的文本刷新频率完全够用。参数说明AliveCount在SpawnManager里要加一个公开属性返回alive.Count否则 UI 拿不到数据。如果你把alive列表设成 public也能拖到 Inspector 里看但暴露只读属性是更好的习惯。4.3 瞄准线最划算的“技能指示器”炮兵玩法最大的体验问题是玩家不知道当前炮口对着哪尤其炮塔需要旋转时视角容易乱。社区里常说的 skill attack indicators 在这里的实现不需要复杂特效用一根LineRenderer画出炮口前方 10 米的射线提示就够了。打中怪物变红没打中变白视觉信息一目了然。// AimLine.cs using UnityEngine; public class AimLine : MonoBehaviour { public LineRenderer line; public float length 12f; private void Update() { if (Physics.Raycast(transform.position, transform.forward, out RaycastHit hit, length)) { line.positionCount 2; line.SetPosition(0, transform.position); line.SetPosition(1, hit.point); line.startColor Color.green; line.endColor Color.red; } else { line.positionCount 2; line.SetPosition(0, transform.position); line.SetPosition(1, transform.position transform.forward * length); line.startColor Color.white; line.endColor Color.white; } } }逻辑说明LineRenderer挂在炮管下的一个空物体上随炮管一起俯仰。射线命中目标点或者地面就把线的终点放到命中点没有命中就放默认距离同时颜色变化提示玩家“打不中”。这套逻辑同时承担了瞄准引导和命中预览性价比很高。参数说明line的材质建议用Sprites/Default宽度设 0.03 到 0.05太宽会遮挡怪物。length不要超过炮弹飞行距离否则指示器显示够得到的怪物炮弹实际飞不到手感会失真。4.4 摄像机跟随让视角稳定是演示不出错的前提炮塔旋转时如果视角跟着一起狂转评委看三秒就会晕。建议把摄像机做成独立的跟随视角在 LateUpdate 里做平滑插值而不是直接父子挂载。父子挂载虽然简单但炮塔转动会带着整个画面抖动。// SmoothFollow.cs using UnityEngine; public class SmoothFollow : MonoBehaviour { public Transform target; public Vector3 offset new Vector3(0, 6, -8); public float smoothTime 0.25f; private Vector3 velocity; private void LateUpdate() { Vector3 desired target.position offset; transform.position Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime); transform.LookAt(target); } }逻辑说明LateUpdate在角色所有 Update 之后执行此时炮塔已经完成转动相机再去跟随不会出现画面拉扯。SmoothDamp里那ref velocity必须保存为字段不能每次临时创建否则平滑效果变成瞬间跳跃。参数说明offset用 (0, 6, -8) 是从斜后方俯视炮塔兼顾操作视野和演出效果。想更贴角色就改成 (0, 4, -3)画面会更近更有压迫感但视野范围变小。smoothTime0.2 到 0.35 之间比较舒服太大会显得镜头反应迟钝。5. 期末夜最容易翻车的五个坑挂错脚本、穿炮弹与授权水印这部分是血泪经验。每个坑都是我亲眼见过或者自己踩过的按“现象 → 原因 → 解决”写遇到类似问题能直接对照。5.1 炮弹直接穿过怪物落在地上弹走现象开炮后炮弹穿过怪物身体怪物不掉血炮弹落在后面地面上弹跳。原因有三种炮弹移动速度太快物理引擎在相邻两个物理帧之间直接跳过了怪物的碰撞体炮弹的Collider放在了子物体上但伤害脚本挂在父物体上OnCollisionEnter里的GetComponent查不到再就是碰撞体被误设成了isTrigger。解决把炮弹Rigidbody的Collision Detection改成ContinuousDynamic高速物体首选把ProjectileDamage和怪物身上的Collider放在同一个物体上。如果必须用子物体碰撞体就写个中间脚本把碰撞事件转发上来别偷懒直接改父物体查找。5.2 控制台时不时上报“Object reference not set”红错现象项目刚点运行Console 面板刷出大量空引用异常画面还在走但炮塔不转、怪物不动半天找不到原因。原因Inspector 里忘了把transform拖到对应的 public 字段或者脚本挂在了没有Rigidbody的父节点上。期末项目最容易漏的就是这个。解决在Start里对关键引用做零值检查没赋值就Debug.LogError并输出组件名。比如if (home null) Debug.LogError(MonsterController 缺 home 引用);。几个月前我也觉得这是浪费时间直到连续两个通宵都在查这种低级问题。踩过坑会发现这比事后猜好得多。5.3 协程还在跑重新进入关卡会出现幽灵刷怪现象打完一局退回主菜单再进入关卡场上怪物数量翻倍生成速度也变快了。原因禁用SpawnManager时没停掉协程。场景禁用了生成脚本但协程还在后台等待WaitForSeconds一旦对象再次激活它继续从上次断点跑。解决在SpawnManager的OnDisable里调用StopAllCoroutines()。另外在协程遍历alive列表时不要直接移除元素先收集待删除列表等循环结束再处理否则会触发InvalidOperationException。还有一个隐藏的坑怪物销毁时如果之前订阅过OnDeath记得在OnDestroy里退订否则残留引用会让列表越积越大。5.4 编辑器界面出现 Unity Personal 授权水印学生慌了现象打开 Unity 工程编辑器顶部或界面角落里出现“Unity Personal”或试用期提示水印学生误以为是自己的操作导致担心展示时被打折扣。原因这是 Unity 编辑器授权状态的正常提示个人开发者和学生在免费授权许可下运行时编辑器界面会出现产品名称和标识不代表项目数据有问题。解决到 Unity Hub 里配置许可学生根据自己账号情况合法激活即可。打包出的 PC 可执行文件不受编辑器界面水印影响这是两码事。网上那些绕过授权提示的做法不要碰违反协议不说等真机评审时反而可能出问题。记住项目本身能不能跑、有没有可玩性才是老师唯一关心的东西。5.5 鼠标指针消失炮塔只能朝着屏幕中心转现象运行后鼠标不见了旋转视角时炮塔不听使唤好像瞄准方向被锁死。原因某个脚本里执行了Cursor.lockState CursorLockMode.Locked并隐藏光标Unity 默认把光标锁在窗口中心开炮变成永远瞄准正前方。解决在炮塔模式脚本Update里加一个解锁条件按下 Esc 时恢复光标可见并解锁。另外在 FireController 里不要依赖屏幕中心射线继续用前面写的ScreenPointToRay(Input.mousePosition)解锁后立刻恢复正常的自由瞄准。6. 提交前我建议你再做三件事第一件事是把炮弹从 Instantiate/Destroy 改成对象池。期末演示连打半小时反复创建和销毁炮弹GC 会越积越多后面几关偶尔卡一下观感立刻掉档次。简化做法是准备一个ListGameObject存闲置炮弹开火时优先从列表里取没有才Instantiate炮弹撞到东西时先SetActive(false)回池而不是直接Destroy。我最早以为这是优化课才需要做的事后来发现只要连发超过一百发手感差别就非常明显。对象池代码本身不难难的只是你愿不愿意在截止前多花四十分钟。第二件事是保存最高分。给得分系统接一个PlayerPrefs.SetInt(HighScore, value)每次击杀刷新判断一下是否高于历史分。演示时退出再重新进入游戏看到“历史最高 2700本次 3100”这行字整个作业的完整度会有明显提升。代码就几行但能让评审觉得你考虑了“多次游玩”的场景而不是只为一镜到底做的demo。第三件事是确认 Build 设置再打包。File → Build Settings 里把当前场景勾进 Build目标平台选 Windows输出路径用全英文。中文路径在个别机器上会出幺蛾子试过才懂。打包完成后先退出 Unity 再双击 exe 跑一遍确认字体、UI 和声音都正常再录一个两分钟的演示视频放在项目文件夹里。老师不一定有时间现场打开引擎视频反而是最稳的交付物。我自己的习惯是每次做小游戏项目最后一天不写新功能只跑“从头到尾完整演示”这件事并把所有参数在 Inspector 里重新过一遍。很多平时没注意的细节——比如炮管初始朝向歪着、炮弹层没设对、摄像机在瓦片边缘穿模——都是在这个时候暴露的。希望这个“开炮打怪物”的最小闭环也能成为你期末作业里扛得住演示的那版核心希望帮到你。本文还有配套的精品资源点击获取