简介一套基于Unity 2020.3.48f1及以上版本的完整企鹅钓鱼游戏源码以C#编写面向Unity游戏开发者、独立团队以及希望深入理解休闲游戏完整实现流程的进阶学习者。项目以阳光可爱的企鹅钓鱼为主题集成AdMob与Unity广告实现广告变现支持竿钓与网钓两种核心玩法内置20种海洋鱼类、无限关卡、鱼竿可被强劲鱼类折断等机制配以惊喜宝箱增加不确定性游戏控制简单、动画丰富适合作为移动端休闲游戏开发参考。资源包为zip压缩格式共包含1158个文件约20.19MB核心资源包括142个C#脚本负责玩法逻辑、66个Prefab预制体用于场景搭建、91个动画片段与45个动画控制器驱动鱼群动作另有PNG贴图、材质、音频以及Android广告SDK相关文件结构清晰便于按模块检索。当前已有575人学习下载通过这份资源可快速获得一套可直接运行的完整项目源码深入学习UI与逻辑分层、广告接入配置以及休闲游戏的数值与关卡设计思路适合用于二次开发或商业项目起版。1. 为什么拆解“Deep Sea Fishing Mania”而不是直接做鱼拿到深海钓鱼狂热这个标题第一眼很多人会去找鱼模型、找海面材质结果往往玩起来十分钟就腻。真正的休闲钓鱼爽点从来不在鱼嘴上而在“等”这段的手感设计从甩杆到浮标入水、从鱼试探到咬钩、从张力对抗到把企鹅拽得东倒西歪每一步都是状态驱动的反馈。这个标题真正值得拆的是背后那套 Unity 钓鱼状态机、鱼钩物理、张力计和企鹅动画骨架。这篇文章就以 Deep Sea Fishing Mania 为引子把一套可直接复现的钓鱼玩法架构写清楚。核心是用 C# 写一个有限状态机管住整场垂钓流程再用可调参数把“手感”这个虚词变成数值表。适合 Unity 休闲游戏开发者、想做超休闲 Demo 的独立开发者以及想从 UGUI 和动画状态机里挖点经验的中级程序员。读完你能直接落地的有状态机代码、张力计实现、鱼群生成权重表、WebGL 存档的坑。2. 企鹅钓鱼核心状态机从浮标入水到甩干上鱼一帧都不能漏2.1 为什么钓鱼玩法必须用状态机而不是在 Update 里堆 if海钓玩法的核心问题不是物理而是“同一个输入在不同阶段含义完全不同”。比如再次点击屏幕浮标漂着的时候是收线鱼咬钩的时候是拉杆鱼拖线的时候是放线同一个按键在四个阶段要做四件不同的事。如果写在 Update 的 if 里前期能跑一旦加入“鱼试探后逃走”“张力爆线”“鱼跃出水面”这些分支状态组合会迅速爆炸。有限状态机FSM的价值在于把“当前能干什么”显式枚举出来。Deep Sea Fishing Mania 这类企鹅钓鱼游戏中我通常会拆出六个状态Idle待机、Cast甩杆、Wait等鱼、Hook咬钩、Drag拉锯、Land上鱼。每个状态只处理自己的输入和计时状态迁移只发生在明确的边界条件上测试时只需要对着状态表验证迁移不需要脑补各种 if 嵌套。public enum FishingState { Idle, Cast, Wait, Hook, Drag, Land }2.2 用 C# 写一个最小可跑的钓鱼状态机下面这段代码是整篇的基础骨架可以直接贴进一个空 MonoBehavior 跑起来。它不依赖任何插件只用 C# 的枚举和协程完成状态迁移。public class FishingFSM : MonoBehaviour { public FishingState current; private IEnumerator currentRoutine; [Header(状态切换冷却防止连点抖动)] public float stateCooldown 0.1f; private float lastSwitchTime; void Start() { SwitchState(FishingState.Idle); } public void OnTap() { // 每个状态各自决定这个点击是否消费 switch (current) { case FishingState.Idle: SwitchState(FishingState.Cast); break; case FishingState.Wait: // 点击收线这是玩家主动放弃本次垂钓 SwitchState(FishingState.Idle); break; case FishingState.Hook: // 咬钩瞬间点击会加速收线但不改变状态 OnFastReel(); break; } } public void SwitchState(FishingState next) { if (Time.time - lastSwitchTime stateCooldown) return; if (currentRoutine ! null) StopCoroutine(currentRoutine); current next; lastSwitchTime Time.time; switch (next) { case FishingState.Cast: currentRoutine CastRoutine(); break; case FishingState.Wait: currentRoutine WaitRoutine(); break; case FishingState.Hook: currentRoutine HookRoutine(); break; case FishingState.Drag: currentRoutine DragRoutine(); break; case FishingState.Land: currentRoutine LandRoutine(); break; } if (currentRoutine ! null) StartCoroutine(currentRoutine); } }这段代码的核心逻辑是把协程当作“状态的行为体”SwitchState 负责停掉上一个协程、启动下一个协程。stateCooldown 是防止玩家连点导致状态在 Idle 和 Cast 之间抖动——这种抖动在真机上特别容易出现因为触摸事件的触发时间间隔往往比你以为的短。参数说明stateCooldown 建议设在 0.08 到 0.15 之间太小挡不住双击太大会吞掉玩家在 Hook 阶段的快速连点。OnFastReel 是在 Hook 和 Drag 状态里都要用的加速函数可以做成虚方法或者事件方便后续接上企鹅动画和音效。2.3 状态迁移表与每个阶段的退出条件光有状态机还不够得把每个状态的退出条件写清楚否则协程不知道什么时候该结束。下面这张表是我做类似项目时固定贴在策划文档里的开发时照着实现即可。状态进入条件退出条件典型停留时长Idle游戏开始 / 上鱼落地后玩家点击“抛竿”无限制Cast点击抛竿浮标入水动画播完0.8 ~ 1.5sWait浮标入水鱼试探 / 鱼咬钩 / 玩家收线3 ~ 20sHook鱼咬钩瞬间进入张力对抗或脱钩0.1 ~ 0.3sDrag鱼开始拉线张力计满爆线或鱼体力耗尽1 ~ 10sLand鱼被拖出水面“获得”动画播完0.5 ~ 1s你在实现 Wait 时要注意鱼咬钩不是一个瞬间事件而是一串“试探——顶漂——咬钩”的过程。建议在 Wait 协程里用 2~4 个随机延迟模拟试探每个延迟结束时小幅移动浮标表现为浮标抖动最后一次才真正触发 Hook。这一步做好了“能不能感觉到鱼在底下”的手感就出来了一半。2.3.1 Wait 协程的随机试探逻辑private IEnumerator WaitRoutine() { float totalWait Random.Range(3f, 12f); float elapsed 0f; while (elapsed totalWait) { // 每 1~3 秒随机产生一次“试探” float nextProbe Random.Range(1f, 3f); yield return new WaitForSeconds(nextProbe); elapsed nextProbe; // 浮标抖动这里只记录事件由动画组件监听 OnBobberProbe?.Invoke(Random.Range(0.2f, 1f)); // 最后一次试探直接变成咬钩 if (elapsed totalWait) { SwitchState(FishingState.Hook); yield break; } // 一定概率玩家没反应鱼跑了 if (Random.value 0.12f) { OnFishEscape?.Invoke(); SwitchState(FishingState.Idle); yield break; } } }这段里面的 OnBobberProbe 和 OnFishEscape 是 C# 事件这样浮标动画不用每帧轮询状态机而是等事件来了再触发抖动性能开销小很多。注意随机试探的间隔不要小于 1 秒否则浮标会像触电一样抖个不停玩家会以为出 Bug 了。3. 企鹅动画与深海视觉让“海洋”不只是一个蓝色背景板3.1 企鹅的拖拽、抖动和欢快庆祝拆成三个动画层来做钓鱼游戏里企鹅的动作可以拆成三个独立动画层身体根运动左右倾斜、拖拽、手臂动作持竿、收线、欢呼、面部表情专注、惊喜、失落。在 Unity 中这三个层次建议用 Animator 的 Layer 和 Avatar Mask 分开控制避免做一个包含所有动作的巨型状态机。用代码驱动比做大量动画剪辑更可控的方式是根运动用代码以正弦波驱动手臂动画交给 AnimationClip面部表情用 Blendshape。这样调手感时只需要调代码里倾斜的幅度和频率不用重新导出 FBX。public class PenguinRootMotion : MonoBehaviour { public float tiltAmount 8f; public float tiltSpeed 2.2f; void Update() { // 拖拽阶段企鹅身体随着张力计数值左右摇摆 float intensity FishingHUD.Instance.tensionValue; // 0~1 float angle Mathf.Sin(Time.time * tiltSpeed) * tiltAmount * intensity; transform.localRotation Quaternion.Euler(0f, 0f, angle); } }这一段的要点是tiltAmount 和 tiltSpeed 不是拍脑袋定的它们是跟着张力计数值走的。张力计超过 0.7 时大幅左右摆低于 0.3 时只微微倾斜。这样玩家不需要看进度条光看企鹅姿态就知道当前多危险。这也是休闲游戏“以表现代 UI”的常见做法。3.2 用“假刘海遮罩”解决水下视角穿帮海面下的鱼在浮标附近游动摄像头一旦倾斜超过一定角度水面边缘就会穿帮。一个很实用的技巧是不真的做水下渲染而是在海面下放一个跟随摄像机的“刘海片”——一块和海面同色的半透明网格挡住水面以下的模型边缘。这段的关键是 Shader 的选择。刘海片不需要反射和折射直接用 Unlit 半透明材质颜色取海面的平均色接近可调取色Alpha 设在 0.85 以上。摄像机切到水下视角时这块刘海片挡住非必要的渲染内容玩家眼睛会自然忽略掉它而注意力会落在浮标和鱼影上。3.2.1 Shader 参数让“深海”有纵深而不是一面死蓝海面颜色如果只是一张贴图玩家看十分钟就会腻。可以在海面 Shader 里叠加两层一层是慢速大波浪法线扰动一层是随深度变暗的颜色渐变。深度渐变需要在 Shader 里取摄像机到像素的深度用LinearEyeDepth做系数。// 片元着色器伪代码片段功能是深度渐暗 float depth LinearEyeDepth(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)).r); float factor saturate((_DeepColorStart - depth) / _DeepColorRange); float3 col lerp(_ShallowColor, _DeepColor, factor);_DeepColorStart 和 _DeepColorRange 是两个公开参数前者决定多深开始变暗后者决定渐变过渡带多宽。调参时建议浅水区 _DeepColorStart 设在 5 米_DeepColorRange 设在 10 米深海主题可以调到 15 米和 20 米让玩家在一开始浅滩能看清鱼影越往深处越有压迫感。3.3 鱼群游动动画用噪声偏移而不是骨骼动画深海钓鱼狂热里鱼群数量通常较多每帧为每条鱼算骨骼动画不划算。鱼群游动的简单做法是模型只做一个左右摆尾的静态网格然后用代码以 Perlin 噪声驱动鱼身的旋转和位置微调。鱼多的时候噪声偏移能制造出“每一条都在独立游动”的视觉错觉。4. 鱼钩物理、张力计与挣扎判定把“手感”拆成可以调的参数4.1 抛竿抛物线不用物理引擎只用 3 个公式抛竿如果用 Rigidbody 模拟会碰到两个麻烦一是每次弹道不稳定二是回看录像时不好复现。钓鱼游戏中更常见的做法是手动计算抛物线public IEnumerator CastAnimation(Transform hook, Vector3 start, Vector3 end, float height, float duration) { float t 0f; while (t 1f) { t Time.deltaTime / duration; Vector3 flat Vector3.Lerp(start, end, t); // 垂直方向叠加一个抛物线高度height 控制最高点 float yOffset height * 4f * (t * (1f - t)); hook.position flat Vector3.up * yOffset; yield return null; } }height 是最高点的抬高量duration 是飞行秒数。注意这个公式里t * (1f - t)在 t0.5 时取最大值 0.25乘 4 后正好等于 height所以 height 就是真实的世界高度。这样调参可以做到“高度 1.2 米、时长 1.1 秒”这种可预期数值而不是反复改力和阻力。4.2 张力计用 Perlin 噪声叠加模拟鱼挣扎“鱼挣扎”不能是纯随机纯随机数字变化太快看起来像神经病。也不能是正弦波太规律看得出破绽。常见做法是用两层 Perlin 噪声叠加一层低频大振幅鱼的大方向挣扎一层高频小振幅表现为抖线。public float GetTension(float time) { float lowFreq Mathf.PerlinNoise(time * _struggleFrequency, 0f); float highFreq Mathf.PerlinNoise(time * _struggleFrequency * 3f, 100f); float combined lowFreq * 0.7f highFreq * 0.3f; return Mathf.Clamp01(combined * _strugglePower _pullOffset); }这个函数在 Drag 状态中每帧调用。_struggleFrequency 控制鱼挣扎的快慢数值越大鱼越躁_strugglePower 控制挣扎的幅度上限_pullOffset 控制基础张力把进度条往危险方向推。通过调整这三个参数可以做出“前期稳后期狂”“全程狂抖”“体力耗尽的鱼只会偶尔拽一下”三种手感完全不用换脚本。4.3 三个手感参数与推荐初始值参数作用推荐初始值极端值效果_struggleFrequency挣扎频率0.81.5 以上为暴走鱼_strugglePower挣扎幅度1.00.5 以下为呆呆鱼_pullOffset基础张力0.20.4 以上为强力鱼这三个值放在鱼的数据对象里也就是每一条鱼都有自己的挣扎配置这就是“稀有鱼更难捕”的底层实现。稀有鱼的 _struggleFrequency 可能高达 1.3_pullOffset 到 0.35而不是简单地把血量翻倍。4.4 张力计进度条 UI 与企鹅动画的同步方式张力计 UI 在 UGUI 里实现时我会用一个 Image 的 fillAmount 显示张力然后在 Update 里直接赋值而不使用 DOTween 补间动画。原因是鱼挣扎是高频变化动画补间会带来视觉延迟让玩家觉得 UI“跟不上手”。public class FishingHUD : MonoBehaviour { public Image tensionBar; public float currentTension; void Update() { if (GameFSM.Instance.current FishingState.Drag) { currentTension GetTension(Time.time); tensionBar.fillAmount currentTension; // 超过 0.85 时让进度条颜色渐变为红色 tensionBar.color Color.Lerp(_safeColor, _dangerColor, Mathf.InverseLerp(0.6f, 1f, currentTension)); } } }UI 和企鹅根运动的同步就藏在这里企鹅的倾斜幅度读取的是同一个 currentTension 值所以企鹅摆到最左侧时张力计也正好在最高位。这种“一个数字驱动两个表现层”的做法比在动画事件里硬编码要稳得多。5. 鱼群生成、稀有度与企鹅商店用权重表和 ScriptableObject 撑起数值5.1 用权重表解决“什么时候出什么鱼”钓鱼游戏数值的核心问题不是鱼的属性怎么配而是“玩家在 30 分钟里应该看到什么”。如果完全均匀随机玩家会一直碰到沙丁鱼体验平淡如果高稀有度鱼出现太频繁商店里的高级鱼饵就没有意义。我一般会用权重表来控制。鱼种权重普通水域权重深海区域稀有度平均挣扎频率小丑鱼10020常见0.6鲷鱼5030常见0.8海马2020稀有1.0金枪鱼515史诗1.2灯笼鱼18传说1.4这张表可以做成 C# 的静态数据结构但更推荐用 ScriptableObject因为策划可以直接在 Inspector 调整权重而不用改代码。运行时加载权重表后用累计权重算法做随机即可。public FishData SelectFish() { int totalWeight 0; foreach (var entry in entries) totalWeight entry.weight; int roll Random.Range(0, totalWeight); int cumulative 0; foreach (var entry in entries) { cumulative entry.weight; if (roll cumulative) return entry.fish; } return entries[0].fish; }这个算法叫“累计权重采样”它的优点是每次只遍历一次不需要排序权重总和可以随便改加新鱼只加一行。要注意 Random.Range 使用整数重载时最大值是开区间所以Random.Range(0, totalWeight)永远不会返回 totalWeight 本身这是 Unity 和普通 C# 行为不一致的地方很多老手也会在这里翻车。5.2 ScriptableObject 数据驱动鱼种属性每一条鱼的数据包括显示名、模型、基础重量、挣扎参数、金币奖励、解锁条件。这些用 ScriptableObject 存比用 JSON 或 CSV 更适合中小型项目因为可以在 Inspector 里直接拖模型引用和做可视化校验。[CreateAssetMenu(fileName FishData, menuName Fishing/New Fish)] public class FishData : ScriptableObject { public string displayName; public GameObject modelPrefab; public float baseWeight; public float struggleFrequency 0.8f; public float strugglePower 1.0f; public float pullOffset 0.2f; public int coinReward; public bool requiresBait; }把“鱼的行为差异”放在数据对象里而不是写在代码里是保证 40 种鱼不写成 40 个分支的关键。新增鱼种就是复制一个 Asset 改参数代码零改动。5.3 企鹅商店和鱼饵消耗用玩家偏好把数值做闭环商店系统的核心是“让玩家主动选择下一条鱼的胜负条件”。最简单的做法是三种鱼饵对应不同权重偏移。虾饵把普通鱼权重乘 0.5、稀有鱼权重乘 2高级饵则反过来压低杂鱼权重。鱼饵是消耗品消耗的就是金币金币来自卖鱼由此形成“钓普通鱼换金币→买好鱼饵→钓稀有鱼→换更多金币”的闭环。// 使用鱼饵时改变权重 public void ApplyBait(BaitData bait, WeightEntry[] entries) { foreach (var entry in entries) { float mult bait.speciesMultiplier.ContainsKey(entry.fish.rarity) ? bait.speciesMultiplier[entry.fish.rarity] : 1f; entry.adjustedWeight (int)(entry.weight * mult); } }这里额外加了一个 adjustedWeight 字段会优先于 weight 被 SelectFish 使用这样不会污染原始权重表退出鱼饵效果时重新生成一份即可。C# 里字典作为配置数据时要注意ScriptableObject 里不能直接暴露Dictionary要在 OnValidate 里用 List 转这是 Unity 序列化系统的硬限制。5.3.1 让 UI 按钮“更好点”的一个隐藏参数商店界面里鱼饵图标很小尤其是手机竖屏手指按不准。Unity 默认按钮点击区域等于 Image 的 RectTransform 尺寸解决方式不是在 Image 外面套空按钮而是给按钮的 targetGraphic 指定一个透明大图或者直接改 Scale 但保持 Image 的 alpha 为 0。6. 存档写入 WebGL 的坑、发布前自查与最后一步调手感6.1 PlayerPrefs 在 WebGL 上的 IDBFS 陷阱如果项目要发布到 WebGL 平台存档会遇到一个非常典型的坑运行时用 PlayerPrefs.SetString 写入后浏览器页面刷新数据还在但一旦清理浏览器缓存就全没了。这是 WebGL 的 IndexedDB 存储机制决定的Unity 的 WebGL 构建默认把 PlayerPrefs 放进持久化路径但持久化路径本身映射到 IDBFS不能保证跨浏览器兼容。排查方法很简单在发布版本里做一个“存档→刷新页面→读档”流程测试。如果刷新后读不到重点检查 PlayerPrefs 的 key 里是否包含特殊字符以及是否在隐私模式测试——隐私模式下 IndexedDB 写入会静默失败。6.2 发布前自查清单发布前我会按下列顺序检查每一项都有可能成为线上事故检查 Build 设置里是否勾选了 “Player Settings → Publishing Settings → Decompression Fallback”否则本地测试正常但线上加载白屏。用真机弱网环境测试首包加载WebGL 不要依赖 CDN 可靠性大一点的项目考虑 Addressables 分包。检查FishingFSM的 stateCooldown 在低帧率下是否导致点击丢失特别是 30 帧以下的老手机。全场用 Profiler 跑一次聚焦 GC Alloc钓鱼循环里每帧创建的临时字符串和 List 是卡顿元凶。6.3 最后的调参表从“能玩”到“好玩”只要三处调什么从多少调到多少效果变化Wait 状态平均时长8 秒 → 5 秒节奏变快适合现代玩家_struggleFrequency0.8 → 1.0稀有条鱼更紧张stateCooldown0.1 → 0.12减少连点误触发手感更“粘”最后一处最容易被忽略把张力计从中心圆环改成竖直进度条观察玩家在竖屏握持时的拇指位置再把张力危险区和按钮上移或下移 30 像素。这一步做完你要的“深海钓鱼狂热”就从一个能跑的项目变成能让人坐在沙发上玩二十分钟的休闲游戏了。本文还有配套的精品资源点击获取