简介面向Unity3D初学者的期末作业捕鱼游戏完整工程包含源程序所有文件适合课程设计、期末备考或个人自学也适配虚拟现实项目入门。工程覆盖炮台发射、碰撞检测、计分统计、玩家操作、UI交互、背景音乐与音量调节、难度选择、加载进度条等完整玩法场景与脚本均经过整理打开即可查看主要功能模块难度选择设计涉及炮弹威力、鱼群速度等参数调整增加练习延展性。压缩包约210.94MB共2000个文件其中1180余张PNG贴图与36张PSD源图用于界面和鱼类素材740余个C#脚本承担游戏逻辑与功能控制92个Prefab预制体组织可复用对象80余个anim动画和同量级controller构成动画系统41个wav音频承担背景音乐和音效另有mat材质、asset配置、dll库及140个Markdown文档用于说明与辅助理解meta文件完整便于工程导入。已有1094人浏览/学习。对初学者而言既能逐行阅读脚本理解炮台、鱼群、得分与难度切换的写法也能通过预制体和UI组件学习Canvas、Button、Slider、AudioSource等常见控件的使用方法还能参考资源目录组织方式来规划自己的项目目录是完整的Unity3D实战入门示例。1. 捕鱼游戏是 Unity3D 期末作业里最值得拆的题型捕鱼游戏是 Unity3D 期末作业里出现频率很高的题型因为它把 2D 游戏的核心环节占全了预制体生成、动画切换、鼠标瞄准、触发器碰撞、实时计分和金币消耗。这类简单小游戏项目做满一个学期正好覆盖 Unity3D 游戏开发的主要链路比单纯做个跑酷或打砖块更能讲出东西。对初学者来说一份带完整源程序的捕鱼工程最有价值的不是「打开就能跑」而是能看清一个 Unity 项目该怎么组织场景、预制体和脚本。鱼移动、子弹碰撞、金币结算分别写在 Fish、Bullet、GameManager 里靠组件引用与 Tag 协作不是一个大脚本包办。下面按一个可运行工程的标准把捕鱼游戏的场景结构、核心脚本、参数调节和期末提交前的检查讲清楚。代码带注释抄进自己的 Unity 2D 项目就能跑。2. 拆解捕鱼游戏的场景与脚本结构2.1 场景层级与空物体职责打开工程里的主场景习惯命名 Main.unity先看 Hierarchy不要急着点 Play。一个能讲得清楚的场景层级从根节点往下大致是这样FishingGame // 场景根节点 ├── Main Camera // 正交投影固定 Z -10 ├── GameArea // 背景海洋、珊瑚、气泡 ├── FishSpawner // 空物体只挂生成脚本 ├── Cannon // 炮台含子物体 FirePoint ├── BulletPool // 子弹池容器 ├── EventSystem // UI 系统自动生成不用手改 └── Canvas // 计分文本与按钮 ├── CoinText ├── ScoreText └── ReplayButton这套层级的关键是「一个空物体只负责一件事」。FishSpawner 不碰 UICannon 只做瞄准和开火Canvas 只显示数据。初学者最常见的写法是把生成、碰撞、计分全挂到 Player 上运行起来确实能动但改一个功能会牵连另外两个期末答辩被问到「这个脚本怎么扩展」时很容易卡住。有两个细节值得在对齐层级时多花一分钟。一是 Cannon 的 pivot 要放在炮台底座中心旋转时才不会绕到贴图角落炮口位置用子物体 FirePoint 表示它是后续取发射方向用的参考点。二是背景、鱼、子弹各占一个 Sort LayerBackground 0Fish 1Bullet 2避免出现「子弹明明飞过去却看不到」的层级遮挡问题。GameArea 下放水草、珊瑚这类纯表现物体位置固定在 (0, 0, 0)让 Spawner 的随机坐标与世界坐标直接对应。2.2 鱼的预制体Animator、碰撞体和 Tag鱼预制体是这套源程序里最值得逐项检查的部分至少包含三样东西Sprite Animator 负责外观与游动Collider2D 负责命中判定Fish.cs 负责属性和移动。Animator 里分两个状态Swim 循环播放游动Die 播放一个短动画后回收。不想做 Die 动画也行直接 SetActive(false)但捕鱼游戏缺少「鱼被击中消失」的反馈会非常闷答辩演示时效果差一大截。碰撞体选择上BoxCollider2D 精度低但开销小PolygonCollider2D 贴鱼形但顶点多。我的习惯是统一用 BoxCollider2D尺寸取鱼身的 60%70%给玩家留一点容错。鱼身上如果挂 Rigidbody2DBody Type 必须选 Kinematic——用 Dynamic 的话物理引擎会把鱼推来推去还会和背景碰撞体发生奇怪的弹开Kinematic 下移动完全交给脚本路径可控。Tag 判定是另一个关键约定。鱼的根物体 Tag 统一设成 Fish子弹碰撞回调里先用 other.CompareTag(Fish) 判断再取组件比一上来就 GetComponent () 少很多无效调用。同类鱼尽量用一个预制体靠随机缩放和速度区分大小鱼不要每种花色的鱼建一个 Prefab——Prefab 越多后期改碰撞参数的工作量越大。网上流传的海洋海底鱼模型资源包里不少是捕鱼达人风格的动作模型导入后先检查三角面数和贴图尺寸模型面数太高的资源放低配机器上演示会掉帧。预制体参数推荐值说明Collider 类型BoxCollider2D命中判定用兼顾精度与开销Collider Size 比例鱼身宽高的 0.60.7略小于贴图给玩家容错Rigidbody2D Body TypeKinematic移动交给脚本避免物理干扰TagFishOnTriggerEnter2D 匹配用2.3 碰撞检测用 Trigger 还是射线子弹与鱼的碰撞有两种实现。第一种是子弹挂 Collider2D 且勾选 isTrigger鱼挂普通 Collider2D子弹脚本里写 OnTriggerEnter2D第二种是每帧向炮口方向发一条 Physics2D.Raycast命中就结算。捕鱼游戏推荐第一种连发频率不高、子弹数量有限触发器开销完全可控代码量最少期末作业也最好讲清楚。射线方案适合「一发命中的狙击感」或需要穿透多目标的场景比如一颗子弹同时命中鱼群里的三只鱼各掉半血这种需求捕鱼游戏里不常见平白增加排错成本。选定 Trigger 方案后有一项配套检查Edit Project Settings Physics 2D 里的 Layer Collision Matrix确认 Default 层之间没有把 2D 碰撞误关。常见情况是之前做过其他项目把矩阵改过导入捕鱼工程后子弹直接穿过鱼查脚本查半天最后发现是碰撞矩阵没勾选。另外提醒一点同一帧里 OnTriggerEnter2D 的回调存在先后顺序子弹和鱼都要回收时先把鱼的扣血逻辑执行完再关闭子弹自己的 GameObject顺序不要反过来否则鱼的组件还没取到就已经被禁用。3. 用 C# 串起捕鱼核心流程生成、瞄准、碰撞与结算3.1 鱼群生成协程比 Update 计时更直观鱼群的生成节奏决定游戏的第一手感。两种写法对比一种是在 Update 里累加计时变量大于阈值就生成另一种是协程用 yield return new WaitForSeconds 控制间隔。协程的优势是节奏「顺序可见」先等多久、再生成几条、再停多久读代码的人一眼能看懂答辩讲解也轻松。下面是一套最长见的波次生成写法using System.Collections; using UnityEngine; public class FishSpawner : MonoBehaviour { public GameObject fishPrefab; // 小鱼预制体Inspector 拖入 public int waveCount 8; // 一波生成几条 public float spawnInterval 0.6f; // 两条鱼之间的间隔秒 public float wavePause 3f; // 波与波之间的停顿秒 public Vector2 rangeY new Vector2(-3.5f, 3.5f); // 出生点 Y 范围 private void Start() { StartCoroutine(SpawnLoop()); } private IEnumerator SpawnLoop() { while (true) // 无限波次适合无尽模式 { for (int i 0; i waveCount; i) { SpawnSingleFish(); yield return new WaitForSeconds(spawnInterval); } yield return new WaitForSeconds(wavePause); } } private void SpawnSingleFish() { float x 16f; // 统一从屏幕右侧边缘外生成 float y Random.Range(rangeY.x, rangeY.y); GameObject fish Instantiate(fishPrefab, new Vector3(x, y, 0), Quaternion.identity); fish.transform.SetParent(transform, true); // 保持世界坐标同时挂到 Spawner 下 } }顶部三个公共参数就是调节奏的入口waveCount 决定一波密度spawnInterval 决定鱼是一条条出场还是一拥而上wavePause 给玩家留出两波之间的喘息时间。固定从 x 16 生成、鱼只向左游配合第 3.3 节的 Fish 脚本末端回收条件只剩一条不用处理左右两种越界。SetParent 的第二个参数 true 表示 worldPositionStays保持生成时的世界坐标不变。初学者常见错误是漏掉第二参数鱼的位置会瞬间被挪到 Spawner 相对原点画面表现为「鱼闪了一下就消失」。另外注意 WaitForSeconds 会受 Time.timeScale 影响做暂停功能时生成协程会跟着停这通常是符合预期的行为。3.2 炮台瞄准与开火ScreenToWorldPoint 和 firePoint.up炮台脚本解决两件事炮口跟着鼠标转开火后子弹沿炮口方向飞。难点不在旋转本身而在坐标转换和方向取用using UnityEngine; public class CannonController : MonoBehaviour { public GameObject bulletPrefab; // 子弹预制体 public Transform firePoint; // 炮口空物体挂在炮台子级 public float rotateSpeed 360f; // 炮台旋转角速度度/秒 public float fireInterval 0.35f; // 最小开火间隔 private Camera mainCamera; private float lastFireTime; private void Start() { mainCamera Camera.main; } private void Update() { Vector3 mousePos mainCamera.ScreenToWorldPoint(Input.mousePosition); mousePos.z 0f; // 世界坐标归零避免 Z 轴偏差 Vector2 direction mousePos - transform.position; float targetAngle Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f; Quaternion target Quaternion.Euler(0f, 0f, targetAngle); transform.rotation Quaternion.RotateTowards( transform.rotation, target, rotateSpeed * Time.deltaTime); if (Input.GetMouseButton(0) Time.time - lastFireTime fireInterval) { Fire(); lastFireTime Time.time; } } private void Fire() { if (!GameManager.Instance.TrySpendCoin()) return; Vector2 dir firePoint.up; // 取炮口当前方向而非鼠标方向 GameObject bullet Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); bullet.GetComponentBullet().SetDirection(dir); } }ScreenToWorldPoint 是新手踩得最多的坑正交相机下它返回的 Z 坐标等于相机自身的 Z 轴通常 -10不手动归零子弹就生成在深度错误的位置。方向计算用 Mathf.Atan2 得到弧度乘 Rad2Deg 转角度再减 90 度是因为炮台贴图默认朝上Y 轴而 Atan2 算出来的是与 X 轴的夹角。如果美术素材默认朝下或朝右这个偏移量要按素材方向改成 0 或 180。发射方向取 firePoint.up 而不是再算一次炮台到鼠标的方向firePoint 是炮台子物体炮台旋转时它跟着转它的 up 就是此刻炮口朝向。旋转用 Quaternion.RotateTowards 而不是直接赋值炮台会有转动动画手感比瞬间跳变好。Fire 里先调 TrySpendCoin 再生成子弹金币不够时直接 return 不发弹这一行把经济系统和射击绑定在一起是捕鱼游戏数值闭环的起点。3.3 子弹与鱼的碰撞先结算再回收子弹脚本和鱼脚本各管一半碰撞逻辑using UnityEngine; public class Bullet : MonoBehaviour { public float speed 8f; // 飞行速度 public float lifeTime 2.5f; // 超时自动回收 private Vector2 direction; public void SetDirection(Vector2 dir) { direction dir.normalized; // 归一化防止斜向发射时速度变慢 } private void Update() { transform.position (Vector3)(direction * speed) * Time.deltaTime; lifeTime - Time.deltaTime; if (lifeTime 0f) { gameObject.SetActive(false); } } private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Fish)) { other.GetComponentFish().TakeDamage(1); gameObject.SetActive(false); } } }using UnityEngine; public class Fish : MonoBehaviour { public int hp 1; // 小鱼 1大鱼可设 35 public int scoreValue 10; // 击倒后加的金币数 public float swimSpeed 1.5f; // 游动速度 private int currentHp; private Vector3 swimDirection Vector3.left; private float directionTimer; private void OnEnable() { currentHp hp; // 每次从池子取出时重置血量 directionTimer Random.Range(1f, 3f); } private void Update() { transform.position swimDirection * swimSpeed * Time.deltaTime; directionTimer - Time.deltaTime; if (directionTimer 0f) { swimDirection Random.value 0.5f ? Vector3.left : Vector3.right; FlipSprite(); directionTimer Random.Range(1f, 3f); } if (transform.position.x -15f) // 游出屏幕左侧范围即回收 { gameObject.SetActive(false); } } public void TakeDamage(int damage) { currentHp - damage; if (currentHp 0) { GameManager.Instance.AddCoin(scoreValue); gameObject.SetActive(false); } } private void FlipSprite() { SpriteRenderer sr GetComponentSpriteRenderer(); if (sr ! null) sr.flipX !sr.flipX; } }配套的金币结算脚本挂在场景里一个常驻物体上using UnityEngine; using TMPro; public class GameManager : MonoBehaviour { public static GameManager Instance; public int coin 100; // 初始金币 public int bulletCost 5; // 每次开火成本 public TMP_Text coinText; // Inspector 拖入 Canvas 的金币文本 private void Awake() { Instance this; } public void AddCoin(int value) { coin value; coinText.text coin.ToString(); } public bool TrySpendCoin() { if (coin bulletCost) return false; coin - bulletCost; coinText.text coin.ToString(); return true; } }Fish 的血量重置写在 OnEnable 而不是 Start 是有讲究的对象池回收后再取出只触发 OnEnableStart 只在首次生成时执行一次。凡是帧率优化、对象复用的工程状态初始化一律放 OnEnable。Bullet 命中后先让 Fish.TakeDamage 执行完鱼加完金币、自己 SetActive(false)子弹随后再关两个物体不会在同帧互相访问已禁用的组件。GameManager 用静态单例让 Fish 和 Cannon 都能直接调用场景里只有一个实例时这是最简单的通信方式。脚本挂载对象核心公共参数职责FishSpawnerSpawner 空物体waveCount、spawnInterval、wavePause持续生成鱼群CannonControllerCannonrotateSpeed、fireInterval瞄准、扣金币、开火Bullet子弹预制体speed、lifeTime直线飞行命中回收Fish鱼预制体hp、scoreValue、swimSpeed移动、换向、扣血结算GameManager场景常驻物体coin、bulletCost、coinText金币账本与 UI 刷新4. 捕鱼游戏参数调试与常见坑4.1 三个改动最明显的手感参数参数调得好不好直接决定答辩演示时的观感鱼多不多、子弹快不快、金币涨不涨。优先级最高的三个参数如下。参数位置常见区间调错时的表现spawnIntervalFishSpawner0.41.0 秒过小满屏鱼没有节奏过大空窗期长玩家没事干speedBullet814小于 6 子弹发飘难瞄准大于 18 单帧位移超过鱼碰撞体穿鱼fireIntervalCannonController0.250.5 秒过小金币瞬间见底过大连发反馈弱三条在期末作业里很加分的调整思路。第一子弹速度至少是鱼速的 4 倍。鱼速 1.5、子弹 8 已经够用鱼速往上加时子弹必须跟着加否则会出现子弹追不上鱼、鱼群骑着子弹过去的观感。第二金币经济要自洽。开火一次扣 5小鱼一条加 10只要玩家保持 50% 以上命中金币就能持续增长不靠任何外部手段补充。这套数值关系建议写进答辩说明比「我随便调的」有说服力得多。第三正交相机的 Size 决定生成范围size 调大时FishSpawner 的 rangeY 要同步拉大否则鱼只出现在屏幕中部的窄条里两侧背景空荡荡。网上能搜到大量海洋海底鱼模型资源风格参考捕鱼达人有的带完整动作。导入后先看三角面数和贴图尺寸面数太高的模型在低配笔记本上会掉帧如果只是做期末作业用 Sprite 序列帧做游动动画反而是最稳的可控性高于 3D 模型转 2D 渲染。4.2 高频问题对照表与处理办法现象原因处理子弹穿过鱼但没判定子弹单帧位移大于鱼碰撞体宽度把 Bullet.speed 降到 14 以下或给子弹加 Rigidbody2D 并开启连续碰撞检测鱼游到右侧后一去不回回收条件只写了 x -15换向后的鱼往右游永远不回收回收条件改成 Mathf.Abs(x) 15左右统一处理金币文本不刷新coinText 没拖进 GameManager或 TMP 字库缺失检查 Inspector 引用Window TextMeshPro 里重新导入 TMP Essentials回收后的子弹再发射方向错乱SetDirection 只改了方向向量没同步旋转在 SetDirection 里同时设置 transform.rotation按 dir 的夹角赋值鱼被击中后整波消失子弹和鱼都用 SetActive(false)同帧顺序反了先执行鱼的 TakeDamage再关子弹顺序不要颠倒重新加载场景后金币还在GameManager 被 DontDestroyOnLoad 保留或用了静态变量去掉 DontDestroyOnLoad让场景重载时重新 Awake每条都可以在拿到源程序后对照着验证一遍。前两条是物理层面的问题改参数或改判断条件就能解决第三条看起来是脚本 Bug实际是资源引用问题答辩前务必确认 TMP 组件在打包机上也正常。4.3 对象池的轻量替代SetActive 复用完整对象池用 Queue 管理空闲实例对期末作业来说稍显重型。更务实的做法是「禁用替代销毁」所有回收动作统一用 SetActive(false)需要子弹时先查缓存队列没有再 Instantiate。using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; private QueueGameObject pool new QueueGameObject(); public GameObject Get() { if (pool.Count 0) { GameObject go pool.Dequeue(); go.SetActive(true); return go; } return Instantiate(bulletPrefab); } public void Return(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }这套代码的意义不在性能数字而在于把「创建对象」和「激活对象」拆开。Destroy 会立即释放对象并产生垃圾内存高频发射子弹时每帧都有 Instantiate/DestroyGC 压力累积到一定程度会出现明显的卡顿帧。改成 SetActive(false) 后对象本体还在内存里只是隐藏再次取出时只做状态重置对应 Fish 的 OnEnable 逻辑开销小一个量级。如果不想引入队列逻辑答辩时直接说明「用 SetActive(false) 替代 Destroy避免频繁分配内存导致卡顿」也能拿分。真正要避免的是拿到源程序后把里面所有 Instantiate 都换成 new GameObject 之类的操作那会让预制体和组件引用全部失效。5. 捕鱼游戏期末提交前的三件事打包、重开和性能5.1 场景必须出现在 Build Settings最常见的提交事故是编辑器里能跑老师拿到 exe 双击后是空场景。原因是主场景没有加进 Build Settings。操作顺序File Build Settings Add Open Scenes把 Main.unity 加入 Scenes In Build并保证它在列表里的 Index 是 0。如果多个场景显示在列表中确认第一个就是能直接进入游戏的那个不然 exe 启动后打开的还是默认空场景。同时检查 Player Settings 里的 Company Name 和 Product Name改成自己的学号或姓名。默认值会让 exe 文件名和安装信息都是 Unity 的通用名称外发文件夹里分不清哪个是哪个。打包平台选 Windows x86_64分辨率建议勾选 Default Is Native Resolution避免在不同教室电脑上出现拉伸变形。5.2 用 SceneManager 做「再来一局」重新开始游戏最可靠的做法不是手动把每个变量归零而是重新加载当前场景所有组件重新 Awake。在 ReplayButton 上写一个方法拖到 Button 的 OnClick 事件里即可using UnityEngine; using UnityEngine.SceneManagement; public class ReplayButton : MonoBehaviour { public void Replay() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } }SceneManager.GetActiveScene().name 取当前场景名LoadScene 传进去就是整局重开。前面 GameManager 没写 DontDestroyOnLoad场景卸载时单例跟着销毁金币、血量、生成波次全部归零不需要额外的 Reset 方法。这个设计是刻意为之期末项目没有跨场景保留数据的需求单例生命周期跟随场景反而最简单。注意按钮的 OnClick 事件要在 Inspector 里手动把 ReplayButton 拖进去并选中 Replay 方法单纯写了脚本却不绑定事件点击无效。5.3 用 Frame Debugger 看 Draw Call而不是凭感觉卡演示前打开 Window Analysis Profiler 跑一分钟看 CPU 曲线。如果波形里频繁出现 Instantiate 和 Destroy 标记说明场景里对象的创建销毁还在走默认路径应该把第 4.3 节的 SetActive 复用逻辑补上。如果整体帧率低但 CPU 不高瓶颈更可能在渲染。这时打开 Window Analysis Frame Debugger截取一帧看 Draw Call 数量。捕鱼游戏的背景、鱼、子弹都是 Sprite理论上可以合批。把鱼和子弹的 Sprite 放进同一个 Sprite AtlasWindow 2D Sprite Atlas背景图合并成一张大图Draw Call 会显著下降。低配笔记本上画面里 20 条鱼加 5 颗子弹未合批时可能是几十个 Draw Call合批后通常能压到个位数。最后补一个演示细节Canvas 的 Canvas Scaler 设为 Scale With Screen Size参考分辨率填 1920×1080。这样教室投影仪切换到 16:9 分辨率时UI 不会挤到角落。Frame Debugger 里确认 Draw Call 数量正常后再跑一遍完整的「开火、命中、金币增加、金币不足」流程这四步都通畅期末作业就算验收通过了。本文还有配套的精品资源点击获取