熟悉我的人都知道,我会习惯性地把一些经典小玩法收集成系列项目,一个一个亲手实现一遍,这套项目编号走到这里已经是第34个了。今天要写的是这个系列里的第34个——大鱼吃小鱼。别小看这个题材,它看起来简单,真正做起来涉及的逻辑其实一点都不少:AI鱼的自主行为、碰撞判定、成长曲线、相机跟随、性能优化,每一块都能拆出一堆值得聊的东西。如果你正在学游戏开发,或者想找一个能练手又能完整跑起来的HTML5游戏项目,这篇会很对胃口。
这个项目的形态很简单:玩家操纵一条小鱼在海里游动,比你们小的鱼可以直接张嘴吃掉,吃掉之后体型慢慢变大,然后能挑战更大的目标;同时海里也会不断出现比你更大的鱼,撞上它们就是游戏结束。整个循环听起来直白,但要把“追、逃、吃、长”这四个动作做得流畅自然,需要处理的技术细节并不少。我把整个实现过程拆成设计和实战两大部分,从为什么要这么做,到代码具体怎么写,都尽量说透。
1. 内容整体设计与思路拆解
1.1 核心玩法规则背后的逻辑
大鱼吃小鱼这类玩法的核心,说白了就是一个“相对大小”的判定游戏。玩家和大大小小的AI鱼同处一片海域,每一条鱼都有属于自己的“尺寸”,尺寸更大的角色可以吞噬尺寸更小的角色,反过来就不行。这个规则听起来简单,但它是整套游戏乐趣的基本盘。
我在设计规则时确认了三个关键设定。第一个是尺寸判定不能只看快慢,要有明确的“安全吞噬比例”,也就是你的体型必须大于目标体型的一定比例才能吃掉对方。如果只设定“比你小就能吃”,玩家接近同体型鱼时判定会很模糊,手感也不好。实际项目里我会把吞噬判定设为“玩家半径大于目标半径的1.1倍”,这个比例经过测试比较合理,既不会出现明明差不多大却能一口吞的违和感,也不会让玩家因为差一点点吃不到而烦躁。
第二个设定是速度与体型反比。大鱼必须比小鱼慢,这是游戏平衡的关键。如果大鱼和小鱼速度一样快,玩家变大后就没有“追不上弱小”的遗憾,挑战感会大打折扣;反过来,如果玩家变大之后还是移动灵活,那整个游戏就没有任何危险感。我采用的公式是速度与半径的幂次成反比,具体参数后面会详细讲。
第三个设定是“一定能看到,但不一定能追上”。AI鱼和玩家之间要有感知范围,AI鱼的视野半径决定了它什么时候会逃跑、什么时候会来追你。这个感知范围我会设计成跟AI鱼的体型相关,体型越大视野越远,这样整个海域的生态感会更强。
1.2 技术选型:为什么用HTML5 Canvas + 原生JavaScript
很多做小游戏的人一上来就会想用Unity、Cocos这类成熟的游戏引擎,或者至少用个Phaser。但对于“大鱼吃小鱼”这种玩法简单、逻辑清晰的项目,我依然坚持用HTML5 Canvas + 原生JavaScript来做,而且这也是我给新手朋友的建议优先选型。
原因很直接:这个玩法的核心是“大量圆形物体的移动、碰撞、渲染”,Canvas 2D API天然就擅长处理这类情况。画一条鱼本质上就是画几个圆和三角形,不需要3D模型,不需要物理引擎,也不用考虑跨平台打包,浏览器打开就能玩。整个项目的依赖为零,复制到任何静态服务器上都能直接运行,这对于分享和演示来说非常方便。
还有一个被很多人忽略的点:用原生JS写,你能更清楚地理解游戏循环和渲染机制。引擎帮你封装了太多东西,比如requestAnimationFrame、碰撞检测、对象管理,初学者用着爽,但出了性能问题往往一头雾水。而用原生Canvas,每一个fps变化、每一次GC停顿、每一帧的绘制开销你都能自己掌控,排查问题的思路会清晰很多。
另外,智能电视、平板、手机这类碎片化设备上,H5游戏的兼容性要比其他方案好太多。Canvas 2D在这类设备上的表现一直很稳定,不需要处理复杂的硬件兼容问题。移动端触摸控制和桌面端鼠标控制在代码层面只需要做很小的适配,非常适合这个项目。
1.3 整体架构:分而治之的模块设计
我习惯在写代码之前先把整个项目的模块划分清楚。这个游戏从架构上看可以分成几个互不干扰的部分:
- 场景管理层:负责游戏状态的切换(开始界面、游戏进行中、结束界面)
- 实体管理模块:维护所有鱼的创建、更新、销毁,也就是一个对象池
- 玩家控制模块:负责接收鼠标或触摸输入,控制玩家鱼移动
- AI行为模块:驱动所有AI鱼的漫游、追击、逃跑行为
- 渲染模块:负责背景、鱼群、特效、UI的绘制
- 碰撞检测模块:负责判断吃与被吃
我会谨慎地避免把所有逻辑堆在一个文件里。实际上这个项目的代码量并不大,全部加起来可能就几KB,但把模块分开写,后续加特效、加道具、加新类型鱼的时候,你只需改对应的部分,其他代码完全不动。这就像做菜时把配菜、主料、调料分开放好,真正下锅时才不会手忙脚乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 鱼的移动手感:速度、惯性与转向
移动手感是整个游戏体验的骨架。如果玩家控制的小鱼移动起来生硬、转头迟钝,那画面再好看也白搭。
这个项目里玩家鱼的控制方式我选了最简单直观的“跟随目标点”:鼠标或者手指按到哪里,鱼就往那个方向游。但有个细节值得注意——我不会让鱼直接朝目标方向“瞬移”过去,而是用一个速度向量配合插值来模拟鱼在水中的真实游动效果,让鱼有一个自然的加速和减速过程。
核心逻辑是这样的:每一帧计算目标方向向量,然后用当前速度向量向目标方向插值。用一段伪代码来表达这个思路:
javascript复制// 计算目标方向向量
let dx = targetX - fish.x;
let dy = targetY - fish.y;
let dist = Math.sqrt(dx * dx + dy * dy);
// 归一化方向
let dirX = dx / dist;
let dirY = dy / dist;
// 速度向目标方向插值,创造自然的惯性
fish.vx += (dirX * fish.maxSpeed - fish.vx) * 0.08;
fish.vy += (dirY * fish.maxSpeed - fish.vy) * 0.08;
// 更新位置
fish.x += fish.vx * dt;
fish.y += fish.vy * dt;
这里有一个关键参数:0.08,它叫“跟随系数”。系数越大,鱼转向越快,操控越灵敏;系数越小,鱼越“飘”,惯性感越强。我用0.08作为默认值,既保留了一定的水下惯性,又不至于让玩家觉得难以控制。
你可能会想,直接让鱼的位置等于目标点不就行了,比这简单多了。但那种做法会丢失“游泳”的感觉。鱼是绕着一个目标方向不断微调的,尤其在玩家快速移动手指时,鱼会划出一道优美的弧线,这个弧线就是惯性的功劳。
2.2 碰撞检测与吞噬判定:圆与圆的精准计算
鱼在整个游戏里都被抽象成圆,碰撞检测自然也采用圆与圆之间的判定。这个是最基础的碰撞算法,初中数学就能看懂:两个圆的圆心距离小于两个圆的半径之和,就意味着碰撞发生。
但这里有个很容易踩的坑——鱼的大小判定不能只用半径这个原始值。绘制鱼的时候,鱼的身体是个椭圆,鱼尾、鱼鳍都有额外的视觉范围。如果直接用半径做判定,会感觉“明明擦到了边缘却没吃掉对方”,或者“看起来还离着一段距离竟然吃了”。我建议引入一个“有效吞噬半径”概念:判定碰撞时使用原始半径的0.8倍作为有效判定尺寸,这样视觉上更贴近玩家的认知,也避免了边界判定过于苛刻带来的挫败感。
在代码里实现时,计算圆心距离使用的是平方距离,开根号能省则省,这在大量碰撞检测时能显著降低计算量:
javascript复制// 计算两个圆心的平方距离
let dx = fishA.x - fishB.x;
let dy = fishA.y - fishB.y;
let distSq = dx * dx + dy * dy;
// 有效碰撞半径
let eatRadiusA = fishA.radius * 0.8;
let eatRadiusB = fishB.radius * 0.8;
let minDist = eatRadiusA + eatRadiusB;
// 判定
if (distSq < minDist * minDist) {
// 发生碰撞
}
判定碰撞之后,还要判断谁吃谁。规则是:如果鱼A的有效尺寸明显大于鱼B的有效尺寸,比如A的半径是B的1.1倍以上,那么A吃掉B成立;反之则B吃掉A。玩家如果撞上比自己大1.1倍以上的鱼,游戏结束。
还有一类特殊情况要处理:两个AI鱼碰撞时怎么办。我选择只让更大的AI吃掉更小的AI,这条规则让整个海域会持续发生“大鱼吃小鱼”的事件,画面更生动,同时也避免了两条同体型鱼互吃导致的死锁。
2.3 AI鱼行为:漫游、追击与逃跑
AI鱼的行为是整个游戏生命力的关键。如果所有鱼都只会直愣愣地朝一个方向游,这个海就死气沉沉。我在设计AI时给每条鱼设定了三种状态:漫游、追击和逃跑。
漫游状态是AI鱼的默认状态。每条AI鱼有一个随机的目标方向和随机速度,每运行一段时间就会重新随机选择一个方向,模拟鱼群在水里漫无目的地游动。为了实现“漫游”的平滑转向,我不会直接改变鱼的移动方向,而是用一个当前方向向量和目标方向向量做插值,效果类似于玩家鱼的惯性控制。
追击和逃跑则需要依靠AI鱼的感知范围。每条AI鱼都有自己的视野半径,视野内部会出现“更小的鱼”和“更大的鱼”两种目标。如果视野范围内存在比自己小一截的鱼,AI就切换为追击状态,朝那个小鱼的方向游过去;如果视野范围内存在比自己大一截的鱼,AI就切换为逃跑状态,朝远离那个大鱼的方向拼命游。
这里需要处理的一个重要细节是:当视野内同时存在“小鱼”和“大鱼”时,逃跑优先级要高于追击。现实中任何生物都会先保命,这一逻辑放进游戏里也能有效减少AI鱼的无脑送死行为,让整个生态看起来更合理。
AI计算的目标方向会临时覆盖漫游方向,但一旦目标从视野中消失,AI会重新进入漫游状态。为了让行为更可预测,我还会让每条AI鱼在追击或逃跑时保持一定的随机抖动,避免所有AI鱼画着完全相同的路线移动,看起来像被同一套代码驱动的机器人。
2.4 体型成长与难度曲线
大鱼吃小鱼的核心快感,来自于“越吃越大”的正反馈。我在设计成长曲线时采用了一个相对平缓的指数增长:
javascript复制// 玩家吃掉一条鱼后,半径的增长公式
player.radius = Math.sqrt(player.radius * player.radius + eatenFish.radius * eatenFish.radius * 0.3);
这个公式的思路是:面积的增加量跟被吃掉鱼的面积成正比,但只吸取一部分“营养”。这样做的好处是,玩家在前期体型小时吃一条鱼会看到很明显的外形变化,但在后期体型大时,单吃一条小鱼几乎看不出变化,必须不断猎食更大目标才能继续成长。
与成长曲线对应的,是游戏难度的动态平衡。游戏开始时海域里绝大多数鱼都是小鱼,大鱼比较稀少;随着玩家体型增长,系统会逐渐提高更大体型的鱼的出现概率。我用一个“威胁系数”来驱动生成逻辑:威胁系数跟玩家尺寸曲线绑定,玩家越大,系统生成的大鱼比例越高,让玩家始终有一种“环顾四周总有危险”的紧张感。
这个设计让整个游戏形成了一个非常自然的节奏:刚开局时玩家轻松猎杀小鱼,快速建立信心;中期会遇到少量威胁,开始考虑“先吃小的还是避开大的”;后期体型庞大,虽然能猎杀绝大多数目标,但海域中也会出现比你更大的存在,每次碰撞都是一次心跳加速。好的“大鱼吃小鱼”作品,玩的就是这种压迫感和成长感的交替。
3. 实操过程与核心环节实现
3.1 项目基础结构与初始化
整个项目我建议放在一个目录下面,文件结构非常简洁,不需要npm、不需要构建工具,纯静态文件就能运行:
code复制fish-game/
├── index.html
├── style.css
├── game.js
index.html里只需要放一个Canvas画布元素。style.css里设置页面背景和Canvas居中显示。game.js是主逻辑文件,所有类的定义、游戏循环、事件监听全都放这里。
游戏初始化的流程我这样安排:页面加载完成后,先初始化游戏实例,然后生成初始鱼群,绑定输入事件,启动游戏帧循环。这里会踩一个新人常见的坑:不要在游戏循环里频繁去获取DOM元素或调用getContext,这样性能开销非常大。正确做法是页面加载时只初始化一次Canvas上下文,之后每一帧直接操作。
javascript复制const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// 初始化画布尺寸,适配设备
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// 相机跟随逻辑中会用到新的中心点
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
3.2 Fish类:一条鱼的完整生命周期
Fish类是游戏里最核心的实体。它承载了位置、速度、体型、行为状态、绘制逻辑所有内容。我这个项目里把所有鱼都统一成一个Fish类,通过一个isPlayer属性区分玩家鱼和AI鱼,这样代码复用率最高,也不用单独维护一套玩家逻辑和一套AI逻辑。
javascript复制class Fish {
constructor(x, y, radius, isPlayer) {
this.x = x;
this.y = y;
this.vx = 0;
this.vy = 0;
this.radius = radius;
this.isPlayer = isPlayer;
this.maxSpeed = isPlayer ? 4.5 : this._initSpeed(radius);
this.direction = Math.random() * Math.PI * 2;
this.targetDirection = this.direction;
this.behavior = 'wander';
this.targetFish = null;
this.turnTimer = 0;
this.color = this._initColor();
this.baseRadius = radius;
}
_initSpeed(radius) {
// 速度与体型成反比,基础速度4.5,越大越慢
return 4.5 * Math.pow(10 / radius, 0.35);
}
update(dt, player) { /* 后续详解 */ }
draw(ctx) { /* 绘制的核心逻辑 */ }
}
构造函数里有个值得注意的_initSpeed函数,它用了幂指数为0.35的反比公式。这个指数不能取得太高,否则大鱼会慢到几乎静止,游戏体验很差;也不能太低,否则大鱼依然飞快,平衡性失控。0.35是我经过多轮试玩后的经验值。
3.3 玩家控制与AI行为更新逻辑
update方法决定了这条鱼这帧怎么移动。玩家鱼和AI鱼会走两条不同的计算路径,但最终运动学部分完全一致。先看玩家控制:
javascript复制// 玩家控制:向鼠标目标点移动
if (this.isPlayer) {
let targetX = mouse.x;
let targetY = mouse.y;
let dx = targetX - this.x;
let dy = targetY - this.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 2) {
let dirX = dx / dist;
let dirY = dy / dist;
this.vx += (dirX * this.maxSpeed - this.vx) * 0.08;
this.vy += (dirY * this.maxSpeed - this.vy) * 0.08;
}
}
AI鱼的行为逻辑大概这样,核心就是上文提到的三种状态切换:
javascript复制// AI行为状态切换
if (!this.isPlayer) {
if (this.targetFish && this.targetFish.alive) {
let reactDist = this._distanceTo(this.targetFish);
if (this.behavior === 'flee') {
if (reactDist > this.radius * 10) this.targetFish = null;
} else if (this.behavior === 'chase') {
if (reactDist > this.radius * 8) this.targetFish = null;
}
}
if (!this.targetFish) {
this._scanBehavior(player); // 扫描视野范围,决定追击还是逃跑
}
if (this.behavior === 'wander') {
this.turnTimer -= dt;
if (this.turnTimer <= 0) {
this.targetDirection = Math.random() * Math.PI * 2;
this.turnTimer = 1 + Math.random() * 3;
}
// 平滑转向
let diff = this.targetDirection - this.direction;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
this.direction += diff * 2 * dt;
this.vx = Math.cos(this.direction) * this.maxSpeed;
this.vy = Math.sin(this.direction) * this.maxSpeed;
}
}
_scanBehavior方法会遍历附近所有鱼,找出视野内“比自己大”和“比自己小”的目标,再根据逃跑优先规则决定当前行为。这里我省略了遍历优化细节,如果鱼的数量在几十条左右,直接全量遍历没问题;如果鱼的数量上百条,我会建议用四叉树或网格空间划分,这个在后面的性能部分细聊。
3.4 鱼的绘制:让画面活起来的细节
画一条好看的鱼,是这个项目最能让新手获得成就感的环节。我先给鱼画一条流线型的身体,再加一条摆动的尾巴,眼睛也要装饰得灵气一些。绘制时我会根据鱼的方向角把Canvas坐标系旋转一下,这样鱼的朝向永远和前进方向一致,画面会非常自然。
javascript复制draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
// 计算朝向角度
let angle = Math.atan2(this.vy, this.vx);
ctx.rotate(angle);
// 身体:椭圆
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.ellipse(0, 0, this.radius * 1.2, this.radius * 0.75, 0, 0, Math.PI * 2);
ctx.fill();
// 尾巴:以身体末端为锚点的三角形
let tailBase = this.radius * 1.2;
let tailLength = this.radius * 0.7;
ctx.beginPath();
ctx.moveTo(tailBase, 0);
ctx.lineTo(tailBase - tailLength, -this.radius * 0.5);
ctx.lineTo(tailBase - tailLength, this.radius * 0.5);
ctx.closePath();
ctx.fill();
// 眼睛
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(this.radius * 0.8, -this.radius * 0.25, this.radius * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#222222';
ctx.beginPath();
ctx.arc(this.radius * 0.9, -this.radius * 0.25, this.radius * 0.12, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
为了让鱼群更有活力,我还会给每条鱼的身体颜色增加一些随机亮度变化,再在鱼身上加一些深浅不一的斑点。这样每一条鱼看起来都是独立的个体,而不是同一个模板复制出来的,画面质感会提升一个明显的档次。
3.5 相机跟随与海洋背景
由于游戏地图比屏幕大得多,需要实现一个“镜头跟随玩家”的效果。实现方式非常直接:在绘制所有鱼之前,把Canvas坐标系统平移到相机的偏移量上。相机的位置指向玩家的位置,但加上一定量的平滑跟随,让镜头不会生硬地钉在玩家身上。
javascript复制// 相机平滑跟随
camera.x += (player.x - camera.x) * 0.1;
camera.y += (player.y - camera.y) * 0.1;
ctx.save();
ctx.translate(-camera.x + canvas.width / 2, -camera.y + canvas.height / 2);
// 绘制所有鱼
fishList.forEach(fish => fish.draw(ctx));
// 还原变换
ctx.restore();
背景层我用的是网格线加气泡粒子。网格线是一层淡淡的水平波纹,模拟海水流动感;气泡每隔一段时间会在随机位置生成,缓慢向上漂浮。这些细节看起来不起眼,但对游戏氛围的提升非常明显,让玩家往任何一个方向游都能感受到“水是活的”。
3.6 吃鱼特效与分数反馈
游戏里最解压的瞬间就是吃掉一条鱼之后“啪”一下的感觉。我会在被吃鱼的位置生成一串气泡粒子,并在玩家控制条上方显示“+X”的字样提示。粒子用数组维护,每一帧更新位置和透明度,生命结束就从数组里删除。
分数系统方面,我给每条鱼设定一个跟体型相关的分值,吃掉的鱼会累加到总分上。UI界面左上角显示当前分数和玩家体型等级,右上角显示当前海域中最大鱼的信息,给玩家一个“前方危险”的心理预期。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
整个开发过程踩过不少坑,我把最容易碰到的问题整理成了一张表格,方便你排查时直接对照:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 鱼移动方向抖动严重 | 目标方向存在数值抖动 | 给目标方向插值增加阈值判断,距离过近时忽略更新 |
| 碰撞偶尔穿透 | 帧率不稳定导致位移过大 | 使用基于deltaTime的运动学计算,或做连续碰撞检测(即在两帧位置之间额外检测一次) |
| 鱼群运行一段时间后大量叠加 | 缺少分离逻辑 | AI鱼之间增加分离力,参考Boids模型中的separation行为 |
| 玩家越变越大后帧率骤降 | 每帧绘制了过多鱼 | 对象池回收死亡鱼,限制同屏数量,并考虑离屏剔除 |
| 触摸屏上玩家鱼不跟手 | 触摸坐标未考虑canvas偏移 | 获取触摸点时减去canvas的getBoundingClientRect偏移 |
| 鱼会游出世界边界消失 | 缺少边界约束 | 给世界设定边界,鱼游到边界时反弹或重置方向 |
4.2 手感Bug实录:鱼的惯性过强
第一次调试完手感后,我的第一反应是“鱼滑得像抹了油的冰块”。问题出在我把跟随系数设成了0.03,加速过慢,鱼在玩家快速移动鼠标时转不过弯来,操控起来非常迟钝。后来的排查方向是把跟随系数逐步往上调,直到0.08时发现手感趋于平衡,鱼不会瞬间掉头,但也不会出现转不过弯的感觉。
这里有一个实用技巧:如果你需要随时调整手感,可以把跟随系数直接暴露成全局变量,然后在浏览器的开发者工具控制台里实时修改测试,直到找到手感最佳的值再固化到代码里。这比改完参数刷新页面重新试要高效得多。
4.3 性能优化:从30fps到60fps
前期测试时,同时存在150条鱼时帧率只有30fps左右,玩家操作有明显的延迟感。排查后发现最耗时的两个部分是:每帧绘制了大量离屏鱼,以及每帧全量遍历所有鱼做碰撞检测。
针对绘制问题,我加了离屏剔除(viewport culling),只绘制相机视野范围内的鱼,视野之外的鱼直接跳过。这个策略非常见效,因为一条鱼在屏幕上通常只占很小一片区域,大部分鱼都处在画面之外。
针对碰撞检测,虽然150条鱼全量遍历不过1万次比较,开销有限,但为了将来扩展到更多实体,我还是做了网格空间划分。把整个地图分成若干个格子,每条鱼只和自己所在格子以及相邻格子里的鱼做碰撞检测,计算量从O(n²)降到接近O(n),实测在300条鱼时依然能稳定60fps。
4.4 游戏难度失衡的调整实录
早期版本里,玩家成长到中等大小后几乎不会死,满地图都是比自己小很多的鱼,过于无聊。另一个极端版本是大鱼密度太高,玩家初期二十秒内就遇到了三米大鲨鱼,挫败感极强。
调整思路是用一个动态生成器来管理鱼群。生成器每隔一段时间检查当前鱼群数量和玩家尺寸,根据玩家尺寸计算当前的安全等级,然后再决定生成鱼的体型分布。大鱼出现概率会随着玩家体型增长而提高,但始终保持在“存在挑战感”合理范围内。这个生成器本质上是在做动态难度调整,我把调整函数写得平滑一些,避免突然大量生成大鱼对玩家进行“恶意袭击”。
调整之后,游戏的节奏变得非常顺滑:前期轻松爽快,中期有悬念,后期有压迫感。你自己试玩时也会很容易判断出难度曲线是否合理——如果玩家失败后马上想重开一局的冲动强烈,说明难度梯度到位了。
4.5 一个整天困惑新手的细节:碰撞判定到底该用哪个半径
这个坑我必须单独拿出来说,因为它迷惑性极强。鱼的radius在代码里既能表示视觉大小,又能表示碰撞大小,初版我直接用这个值来判定吞噬比例,结果出现了大量“看起来鱼叠在一起了,但判定没有生效”的情况。原因就是视觉上鱼身体是接近椭圆形的,但碰撞用的是圆形判定,而椭圆的中心区域和圆形的边界之间有明显的空档,这就造成了视觉和碰撞不一致。
我的解决方法是把“视觉半径”和“碰撞半径”分开计算。视觉半径负责画图,决定鱼看起来多大;碰撞半径负责判定,是视觉半径的0.75倍到0.8倍。这样玩家会感觉自己是在“用鱼头的中心区域”去碰别人,而不是拿视觉上比自身宽得多的身体边缘去蹭,整体手感更干净利落。
4.6 代码结构上的常见拉扯:全局变量vs类封装
小游戏项目的一大争议点是:逻辑这么简单,直接裸写全局函数就好,何必大动干戈写类。我自己写这个项目时也经历过这样的拉扯。最后的结论是:对于这种单页面、功能单一的小项目,用类来组织代码确实会让文件行数变多,但每一部分都非常清晰。当你需要加一个“金币掉落”系统,或者加一个“进化”系统时,只需在Fish类旁边加一个新类,在游戏主循环里多调用一个新接口即可,不需要动现有代码。这种低成本扩展性,是裸写全局变量无法给你的。
同时我也要提醒一句克制:不要为了追求“设计模式”而过度抽象。有人会在这个项目里引入事件系统、状态机框架、组件系统,这纯属杀鸡用牛刀。游戏小,代码写得直接一点、简单一点,反而更好维护。核心是把“每一帧发生了什么”讲清楚。
5. 实际运行效果与体验优化建议
项目开发完成后,我分别在桌面Chrome和移动端Safari上都跑了一遍。桌面端用鼠标控制,鱼的跟随感稳定,画面流畅;移动端用触摸控制,做了一点小改动,触摸点的坐标转换和鼠标逻辑稍有不同,但手感一样顺滑。我在移动端还额外把Canvas尺寸设置成适应屏幕宽度和高度,屏幕旋转时也能自动适配。
除了核心技术部分,我还想把这个项目的几个体验优化细节分享一下,它们看起来不起眼,但直接影响玩家对游戏“专业感”的感知。
第一是玩家鱼的“受击反馈”。当玩家鱼附近出现比自己大很多的敌人时,画面边缘会泛出淡淡的红色警示,同时玩家的移动速度会有小幅降低,模拟紧张状态下的僵硬感。这个设计虽然只是改变了屏幕边缘的一个渐变遮罩,但对紧张氛围的提升非常有效。
第二是吞噬时的“屏幕冲击”。当玩家吃掉一条size很大的鱼时,画面中心会出现一个极短暂的缩放脉冲效果,类似镜头被震了一下。这个效果实现起来极其简单:全局scale变量从1.01缓缓回到1.0即可,但带来的打击感加成远超代码量。对动作游戏来说,打击感往往来源于这种微小的视觉脉冲。
第三是音乐音效。考虑到H5游戏加载速度,我建议用Web Audio API在浏览器里实时合成简单的音效,而不是引入音频文件。吃鱼时的咕噜声、警报声、升级时的悦耳提示音,都能用几行代码完成生成。如果不想在音效上花时间,至少要让吃鱼有视觉反馈,否则会显得特别干瘪。
第四是重新开始的对局引导。游戏结束后不要直接跳到冷冰冰的“Game Over”界面,我会把玩家是否存活跟成绩一起显示,同时展示“本局最大体型”和“总共吞噬数量”两个数据,让玩家有复盘动力,而不是单纯觉得自己输了。
最后再分享两个小技巧
第一,如果你想把这类H5游戏分享给朋友玩,不必去买服务器,直接把它当作单个HTML文件发出去,对方用浏览器打开就能跑。这个项目全部代码加起来不到30KB,一张图片都没用到,非常适合这种轻量级分享。
第二,等你把这个基础版跑通了,试着在现有架构上做一个“玩家如果吃了特定数量的同级鱼就能进化形态”的升级系统。比如从小丑鱼进化到金枪鱼,最终进化成鲸鲨,每种形态不仅有视觉差异,还有移动速度和体型上的实质变化。这会让游戏的可玩性再上一个大台阶,而且基于现在的架构,实现成本很低。
我个人的操作体会是,越简单的游戏越考验细节打磨。大鱼吃小鱼这种品类的核心乐趣不在规则复杂度,而在于玩家每一次咬下目标时的那一下快感。把移动手感、碰撞判定的边缘、AI行为的合理性、成长曲线的节奏这些细节逐一调到位,你就能把一个看似简单的题材做出远超预期的品质。希望这篇记录对你的项目能有些实在的帮助。
