捕鱼达人H5源码拆解:Canvas渲染循环与二次开发实践
简介捕鱼达人-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 * dtspeed按每帧多少像素填了 2结果dt是 0.0167鱼慢得像蜗牛。约定好单位比事后一个个排查省事得多。3. 核心玩法拆解从鱼群轨迹到命中结算的四段链路把骨架看明白之后玩法其实就是四条链路串起来刷鱼、游动、开火、判定结算。这四段彼此通过实体对象解耦spawner只管往场景里塞鱼cannon只管造子弹collision每帧扫一遍活跃子弹和活跃鱼settle在命中的瞬间算分。下面逐段拆。3.1 鱼群生成波次表驱动的刷鱼节奏刷鱼最忌讳写死setInterval。捕鱼玩法的节奏感来自什么时候出小鱼、什么时候放大招、什么时候空场让玩家喘口气这些必须数据化。这份工程把节奏全部外置到waves.jsonspawner只做两件事按时间轴触发波次、按间隔吐出个体。// 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 / clientYCSS 像素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/s60 帧下每帧位移约 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 Moduleindex.html里是script typemodule 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 错误原因基本可以锁定在协议上。工程用了typemodule浏览器对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 nameviewport配了initial-scalerect是缩放后的值这套换算是自洽的不用额外处理。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 或者没写这个字段实体里读到undefinedundefined 数字得到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上行目标鱼实例 idcannonLevel上行开火时的炮台等级决定消耗cost下行本次开火扣除的分数hit下行是否命中reward下行命中奖励未命中为 0balance下行服务端结算后的余额客户端拿到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 录十秒看帧率曲线。这三步看着笨但省下来的排查时间远大于花掉的。有一次我图省事跳过了真机验证结果上线后才发现横屏下炮台坐标是全错的那次的教训让我把真机五分钟写进了自己的检查清单。希望帮到你。本文还有配套的精品资源点击获取