☰
捕鱼达人H5源码深度拆解:渲染、概率与联机实战
2026/10/15 17:11:58 网站建设 项目流程

简介:面向H5游戏开发者与前端学习者的捕鱼达人网页游戏源码,基于HTML5、CSS3和JavaScript构建,涵盖鱼群生成、瞄准发射、捕获结算、界面更新等核心玩法,适合快速搭建基础捕鱼平台,也适合作为游戏框架学习样本。压缩包为rar格式,共85个文件,其中53个png提供鱼群、炮台、金币、背景等美术素材,13个js构成主要逻辑,plist、tps、json用于动画帧与资源配置,html为入口页面,整体仅4.4MB,目录按资源、脚本与页面分层,替换美术和调试逻辑都很方便。已有715人学习下载,适合入门参考。源码内含注释与模块划分,二次开发可新增鱼类、商店、排行榜或社交分享,也能调整美术风格、音效与性能;同时鱼群生成、碰撞检测、得分结算等模块相互独立,便于定位参数或改写规则,适合深入掌握H5游戏组织方式。

1. 捕鱼达人-H5游戏源码:为什么一个“打鱼”项目值得你拆开看

打开一个基于 HTML5 的捕鱼达人源码包,第一眼看到的是满屏幕游动的鱼、旋转的炮台和炸开的渔网。但如果只把它当成一个“用 Canvas 画鱼然后随机死几只”的 Demo,你会错过这个品类里真正值钱的东西:一套把数学概率、渲染性能和触摸交互强行揉进 60 帧的真实现场。捕鱼达人-H5游戏源码里最难的从来不是“画一条鱼”,而是几十条鱼同时在场、子弹与渔网同步结算、低端手机也不掉帧的取舍。本文会从整个 H5 捕鱼项目的链路出发,把渲染选型、鱼群运动模型、命中判定、移动端适配和联机改造逐个拆开,给你一套能照着实现的工程方案。适合想入门 H5 游戏开发、或者想接一个“交付出得了手”的互动项目、又不想只停留在替换图片层面的读者。

2. 渲染选型与项目骨架:为什么我放弃了纯 Canvas 而选了 PixiJS

2.1 三个可用方案:Canvas 2D、PixiJS、Phaser 的取舍

拿到“写一个 H5 捕鱼”这个需求时,第一个要决定的问题是用什么画。纯 Canvas 2D 能画,但捕鱼场景是典型的“同屏几十个精灵 + 持续发射粒子 + 全屏闪烁特效”,对渲染效率要求很高。Canvas 2D 的 drawImage 每次调用都走一次完整的光栅化管线,几十条鱼加上十几发子弹后帧率就开始崩。Phaser 自带完整游戏框架,但捕鱼这个项目里用不到它的场景管理、物理引擎和动画状态机,反而框架本身几百 KB 的体积拖慢了首屏加载。我一般会选 PixiJS:它本质是一个 WebGL 渲染器,所有精灵在 GPU 里做矩阵变换,CPU 只负责更新逻辑数据。对捕鱼这种“逻辑简单但渲染对象多”的游戏,这是性价比最高的选择。

2.1.1 项目骨架的最小实现:入口文件与游戏主循环

用 Vite 建一个最简项目,装好 pixi.js 之后,入口文件长这样:

// main.js —— 捕鱼游戏入口 import { Application } from 'pixi.js'; // 创建 PIXI 应用:antialias 关掉,捕鱼游戏里的鱼身上有大量细碎高光,抗锯齿反而会糊 const app = new Application({ view: document.getElementById('game-canvas'), width: 750, height: 1334, backgroundColor: 0x0a2a4a, // 深海蓝 antialias: false, resolution: Math.min(window.devicePixelRatio, 2), // 限制在2,避免低端机渲染压力过大 }); document.body.appendChild(app.view); // 游戏主循环:所有鱼、子弹、特效都在 ticker 里更新 app.ticker.add((delta) => { // delta 是帧间隔的倍数,PIXI 默认目标 60 帧 updateFish(delta); updateBullets(delta); updateNets(delta); });

这里最关键的参数是resolution。现在很多手机的 devicePixelRatio 已经到了 3,如果照单全收,GPU 的填充率直接翻好几倍,低端机很快就烫手。限制到 2 之后画面依然锐利,但帧率稳定得多。antialias: false也是捕鱼项目的特殊取舍——鱼身上的鳞片高光往往是用渐变叠加出来的,开了抗锯齿反而产生严重的色彩溢出,关闭后用贴图本身的透明度去做边缘过渡,视觉更干净。

2.1.2 资源加载与精灵图集的组织

捕鱼项目的贴图资源通常是几十张离散的 PNG:鱼要分 6 到 8 种,每种又要有 2 到 4 帧游动动画,再加上炮台、子弹、渔网碎片、水泡特效,加载请求会达到上百个。正确做法是用 TexturePacker 之类的工具把所有鱼打包成一张精灵图集(spritesheet)加一份 JSON 索引。运行时用 PIXI.Loader 一次性加载,然后从图集里按帧名取纹理:

// 加载图集:单张图集 + json索引 await app.loader.add('fish', 'assets/fish.json').load(); // 从图集里取一条“小丑鱼”的第 1 帧 const texture = app.loader.resources['fish'].textures['fish_clown_01.png']; const fish = new PIXI.Sprite(texture);

图集的核心收益不在省流量,而在减少 WebGL 的纹理切换次数。GPU 在同一张纹理上裁剪出不同精灵的开销极低,但如果每帧要切换 6 张纹理,draw call 数量就会翻倍,移动端的帧率直接腰斩。我也见过一些人图集打包得很随意,把不同倍率的 UI 图和游戏贴图混在一起,结果某张图被放大后发虚。经验之谈:鱼和子弹打一张图集,UI 单独打一张,不然项目后期替换皮肤时会非常痛苦。

2.2 为什么不用 DOM + CSS3 动画来做

有一个容易让人心动的旁路:用 div + CSS3 transform 做鱼的位移和旋转,动画用 animation 驱动。CSS3 的 transform 确实走 GPU 合成,做几十个 div 的位移动画看起来也不卡。但捕鱼这个品类的交互太密集:每开一炮要立即产生渔网扩散效果,子弹命中鱼后要播放炸裂动画,同一帧里可能有十几个事件竞争。DOM 的样式计算和布局是串行的,事件驱动 + 强制 reflow 很容易让主线程卡住。而且捕鱼的鱼是半透明的,很多贴图带叠加混合效果(比如渔网的发光边缘用 additive 混合),CSS 的 opacity 混合模式远不如 WebGL 灵活。

3. 鱼群系统:数量、路径与死亡反弹率的核心数学模型

捕鱼游戏的“手感”70% 由鱼群运动模型决定。一个常见的翻车做法是让鱼沿着直线从屏幕一头游到另一头,那样整个画面死板,玩家的炮口几乎不用预判。我见过做得比较好的方案,是把鱼群分成“编队 + 个体微扰”两层:编队沿着一条贝塞尔曲线走,个体在编队轨迹上叠加正弦摆动和速度起伏。

3.1 鱼群的生成与回收:对象池是帧率的底线

每一个捕鱼工程初期最容易犯的错,是用 new Sprite() 来生鱼、用 destroy() 来删鱼。鱼的生成频率很高,几十条鱼同时在场时,GC 的停顿会让动画肉眼可见地掉帧。对象池是必须的。

// FishPool.js —— 对象池:控制鱼实例的复用 class FishPool { constructor(createFunc, size) { this._pool = []; this._active = []; for (let i = 0; i < size; i++) { this._pool.push(createFunc()); } } acquire() { let fish = this._pool.pop() || createFish(); // 池为空则临时新造,但这种情况应该极少 this._active.push(fish); return fish; } release(fish) { // 从 active 数组移除 const idx = this._active.indexOf(fish); if (idx >= 0) this._active.splice(idx, 1); // 重置状态 fish.visible = false; fish.rotation = 0; fish.scale.set(1); this._pool.push(fish); // 归还池中 } }

对象池的核心是“实例永远不销毁,状态反复重置”。注意visible = false这一步必须做,否则被释放的鱼还在渲染列表里,看起来像“幽灵鱼”。池的初始容量我一般按照同屏最大鱼数量的 120% 来分配,因为子弹生成渔网碎片时也要借用同一套池子,防止高峰时池被突破。

3.2 鱼的运动模型:贝塞尔路径 + 正弦微扰 + 边界回绕

鱼不应当是“直线游到底”。一支鱼群从一个出口生成,沿着一条三次贝塞尔曲线向目标点游动,然后回绕到屏幕的另一个出口继续游。单条鱼在整体曲线上叠加一个横向的正弦摆动,让鱼看起来像在摇头摆尾。

// Fish.js —— 鱼的运动逻辑(节选) updateFish(delta) { this.t += delta * this.speed; // speed 是每条鱼的基础速度,单位是“曲线进度/s” const pos = this.bezier.getPoint(this.t); // 贝塞尔曲线上的点 const wobble = Math.sin(this.t * this.wobbleFreq) * this.wobbleAmp; // 正弦微扰 this.sprite.x = pos.x + wobble; this.sprite.y = pos.y; this.sprite.rotation = Math.atan2(pos.y - this.prevY, pos.x - this.prevX); this.prevX = pos.x; this.prevY = pos.y; }

这里比较微妙的参数是wobbleFreq和wobbleAmp。频率高会让鱼显得像抽筋,频率太低则看不出微扰效果。经验值:频率 4 到 6,振幅 5 到 12 像素,视鱼的体型而定。大鱼振幅要大、频率要小,小鱼反过来。还有一个不容易注意的潜在坑:贝塞尔曲线默认的 t 是 0 到 1 线性变化,但贝塞尔曲线的弧长不是均匀的,t 变化快时鱼在曲线弯曲部分会突然加速。要修正就得做“按弧长重新参数化”,把 t 映射到真正的弧长位置。但捕鱼游戏的玩家注意力高度集中在打捞和得分上,这个加速现象在弯曲不太夸张的路径上极难察觉,我通常省略这一步,省下的计算量留给更关键的命中判定。

3.3 命中概率与死亡反弹率:如何把“随机”做成“可控”

捕鱼游戏里玩家关心的是“我打了这么多次,鱼该死了吧”。源码里这一块往往是赌上游戏口碑的最关键代码。你不能让每一次开炮都等概率独立判定,那样会经常出现 5 炮打不死一条小鱼,玩家瞬间流失。行业做法是引入“保底计数”——鱼被击中但未死亡时,计数加一,同时按已开炮次数递增死亡概率。我把这个概率函数做成一个简单的分段线性上升:

// hit.js —— 死亡概率计算 function getDeathRate(base, hitCount) { // base: 基准死亡率(小鱼 0.12,大鱼 0.02) // hitCount: 这条鱼当前已被击中次数 // 每次未死,下一击的死亡率线性 +0.03,但封顶到 0.6 return Math.min(base + hitCount * 0.03, 0.6); }

参数选型上,小鱼基准 0.12、大鱼 0.02,看起来像“大鱼很难打”,但配合线性补偿,打 10 炮没死的大鱼下一炮死亡率也有 0.32,玩家的挫败感可控。封顶 0.6 是纪律线:超过 0.6 会让玩家觉得鱼太容易死,经济系统容易失去平衡,被钻空子刷分。这个保底模型在源码里经常被实现成一个“弹着点随机 + 全局伪随机种子”的方案,但我实践下来,只要保证死亡判定里用的是均匀随机数而非某些平台伪随机库的低位随机数,效果就没有区别。真正的坑在浮点精度:客户端帧率不稳定时 delta 可能在 1.0 和 1.5 之间跳动,导致命中计数累加的速度忽快忽慢,这一点要靠“命中时间戳比对”而不是单纯递增计数来修正。

4. 炮台与子弹设计:轨迹、命中判定与渔网特效的性能陷阱

从玩家点击触发炮台,到子弹飞出去、渔网炸开的整个过程,直接决定“爽感”。一段从按下到收网反馈的完整链路是:点击 → 计算炮口方向 → 生成子弹精灵 → 子弹按直线飞行 → 子弹到达目标点(或最大距离)→ 生成渔网 → 渔网扩散并做命中检测 → 被命中的鱼依次播放死亡表现。每一步都有性能陷阱。

4.1 子弹飞行与开炮间隔:点射的频率上限

捕鱼游戏里炮台的开炮频率是玩家最敏感的数值。频率太高,玩家满屏撒子弹,视觉混乱且被判定为“脚本刷子”;频率太低则打击感全无。我一般把频率限制在 0.15 秒到 0.25 秒一发之间。实现层面,不能靠玩家“点了就发射”的原始事件,必须用“上次发射时间戳 + 冷却判断”的节流阀:

// PlayerCannon.js // 节流:控制开炮的最短间隔 lastFireTime = 0; fire(targetX, targetY) { const now = performance.now(); const cooldown = 150; // ms,换算下来约 0.15s 一发 if (now - this.lastFireTime < cooldown) return; // 冷却中则不发射 this.lastFireTime = now; this.spawnBullet(targetX, targetY); }

这里有个容易忽略的点:玩家快速点击屏幕时,touchstart 事件累计的速度远比这个节流阀快,所以cooldown的判定一定不能基于原生事件数量,而是要基于时间戳。另一个细节是子弹的贴图方向。子弹要根据炮口与目标的夹角做旋转,旋转用sprite.rotation = Math.atan2(dy, dx)完成,但炮塔本身的旋转动画有惯性,不能瞬间指向目标。我会把炮塔旋转角做成“每帧朝目标角度逼近”的平滑值,逼近速度约每帧 10 度,这样视觉上炮塔是甩过去的,手感更“重”。

4.2 渔网扩散与命中检测:判定的两个阶段

子弹到达最远距离后生成渔网。渔网是一个扩散的圆环,扩散半径随时间线性增大。命中检测需要区分两个阶段:第一阶段是“渔网半径是否碰到鱼的身体”,第二阶段是“鱼死亡后播放的碎裂动画是否已经播完”。只做一次命中检测会让渔网看起来“穿鱼而过”,玩家会骂判定太假。

// Net.js —— 渔网命中检测(节选) updateNets(delta) { for (let net of this.nets) { net.radius += net.speed * delta; // 渔网扩散 if (net.radius > net.maxRadius) { this.removeNet(net); continue; } // 第一层检测:圆形与鱼的 AABB 相交 for (let fish of this.fishPool.active) { if (!fish.visible) continue; const dx = fish.sprite.x - net.x; const dy = fish.sprite.y - net.y; const dist = Math.hypot(dx, dy); if (dist < net.radius + fish.radius) { this.hitFish(fish, net); // 进入命中处理 } } } }

这个实现里我故意用了 AABB 的简化判断:Math.hypot在几十次循环里没问题,但渔网一多,每帧做几百次开方也是个开销。可以先用“距离平方 < 半径和的平方”做粗判,只有粗判通过才做精细的矩形重叠判断,把开方次数再压一个量级。命中之后的死亡表现也不能阻塞主循环:死鱼的碎裂动画用粒子发射器独立播放,死亡表现期间把鱼从鱼群更新循环里摘出来,只保留一个“死亡动画更新”的轻量列表。

4.3 渔网特效的混合模式:为什么用 additive 混合会闪白

渔网炸开瞬间的高光表现,业内常用 additive 混合(PIXI.BLEND_MODES.ADD),这在 WebGL 里表现为像素值直接相加,颜色越叠越亮。但如果整张渔网贴图本身带着半透明白色渐变,加性混合会瞬间把一大片区域推到纯白,玩家体验是“闪瞎眼”。我的做法是把渔网的透明度压到 0.6,叠加一层纯色发光描边,再配合一个径向模糊遮罩。这样既有炸开的“光爆”感,又不会白成一片。混合模式这个参数在项目调试中经常被说成“玄学”,实际就是加减乘除的事——把贴图里不必要的白色区域去掉,加性混合就很安全。

5. 避坑:移动端闪退、贴图黑边、帧率不稳,捕鱼 H5 最常见的 4 个坑

5.1 坑:精灵图发布后透明边缘出现黑边

现象:鱼身体周围出现一圈黑色像素线,尤其在旋转到非水平角度时特别明显。

原因:这是 WebGL 纹理采样时的“透明度出血”问题。PixiJS 默认启用了 Premultiplied Alpha,但打包精灵图集时若不勾选对应的 alpha 模式,透明区域的 RGB 通道会残留杂色。当纹理被旋转或缩放时,GPU 对这些残留像素做线性插值,黑色边就出来了。

解决:在 TexturePacker 的导出配置里,把 Premultiplier Alpha 设为“不预乘”,同时在 PIXI.Loader 加载图集时关闭纹理的预乘选项,具体做法是给加载资源的纹理设置texture.baseTexture.premultiplyAlpha为 false,或者统一在 Canvas 初始化时传入premultipliedAlpha: false的 WebGL 配置。改完后透明边缘消失,旋转时也不会在鱼身上看到一圈黑线。

5.2 坑:低端安卓机后台切换到前台直接白屏

现象:游戏到后台再回来,画布白屏或闪一下黑屏,偶尔触发浏览器内存警告。

原因:移动端浏览器在后台时为了省电会回收 WebGL 上下文。PixiJS 里的贴图、着色器全部绑定在旧的 GL 上下文上,切回前台后发现上下文丢失,渲染就会停摆。

解决:监听webglcontextlost和webglcontextrestored事件。捕获到 context lost 后用app.renderer.context重新初始化,或者简单粗暴地把整个 PIXI.Application 重建,用事件回调里app.renderer.gl是否为空来判断。实际项目中我用的是“重建 Application + 全部纹理重新加载”的保守方案,缺点是会有一次短白屏,但对稳定性的收益远大于这点体验损耗。注意不要只刷新页面不保留用户数据,捕鱼玩到一半概率丢了也会被骂。

5.3 坑:同屏鱼一多,老 iPhone 帧率掉到 30 帧以下

现象:iPhone 8 及以下机型,鱼的数量超过 25 条时帧率明显下降,炮台旋转都有延迟感。

原因:这不是渲染瓶颈,而是更新逻辑里对每条鱼每帧都做了一次“贝塞尔曲线点计算 + 正弦函数 + 距离判断”,CPU 时间爆了。JavaScript 的 Math.sin 和 Math.atan2 在旧设备上极贵。

解决:把每个鱼群编队的贝塞尔曲线计算放到子线程或者用查表法代替。我实际用过的方案是:预计算一条贝塞尔曲线上的 100 个采样点,鱼的t值按比例映射到最近的两个采样点之间做线性插值,正弦摆动同样用查表 + 线性插值。数学误差肉眼看不出来,帧率直接回升 20%。如果项目允许放弃兼容老机型,则可用“距离屏幕中心超过 1.5 倍宽度的鱼不更新位置”的懒更新策略来达到类似效果。

5.4 坑:子弹和渔网频繁创建导致 GC 卡顿

现象:用 Chrome DevTools 的 Performance 面板录制,看到每 2 到 3 秒出现一次长任务,伴随锯齿状的帧率波动。

原因:子弹发射和渔网消失频繁触发垃圾回收。很多源码实现是用new Bullet()建子弹、用sprite.destroy()删渔网,短时间里大量小对象进出堆区,GC 被频繁唤醒。

解决:给子弹和渔网也建对象池,与鱼共享同一套池子或单独建两个池。子弹的池子大小为最大同屏子弹数(比如 30 发),渔网的池子大小为 10 到 15。池子的逻辑和鱼池完全一样:acquire、release、reset。还有一个技巧:子弹的纹理是一张很小的圆形贴图,把这张贴图固定在同一张图集上,能进一步减少纹理切换。做完之后 GC 长任务基本消失,整个帧率曲线变成一条近水平直线。

6. 进阶方向:把单机捕鱼改成局域网联机时,最值得动的三个地方

单机版做得再顺,也只是“自己爽”。捕鱼这个品类的社交属性非常强,同屏比拼谁打的鱼多分高才是完整形态。但完整后端联机又是一个大工程,这里给出一个“局域网联机”的轻量改法,三步走。

第一步:把游戏逻辑改成“确定性逻辑 + 客户端渲染”。所有鱼的生成、移动、死亡判定全部由服务器发指令驱动,客户端的鱼群运动代码只负责“渲染轨迹”,不负责“决定结果”。我一般用一个简单的 WebSocket 服务器,每 100ms 广播一次全量的鱼群状态:鱼的唯一 ID、坐标 x/y、当前动画帧索引。客户端接到状态后把对应 ID 的鱼直接tween到目标位置,其余逻辑照旧。这个方案不追求极高同步帧率,捕鱼不是操作精确到像素的游戏,100ms 的延迟玩家感知不强。

第二步:把概率计算移到服务器端。单机版里鱼的死亡判定在客户端做,联机时必须杜绝“改本地概率刷分”的可能。把getDeathRate函数原样搬到服务器,服务器收到开炮请求后计算命中与否,再广播“鱼死亡事件”。这也是捕鱼联机改造里最容易翻车的地方——如果还是由客户端上报“我打中了”,那别人就能伪造上报,排行榜直接废掉。

第三步:用“房间制”管理多个对局,每个房间一个独立的游戏循环实例。对捕鱼这类场景,一个房间不超过 4 个玩家,房间里的鱼群数量、命中概率都和单机保持一致,不因玩家数增多而稀释。服务器的状态广播带一个单调递增的序号,客户端发现序号跳跃时主动请求一次快照同步,避免因丢包造成的“鱼瞬移”。

这里要特别强调的是——单机版本的味道不要动。鱼群的运动路径参数、保底命中曲线、渔网扩散速度,这些数值是经过反复打磨的“手感配方”,联机改造中只要原样保留,玩家就不会觉得是另一个游戏。我见过一个项目在联机化时顺手把鱼的游动速度调快了 30%,理由是“联机时节奏要更快”,结果老玩家第一天就骂手感全变了,回退才平息。联机只是传输层换了一种方式,手感层动一分都是大事。这是我自己踩过的教训,希望帮到你。

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

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

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

立即咨询