1. 先把玩法想清楚:机制设计与项目拆解
1.1 为什么“大鱼吃小鱼”是绕不开的入门经典
这个项目排在我自己的练手列表第34位,也是整套小游戏复刻系列里反馈最好的一期。很多人一听“大鱼吃小鱼”就觉得是小时候玩过的Flash小游戏,简单得不能再简单,但真到动手实现的时候,你会发现它把游戏开发里最核心的几个问题全占了:游戏循环怎么驱动、角色怎么移动、碰撞检测怎么做、数值怎么调平衡、游戏节奏怎么从轻松变成紧张。这些东西在任何一个商业游戏项目里都会遇到,只不过大鱼吃小鱼用一种最直观、最容易验证的方式把这些概念一次性打包了。
我推荐它作为第一个完整项目的理由很简单:开发周期短,正常写两天能出完整玩法;反馈即时,吃掉一条鱼马上变大的成就感非常直观;扩展空间大,做完基础版之后想加道具、加排行榜、加多人模式都顺理成章。它适合刚入门游戏开发的读者,也适合已经有一定经验、但没完整做过一个“能正常开场、游玩、结束”的闭环项目的人。甚至对于只想搞懂“游戏手感是怎么调出来的”这类抽象问题的朋友,这个项目也是绝佳的观察样本。
1.2 核心玩法循环与数值平衡设计
先拆玩法的底层循环。大鱼吃小鱼的每一秒都在重复四个状态:观察、判断、移动、结算。你操控的鱼要不断判断周围哪些鱼能吃、哪些鱼得躲,然后移动过去吃掉或逃离;成功吃掉后角色变大,可捕食的范围变广,但也会引来更大的鱼。这个循环不断叠加,玩家的紧张感和成就感交替上升,游戏才留得住人。
数值平衡是这里最容易翻车的地方。如果游戏里全是小鱼,玩家毫无压力,几分钟就腻;如果大鱼比例太高,玩家寸步难行,挫败感拉满。我用的基础策略是“梯度生成”:初期NPC鱼的半径集中在8到20像素之间,玩家初始半径20像素,保证大多数鱼都能吃;随着得分增长,NPC里大鱼的比例逐渐提高,最大半径可以长到160像素以上。这样玩家的成长曲线和大鱼的威胁曲线是同步拉升的,游戏从前期的“无脑吃”自然过渡到后期的“边吃边躲”。
另一个容易忽略的细节是“判定阈值”。吃与被吃的边界不能太生硬,如果双方半径只差1%,直接判定大鱼吃掉小鱼,玩家会觉得系统不讲道理。我实际测试下来,设定一个1.08倍的半径比作为触发阈值比较合理,也就是说对方至少要比你大8%或小8%,才触发吃或被吃的结算。这个缓冲区间给了玩家反应时间,也是手感好的关键之一。
1.3 技术方案选型:为什么推荐 H5 Canvas
这个项目我选的是HTML5 Canvas加原生JavaScript,没有用Unity,也没有用Godot,连PIXI.js这类渲染库都没引。原因很朴素:门槛最低、复现最快。一个浏览器打开就能跑,不需要安装引擎、不需要配置环境、不需要处理跨平台打包,这对新手极其友好;同时代码量也足够小,可以把全部注意力放在游戏逻辑本身,而不是被引擎的编辑器、资源管线这些东西分散精力。
当然,如果你的目标是做手游或者微信小游戏,用Unity或者Cocos也完全没问题,核心玩法逻辑大同小异,只是渲染层和输入层的API不同。我在文章后半部分会标注哪些代码属于“通用逻辑”,哪些属于“Canvas专属实现”,方便你后续移植到别的技术栈。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心系统设计与技术选型拆解
2.1 鱼的抽象:玩家鱼和NPC鱼共用同一个类
很多人一上来就把玩家鱼和NPC鱼分开写成两个类,这是新手最常见的设计问题。实际上它们拥有完全相同的属性:坐标、半径、速度、颜色、移动方向,甚至绘制方式都一样。分开写意味着同样的属性维护两份代码,后续加一个新的鱼种类时,你要改两个地方,非常容易漏。
我用的方案是写一个通用的Fish类,所有鱼都基于它实例化,用type字段来区分身份。玩家鱼和NPC鱼的区别只在“控制逻辑”:玩家鱼的方向来自鼠标输入,NPC鱼的方向来自随机漫游AI。这个设计让后续扩展新鱼种变得异常轻松,比如想加一条“只会左右游的呆鱼”,只需在它的运动更新函数里替换一行移动逻辑。
类的核心字段大概是这样的:
javascript复制class Fish {
constructor(x, y, r, color, type) {
this.x = x;
this.y = y;
this.r = r;
this.color = color;
this.type = type; // 'player' 或 'npc'
this.angle = 0;
this.speed = 0;
this.maxSpeed = 200;
this.alive = true;
}
}
2.2 移动与操控手感:鼠标跟随和随机漫游
玩家鱼的操作方案,我最终选择的是“鼠标跟随”。这个方案在PC上体验最自然,手指或鼠标指到哪,鱼就往哪游。关键点在于:不要让鱼瞬移到鼠标位置,而是让鱼持续朝鼠标方向游动,这样才有“游泳”的质感,而不是“拖拽”的质感。
每帧更新的伪逻辑是:计算鱼与鼠标的角度,然后把速度分解到x和y方向,加上最大速度限制。这里还可以加一个lerp平滑,让鱼的转向不会过于生硬,特别是大鱼转向时要有一种笨重感,才符合物理直觉。角度与速度分解的核心代码我放在了后面第3章。
NPC鱼的游动我用的是随机漫游:鱼每隔一段时间随机换一个方向,然后以固定速度游动。越大的鱼移动速度越慢,这样玩家在后期追一条小鱼时需要有预判,游戏才不无聊。NPC游出屏幕后从另一侧绕回来,也就是“回绕边界”,这样既保证了场景里有持续的鱼源,又不会出现鱼突然消失的突兀感。
2.3 碰撞检测与吃掉判定:一个被低估的核心环节
碰撞检测是大鱼吃小鱼最核心的技术点。这里用的是圆形碰撞检测,判定条件是两圆心距离小于两者半径之和。这个算法本身不复杂,初级写法几行就能搞定,但真正决定游戏好不好玩的,是“判定阈值怎么设”和“触发动作怎么分”。
我们要区分三种情况:对方足够小、对方足够大、对方大小接近。前两种分别触发“吃掉”和“被吃”,第三种什么都不触发。如果第三种也强行判定,玩家在游过一群和自己体型差不多的鱼群时,会感觉到处都是“粘性边界”,一点都不清爽。我在项目里把所有碰撞判定都收敛到一个函数里,返回一个枚举结果,这样逻辑清晰也方便调试。
另外提一个视觉公平性的细节:鱼的实际视觉体型比碰撞圆要小一点。因为绘制时外面还会画鱼鳍、尾巴、眼睛等装饰,如果碰撞圆和视觉边缘完全重合,玩家会觉得“明明没碰到也被吃了”。我通常把碰撞半径设为视觉半径的0.85倍,留出一点视觉缓冲,手感立刻舒服很多。
2.4 成长数值模型:为什么吃鱼是按面积算而不是按半径算
这也是新手容易踩的坑。很多初版实现里,鱼吃了另一个鱼后,直接把自己半径加上对方的半径,结果发现长大后体型增长的速度非常夸张,没吃几条鱼就糊满整个屏幕。正确的做法是按面积合并。
鱼的大小实际上对应的是视觉体积,体积应该按圆的面积累加。吃掉一条半径r2的鱼后,你的新半径应该满足:
新面积 = 旧面积 + 被吃鱼面积
展开就是 PI * R新^2 = PI * R旧^2 + PI * r2^2,约掉PI后得到 R新 = Math.sqrt(R旧^2 + r2^2)。
这样每吃一条小鱼,体型增长都是渐进的,玩家能明显感觉到变大,但不会瞬间膨胀。同时我也设置了一个最大半径上限,比如280像素,超过之后不再增大,避免后期画面被主角完全撑满,那已经不是游戏而是占屏测试了。
速度衰减公式我实际用下来比较合理的是:随着半径从初始值开始增长,最大速度按比例下降,但设一个下限,防止鱼大得离谱后完全跑不动。具体参数是:最大速度 = 280,速度衰减系数 = 0.35,公式为 maxSpeed * (1 / (1 + 衰减系数 * ((当前半径 - 初始半径) / 初始半径)))。半径20时速度280,半径100时速度大约降到170,依然能操作但明显感觉笨重,这种节奏刚好。
3. 完整实现:从零写一个能玩的 H5 版本
3.1 画布初始化与游戏循环骨架
先准备画布。这一步有两个关键点:一是兼容高清屏,也就是devicePixelRatio适配;二是游戏循环要用requestAnimationFrame,而不是setInterval。requestAnimationFrame的优势在于和屏幕刷新率同步,动画更流畅,而且页面切到后台时浏览器会自动暂停,不会白白消耗CPU。
javascript复制const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
const width = window.innerWidth;
const height = window.innerHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
canvas.logicalWidth = width;
canvas.logicalHeight = height;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
然后是游戏循环。这里有一个必须处理的细节:每帧的时间差dt不能无限大。如果玩家切后台过了一会儿再回来,两次requestAnimationFrame回调之间的时间差可能是好几秒,如果不做限制,鱼会瞬间瞬移出去老远。我习惯把dt强制收敛在0.05秒以内,也就是最多按50毫秒算,超过的部分丢弃。
javascript复制let lastTime = 0;
function gameLoop(time) {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
update(dt);
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
3.2 玩家鱼的鼠标控制和移动更新
玩家鱼的移动逻辑是:计算鼠标位置与玩家坐标形成的方向向量,然后朝着这个方向以最大速度移动。为了让转向更平滑,我不直接使用方向向量,而是让鱼的当前角度向目标角度逐步靠拢,接近目标的速度和当前速度有关。这个“让角度慢慢转过去”的处理对后期大鱼转向的手感影响很大。
javascript复制class PlayerFish extends Fish {
constructor(x, y, r) {
super(x, y, r, '#ff9800', 'player');
this.maxSpeed = 280;
this.angle = 0;
}
update(dt, mouse) {
const targetAngle = Math.atan2(mouse.y - this.y, mouse.x - this.x);
let angleDiff = targetAngle - this.angle;
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
this.angle += angleDiff * Math.min(1, 10 * dt);
this.vx = Math.cos(this.angle) * this.maxSpeed;
this.vy = Math.sin(this.angle) * this.maxSpeed;
this.x += this.vx * dt;
this.y += this.vy * dt;
// 边界限制,防止玩家鱼游出屏幕
this.x = Math.max(this.r, Math.min(canvas.logicalWidth - this.r, this.x));
this.y = Math.max(this.r, Math.min(canvas.logicalHeight - this.r, this.y));
}
}
鼠标监听部分比较简单,用一个全局变量记录鼠标坐标就可以:
javascript复制const mouse = { x: 0, y: 0 };
canvas.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
3.3 NPC鱼的生成与随机漫游
NPC鱼的游动不能太死板,否则一眼看去全是直挺挺的平行线,显得假。我这里的方案是:每条NPC鱼维护一个“换方向计时器”,计时归零后随机转向一个角度,然后继续游。转角的幅度也限制在一定范围内,保证游动轨迹是平滑的,而不是随机跳变。
生成机制采用定时生成加数量上限。我每秒尝试生成若干条新鱼,但场景里的NPC总数不超过70条;同时生成时会根据玩家当前得分决定鱼的大小分布,分数越高,大鱼出现概率越大。这就是前面提到的难度曲线落地的具体方式。
javascript复制class NpcFish extends Fish {
constructor(x, y, r, color) {
super(x, y, r, color, 'npc');
this.speed = this.maxSpeed * (0.6 + Math.random() * 0.4);
this.angle = Math.random() * Math.PI * 2;
this.turnTimer = 0;
this.turnInterval = 1.5 + Math.random() * 3;
}
update(dt) {
this.turnTimer -= dt;
if (this.turnTimer <= 0) {
this.angle += (Math.random() - 0.5) * 1.2;
this.turnTimer = this.turnInterval;
this.speed = this.maxSpeed * (0.6 + Math.random() * 0.4);
}
this.x += Math.cos(this.angle) * this.speed * dt;
this.y += Math.sin(this.angle) * this.speed * dt;
// 屏幕边缘回绕
const margin = 60;
if (this.x < -margin) this.x = canvas.logicalWidth + margin;
if (this.x > canvas.logicalWidth + margin) this.x = -margin;
if (this.y < -margin) this.y = canvas.logicalHeight + margin;
if (this.y > canvas.logicalHeight + margin) this.y = -margin;
}
}
注意速度和大小的关系:生成NPC鱼时,我按照半径反比来设置最大速度,比如半径只有10的小鱼游得飞快,半径60的大鱼慢悠悠。这符合直觉,也让玩家追逐小鱼时需要一点操作技巧,而不是无脑跑直线就能追上。
3.4 碰撞检测、吃鱼成长与游戏结束判定
碰撞检测放在update阶段。遍历所有NPC鱼,计算玩家与每条NPC的距离,如果距离小于两者的碰撞半径之和,就按前面说的阈值逻辑进行结算。吃掉NPC后,把NPC从场景数组里移除,并给玩家增加对应的半径和分数。
javascript复制function checkEat(player, npc) {
const dist = Math.hypot(player.x - npc.x, player.y - npc.y);
const playerRadius = player.r * 0.85;
const npcRadius = npc.r * 0.85;
if (dist > playerRadius + npcRadius) return null;
if (player.r > npc.r * 1.08) {
return 'eat';
} else if (npc.r > player.r * 1.08) {
return 'dead';
}
return null;
}
吃鱼后成长的计算就是前面说的面积叠加:
javascript复制function growPlayer(player, eatenFish) {
player.r = Math.sqrt(player.r * player.r + eatenFish.r * eatenFish.r);
if (player.r > maxRadius) player.r = maxRadius;
player.score += Math.ceil(eatenFish.r * 0.5);
}
游戏结束条件是被吃掉,也就是checkEat返回'dead'。这时把游戏状态切换到'gameover',停止更新逻辑,显示最终得分。我额外加了一个缓动效果:触发结束时画面轻微暂停0.3秒再做全屏淡入,给玩家一个视觉上的“死亡反馈”时间,而不是瞬间中断。
3.5 计分、开局流程和简单的UI
UI方面这个项目不需要复杂的UI框架,直接用Canvas绘制文字就行。左上角显示当前得分,中间偏上的位置显示当前体长排名,游戏结束后在屏幕中央绘制分数并提示重新开始。开始界面我做成“点击任意位置开始”,这样玩家进入页面的第一眼就能看到游戏场景,操作成本低。
计分规则我故意做得保留了“吃小鱼收益低、吃大鱼收益高”的差异。吃一条半径10的小鱼得5分,吃一条半径80的大鱼得40分,鼓励玩家冒险去靠近那些比自己略大的鱼。这里给玩家一个心理权衡:越危险的目标,收益越高。
4. 常见问题与排查技巧实录
4.1 鱼一多就明显卡顿怎么优化
这个问题我调试过很久。最开始为了让画面丰富,我同时让100条NPC鱼在场景里游动,然后发现帧率直接掉到30以下。排查下来主要瓶颈在三个方面:一是每条鱼每帧都在用Math.random生成方向变化,随机数调用密集到一定程度会影响效率;二是每条鱼绘制时都单独调用beginPath、fill等,大量状态切换非常耗时;三是全局鱼对象数组里包含了大量已死亡但没被清理的垃圾对象。
解决方案也分三步。第一,降低刷新方向的频率,把turnTimer的间隔从每帧生成改为至少间隔0.8秒。第二,绘制时尽量合并同类操作:先用beginPath一次性画出所有鱼的轮廓路径,最后统一填充,颜色相同或相近的鱼可以分组合并。第三,用对象池复用死亡鱼的实例,减少垃圾回收压力。优化之后同样100条鱼,帧率稳定在60,实测非常有效。
对象池的简单实现是维护一个空闲数组,鱼被移除时把它的alive标记设为false并放进池里,新鱼生成时优先从池里取出复用。核心思想是避免频繁创建和销毁对象,减少CPU和内存波动。实际体验就是项目长时间运行也不会越来越卡。
4.2 碰撞检测出现漏判或误判
漏判的最常见原因是距离阈值计算错误。有人会把圆心距离和“半径差”做比较,而不是和“半径和”做比较,导致大鱼在小鱼上方掠过时两者明明已经重叠却判定尚未发生。这类问题用肉眼很难看出来,我的调试方法是在渲染阶段画碰撞圆,也就是把每个鱼的碰撞半径用半透明红色圆圈画出来,边跑边看。这样碰撞范围是否合理一目了然。
还有误判的情况:鱼在屏幕边缘回绕时,坐标从左侧瞬间跳到了右侧,但如果更新顺序是先更新跳跃再检测碰撞,那么跳跃前后的坐标都不会和场景中其他鱼发生正确的重叠比较。解决方法是在更新完所有鱼的坐标后,统一做一次碰撞检测,而不是边移动边检测。顺序对了,误判就少了。
4.3 玩家成长到后期追不上小鱼
追不上小鱼这个问题我一开始没注意,直到自己玩到后期,发现半径已经150像素,但周围半径10像素的小鱼游得飞快,自己完全跟不上,游戏体验变成“看得见吃不着”。原因就出在速度衰减公式上,衰减系数如果太大,后期玩家会显得过于笨重。
解决方案是给速度衰减设一个下限,我设定0.45倍的初始最大速度,也就是玩家再大,速度也不会低于126每秒像素。同时,后期NPC生成时,小鱼的半径下限适当提高到16像素左右,速度上限也稍微下调,保证玩家至少能追到一部分鱼。实测体验是后期依然有挑战,但不会让人彻底绝望。
4.4 高分屏上画面模糊或布局错乱
高清屏模糊是因为没有处理devicePixelRatio。如果不做适配,在Retina屏幕或高缩放的Windows系统上,画布会被拉伸显示,边缘锯齿非常明显。适配方法就是第3.1节里的resizeCanvas函数,核心是让Canvas的实际像素尺寸乘以dpr,再通过ctx.setTransform把逻辑坐标系缩放回去。
要注意的是,适配之后所有逻辑坐标都基于CSS像素尺寸,在update里计算的canvas.logicalWidth才是游戏正确使用的逻辑尺寸。如果直接用canvas.width做边界计算,在高分屏上边界的实际距离会成倍变大,鱼会在屏幕外游很久才触发回绕。这也是一个非常隐蔽的坑,我一开始就踩了。
5. 后续扩展方向与个人经验总结
做完基础版之后,这个项目的天花板远不止于此。我自己后续有时间的话,打算往这几个方向扩展:加道具系统,比如加速药水、护盾、体型缩小器,让玩家在大鱼追击时有更多策略选择;加关卡模式,每一关设定目标分数和时间限制,创造更强的目标感;加排行榜,把成绩上传到服务器,每周重置一次,这是把小游戏变成可运营产品最有效的手段。
如果考虑平台适配,可以往微信小游戏迁:渲染层从Canvas换成小游戏Canvas接口,输入层从鼠标事件换成touch事件,其余核心逻辑百分之八十可以复用。腾讯云开发提供的云数据库能非常低成本地搞定排行榜接口,这个扩展方向对想要上线作品的人来说很有吸引力。
最后再分享一个我反复强调的小技巧:这个游戏最花时间的不是写代码,而是调数值。我建议你每次调整参数,比如碰撞阈值、速度衰减、NPC生成比例,就认真玩三分钟,记录下自己的感受,然后再改下一个参数。千万不要一次性改五六个参数,否则手感出了变化你都定位不到是哪个参数引起的。我前前后后为了“手感”聊胜于无地调了两天,最终的参数和初版相比变化巨大,但每一步都是基于实际体验的修正。这个项目做完之后,你对“游戏手感到底是怎么来的”一定会有远超理论的理解。
