简介:捕鱼达人-H5游戏源码是一套面向HTML5游戏开发者与前端学习者的休闲捕鱼游戏基础工程,适合用来理解经典捕鱼玩法的前端实现、素材组织与逻辑拆分,并作为课程设计或二次开发起点。压缩包共85个文件、约4.4MB,主要由PNG图像、JavaScript脚本、plist动画配置、JSON数据及HTML入口页面组成,覆盖界面资源、鱼类与炮台动画、游戏主逻辑和页面加载等环节。已有719人学习或下载,具备一定参考热度。源码将捕鱼核心流程拆分为多个JS模块,配合位图与帧动画资源,便于读者研究碰撞检测、鱼群生成、子弹发射和积分结算等常见机制;也可据此替换美术、增加鱼类与关卡、调整数值,逐步扩展排行榜或社交分享等功能。整体包体轻量、结构直观,适合在掌握基础前端知识后用于拆解H5小游戏架构与进行个性化改造。
1. 捕鱼达人 H5 游戏源码:一套能直接点开玩的 Canvas 玩法骨架
接一个捕鱼玩法的 Demo 需求,最费劲的从来不是鱼怎么游,而是那些没人愿意从零写的手工活用:渲染循环、图集切帧、手指坐标换算、子弹回收,少一样就跑不顺。手上这份捕鱼达人 H5 游戏源码把这些底层铺好了,纯前端工程,Canvas 2D 渲染,不挂重型引擎,起个静态服务就能看到炮台跟着手指转、鱼群按波次游过、子弹命中后金币飞向分数栏。它适合两类人:要在短时间内交出可玩捕鱼 Demo 的开发者,以及拿到 H5 游戏源码后想换皮、改玩法、接自己结算逻辑的人。下面按我拆它的顺序,从骨架、玩法、配置一路讲到跑起来和改起来。
2. 项目骨架与技术选型:一个 requestAnimationFrame 撑起的渲染循环
这套工程第一眼看上去很"朴素",没有场景编辑器,没有预制体,没有可视化时间轴,打开就是一堆 js 文件和几个资源目录。但这恰恰是它好拆的原因:所有东西都在明面上,改一行刷新就见效,不用等编译,也不用去翻引擎的黑匣子。对二次开发来说,这种透明度的价值往往比功能齐全更高。
2.1 目录结构:先认清哪几个文件才是真正跑起来的
我拿到一份不熟悉的 H5 游戏源码,第一件事不是读代码,而是把目录扫一遍,找出"入口在哪、循环在哪、资源在哪、数据在哪"。这套工程的分层比较规整,大致是这样:
fishing-h5/ ├── index.html # 唯一入口,挂 canvas 和启动脚本 ├── src/ │ ├── main.js # 引导:加载资源 -> 建场景 -> 开循环 │ ├── core/ │ │ ├── game.js # 全局单例,持有 canvas、ctx、当前场景 │ │ ├── loop.js # 固定步长主循环 │ │ ├── renderer.js # 分辨率适配、坐标变换 │ │ ├── loader.js # 图片/图集/音频的并发加载与进度 │ │ └── pool.js # 通用对象池 │ ├── scene/ │ │ ├── sceneManager.js # 场景切换与生命周期 │ │ ├── bootScene.js # 加载进度条 │ │ └── gameScene.js # 主玩法场景,串起所有系统 │ ├── entity/ │ │ ├── fish.js # 单条鱼:状态、帧动画、轨迹 │ │ ├── bullet.js # 单颗子弹 │ │ ├── cannon.js # 炮台:角度、等级、开火 │ │ └── coin.js # 命中的金币飞行动画 │ ├── system/ │ │ ├── spawner.js # 波次刷鱼调度 │ │ ├── collision.js # 碰撞检测 │ │ └── settle.js # 命中后的分数与倍率结算 │ └── utils/ │ ├── math.js # 角度、插值、随机 │ └── rng.js # 带权随机(掉率用) ├── assets/ │ ├── atlas/ # 图集 png + json 帧描述 │ ├── audio/ # 背景乐与音效 │ └── config/ # fish.json / waves.json / weapon.json └── README.md真正参与一次"刷鱼到结算"的文件其实只有七个:loop.js推动时间,spawner.js决定什么时候出鱼,fish.js负责游,cannon.js负责转和射,bullet.js负责飞,collision.js负责判,settle.js负责给分。剩下的都是外围。新手容易犯的错是从gameScene.js从头往下读,读到两千行就晕了;正确的顺序是先读main.js弄清启动链路,再挑一个实体(比如鱼)顺着它的 update 走一遍,链路自然就通了。
main.js的引导逻辑大概是这个形状,值得对着看一遍:
// src/main.js import { Game } from './core/game.js'; import { Loader } from './core/loader.js'; import { SceneManager } from './scene/sceneManager.js'; import { BootScene } from './scene/bootScene.js'; import { GameScene } from './scene/gameScene.js'; const game = new Game(document.getElementById('stage')); // 资源清单集中声明,加载器负责并发与进度回调 const manifest = { atlas: ['fish_small', 'fish_big', 'weapon', 'ui'], audio: ['bgm', 'fire', 'hit', 'coin'], config: ['fish', 'waves', 'weapon'] }; const loader = new Loader(manifest); loader.onProgress = (p) => game.sceneManager.current?.setProgress?.(p); loader.loadAll().then((res) => { game.assets = res; const scenes = new SceneManager(game); scenes.register('boot', new BootScene(game)); scenes.register('game', new GameScene(game)); scenes.start('game'); // 资源已就绪,直接进主玩法 game.loop.start(); // 循环一旦开起来就不停,场景内部自己判断是否暂停 });这里有两个设计点值得注意。一是资源清单是声明式的,加一张图只要往manifest.atlas里塞个名字,加载器会自己去assets/atlas/下找同名的 png 和 json,不需要改加载代码。二是loop.start()放在最后、只调用一次,场景切换不会重建循环,切换时由SceneManager负责调用旧场景的onExit和新场景的onEnter,把解绑监听、清空池子的活干完。不少自己攒的 H5 小游戏就栽在这里:每切一次场景requestAnimationFrame多挂一条,跑几次就开始抖。
2.2 技术选型:为什么是原生 Canvas 而不是 Cocos / Laya
看到"捕鱼类 H5",很多人的第一反应是"这不得上个引擎"。但捕鱼这个品类的技术需求其实很窄:大量同质精灵、简单的圆形碰撞、没有物理刚体、没有复杂骨骼动画、UI 也就几个固定按钮。这种场景下上引擎,换来的是编辑器,付出的是包体和一层抽象。
我一般会按这几个维度判断该不该上引擎:
| 维度 | 原生 Canvas(本项目) | Cocos / Laya 类引擎 |
|---|---|---|
| 首包体积 | 只有业务代码,几十 KB 级 | 引擎运行时本身就有一定体积 |
| 构建流程 | 无,改完刷新 | 需要构建/预览服务 |
| 美术协作 | 图集靠工具导出,代码里对帧名 | 编辑器里拖拽,有场景文件 |
| 动画能力 | 帧动画 + 手写补间 | 自带时间轴、骨骼 |
| 嵌入已有页面 | 一个 canvas 标签即可 | 通常要占满一个容器,样式易冲突 |
| 出问题排查 | 全在源码里,能断点 | 引擎内部是黑匣子 |
这份源码选了前者,好处是嵌入灵活——你想把它塞进一个已有的活动页,只要给个固定尺寸的容器就行。代价也很明确:没有现成的动画系统,所有补间都得自己写;没有资源管理器,图集的帧名得自己核对;没有场景编辑器,鱼从哪出、走哪条曲线,全靠配置和手调。这三点基本决定了后面二次开发的成本分布,心里要先有数。
2.3 主循环:固定步长加累加器,为什么不能直接拿 delta 喂逻辑
主循环是整个工程里最值得先读懂的一段。很多人写 H5 小游戏直接requestAnimationFrame(dt => update(dt)),在 60Hz 屏上跑得好好的,到了 120Hz 或者某帧卡了一下,鱼的速度、子弹的位移就全乱了——这就是典型的玄学 bug,本地复现不了,用户那边时快时慢。
这份工程用的是固定步长加累加器的写法:
// src/core/loop.js const FIXED_STEP = 1000 / 60; // 逻辑固定按 60 次/秒推进 const MAX_FRAME = 1000 / 15; // 单帧最大补偿 66.7ms,防止切后台回来炸掉 export class GameLoop { constructor({ update, render }) { this.update = update; this.render = render; this.acc = 0; // 累加器:还没被逻辑消耗掉的时间 this.last = 0; this.running = false; this._tick = this._tick.bind(this); // 绑定一次,避免每帧新建函数 } start() { if (this.running) return; this.running = true; this.last = performance.now(); requestAnimationFrame(this._tick); } stop() { this.running = false; } _tick(now) { if (!this.running) return; let delta = now - this.last; this.last = now; // 切标签页回来时 delta 可能是好几秒,必须钳位 if (delta > MAX_FRAME) delta = MAX_FRAME; this.acc += delta; while (this.acc >= FIXED_STEP) { this.update(FIXED_STEP / 1000); // 传秒,实体里统一用 px/s 做速度单位 this.acc -= FIXED_STEP; } // 剩余不足一步的部分作为插值系数交给渲染层,画面更顺 this.render(this.acc / FIXED_STEP); requestAnimationFrame(this._tick); } }几个参数的作用得说清楚。FIXED_STEP是逻辑步长,写死 1/60 秒,好处是所有实体拿到的dt恒定,速度、加速度、衰减这些公式不用做时间归一化,调参时的心算成本低很多。MAX_FRAME是保险丝,浏览器切到后台再切回来,performance.now()的差值可能是几千毫秒,不钳位的话while循环会一口气跑几百次,表现就是鱼瞬移、子弹全打空,严重的直接卡死。acc / FIXED_STEP是渲染插值系数,实体渲染时可以按prevX + (x - prevX) * alpha做一帧平滑,不做也能跑,做了在 120Hz 屏上顺滑度肉眼可见。
update里传的是秒而不是毫秒,这点要在写新实体时保持统一。我见过有人在鱼里写this.x += this.speed * dt,speed按"每帧多少像素"填了 2,结果dt是 0.0167,鱼慢得像蜗牛。约定好单位,比事后一个个排查省事得多。
3. 核心玩法拆解:从鱼群轨迹到命中结算的四段链路
把骨架看明白之后,玩法其实就是四条链路串起来:刷鱼、游动、开火、判定结算。这四段彼此通过实体对象解耦,spawner只管往场景里塞鱼,cannon只管造子弹,collision每帧扫一遍活跃子弹和活跃鱼,settle在命中的瞬间算分。下面逐段拆。
3.1 鱼群生成:波次表驱动的刷鱼节奏
刷鱼最忌讳写死setInterval。捕鱼玩法的节奏感来自"什么时候出小鱼、什么时候放大招、什么时候空场让玩家喘口气",这些必须数据化。这份工程把节奏全部外置到waves.json,spawner只做两件事:按时间轴触发波次、按间隔吐出个体。
// src/system/spawner.js export class Spawner { constructor(game, waves) { this.game = game; this.waves = waves; // 已按 at 升序排好的波次数组 this.cursor = 0; // 当前推进到第几波 this.elapsed = 0; // 本轮已过时间(秒) this.pending = []; // 正在吐的波次队列 } update(dt) { this.elapsed += dt; // 1. 触发所有到点的波次 while (this.cursor < this.waves.length && this.waves[this.cursor].at <= this.elapsed) { const wave = this.waves[this.cursor++]; this.pending.push({ wave, left: wave.count - 1, timer: wave.interval / 1000 // 配置写毫秒,这里换成秒 }); this.spawnOne(wave); // 首条立即出,别等一个 interval } // 2. 逐个吐波次剩下的个体 for (let i = this.pending.length - 1; i >= 0; i--) { const p = this.pending[i]; p.timer -= dt; if (p.timer <= 0) { this.spawnOne(p.wave); p.left--; p.timer = p.wave.interval / 1000; } if (p.left <= 0) this.pending.splice(i, 1); } } spawnOne(wave) { const conf = this.game.assets.config.fish.types[wave.group]; const path = this.game.assets.config.fish.paths[wave.path]; const fish = this.game.pools.fish.get(); fish.reset(conf, path, wave.extra || {}); this.game.scene.current.addEntity(fish); } }at是相对本轮开始的毫秒数,interval是同波个体之间的间隔毫秒,group指向鱼种配置,path指向轨迹配置。这三个字段构成了整个节奏调节的手柄。想让某波鱼出得密一点,把interval从 400 调到 200 就行;想让大鱼晚点进场,把at从 6000 调到 12000。改完刷新页面立刻能看到效果,这个反馈速度是这套无构建工程最大的爽点。
cursor是单向游标,波次表跑完就结束,不会回绕。如果需要循环刷,别在update里判断cursor >= length然后重置为 0,那样时间轴会错乱;正确做法是重置时把elapsed减去整轮的时长,让时间轴平滑续上。这是我自己翻过车的地方,第一版直接置零,结果第二轮的第一波和第一轮的最后一波叠在一起出,场面一度很混乱。
3.2 游动轨迹:正弦摆动叠加路径点
鱼的游动如果只是直线匀速,看起来就是塑料感。捕鱼类游戏里鱼的姿态有三个要素:沿路径的位移、垂直于路径的正弦摆动、随速度变化的帧动画速率。这份工程的fish.js把三者拆开算:
// src/entity/fish.js(节选) update(dt) { this.t += dt; // 1. 沿路径推进(path 是预先采样好的点数组,t 归一化到 0~1) const p = this.t / this.duration; const pos = samplePath(this.path, Math.min(p, 1)); // 2. 垂直于前进方向做正弦摆动,amplitude 由鱼种配置 const sway = Math.sin(this.t * this.swayFreq + this.phase) * this.swayAmp; const nx = -pos.dy, ny = pos.dx; // 路径切线的法线 this.x = pos.x + nx * sway; this.y = pos.y + ny * sway; // 3. 朝向跟着切线走,翻转用 scaleX,不要重画图集 this.facing = pos.dx >= 0 ? 1 : -1; // 4. 帧动画速度与实际速度挂钩,慢鱼摆尾慢 this.frameTimer += dt * this.animSpeed; if (this.frameTimer >= this.frameDuration) { this.frameTimer -= this.frameDuration; this.frameIndex = (this.frameIndex + 1) % this.frameCount; } if (p >= 1) this.dead = true; // 走出屏幕,标记回收 }samplePath是对路径点做线性插值,返回位置和切线方向。swayAmp一般取鱼体半径的 0.3 到 0.6 倍,太大会显得鱼在抽搐;swayFreq控制在 1.5 到 3 之间,和帧动画速率对齐,看起来才像"摆尾带动身体"。phase是随机初相,同批鱼如果phase一样,会整齐划一地摆,像复制粘贴,一定要在reset时随机化。
朝向用scaleX = -1翻转而不是准备左右两套图,这是 H5 里省资源的常规做法,代价是绘制时多一次变换。真正要注意的是:翻转后碰撞圆的圆心不会跟着变,如果你的鱼不是左右对称的(比如带个拖尾),翻转后视觉中心会偏,得在reset里额外补偿一个offsetX。
鱼的死亡条件除了走出屏幕,还有被击杀。这两种都只置dead = true,真正的回收交给场景在每帧末尾统一扫描,避免在遍历中删元素。这个模式在整套工程里反复出现,是个值得学的习惯。
3.3 炮台与子弹:触摸坐标到发射角的换算
炮台这段代码不长,但坐标换算是新手最容易翻车的地方。浏览器给的是clientX / clientY(CSS 像素),Canvas 内部是按设计分辨率绘制的,中间隔着缩放和 DPR 两层,直接相减算角度必然偏。
// src/entity/cannon.js(节选) export class Cannon { constructor(game) { this.game = game; this.pivotX = 640; // 设计分辨率 1280x720,炮台在底边中央 this.pivotY = 700; this.angle = -Math.PI / 2; // 默认朝正上方 this.level = 1; this.cooldown = 0; } aim(clientX, clientY) { const canvas = this.game.canvas; const rect = canvas.getBoundingClientRect(); // CSS 像素 -> 设计分辨率坐标 const x = (clientX - rect.left) / rect.width * this.game.designW; const y = (clientY - rect.top) / rect.height * this.game.designH; let a = Math.atan2(y - this.pivotY, x - this.pivotX); // 只允许朝上半平面射击,把角度夹在 [-PI, 0] if (a > 0) a = (x >= this.pivotX) ? -0.05 : -Math.PI + 0.05; this.angle = a; } update(dt) { if (this.cooldown > 0) this.cooldown -= dt; } fire() { if (this.cooldown > 0) return; const conf = this.game.assets.config.weapon.levels[this.level]; this.cooldown = conf.cooldown; const bullet = this.game.pools.bullet.get(); // 从炮口位置出发,方向取当前角度 bullet.reset( this.pivotX + Math.cos(this.angle) * conf.muzzle, this.pivotY + Math.sin(this.angle) * conf.muzzle, Math.cos(this.angle) * conf.speed, Math.sin(this.angle) * conf.speed, conf.scoreCost ); this.game.scene.current.addEntity(bullet); this.game.audio.play('fire'); } }rect.width是 canvas 在 CSS 里的实际宽度,不是canvas.width(那是物理像素,乘过 DPR)。这个区别如果搞混,在 iPhone 上点右上角,炮口会转到右下角,或者干脆不动。Math.atan2(y - pivotY, x - pivotX)里 y 轴向下为正,所以朝上是负角度,这个坐标系跟数学课本是反的,写限制条件的时候要留意。
cooldown是开火间隔,从weapon.json里按炮台等级取,等级越高间隔越短、单发消耗越高。muzzle是炮口到旋转中心的距离,子弹从这里出生,不然会从炮台中心冒出来,看着像穿模。speed是子弹速度,单位 px/s,通常在 1200 到 2000 之间;低于 800 会有明显的"子弹追不上鱼"的迟滞感。
开火时机要绑在pointerdown上,同时支持按住连发——这就需要在场景里维护一个isFiring标志,在update里按冷却时间反复调fire(),而不是靠pointerdown事件本身重复触发。触摸设备上按住不放是常态,只监听pointerdown会导致玩家必须不停地戳屏幕,手感很差。
3.4 命中判定与结算:圆检测、扫掠检测和对象池
碰撞是这套源码里我改动最多的地方,因为原始版本是纯粹的圆对圆,在子弹快、鱼也快的时候会漏判。先看基础版本:
// src/system/collision.js export function circleHit(ax, ay, ar, bx, by, br) { const dx = ax - bx; const dy = ay - by; const r = ar + br; return dx * dx + dy * dy <= r * r; // 不开方,省一次 sqrt } // 子弹一帧从 (x0,y0) 走到 (x1,y1),做线段与圆的最近距离判定 export function sweptHit(x0, y0, x1, y1, cx, cy, cr) { const vx = x1 - x0; const vy = y1 - y0; const wx = cx - x0; const wy = cy - y0; const len2 = vx * vx + vy * vy; // 投影参数 t 夹到 [0,1],得到线段上离圆心最近的点 let t = len2 === 0 ? 0 : (wx * vx + wy * vy) / len2; t = t < 0 ? 0 : (t > 1 ? 1 : t); const px = x0 + vx * t; const py = y0 + vy * t; const dx = cx - px; const dy = cy - py; return dx * dx + dy * dy <= cr * cr; }子弹速度 1600 px/s,60 帧下每帧位移约 26.7 px,而小鱼的碰撞半径可能只有 20 px。两者相向而行时,一帧之内子弹完全可能从鱼的一侧跳到另一侧,圆对圆判定就漏了。sweptHit用线段代替点,把这一帧的运动轨迹都算进去,漏判率基本归零。代价是每条鱼要多算几次乘除,但捕鱼场景里活跃子弹通常不超过 20 颗、活跃鱼不超过 40 条,暴力两两检测就是 800 次,一帧内毫无压力,不需要上四叉树。
真正的性能瓶颈不在这里,而在对象的创建和销毁。每开一枪new Bullet()、每命中一次new Coin(),GC 会被频繁触发,表现就是每隔几秒卡一下。所以工程里所有高频实体都走对象池:
// src/core/pool.js export class ObjectPool { constructor(factory, reset, initial = 0) { this.factory = factory; // () => new Bullet() this.reset = reset; // (obj) => obj.xxx = ... this.free = []; this.active = []; for (let i = 0; i < initial; i++) this.free.push(factory()); } get() { const obj = this.free.pop() || this.factory(); this.active.push(obj); return obj; } release(obj) { const i = this.active.indexOf(obj); if (i === -1) return; // 防止重复回收 // 交换删除,O(1),别用 splice this.active[i] = this.active[this.active.length - 1]; this.active.pop(); this.reset(obj); this.free.push(obj); } releaseAll() { while (this.active.length) this.release(this.active[this.active.length - 1]); } }indexOf在活跃数量小的时候够用,如果池子里同时有几百个对象,建议在对象上挂一个_poolIndex做 O(1) 定位。releaseAll在场景切换时必须调用,否则上一局的子弹会留在池子里,下一局开始就飘出来几颗幽灵子弹——这个 bug 我第一次遇到时排查了快一个小时。
结算部分在settle.js,逻辑是把鱼配置里的score乘以炮台等级倍率,再扣掉子弹成本,差值写进玩家分数。掉率如果要做"打中不一定死",就在鱼身上挂hp,每次命中扣血,归零才触发死亡和金币动画。这套源码默认是单发秒杀,配置里hp字段保留着,改成多血只是把settle里的判断改一行的事。
4. 配置与启动:改 JSON 出新鱼,起个静态服务就能跑
玩法代码看完,接下来是让它跑起来、并且能自己改。这套工程把"变化频繁的东西"全部从代码里抽到了assets/config/下,三个 JSON 文件覆盖了绝大部分策划向调整。
4.1 鱼种配置表:改数值就能出一批新鱼
fish.json是最常改的文件,结构大致如下:
{ "paths": { "line_left_2": { "points": [[-80, 260], [1360, 320]], "duration": 9 }, "curve_top": { "points": [[-80, 120], [400, 90], [900, 200], [1360, 160]], "duration": 14 } }, "types": { "f_small_01": { "atlas": "fish_small", "framePrefix": "small_01_", "frameCount": 8, "frameDuration": 0.08, "radius": 20, "duration": 9, "swayAmp": 12, "swayFreq": 2.2, "score": 2, "weight": 60 }, "f_big_01": { "atlas": "fish_big", "framePrefix": "big_01_", "frameCount": 12, "frameDuration": 0.1, "radius": 62, "duration": 14, "swayAmp": 26, "swayFreq": 1.6, "score": 20, "hp": 3, "weight": 8 } } }字段含义逐个说:radius是碰撞半径,必须和视觉大小匹配,配大了会出现"子弹擦着鱼边就判定命中"的假手感;duration是从路径头游到路径尾的秒数,和paths里的同名值配合,实体里用paths的为准;swayAmp和swayFreq决定摆动幅度和频率;score是基础分;weight是带权随机的权重,只在"随机刷鱼"模式下用得上。
新增一条鱼的最小改动是三步:图集里加帧、fish.json里加一个 type、waves.json里把它的 id 塞进某一波。全程不碰 js 代码。这也是我建议二次开发时优先走配置的原因——改配置不容易引入 bug,改代码容易。
framePrefix和frameCount配合图集 JSON 的帧名做索引,常见命名是small_01_0、small_01_1这样连续编号。如果你的美术导出的是small_01_0001,那就在loader.js里加个补零函数,别去改图集,图集一改全文引用都得跟着动。
4.2 关卡波次与掉率:节奏感是调出来的
waves.json决定一整局的呼吸感。一个比较稳的开局结构是:前 10 秒只出小鱼让玩家熟悉手感,10 到 30 秒混入中鱼提高收益,30 秒后开始放大鱼并穿插空场。配置长这样:
{ "loopDuration": 60, "waves": [ { "at": 0, "group": "f_small_01", "count": 12, "interval": 400, "path": "line_left_2" }, { "at": 2000, "group": "f_small_01", "count": 10, "interval": 500, "path": "curve_top" }, { "at": 10000, "group": "f_mid_01", "count": 6, "interval": 900, "path": "curve_top" }, { "at": 22000, "group": "f_small_01", "count": 18, "interval": 300, "path": "line_left_2" }, { "at": 34000, "group": "f_big_01", "count": 2, "interval": 2500, "path": "curve_top" }, { "at": 42000, "group": "f_mid_01", "count": 8, "interval": 700, "path": "line_left_2" } ] }loopDuration是一轮的总长,秒为单位。at是相对本轮开始的毫秒偏移,必须升序,spawner的单向游标依赖这个前提。如果手工维护容易出现乱序,我一般会在loader加载完配置后加一段排序加校验:at乱序就排一次序,group在types里找不到就 console.warn 并跳过这条。这类防御性校验在配置文件由别人维护时特别值钱,能省掉大量"鱼不出来"的排查时间。
掉率方面,如果玩法是"打中必死",那weight就没用;如果要做"随机鱼池",就在spawner里把group换成pool: "random",然后按weight做带权抽样。带权抽样的实现放在utils/rng.js,思路是权重累加后取随机数落区间,不要用"随机一个然后按概率判断"的写法,那种写法在多次抽样后分布会偏。
4.3 本地起服务与移动端适配:DPR 和触摸事件
这套工程用了 ES Module,index.html里是<script type="module" src="./src/main.js">,同时资源加载走fetch。这两个前提意味着不能双击打开——file://协议下模块加载和 fetch 都会被浏览器拦掉,页面一片白。正确的起法是随便找个静态服务器:
# 任选其一,别双击 index.html python3 -m http.server 8080 # 或者装了 node 的话 npx serve . -l 8080起来之后访问http://localhost:8080,正常应该先看到加载进度条,然后进主场景。手机联调时把地址换成电脑的局域网 IP,同一个 Wi-Fi 下直接访问。这里有个小提醒:npx serve默认会把目录列表打开,如果目录里有大文件会稍慢,加-l指定端口即可。
分辨率适配是移动端的重头戏。设计分辨率假定 1280×720,实际屏幕比例千差万别,处理方式是按容器尺寸缩放并统一变换:
// src/core/renderer.js export function fitCanvas(canvas, designW = 1280, designH = 720) { const dpr = Math.min(window.devicePixelRatio || 1, 2); // 3x 屏没必要,性能优先 const rect = canvas.getBoundingClientRect(); canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); const ctx = canvas.getContext('2d'); // 一次性把坐标系映射到设计分辨率,之后所有绘制都用设计坐标 ctx.setTransform( canvas.width / designW, 0, 0, canvas.height / designH, 0, 0 ); return ctx; }dpr封顶 2 是个经验值。iPhone 的 DPR 是 3,按 3 倍开画布,像素量是 2 倍的 2.25 倍,捕鱼这种满屏精灵的场景很容易掉帧,而肉眼在中尺寸手机上几乎分辨不出 2 和 3 的差别。setTransform把 DPR 缩放和设计分辨率缩放合并成一次矩阵,后续所有绘制代码写ctx.drawImage(img, 100, 200)就是用设计坐标,不用到处乘系数。
窗口resize和手机横竖屏切换时要重跑一次fitCanvas,并且重跑之后要把cannon.pivotX/pivotY之类的固定坐标重新算一遍——如果它们是按设计分辨率写死的常量就不用,如果是从canvas.width推出来的就必须重算。这个细节我在几个项目里都踩过,横屏转竖屏后炮台跑到屏幕外面去了。
触摸事件绑定用 Pointer Events 一套搞定鼠标和触摸,比分开写touchstart / mousedown少一半代码:
canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); // 阻止移动端的长按选中和滚动 cannon.aim(e.clientX, e.clientY); firing = true; }); canvas.addEventListener('pointermove', (e) => { if (firing) cannon.aim(e.clientX, e.clientY); // 按住拖动时炮口跟随 }); window.addEventListener('pointerup', () => { firing = false; });e.preventDefault()在移动端必须加,不加会出现按住屏幕触发文本选中、或者页面跟着手指滚动的现象。另外 CSS 里要补touch-action: none;和user-select: none;,双保险。
5. 常见问题排查:黑屏、点不准、越玩越卡的 5 个坑
这一节是我把这套源码跑在不同设备上、以及帮别人看他们改过的版本时,反复遇到的五类问题。现象、原因、解决都写清楚,对着排基本能定位。
5.1 现象:双击 index.html,浏览器打开一片白,控制台报 CORS 或 MIME 错误
原因基本可以锁定在协议上。工程用了type="module",浏览器对file://下的模块加载有严格限制,会直接拒绝;同时loader.js用fetch读图集 JSON 和配置文件,file://下同样被拦。控制台通常会出现Access to script at 'file:///...' from origin 'null' has been blocked by CORS policy或者Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of ""。
解决办法就是起一个静态服务器,python3 -m http.server 8080或npx serve .都行,然后走http://localhost:8080。如果必须在无服务环境里跑(比如塞进某个只能上传 HTML 的投放系统),那就得把模块打包成单个 IIFE 文件,同时把 JSON 配置内联成 js 变量,图片改成 base64 内嵌。这条路能通但包体会变大,非必要不走。
5.2 现象:手机上点哪炮口都不转,或者点在右上角炮口转到左下角
原因有两层。第一层是没有做坐标换算,直接拿clientX去和设计分辨率里的pivotX相减,中间差了 CSS 尺寸到设计尺寸的缩放比。第二层是getBoundingClientRect()拿到的是 canvas 在页面里的位置,如果 canvas 有 margin、padding 或者被 flex 居中,rect.left / rect.top不是 0,不减掉就会整体偏移。
解决就是aim()里那三行:先getBoundingClientRect(),再用(clientX - rect.left) / rect.width * designW换到设计坐标。排查时可以临时在aim里打一行console.log(x, y, this.angle),手指点屏幕正中央,看输出的 x 是不是接近 640、y 是不是接近 360,对不上就说明换算有问题。另外如果页面本身有缩放(比如<meta name="viewport">配了initial-scale),rect是缩放后的值,这套换算是自洽的,不用额外处理。
5.3 现象:刚进去很流畅,玩两三分钟帧率从 60 掉到 30 甚至 20,越玩越卡
这是最典型的对象泄漏。三个高发点:子弹飞出屏幕没有回池而是直接splice掉;金币动画播完没有回收;每次进场景都addEventListener但退出时没removeEventListener。表现是活跃数组越来越长,每帧遍历和绘制的东西越来越多,内存占用也一路上涨,开 DevTools 的 Performance 面板录一段,会看到 GC(垃圾回收)的锯齿越来越密。
解决分三步。第一步,所有实体的销毁统一走dead = true,在场景 update 末尾统一扫描回收,回收时必须pool.release(obj),不能只从数组里删。第二步,GameScene.onExit()里调pools.bullet.releaseAll()、pools.fish.releaseAll()、pools.coin.releaseAll(),把池子清干净。第三步,把事件监听的绑定和解绑写成成对的方法,比如bindInput()和unbindInput(),场景进出时各调一次,别散落在各处。改完之后再录一段,内存曲线应该是平的。
5.4 现象:子弹明明从鱼身上穿过去了,没有判定命中也没有金币动画
分两种情况。如果鱼很大、子弹很慢也不判定,那是碰撞半径配错了,去fish.json检查那条鱼的radius,很可能配成了 0 或者没写这个字段,实体里读到undefined,undefined + 数字得到NaN,比较永远为false。这种沉默失败最难查,因为不报错。
如果只是快速子弹偶尔漏判,那就是离散碰撞的固有缺陷。子弹速度 1600 px/s,一帧位移 26.7 px,小鱼半径 20 px,两者相向时一帧跨越的距离超过直径,用点对圆判定必然漏。解决办法是换成sweptHit,用上一帧位置到当前帧位置构成的线段去和圆做最近距离判定。改的时候记得在子弹里存prevX / prevY,在update开头先记录再位移,顺序写反了等于没改。
5.5 现象:iOS 上背景音乐和开火音效都没有声音,安卓正常
浏览器的自动播放策略。iOS 的 Safari 和 WebView 要求音频必须在用户手势(click / pointerdown / touchstart)的同步调用栈里播放或解锁,页面加载完自动audio.play()会被静默拒绝,Promise 进 rejected 但没有 catch,控制台可能什么都不显示。
解决办法是在第一次pointerdown里做一次解锁:把所有Audio实例play()一下再立刻pause()并重置currentTime = 0,或者用 WebAudio 的话在同一个手势里audioContext.resume()。解锁只需要一次,用一个unlocked标志位挡住后续重复调用。如果用的是 WebAudio 的 buffer source,还要注意每次播放要新建 source 节点,source 是一次性的,复用一个节点第二次就没声了——这个坑我也踩过,表现为第一枪有声音,后面全哑。
6. 二次开发:把捕鱼骨架改成你自己的产品
拿到这套源码之后,改造方向大致分四档,成本从低到高。第一档是纯换皮,换图集、换配色、调配置,一行代码不动,半天能出效果。第二档是加玩法元素,比如锁定炮、连击加成、BOSS 鱼、狂暴模式,改动集中在entity/和system/,不需要动主循环。第三档是改结算模型,把本地算分换成服务端校验,客户端只保留表现层。第四档是移植平台,把它塞进小游戏容器或者某个活动页的 iframe 里。
我一般建议按这个顺序推进,因为每一档都在验证前一档的稳定性。直接跳到第三档的人,往往在服务端联调时才发现客户端的实体回收都没做对,一个 bug 卡两天。
改造时最值得动手的地方是结算下沉,这里给一个接口约定,客户端只负责上报"谁打中了谁",分数由服务端返回:
| 字段 | 方向 | 说明 |
|---|---|---|
roomId | 上行 | 房间标识 |
bulletId | 上行 | 客户端生成的子弹序列号,用于幂等 |
fishId | 上行 | 目标鱼实例 id |
cannonLevel | 上行 | 开火时的炮台等级,决定消耗 |
cost | 下行 | 本次开火扣除的分数 |
hit | 下行 | 是否命中 |
reward | 下行 | 命中奖励,未命中为 0 |
balance | 下行 | 服务端结算后的余额 |
客户端拿到hit才播金币动画,拿不到就按超时处理、回滚本地预扣。这样即使有人改前端代码把score调成 9999,也拿不到真实分数。对应的客户端改动是在settle.js里把原来的同步算分换成一个带请求队列的异步方法,请求在途时先用乐观更新显示,收到响应再校正。
// src/system/settle.js(改造后示意) export async function settleHit(game, bullet, fish) { const key = `${bullet.id}`; if (game.settled.has(key)) return; // 同一颗子弹只结算一次 game.settled.add(key); const res = await game.net.post('/api/hit', { roomId: game.roomId, bulletId: bullet.id, fishId: fish.id, cannonLevel: bullet.level }); if (!res.hit) return; // 服务端判未命中,静默丢弃 game.score = res.balance; // 以服务端余额为准 fish.dead = true; spawnCoinAnimation(game, fish, res.reward); }这里的game.settled是个 Set,用来做本地幂等,防止同一颗子弹在一帧内和两条鱼相交触发两次请求。长期运行时这个 Set 会涨,记得在场景切换时清空,或者用子弹回收时删掉对应 key。这个细节不注意,玩久了内存会慢慢爬。
还有一个具体的提速技巧:如果你只想验证玩法而不想改代码,可以直接改waves.json把整轮时长压到 20 秒、把interval全部除以 3,跑一遍就能看出节奏哪里空、哪里挤。比在脑子里推演快得多,也比写测试用例直接。
我自己的习惯是,任何一次改完配置或实体逻辑,都强制走一遍三件事:清空缓存硬刷新、在手机上真机点五分钟、开 Performance 录十秒看帧率曲线。这三步看着笨,但省下来的排查时间远大于花掉的。有一次我图省事跳过了真机验证,结果上线后才发现横屏下炮台坐标是全错的,那次的教训让我把"真机五分钟"写进了自己的检查清单。
希望帮到你。
本文还有配套的精品资源,点击获取