Unity企鹅钓鱼游戏开发:从状态机到WebGL发布实战 📅 发布时间:2026/9/15 3:22:16 👁 浏览次数: 简介面向Unity 2020.3.48f1及以上版本的开发者这套深海钓鱼狂热项目源码展示了完整的休闲钓鱼游戏范例。企鹅主角可在海面下竿钓或撒网收集小丑鱼、河豚、金枪鱼等二十种海洋生物并通过宝箱获取金币、额外时间或意外惊喜兼备趣味与变现设计。项目压缩包约20.19MB共一千一百五十八个文件其中包含一百四十二个C#脚本、六十六个预制体、九十一个动画、四十五个动画控制器以及材质、音频、贴图、UI配置等构成可直接运行的Unity工程并内置AdMob与Unity广告接入示例。两种游戏模式、无尽关卡和宝箱系统让项目具备完整可玩性适合研究移动端休闲玩法和广告变现的Unity学习者。目前已有五百七十五人学习浏览这套轻量而完整的源码可为游戏编程实战提供从玩法设计到广告集成的全程参考。1. 企鹅钓鱼游戏的 Unity 与 C# 设计从“先把鱼钓上来”开始钓鱼玩法看似简单却是把 Unity 的物理交互、状态机和随机性练到最扎实的一类小游戏题材。一个“Deep Sea Fishing Mania 深海钓鱼狂热”标题背后真正的技术含量不在水面贴图而在“鱼咬钩那一刻”你的代码怎么判断、怎么反馈才能让玩家觉得手感真实。企鹅作为角色又额外叠加了动画切换和道具使用的逻辑属于典型的“看起来卡通、实现起来有边界”的项目。这篇文章会以 Unity C# 为落地语言讲清楚一套企鹅钓鱼游戏的模块怎么拆、参数怎么设、坑在哪。全文按逻辑层、状态层、表现层、持久化层和平台适配逐层推进。即使你手里的源码包和我的目录有过差异也可以沿着这套结构把独立模块抠出来对接不必改装整套框架。2. 鱼类行为用状态机驱动而不是让 Update 里堆满 if许多新手做钓鱼游戏会把所有判断塞进Update()几周后代码变成一张 if 嵌套的大网。常见做法是给每条鱼一个轻量状态机至少包含Idle、Approach、Hooked、Escaping和Caught五个状态。状态机的好处是让每条鱼独立持有自己的行为节奏调参时可以单独改“接近玩家的速度”而不影响“咬钩后的挣扎力度”。2.1 用枚举定义鱼的状态把状态迁移收敛到单个方法先看一个 C# 状态机的核心结构。这里我用枚举定义状态再写ChangeState()统一处理迁移时的清理与初始化public enum FishState { Idle, // 自由游动 Approach, // 发现诱饵游向鱼钩 Hooked, // 已咬钩开始挣扎 Escaping, // 拉线力度过大或长时间没操作鱼挣脱 Caught // 拉力条进入收杆区间被钓起 } public class Fish : MonoBehaviour { public FishState currentState; private float stateTimer; public void ChangeState(FishState nextState) { // 离开旧状态时做的收尾复位计时器、清理动画标签等 currentState nextState; stateTimer 0f; switch (nextState) { case FishState.Hooked: // 初次咬钩瞬间的震动反馈可以放在这里触发 break; case FishState.Escaping: // 通知 UI 隐藏拉力条播放鱼线崩断音效 break; } } void Update() { stateTimer Time.deltaTime; switch (currentState) { case FishState.Idle: UpdateIdle(); break; case FishState.Approach: UpdateApproach(); break; case FishState.Hooked: UpdateHooked(); break; case FishState.Escaping: UpdateEscaping(); break; case FishState.Caught: UpdateCaught(); break; } } }这个结构的价值在于把“迁移条件”和“迁移动作”分离。Update()里每帧只调当前状态对应的方法而不是逐帧判断所有可能。每个状态方法控制在几十行以内后面要加新鱼种直接继承或挂载不同参数即可不用改主循环。stateTimer是统一的计时器为每个状态提供独立的持续时间参考比如Escaping持续 2 秒后自动回归Idle不需要额外声明成员变量。2.2 鱼上钩的判定范围与等待窗口决定手感的松紧鱼上钩不能在第 1 帧就触发要设计一个窗口期鱼钩下落后Approach状态需要维持约 0.6 到 1.8 秒。这个区间可以理解为“鱼的警惕时间”过短会显得鱼很蠢过长会让玩家焦虑。常见做法是在鱼身边放一个 Trigger Box当鱼进入触发范围时开始计时期间玩家点按收杆按钮才进入Hooked。参数上我建议把“等待窗口”和“咬钩概率”分离而不是直接写死“碰一下就咬”。像企鹅钓鱼这种偏休闲向的项目可以在Hooked触发时用拉力条系统接管操作逻辑否则玩家会因前几次失败产生挫败感。拉力条本质上是一个Mathf.Sin(Time.time * speed)的横条指针指针落到绿色区间时收杆才算捕获成功这样“咬钩”和“入桶”之间就多了一层可调节的难度带。我整理了一张参数表适合休闲向企鹅钓鱼的初始手感按需调整参数名推荐初值作用与调整思路ApproachTimeMin0.6 秒调大后鱼对鱼钩更“谨慎”ApproachTimeMax1.8 秒与 Min 共同形成随机区间HookCheckRadius1.2 米Trigger 检测半径过大会让鱼隔空咬钩StruggleSpeed2.5 秒每周期拉力条指针摆动速度越快越难CatchZoneWidth0.4绿色捕获区间宽度值越小越难钓到稀有鱼ValidateHoldTime0.8 秒指针到绿色区还需按住的时间防止甩枪秒收其中ValidateHoldTime是容易被忽略的一个参数。如果没有这个参数玩家只要在指针经过绿色区瞬间点一下就能收杆高手会轻松到无脑刷鱼设到 0.5 到 1 秒后玩家需要配合指针摆动节奏按住一段时间手感立刻厚重起来。对于企鹅钓鱼这类轻度休闲游戏我建议保留这个参数但数值不宜超过 1 秒否则会拖慢节奏。3. 浮漂交互与水面表现用轻量模拟替代物理引擎依赖钓鱼游戏的反馈延迟是硬指标。玩家按一下按钮如果水花或浮漂反应滞后 200 毫秒手感就会“肉”。Unity 的物理引擎在这里反而是负担浮漂受水波影响时如果用 Rigidbody 配合力场模拟更新频率和浮力参数很难调到自然而且移动端性能也不划算。3.1 用正弦叠加模拟水波驱动浮漂而不是 FloatingObject 插件常见做法是通过多层正弦函数叠加生成浮漂的高度偏移频率和振幅分别对应“微风”和“大浪”两个层级。代码实现public class BobberFloat : MonoBehaviour { public float waveAmplitude 0.15f; public float waveFrequency 2.4f; public float bobbingSpeed 1.8f; float seed; void Start() { // 给每个浮漂随机相位避免所有浮漂动作完全同步 seed Random.Range(0f, Mathf.PI * 2f); } void Update() { float t Time.time * bobbingSpeed; // 两层正弦叠加低频产生主浮动高频产生细微抖动 float heightA Mathf.Sin(t seed) * waveAmplitude; float heightB Mathf.Sin(t * 2.3f seed * 1.7f) * waveAmplitude * 0.3f; Vector3 pos transform.position; pos.y baseY heightA heightB; transform.position pos; // 浮漂在水面轻微旋转增加自然感 transform.rotation Quaternion.Euler( Mathf.Sin(t * 0.8f seed) * 3f, Mathf.Sin(t * 0.5f seed) * 2f, Mathf.Sin(t seed) * 4f ); } }baseY是浮漂静止时水面的高度也就是Start()里记录一次原始位置。waveAmplitude和waveFrequency可以写到公开字段在 Inspector 里调不用改代码。两层正弦叠加的好处是视觉上不会被玩家一眼看穿是规则运动而性能消耗几乎为零。浮漂被鱼拖下水时不能继续跑这个浮动逻辑否则表现会打架。我一般会给BobberFloat增加一个激活开关当鱼进入Hooked状态时关闭浮动让浮漂沿鱼钩线方向做下潜动画。下潜用Vector3.Lerp配合动画曲线控制而不是直接改位置可以把“咬钩瞬间的顿挫感”做出来。3.2 水面材质用 Shader 做顶点波动C# 只负责传全局时间水面有两大表现波纹和反射。反射用 Camera Reflection 代价偏高移动端不友好如果项目目标平台包含 WebGL 或中低端安卓机建议直接上 Shader 顶点波动让水面的三角面顶点沿法线方向做正弦位移。C# 侧只需要在Update()里把Time.time传给材质或 Shader 的全局属性剩下的计算交给 GPU。对应的 Shader 片段思路可以用内插顶点偏移来实现不必依赖任何第三方插件。C# 脚本只需要维护时间和波浪强度public class WaterTimeDriver : MonoBehaviour { public Material waterMaterial; void Update() { // _WaveTime 是 Shader 里声明的全局属性名 waterMaterial.SetFloat(_WaveTime, Time.time); waterMaterial.SetFloat(_WindIntensity, Mathf.Sin(Time.time * 0.2f) * 0.5f 0.5f); } }_WindIntensity让水面波动的强度也随时间轻微变化这样玩家长时间盯着水面也不会觉得是一段无限循环动画。Shader 内顶点偏移的公式主要围绕sin(distance(position, center) * frequency - time)这种形式分量混合后自然形成同心圆扩散效果。钓鱼游戏的水面不需要浪花翻卷只要在鱼钩落水点额外做一个环形涟漪粒子即可。粒子系统用ParticleSystem的默认圆形发射器把起始速度设为 0靠尺寸变化模拟扩散。3.3 UGUI 点击区域的坑按钮范围扩大不能只调 RectTransform企鹅钓鱼游戏里收杆按钮和抛竿按钮的点击体验直接影响口碑。Unity UGUI 的Button可点击区域默认等于Image的矩形范围。如果玩家快速点按或手指尺寸较大很容易按空。常见做法是外层挂一个透明Image把Raycast Target勾选作为更大的命中判定区。这一点在移动端尤其重要因为物理按钮要适配不同屏幕尺寸。但要注意透明 Image 的Raycast Target如果不去掉会挡住它下方的其他 UI 元素。所以挂透明ClickGuard组件时我一般会单独写一个只扩展点击区域、但不参与渲染的辅助类选中时把Image.color设为全透明同时保留Raycast Target。这样可以避免新增一层透明 Image 后需要手动调层级导致误触周围的背包按钮。4. 企鹅角色的动画与控制混合树比逐段播放更好维护企鹅在钓鱼场景里的行为可以拆为等待、抛竿、握竿、收竿、欢呼、失败叹气。如果这些用Animator.Play(Penguin_Idle)直接切换后期新增一个小动作逻辑会到处打补丁。更合理的做法是用 Animator 的混合树Blend Tree用 1D 参数控制“兴奋度”或“动作强度”让等待到欢呼之间有一段自然过渡。4.1 把动画参数收敛成“拉力进度”减少状态分支在钓鱼小游戏中企鹅的动画多半取决于拉力条状态指针在绿色区间内且ValidateHoldTime积攒时间越长企鹅越“紧张”或“兴奋”。我建议把拉力条进度归一化成 0 到 1 的浮点参数Animator 用这个参数驱动混合树public class PenguinAnimatorDriver : MonoBehaviour { public Animator penguinAnimator; public PullBarLogic pullBar; void Update() { // 直接把拉力条成功区间比例传给 Animator float holdProgress pullBar.GetCurrentHoldProgress(); penguinAnimator.SetFloat(HoldIntensity, holdProgress); // 企鹅收杆后立刻播放欢呼用 SetTrigger 更可控 if (pullBar.IsCatchSucceed()) { penguinAnimator.SetTrigger(CatchSuccess); } } }GetCurrentHoldProgress()返回 0 到 1 的累计值玩家在绿色区按住到 0.8 秒就接近满值松开后按时间衰减。Animator 混合树里把Idle与HookedStruggle混合用HoldIntensity作为权重这样玩家按住按钮越久企鹅身体后倾就越明显而不需要单独播放一整段动画。混合树的另一个好处是不同体格企鹅可以复用一个 Animator Controller只需替换动画资源。偏休闲向的企鹅脚本可以直接在 Inspector 里把HoldIntensity到StrugglePose的映射做成曲线这样美术调手感时不需要打开 Animator 面板。4.2 鱼竿角度用 IK 或 Child Transform 对齐不要旋转整只企鹅钓鱼游戏里“鱼竿方向”是玩家判断拉线成功率的视觉线索。鱼竿应该朝鱼所在方向而企鹅身体大致面向镜头。直接旋转企鹅会导致角色和观察角度错位观感很差。常见做法是在企鹅的手部挂一个空子物体作为鱼竿锚点IKInverse Kinematics控制手臂朝向锚点鱼竿作为锚点的子物体自动获得朝向。这套方案中鱼竿不会穿模企鹅手臂始终指向鱼方向。如果项目没有用 IK 插件也可以退一步鱼竿旋转用Quaternion.LookRotation朝向鱼钩位置但仅旋转鱼竿模型。这个方法视觉上尚可但手臂穿帮的风险高企鹅爪子会直接嵌进鱼竿模型。企鹅的短手加大肚子穿模会非常明显所以有条件时优先用 IK或至少让手臂绕肩膀关节做俯仰角度映射。Jewelry 与特殊鱼上钩时可以让企鹅换持竿手这里额外提一个细节换手时要同时调整鱼竿锚点位置而不是旋转鱼竿本身。锚点从一个手关节切换到另一个时用Vector3.Lerp在 0.2 秒内过渡避免瞬间跳变造成违和。5. 存档、背包与道具系统C# 的 JSON 持久化要做到“一次写入多次读取”钓鱼游戏的核心收集目标是鱼的图鉴和背包库存。C# 侧做存档不复杂但最危险的坑是数据序列化时用错了集合类型或者在不同版本的FishData类结构间迁移导致旧存档读不出来。推荐结构是“存档对象 JSON 字符串 文件写入”三件套不引入PlayerPrefs存复杂对象。5.1 存档数据用 ScriptableObject 做鱼库配置运行时用类实例做快照鱼库的静态数据鱼名、稀有度、价格、体重区间用ScriptableObject做配置运行时存档数据只存FishCastInfo列表包含鱼的 ID、捕获次数和最大值。这样改鱼的价格后不需要迁移旧存档因为旧存档根本没有价格字段。[System.Serializable] public class FishSaveData { public int fishId; public int caughtCount; public float maxWeight; } [System.Serializable] public class SaveDataContainer { public ListFishSaveData fishList new ListFishSaveData(); public int coinCount 0; public string saveVersion 1.0; }存盘时将SaveDataContainer用JsonUtility.ToJson序列化成字符串再写到Application.persistentDataPath下的文件。读档时先读文件再JsonUtility.FromJsonSaveDataContainer(jsonString)还原。JsonUtility在 Unity 中可以直接序列化[System.Serializable]的类但要注意它不支持字典所以存档里凡是“鱼 ID 到数量”的映射一律用ListFishSaveData代替否则运行时报错而不会生成编译错误。5.2 存档路径与加密混淆钓鱼游戏需不需要认真做休闲向企鹅钓鱼的存档没必要做加密persistentDataPath已经隔离了各平台的文件系统本地明文存档完全可以接受。不过如果要做排行榜或每日挑战那么coinCount这类数据要放到服务端校验。如果只是单机图鉴收集保存到本地文件是最稳的。写入时机建议在以下三个点触发成功捕获一条鱼后、玩家返回主菜单时、应用OnApplicationPause时。不要每帧写移动端闪存寿命不重要但频繁写文件会在 UI 操作时出现肉眼可见的卡顿。所有写盘操作放到协程或async void方法里用Task.Run做文件写入UI 线程不等待。5.3 鱼饵道具与背包界面的 ListView 性能一次显示 20 条以上就要做复用背包界面常见误区是直接生成一堆文本子物体鱼多时掉帧。Unity UGUI 的Grid Layout Group在列表项超过 20 时性能下降明显因为每项都独立重建几何。推荐用ScrollRect配合对象池只需要实例化可见范围内的鱼条目滑动时回收复用。对象池不必上完整框架一个QueueGameObject加一个Instantiate的冷却逻辑即可。public class FishSlotPool : MonoBehaviour { public GameObject slotPrefab; public Transform slotParent; QueueGameObject pool new QueueGameObject(); public GameObject Spawn(int fishId) { GameObject slot; if (pool.Count 0) { slot pool.Dequeue(); } else { slot Instantiate(slotPrefab, slotParent); } // 每次复用前刷新显示内容避免残留旧数据 slot.GetComponentFishSlotUI().Refresh(fishId); slot.SetActive(true); return slot; } public void Recycle(GameObject slot) { slot.SetActive(false); pool.Enqueue(slot); } }这个池子唯一的注意点是slot.SetActive(false)放在入队之前避免中断ScrollRect的布局计算。当玩家快速滚动背包时Recycle可能频繁触发用OnDisable时回调更新下一个显示的条目不要在Update()中轮询所有列表项。6. 平台适配与 WebGL 发布从Application.persistentDataPath到 IndexedDBUnity 项目发布 WebGL 后persistentDataPath的数据实际上会写入浏览器的 IndexedDB而不是文件系统。这就引出标题相关热词里反复出现的unity 发布 webgl 使用 idbfs 写入失败的坑。报错通常表现为存档写入时抛IDBFS相关错误或者刷新页面后存档不生效。6.1 避免高频写盘WebGL 下使用 IndexedDB 需要明确同步时机WebGL 的 IDBFS 是 Unity 内部封装的虚拟文件系统写入操作可能被浏览器延迟刷盘。如果游戏在写入后立即location.reload()数据大概率丢失。需要主动调用FS.syncfs强制刷盘或者在写盘后等待几百毫秒再让玩家手动刷新。using System.Runtime.InteropServices; public class WebGLSaveSync { [DllImport(__Internal)] static extern void SyncIDBFS(); public static void ForceSync() { #if UNITY_WEBGL !UNITY_EDITOR SyncIDBFS(); #endif } }在捕获鱼保存存档后调用ForceSync()可以大幅减少刷新丢档。同时存档文件写入的时机别放在OnApplicationQuit因为 WebGL 的浏览器标签页关闭时Unity 执行完所有退出代码的机会不被保证。最好的做法是“关键数据每产生一次就立刻写盘”并把写盘和syncfs绑在一起。6.2 阴影与光照烘焙WebGL 对动态阴影的承受力很弱Unity WebGL 发布后最容易出问题的除了IDBFS第二个就是阴影。实时阴影在移动浏览器上渲染开销高还容易出现 shadow acne。对企鹅钓鱼这类场景建议方向光用Baked光照贴图水面不需要阴影企鹅脚下用一个圆形Projector或贴片模拟假阴影。发射钓鱼线时不必做动态阴影鱼钩在空中的阴影直接用一个小半透明贴片跟随。Unity阴影问题的热搜词在 WebGL 场景下尤其应避免实时阴影。如果是独立站或桌面端可以用中高画质但Deep Sea Fishing Mania这类游戏一旦发布到网页端浏览器用户会用各种规格的设备打开低端设备跑实时阴影的掉帧幅度肉眼可察。我在优化时会把 Shadow Distance 压到 30 米以内并设为Low分辨率。6.3 扩大按钮点击范围的另一种 WebGL 陷阱Player Settings 里的 Canvas 缩放Unity如何扩大按钮的点击范围在 WebGL 上还有一个特殊点浏览器地址栏和浏览器缩放比例会导致 Canvas 的实际像素尺寸与 CSS 尺寸不一致UGUI 的RectTransform缩放后点击区域会被偏移。在CanvasScaler的Match Width Or Height参数不是 0.5 时低分辨率和高分辨率设备点按钮的误差会更明显。实测下来WebGL 发布时把Canvas Scaler的Screen Match Mode设为Shrink并勾选Ignore Screen Size以外最好保留底部 40 像素的安全区避免浏览器标签栏遮挡。企鹅钓鱼的收杆按钮在屏幕右侧如果玩家在手机上以横屏模式游玩底部安全区不足时按钮会被浏览器地址栏挡住导致点击完全无效。移动端浏览器判断参考Screen.height与Screen.safeArea的差值手动垫高按钮层级或整体 Canvas 偏移。6.4 游戏内文字与 C# 编码WebGL 下特殊字符容易变乱码WebGL 发布后JsonUtility序列化中文字符串时如果 HTML 模板没有设置meta charsetUTF-8会出现图鉴名称乱码。这个问题在编辑器里一切正常发布后立刻暴露。检查index.html模板确保声明 UTF-8 编码。如果Application.persistentDataPath存档里存了中文字符则要按 UTF-8 编码读文件File.ReadAllText默认在多数平台是 UTF-8但在部分 Windows 环境会因系统代码页不同而读出乱码。统一用new StreamReader(path, Encoding.UTF8)读File.WriteAllText(path, json, Encoding.UTF8)写即可免去平台差异。GameAssembly.dll 在 WebGL 中会被编译成 WebAssembly所以registry层的存档读写和本地文件系统 API 不能被P/Invoke直接调用。如果有开发者在接入某个原生插件时遇到 web 平台报错绕开__Internal调用的唯一路径就是纯托管代码实现。企鹅钓鱼的道具商店和鱼饵消耗如果与服务器对接务必把时间戳和货币数做服务端校验否则本地伪造存档可以直接刷金币。本文还有配套的精品资源点击获取