恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
接水果小游戏:用HTML、CSS与JavaScript实战练手前端基础
首页
资讯中心
/
接水果小游戏:用HTML、CSS与JavaScript实战练手前端基础
接水果小游戏:用HTML、CSS与JavaScript实战练手前端基础
发布时间:2026/10/16 10:32:55
作为前端练手项目“接水果”是很多人接触 HTML、CSS、JavaScript 三件套时绕不开的一个小游戏。它麻雀虽小却把 DOM 操作、事件监听、定时器、碰撞检测这些前端基础知识点全串起来了。我最初写这个项目是在学完基础语法之后当时正好想找一个“能把三件套都用上、又不用太复杂”的练手目标。做了两三版之后我发现这个项目对新手特别友好因为它的核心逻辑足够清晰但细节又足够多能让你踩到不少真实的 bug比如坐标算错、定时器叠加、碰撞判定不准这些。这个项目也适合两类人一类是刚学完前端基础、想用完整项目巩固一遍的人另一类是准备面试前想快速做一个可演示的实战 Demo 的求职者。整个游戏的核心只有几个部分随机生成下落的水果、玩家用鼠标控制篮子接住、判定接住或漏掉、更新分数和生命。本文会把完整代码放在后面并逐步拆解每一处设计思路和踩坑点如果你准备复现直接照着抄就能跑起来。1. 项目整体设计与技术方案拆解1.1 这个游戏的本质一个往复运行的判定循环接水果不是复杂的游戏引擎项目但它的底层结构和你玩过的任何大游戏都是一样的。一个游戏在本质上就是“循环 状态更新 渲染”。拿这个项目来说每一帧做的事可以拆成这样计算所有水果的新位置往下落多少判断每个水果是否被篮子接住或者漏到了底部更新得分和生命值在页面上重新渲染所有元素的位置这个循环会一直运行直到生命值归零。理解了这个模型你就知道写游戏不是“写一段顺序执行的脚本”而是“写一个不断被唤醒的状态机”。我建议你用这个思路去设计变量和函数后面维护起来会舒服很多。1.2 为什么用 DOM CSS 定位而不是 Canvas项目标题里明确写了 htmlcssjavascript所以主体方案当然是用 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 计分和生命控制状态更新的边界条件计分和生命控制的核心是“这个水果到底有没有被接住”。这里有个容易乱掉的地方就是水果从生成到消失可能经历的状态还在空中下落已经进入篮子所在区域但 x 方向上没重叠已经进入篮子所在区域并且 x 方向重叠判定接住已经越过游戏区底部判定漏掉状态 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 langzh-CN head meta charsetUTF-8 title接水果小游戏/title link relstylesheet hrefstyle.css /head body div idgameArea div idbasket/div /div div idscoreBoard得分: 0/div div idlifeBoard生命: 3/div button idstartBtn开始游戏/button script srcgame.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 srcgame.js这种相对路径在所有静态托管平台上都能用但如果你写成/game.js这种绝对路径在某些子目录部署时就会引用失败。这是新手比较容易忽略的细节。最后分享一点我的体会这个接水果项目我前后写过几个版本。第一次写的时候我只想着把功能跑通代码全挤在一个文件里变量也随意。后来重新写的时候我把逻辑拆成了创建、移动、碰撞、计分几个模块才发现这样无论是调试还是加新功能都轻松得多。很多新手喜欢一上来就追求炫酷效果其实先把基础逻辑理清楚再慢慢迭代才是更踏实的路线。如果你照着这份代码做完了建议先改一个微小的功能试试手比如把水果改成不同的表情符号或者让篮子能左右自动移动。把某一处细节改成你想要的样子你才会真正理解这个项目的结构。这个小游戏虽然简单但它值得你认真做一遍。