☰
接水果小游戏:用HTML、CSS与JavaScript实战练手前端基础
2026/10/17 13:00:57 网站建设 项目流程

作为前端练手项目,“接水果”是很多人接触 HTML、CSS、JavaScript 三件套时绕不开的一个小游戏。它麻雀虽小,却把 DOM 操作、事件监听、定时器、碰撞检测这些前端基础知识点全串起来了。我最初写这个项目是在学完基础语法之后,当时正好想找一个“能把三件套都用上、又不用太复杂”的练手目标。做了两三版之后,我发现这个项目对新手特别友好,因为它的核心逻辑足够清晰,但细节又足够多,能让你踩到不少真实的 bug,比如坐标算错、定时器叠加、碰撞判定不准这些。

这个项目也适合两类人:一类是刚学完前端基础、想用完整项目巩固一遍的人;另一类是准备面试前想快速做一个可演示的实战 Demo 的求职者。整个游戏的核心只有几个部分:随机生成下落的水果、玩家用鼠标控制篮子接住、判定接住或漏掉、更新分数和生命。本文会把完整代码放在后面,并逐步拆解每一处设计思路和踩坑点,如果你准备复现,直接照着抄就能跑起来。

1. 项目整体设计与技术方案拆解

1.1 这个游戏的本质:一个往复运行的判定循环

接水果不是复杂的游戏引擎项目,但它的底层结构和你玩过的任何大游戏都是一样的。一个游戏在本质上就是“循环 + 状态更新 + 渲染”。拿这个项目来说,每一帧做的事可以拆成这样:

  1. 计算所有水果的新位置(往下落多少)
  2. 判断每个水果是否被篮子接住,或者漏到了底部
  3. 更新得分和生命值
  4. 在页面上重新渲染所有元素的位置

这个循环会一直运行,直到生命值归零。理解了这个模型,你就知道写游戏不是“写一段顺序执行的脚本”,而是“写一个不断被唤醒的状态机”。我建议你用这个思路去设计变量和函数,后面维护起来会舒服很多。

1.2 为什么用 DOM + CSS 定位,而不是 Canvas

项目标题里明确写了 html+css+javascript,所以主体方案当然是用 DOM 元素做游戏对象,用 CSS 做样式和布局。不过很多人在做类似项目时会纠结一个问题:同类游戏很多人用 Canvas 写,我为什么推荐新手先用 DOM?

区别主要在三点:

  • DOM 方案的调试更直观。每个水果都是一个真实的 div,你可以在浏览器开发者工具里直接查看它的位置、尺寸、类名,也能直接改属性看效果。这对刚接触前端的人来说非常友好。
  • DOM 方案不需要理解图形上下文和重绘机制。Canvas 是一个位图画布,你说到底是在“画”图像,而不是在“创建”物体。很多初学者刚上手 Canvas 会被坐标系、渲染循环、清屏逻辑绕晕。
  • 但 DOM 方案的性能上限低于 Canvas。当页面上对象足够多时,频繁操作 DOM 会导致重排和卡顿。接水果游戏的物体数量通常在二三十个以内,完全在 DOM 的舒适区里。

如果以后你想做粒子特效、碰撞弹跳、大量敌人的游戏,再迁移到 Canvas 是完全合理的过渡路线。

1.3 游戏状态与数据结构设计

写代码之前,先想清楚需要维护哪些全局状态。在这个项目里,我用了这组变量:

let score = 0; // 分数 let life = 3; // 剩余生命 let fruits = []; // 当前场上的水果数组 let moveTimer = null; // 下落循环定时器 let createTimer = null; // 生成水果定时器 let gameOverFlag = false; // 游戏是否结束

把fruits设计成数组很重要。你可以把每个水果理解为一个“游戏实体”,它既是一个 DOM 元素,又携带了自己的下落速度、当前坐标等数据。把这些实体放在一个数组里统一管理,每一帧遍历数组做位置更新和碰撞判断,比散落的变量要清晰得多。这也是后面做碰撞检测的基础。

2. 核心细节解析与实操要点

2.1 水果的生成:随机性应该怎么设计

水果生成的随机性体现在三个地方:类型、横向位置、下落速度。类型不同,我会用不同的背景色来表示,比如红色代表苹果、黄色代表香蕉、绿色代表西瓜。纯粹的视觉差异也能让玩家有目标感,不至于全场都是一个颜色的球。

代码上,我定义了一个水果配置数组,生成水果时从里面随机取一个:

const fruitTypes = [ { name: 'apple', color: '#e6352b' }, { name: 'banana', color: '#f6c640' }, { name: 'melon', color: '#35b45a' } ]; function createFruit() { const fruit = document.createElement('div'); fruit.className = 'fruit'; const type = fruitTypes[Math.floor(Math.random() * fruitTypes.length)]; fruit.style.backgroundColor = type.color; const maxLeft = gameArea.clientWidth - FRUIT_SIZE; fruit.style.left = (Math.random() * maxLeft) + 'px'; fruit.style.top = '0px'; fruit.dataset.speed = (2 + Math.random() * 3).toFixed(2); gameArea.appendChild(fruit); fruits.push(fruit); }

注意maxLeft的计算:水果的宽度是 30px,随机 left 的范围应该是0 到 游戏区宽度 - 30,否则水果会生成到游戏区边框外面去。这个细节很基础,但刚写的时候很容易漏掉。动速度时我用的是2 + Math.random() * 3,每秒移动速度大约在 2~5 个单位范围内,这个范围既能让玩家来得及反应,又不至于太无聊。

生成间隔我建议用固定定时器,比如每 700ms 生成一个。如果你想让节奏更丰富,可以换成随机间隔,这个我在后面优化部分会提到。

2.2 篮子移动:鼠标坐标换算的坑

篮子跟随鼠标移动是第一个容易踩坑的点。很多新手会直接写basket.style.left = e.clientX + 'px',然后发现篮子跑到游戏区域外面去了,或者位置总差一截。

原因是:e.clientX是鼠标相对浏览器窗口左上角的坐标,而basket的left是相对游戏区父元素的坐标。两者起点不同,不能直接画等号。

正确的做法是先拿到游戏区相对窗口的位置,做一次坐标换算:

gameArea.addEventListener('mousemove', function (e) { const rect = gameArea.getBoundingClientRect(); let x = e.clientX - rect.left - basket.offsetWidth / 2; x = Math.max(0, Math.min(x, gameArea.clientWidth - basket.offsetWidth)); basket.style.left = x + 'px'; });

rect.left就是游戏区左边缘相对窗口的偏移。basket.offsetWidth / 2是为了让篮子中心对准鼠标光标,而不是篮子的左边缘对准光标,不然手感会很奇怪。

Math.max和Math.min的组合是为了把x限制在游戏区内,防止篮子移出边界。这行边界约束在同类项目中几乎是标配,最好养成习惯。

2.3 碰撞检测:AABB 矩形象相交判定

接水果的碰撞检测本质是二维矩形碰撞。游戏中的每一个水果大致是一个正方形区域,篮子也是一个矩形区域。要判断两者是否相撞,用的是一种很经典的算法:AABB(轴对齐包围盒)判定。

理解这个算法有个生活化的类比:想象两个人站在地图上,每个人占一块方格子。两个人“相遇”当且仅当两个格子在横向和纵向同时有重叠。横向重叠靠左边和右边交叉判断,纵向重叠靠上边和下边交叉判断。这个判定在代码里的样子是:

function checkHit(fruit, basket) { const fruitLeft = parseFloat(fruit.style.left); const fruitTop = parseFloat(fruit.style.top); const basketLeft = basket.offsetLeft; const basketTop = basket.offsetTop; return ( fruitLeft + FRUIT_SIZE > basketLeft && fruitLeft < basketLeft + BASKET_WIDTH && fruitTop + FRUIT_SIZE > basketTop && fruitTop < basketTop + BASKET_HEIGHT ); }

我建议所有刚学碰撞检测的人先把这张矩形图在脑海里画出来。你不需要背公式,只要记住“两个矩形分别从横向纵向判断重叠区域是否为空”这个思路,后面遇到任何 AABB 场景都能自己推出来。

如果你看过其他版本的接水果代码,有些人会用“圆心距离小于半径之和”来判断。那种方式不适合这个项目,因为篮子是矩形而不是圆形,用 AABB 更自然。当然,你也可以把水果看成圆形,做圆和矩形的碰撞检测,但复杂度会增加不少,对新人不划算。

2.4 计分和生命控制:状态更新的边界条件

计分和生命控制的核心是“这个水果到底有没有被接住”。这里有个容易乱掉的地方,就是水果从生成到消失可能经历的状态:

  1. 还在空中下落
  2. 已经进入篮子所在区域,但 x 方向上没重叠
  3. 已经进入篮子所在区域,并且 x 方向重叠,判定接住
  4. 已经越过游戏区底部,判定漏掉

状态 1 最好处理,直接更新位置。状态 2 和 3 需要先判断是否接住,没接住的话继续下落。状态 4 要扣生命并删除水果。

所以我在updateFruits里的逻辑顺序很讲究:先移动水果,接着判断是否进入“判定区”,然后尝试判定接住,最后再看是否超出底部。如果顺序反了,比如先判断是否超出底部再判断接住,就有可能出现“水果明明已经碰到篮子,但因为坐标已经越过底线而被当成漏掉”的误判。

3. 完整代码实现与运行说明

3.1 先跑起来:文件结构和运行方式

做这个项目只需要三个文件:index.html、style.css、game.js,放在同一个文件夹里。直接双击打开index.html就能玩,不需要装依赖、配构建工具。这也是纯前端项目最大的优点。

如果双击打开后发现页面空白,多半是文件路径或 script 标签引用的问题。检查一下三个文件是否在同一目录,以及game.js是否是在</body>之前引入的。

提示:game.js必须在 DOM 渲染完成后再执行,否则document.getElementById('gameArea')会拿到 null。放到</body>前面是最稳妥的写法,比放在<head>里加defer更直观,新手也更容易理解。

3.2 HTML 骨架和 CSS 样式解析

HTML 结构非常简单,就是游戏区、篮子、得分板、生命值和一个开始按钮。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>接水果小游戏</title> <link rel="stylesheet" href="style.css"> </head> <body> <div id="gameArea"> <div id="basket"></div> </div> <div id="scoreBoard">得分: 0</div> <div id="lifeBoard">生命: 3</div> <button id="startBtn">开始游戏</button> <script src="game.js"></script> </body> </html>

样式部分重点在于游戏区的定位和边界处理。gameArea需要position: relative,这样内部水果和篮子才能用absolute定位相对于它来定位。overflow: hidden可以防止水果掉出游戏区边界时显示到外面。

* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #f0f4f8; font-family: Arial, sans-serif; user-select: none; } #gameArea { width: 400px; height: 600px; margin: 30px auto; position: relative; border: 2px solid #ccc; background: linear-gradient(#aee1ff, #f7f7f7); overflow: hidden; cursor: default; } #basket { position: absolute; width: 80px; height: 30px; bottom: 10px; left: 160px; background: #8b5a2b; border-radius: 5px; } .fruit { position: absolute; width: 30px; height: 30px; border-radius: 50%; } #scoreBoard { position: absolute; top: 10px; left: 10px; font: bold 16px sans-serif; } #lifeBoard { position: absolute; top: 10px; right: 10px; font: bold 16px sans-serif; } #startBtn { display: block; margin: 20px auto; padding: 10px 30px; font-size: 16px; cursor: pointer; }

这里有个小细节:scoreBoard和lifeBoard我放在body下,而不是gameArea里。它们用了position: absolute但没有设置父容器,会相对 body 定位。如果你希望它们固定在游戏区上方,也可以把它们放进gameArea里,用position: absolute定位到顶部。两种做法视觉效果差不多,看个人习惯。

user-select: none可以防止玩家在快速移动鼠标时全选页面文字,体验会好一些。

3.3 JavaScript 主逻辑完整实现

下面是完整代码。我加了行注释,方便你逐段理解。

const gameArea = document.getElementById('gameArea'); const basket = document.getElementById('basket'); const scoreBoard = document.getElementById('scoreBoard'); const lifeBoard = document.getElementById('lifeBoard'); const startBtn = document.getElementById('startBtn'); const FRUIT_SIZE = 30; const BASKET_WIDTH = 80; const BASKET_HEIGHT = 30; const BOTTOM_MARGIN = 10; const fruitTypes = [ { name: 'apple', color: '#e6352b' }, { name: 'banana', color: '#f6c640' }, { name: 'melon', color: '#35b45a' } ]; let score = 0; let life = 3; let fruits = []; let moveTimer = null; let createTimer = null; let gameOverFlag = false; function createFruit() { const fruit = document.createElement('div'); fruit.className = 'fruit'; const type = fruitTypes[Math.floor(Math.random() * fruitTypes.length)]; fruit.style.backgroundColor = type.color; const maxLeft = gameArea.clientWidth - FRUIT_SIZE; fruit.style.left = (Math.random() * maxLeft) + 'px'; fruit.style.top = '0px'; fruit.dataset.speed = (2 + Math.random() * 3).toFixed(2); gameArea.appendChild(fruit); fruits.push(fruit); } function moveBasket(e) { const rect = gameArea.getBoundingClientRect(); let x = e.clientX - rect.left - basket.offsetWidth / 2; x = Math.max(0, Math.min(x, gameArea.clientWidth - basket.offsetWidth)); basket.style.left = x + 'px'; } const catchZoneTop = gameArea.clientHeight - BASKET_HEIGHT - BOTTOM_MARGIN; function updateFruits() { if (gameOverFlag) return; for (let i = fruits.length - 1; i >= 0; i--) { const f = fruits[i]; let y = parseFloat(f.style.top); y += parseFloat(f.dataset.speed) * 3; const fruitLeft = parseFloat(f.style.left); const basketLeft = basket.offsetLeft; const isCaught = y + FRUIT_SIZE >= catchZoneTop && fruitLeft + FRUIT_SIZE > basketLeft && fruitLeft < basketLeft + BASKET_WIDTH; if (isCaught) { score += 10; scoreBoard.textContent = '得分: ' + score; f.remove(); fruits.splice(i, 1); continue; } if (y > gameArea.clientHeight - FRUIT_SIZE) { f.remove(); fruits.splice(i, 1); life--; lifeBoard.textContent = '生命: ' + life; if (life <= 0) { endGame(); return; } continue; } f.style.top = y + 'px'; } } function startGame() { gameOverFlag = false; score = 0; life = 3; scoreBoard.textContent = '得分: 0'; lifeBoard.textContent = '生命: 3'; startBtn.disabled = true; fruits.forEach(f => f.remove()); fruits = []; if (moveTimer) clearInterval(moveTimer); if (createTimer) clearInterval(createTimer); moveTimer = setInterval(updateFruits, 50); createTimer = setInterval(createFruit, 700); } function endGame() { gameOverFlag = true; clearInterval(moveTimer); clearInterval(createTimer); startBtn.disabled = false; alert('游戏结束!最终得分:' + score); } basket.style.top = catchZoneTop + 'px'; gameArea.addEventListener('mousemove', moveBasket); startBtn.addEventListener('click', startGame);

这个版本有几个点需要特别说明:

catchZoneTop指的是篮子的顶部坐标。因为篮子是用bottom: 10px定位的,篮子的实际顶部位置等于游戏区高度减去篮子高度再减去底部间距。我把它提出来计算,一方面是让代码可读性更好,另一方面是后面调难度时方便统一修改。

updateFruits里的isCaught判断用的是横向 AABB + 纵向进入判定区的组合。纵向只要水果底部进入篮子顶部以下就进入接住判定,横向则严格用相交判断。这个写法在实际运行中手感比较宽松,不会让玩家觉得“明明碰到了却没接住”。

for循环倒序遍历fruits数组是有讲究的。因为在接住或漏掉时,我会用fruits.splice(i, 1)把当前水果从数组里移除。如果正序遍历,索引会因为移除而错位,可能导致某个水果被跳过。倒序删除是处理这类问题时的经典方案。

startGame做了一次完整的清理和重启。无论之前游戏是否结束,点开始都从头来。用clearInterval清掉已有的定时器,避免重复点击开始按钮后出现两个下落循环。

gameArea.addEventListener('mousemove', moveBasket)监听在游戏区域上,鼠标移出区域后篮子就不跟手,但位置会保持在移出前的最后位置。这个体验比较自然,也不会出现篮子跑出界的问题。

跑起来以后,核心逻辑是:生成水果、水果下落、碰撞判定、计分或扣命、游戏结束。整套流程的运行频率由两个定时器决定,生成间隔 700ms,移动间隔 50ms。移动间隔 50ms 意味着每秒约 20 帧画面刷新,对于这个体量的小游戏已经足够流畅。

4. 常见问题与排查技巧实录

我复现和调试这个项目时,遇到过一些很有代表性的问题,这里整理成一份速查表,方便你对照排查。

现象可能原因解决方案
水果卡在顶部不动,或很久才出现生成定时器没有启动,或createFruit中水果被创建但没有往fruits里 push检查startGame中setInterval(createFruit, 700)是否执行,打印fruits.length确认数组是否有元素
水果从游戏区右侧或底部飞出去生成水果时left没限制最大范围,或overflow: hidden没生效用gameArea.clientWidth - FRUIT_SIZE限制随机left,检查 CSS 的overflow: hidden
篮子接住了水果但分数不加碰撞判定里y + FRUIT_SIZE >= catchZoneTop和横向判断写反了在if里临时加console.log(y, basketLeft, fruitLeft)看不满足哪个条件,逐步定位
游戏结束后还有水果继续下落,扣生命卡死结束函数没清掉移动定时器,或返回后又执行了updateFruits确认endGame里clearInterval(moveTimer)执行过,并检查gameOverFlag在updateFruits开头是否拦截
重复点击“开始游戏”后水果生成速度变快startGame调用时没有清老定时器,新定时器叠加在startGame开头加clearInterval(moveTimer)和clearInterval(createTimer)
鼠标在游戏区内移动时篮子抖动每次移动都触发 DOM 重排,或监听对象写错成了document监听gameArea而不是document,如果仍然抖动可考虑加requestAnimationFrame优化
页面文字在鼠标快速移动时被选中缺少user-select: none设置在 CSS 里加body { user-select: none; }

再单独聊一个比较隐蔽的问题:当你把鼠标移出游戏区后,mousemove事件不再触发,篮子的位置就留在原地。这并不是 bug,但如果你在测试时鼠标偏到页面外面,会感觉游戏好像卡住了。实际运行体验上,我建议你把篮子移动的监听绑定在gameArea上而不是整个document上,这样玩家只有在游戏区内鼠标移动时才操作篮子,更符合直觉。

如果你在调试过程中发现“明明显示得分了,但水果还在画面上”,那说明水果元素没有从 DOM 中移除。检查f.remove()是否执行,以及splice的索引是否对应上。很多时候不是逻辑错了,而是删除分支被跳过了。

5. 从练手项目到真实项目的扩展思路

5.1 增加难度曲线

现在的版本是固定速度、固定生成间隔,玩久了会有点单调。你可以把难度跟得分挂钩:得分每增加 100 分,生成水果的间隔就缩短一点,下落速度的整体区间也往上提一点。实现方法很简单,在createFruit里用当前的score动态计算 speed 范围,在startGame里调整setInterval的间隔即可。

比如把生成间隔从固定 700ms 改成:

const interval = Math.max(250, 700 - Math.floor(score / 100) * 50); clearInterval(createTimer); createTimer = setInterval(createFruit, interval);

这样游戏的节奏会越来越快,可玩性高不少。不过要注意,interval不能无限缩小,建议设一个下限,比如 250ms,否则水果生成过快,玩家反应不过来就完全是折磨了。

5.2 增加道具和分数差异

你可以给不同颜色的水果定义不同分值,比如本来就有的三种水果分别是 10 分、20 分、30 分。再比如偶尔生成一个“炸弹”水果,接到就扣生命,增加刺激感。逻辑实现上只要在fruitTypes里增加一个score字段和一个isBomb标记,然后在updateFruits的接住分支里按类型处理即可。

这种扩展最大的价值不是玩法多有趣,而是让你练习“在已有的数据结构上扩展字段”,这是真实项目里非常常见的工作节奏。你会在改代码的过程中发现:不要把所有信息都写死在具体逻辑里,而是尽量用配置数据去表达。

5.3 用 Canvas 重写一版

当你把 DOM 版本做得足够熟练了,建议再尝试用 Canvas 重写一遍。Canvas 和 DOM 的差异不只是技术栈不同,而是思考方式不同。Canvas 里没有“水果元素”,只有每一帧重新绘制的图形。你要自己维护所有物体的坐标数组,每一帧调用clearRect清除画布,再重新绘制所有内容。这个过程会让你更深入地理解渲染循环和数据驱动。

从学习路径上讲,先把 DOM 版做熟,再写 Canvas 版,是一种很自然的进阶顺序。前者让你理解“对象怎么管理”,后者让你理解“画面怎么绘制”。

5.4 部署到线上给别人玩

这个项目做完之后,如果你想让朋友也试玩一下,可以把三个文件传到任意静态托管平台。因为项目不依赖任何打包工具,直接放置源码就能运行。这是一个很适合作为第一个线上作品的小项目。

部署时要注意一点:路径引用别写绝对路径。比如<script src="game.js">这种相对路径在所有静态托管平台上都能用,但如果你写成/game.js这种绝对路径,在某些子目录部署时就会引用失败。这是新手比较容易忽略的细节。

最后分享一点我的体会

这个接水果项目我前后写过几个版本。第一次写的时候,我只想着把功能跑通,代码全挤在一个文件里,变量也随意。后来重新写的时候,我把逻辑拆成了创建、移动、碰撞、计分几个模块,才发现这样无论是调试还是加新功能都轻松得多。很多新手喜欢一上来就追求炫酷效果,其实先把基础逻辑理清楚,再慢慢迭代,才是更踏实的路线。

如果你照着这份代码做完了,建议先改一个微小的功能试试手,比如把水果改成不同的表情符号,或者让篮子能左右自动移动。把某一处细节改成你想要的样子,你才会真正理解这个项目的结构。这个小游戏虽然简单,但它值得你认真做一遍。

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

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

立即咨询