Unity钓鱼游戏玩法拆解:状态机、张力计与企鹅动画实战
2026/9/15 15:28:53 网站建设 项目流程

简介:一套基于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.5s
Wait浮标入水鱼试探 / 鱼咬钩 / 玩家收线3 ~ 20s
Hook鱼咬钩瞬间进入张力对抗或脱钩0.1 ~ 0.3s
Drag鱼开始拉线张力计满(爆线)或鱼体力耗尽1 ~ 10s
Land鱼被拖出水面“获得”动画播完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)在 t=0.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 像素。这一步做完,你要的“深海钓鱼狂热”就从一个能跑的项目变成能让人坐在沙发上玩二十分钟的休闲游戏了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询