
接水果这个项目我在不同的阶段写过好几版最早是用 DOM 元素 setInterval 做的后来接触了 Canvas 和 requestAnimationFrame又推倒重写了一版。代码不难但麻雀虽小五脏俱全游戏循环、碰撞检测、随机生成、用户输入这些前端核心概念全都能练到。如果你正愁 HTML CSS JavaScript 学完之后不知道做什么练手或者想给简历里的项目库加一个能直接运行、能讲出设计思路的小作品这个接水果游戏是很合适的切入点。不夸张地说把这个项目从头写一遍、再按照下面的思路改一遍你对 JavaScript 动画编程的理解会上一个台阶。这篇文章会把完整代码放在文后也会把每个核心机制的“为什么这么做”讲透不是那种只贴代码、让人看不懂的教程。1. 项目拆解解决什么问题以及为什么要这么设计1.1 功能定位和场景接水果游戏的核心玩法是水果从屏幕顶部掉落玩家通过鼠标或键盘控制底部的篮子左右移动在水果落地之前接住它。接住普通水果加分接住炸弹扣命生命条归零则游戏结束。这个玩法听起来简单但把它拆开看它几乎覆盖了浏览器游戏开发最基础的所有环节画面渲染、连续动画、实体生成与销毁、碰撞关系的判定、玩家的输入处理、游戏状态的管理。这些能力不管你以后是做复杂的网页交互还是做数据可视化都绕不开。适合谁来写有两种人特别适合一种是 JavaScript 语法学完了但不知道能做什么的初学者还有一种是做过静态页面、想尝试“动态画面”的前端学习者。不需要框架不需要构建工具一个文本编辑器加浏览器就能跑这种低门槛是它作为练习项目最大的优势。1.2 技术选型Canvas 还是 DOM 操作我第一版是用 DOM 做的水果就是一堆绝对定位的 div每隔一段时间改一次 top 值。当时做了七八个水果就已经觉得代码很啰嗦每个水果都要单独创建节点、更新样式、再判断删除逻辑和界面完全纠缠在一起。后来改成 Canvas 方案整个思路一下就清晰了。Canvas 本质上是一块画布你用 JavaScript 在画布上绘制每一帧的画面清屏、画背景、画水果、画篮子然后进入下一帧重新画。和 DOM 方案相比最大的区别是你的代码更像在“画画”而不是在操作一堆 HTML 元素。下面这个表格是我实测下来的对比感受不是理论空谈对比维度Canvas 方案DOM 方案画面刷新每帧整体重绘动起来顺滑频繁改样式容易引发布局抖动元素数量几十个实体同时绘制没问题十几个节点时已有卡顿风险代码结构渲染逻辑和状态逻辑分离清晰操作 DOM 的代码混杂业务逻辑碰撞检测直接用坐标计算直观得反复读取元素的 offsetTop 等属性移动端适配只需要处理画布坐标换算要处理各种触摸事件的样式和位置当然 DOM 方案也不是一无是处如果你写的是频道里只有一个不断移动的公告条那直接用 CSS 动画加 transform 就能搞定。但接水果这种需要几十个动态对象、还需要频繁判定碰撞的游戏Canvas 是更合适的选择。1.3 功能清单与实现权重动手之前先列需求这比直接写代码重要得多。我的功能清单是这样的用 Canvas 绘制游戏场景包括天空渐变、草地、漂浮的云朵底部一个篮子跟随鼠标移动也支持键盘左右键水果从顶部随机位置掉落不同种类有不同分数和大小同时掉落炸弹接住炸弹扣一条命漏接普通水果也扣一条命界面实时显示分数和剩余生命游戏结束后显示最终分数可以重新开始随分数提升水果下落速度逐渐加快生成间隔逐渐缩短这份清单里最核心、也最值得反复琢磨的是三个机制游戏循环、碰撞检测、随机掉落。下面单独用一节来拆解这三个机制。2. 核心机制原理解析循环、碰撞和随机2.1 游戏循环requestAnimationFrame 的工作方式游戏和普通网页最大的区别在于画面需要持续变化哪怕是没有任何操作的时候背景里的云朵、掉落中的水果都在动。这种持续的更新和绘制就是游戏循环。实现游戏循环的方式有好几种。setInterval 是最容易想到的但它有两个问题第一它不是为渲染而生的浏览器可能因为同时处理其他任务而延迟回调导致动画不平滑第二setInterval 的触发频率和屏幕刷新率不一定同步容易出现掉帧或者重复执行。requestAnimationFrame 是浏览器专门为动画提供的 API它的执行频率和显示器的刷新率一致通常是每秒 60 次。它会等当前帧的布局、绘制都完成了再执行下一次回调所以不会出现掉帧错乱的问题。还有一个被我忽略后来才重视的好处当浏览器标签页切到后台时requestAnimationFrame 会自动暂停不消耗资源而 setInterval 在后台仍然会傻傻地跑。使用 rAF 的灵魂在于 dt也就是当前帧与上一帧的时间差。不能假定每一帧间隔都是 16.67 毫秒因为用户的电脑性能不同浏览器也可能因为其他任务让某一帧间隔变成 30 毫秒或者更长。用 dt 来控制物体的移动距离动画速度才能和时间挂钩。比如一个水果的下落速度是 100 像素/秒那么 dt 为 20 毫秒时它就应该移动 2 像素。这里还有一个小细节必须处理当画面卡顿或者浏览器从后台切回来时dt 可能是一个异常大的值导致水果瞬间移动几百像素游戏直接判负。所以我在代码里用 Math.min(dt, 50) 给时间差设了个上限超过 50 毫秒的部分直接丢弃保证任何情况下画面都不会“抽风”。2.2 碰撞检测一个够用且宽容的判定方式碰撞检测是很多人第一次接触会发怵的东西。估计有人会想是不是要用到图形学、物理引擎完全不需要接水果这种 2D 游戏的碰撞说到底就是小学数学里的坐标比较。篮子的位置可以用一个矩形来表示左上角的 x、y 坐标以及宽 w 和高 h。水果则简化成一个圆用圆心坐标 cx、cy 和半径 r 来表示。香蕉还是苹果的形状差异在碰撞判定里根本不重要统一用圆去近似既清晰又高效。我的判定思路分成两步。第一步判断竖直方向当水果的下边缘cy r到达篮子所在的纵坐标区间时说明它已经落到了“该接住”的高度。第二步判断水平方向水果的圆心横坐标是不是落在篮子左右两边扩充了水果半径后的范围里。水平范围扩充半径是为了让判定更宽容一些玩家碰到水果的边缘也能接住体验会好很多。这个判定逻辑是逐帧执行的而篮子本身有一定高度所以只要水果的下落速度没有快到一帧越过整个篮子就不会漏判。我在代码里给 dt 设了上限、给速度设置了范围目的之一就是保证碰撞判定在绝大部分情况下是可靠的。2.3 加权随机让水果掉落有规律又不失惊喜随机让水果掉落不难Math.random() 一下就能做但如果每种水果出现的概率完全一样玩起来会很单调。我想让草莓出现得最多、西瓜出现得少、炸弹偶尔出现但总是让人紧张就需要一种带权重的随机算法。具体做法是给每种水果一个权重数字比如草莓 40、橙子 30、西瓜 20、炸弹 10。生成水果时先求出所有权重的总和然后生成一个 0 到总和之间的随机数接着用这个随机数依次减去每种水果的权重减到小于等于零时命中的就是那一种水果。这个方案十几行代码就能实现而且调整掉落概率时只需要改数字非常方便。我把炸弹的权重定得比较低因为这个游戏里炸弹的存在是增加紧张感而不是让玩家绝望。分数越高整体难度越强时炸弹的绝对出现次数也会跟着增加因为生成速度变快了这种感觉刚刚好。3. 完整代码实现与逐段解析3.1 项目文件结构为了让读者方便复现我写的代码是一个完整的 HTML 文件CSS 写在 style 标签里、JavaScript 写在 script 标签里。这样做的好处是新建一个 .html 文件双击用浏览器打开就能玩不需要启动任何本地服务器。如果后续你想把项目做得更工程化完全可以拆成三个文件index.html、style.css、game.js。这个游戏不涉及请求接口或者加载本地图片所以单文件方案在现阶段是最简洁、最不容易出错的结构。3.2 HTML 和 CSS骨架与皮肤HTML 部分需要的东西不多一个 canvas 画布、一个显示分数和生命值的 HUD 区域、两个覆盖层 overlay一个用于游戏开始前的提示一个用于游戏结束后的结算。CSS 的作用除了让页面居中、让画布有一点圆角和阴影之外最重要的一个点是 overlay 的定位。游戏容器使用相对定位canvas 和 overlay 都在容器内部overlay 用绝对定位铺满整个容器这样开始和结束界面就能准确覆盖在游戏画面上。HUD 区域需要设置 pointer-events: none不然鼠标移动到分数区域时会挡住 canvas 的 mousemove 事件这是一种非常隐蔽但容易踩的坑。3.3 JavaScript游戏逻辑的心脏JavaScript 部分按照职责可以分成几个模块第一是状态管理。游戏的状态用 state 变量来表示start 表示未开始、playing 表示进行中、over 表示已结束。帧循环里根据当前状态决定是否更新游戏逻辑这样开始和结束的时候画面会静止玩家可以在开始界面看到背景的云朵和草地但不会看到水果往下掉。第二是实体管理。我维护了一个 fruits 数组每次生成水果就往里 push 一个包含位置、速度、半径、类型等信息的对象。帧循环里遍历数组做更新生命周期结束的水果用 splice 从数组中移除。这里特别注意遍历的方向要从数组末尾往前遍历因为你删除当前元素时后面的元素下标会往前移动倒序删除就不会发生跳过元素的问题。第三是用户输入。鼠标移动时通过 getBoundingClientRect 得到画布在页面中的位置再结合画布实际宽度和 CSS 显示宽度的比例把鼠标的屏幕坐标换算成画布坐标。键盘监听则直接改篮子的 x 坐标同时做边界钳制防止篮子跑出屏幕。第四是渲染。每一帧先清空画布然后画背景、画所有水果、画篮子。画背景时用 createLinearGradient 做一个天空渐变再用几个圆形拼成云朵底部画两条绿色的色带当成草地。每个水果的绘制也有讲究。普通水果是一个带颜色深浅变化的圆形加上一块半透明白色高光再画一个小弧线当叶子这样视觉上比单纯的圆圈立体很多。炸弹则画成深色圆球顶部加一根引线和黄色火花让玩家一眼就能辨识出来。3.4 完整代码!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title接水果小游戏/title style * { box-sizing: border-box; margin: 0; padding: 0; } body { background: #1a1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: Arial, Microsoft YaHei, sans-serif; } .game-container { position: relative; border-radius: 12px; overflow: hidden; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); } canvas { display: block; } .hud { position: absolute; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; padding: 6px 14px; background: rgba(0, 0, 0, 0.3); border-radius: 8px; color: #fff; font-size: 16px; font-weight: bold; pointer-events: none; z-index: 2; } .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 14px; background: rgba(0, 0, 0, 0.6); color: #fff; z-index: 3; text-align: center; } .overlay h2 { font-size: 36px; letter-spacing: 4px; } .overlay p { font-size: 16px; opacity: 0.9; } .overlay button { margin-top: 8px; padding: 10px 32px; font-size: 18px; border: none; border-radius: 8px; background: #f59e0b; color: #1f2937; cursor: pointer; font-weight: bold; transition: transform 0.15s ease; } .overlay button:hover { transform: scale(1.06); } .hidden { display: none; } /style /head body div classgame-container canvas idgameCanvas width480 height640/canvas div classhud span分数: span idscore0/span/span span生命: span idlives3/span/span /div div classoverlay idstartOverlay h2接水果/h2 p移动篮子接住水果小心炸弹/p button idstartBtn开始游戏/button /div div classoverlay hidden idoverOverlay h2游戏结束/h2 p最终得分: span idfinalScore0/span/p button idrestartBtn再来一局/button /div /div script (function () { const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const W canvas.width; const H canvas.height; const scoreEl document.getElementById(score); const livesEl document.getElementById(lives); const startOverlay document.getElementById(startOverlay); const overOverlay document.getElementById(overOverlay); const finalScoreEl document.getElementById(finalScore); const startBtn document.getElementById(startBtn); const restartBtn document.getElementById(restartBtn); const BASKET_W 96; const BASKET_H 18; const BASKET_Y H - 56; const MAX_LIVES 3; const BASE_SPAWN_INTERVAL 900; const BASE_SPEED 90; let state start; let score 0; let lives MAX_LIVES; let fruits []; let basket { x: W / 2 - BASKET_W / 2 }; let lastTime 0; let spawnTimer 0; let difficulty 1; const FRUIT_TYPES [ { name: 草莓, color: #ff4d6d, r: 12, points: 10, weight: 40, bomb: false }, { name: 橙子, color: #ff9f1c, r: 14, points: 20, weight: 30, bomb: false }, { name: 西瓜, color: #3cbe5d, r: 18, points: 30, weight: 20, bomb: false }, { name: 炸弹, color: #333333, r: 14, points: 0, weight: 10, bomb: true } ]; function randomFruitType() { const total FRUIT_TYPES.reduce((sum, t) sum t.weight, 0); let rand Math.random() * total; for (let i 0; i FRUIT_TYPES.length; i) { rand - FRUIT_TYPES[i].weight; if (rand 0) { return FRUIT_TYPES[i]; } } return FRUIT_TYPES[0]; } function spawnFruit() { const type randomFruitType(); fruits.push({ x: 20 Math.random() * (W - 40), y: -type.r - 10, r: type.r, color: type.color, name: type.name, points: type.points, bomb: type.bomb, speed: BASE_SPEED 40 * Math.random() (difficulty - 1) * 25, rotation: Math.random() * Math.PI }); } function updateHUD() { scoreEl.textContent score; livesEl.textContent lives; } function resetGame() { score 0; lives MAX_LIVES; fruits []; spawnTimer 0; difficulty 1; basket.x W / 2 - BASKET_W / 2; updateHUD(); } function startGame() { resetGame(); state playing; startOverlay.classList.add(hidden); overOverlay.classList.add(hidden); } function endGame() { state over; finalScoreEl.textContent score; overOverlay.classList.remove(hidden); } function getSpawnInterval() { let interval BASE_SPAWN_INTERVAL - (difficulty - 1) * 80; return Math.max(interval, 380); } function update(dt) { difficulty Math.min(1 Math.floor(score / 100), 8); spawnTimer dt; if (spawnTimer getSpawnInterval()) { spawnTimer - getSpawnInterval(); spawnFruit(); } for (let i fruits.length - 1; i 0; i--) { const f fruits[i]; f.y f.speed * dt / 1000; // 漏到屏幕下方 if (f.y - f.r H) { fruits.splice(i, 1); if (!f.bomb) { lives--; updateHUD(); if (lives 0) { endGame(); return; } } continue; } // 碰撞判定竖直方向到达篮子高度水平方向接触篮子 if (f.y f.r BASKET_Y f.y BASKET_Y BASKET_H f.r) { const hitX f.x basket.x - f.r f.x basket.x BASKET_W f.r; if (hitX) { if (f.bomb) { lives--; } else { score f.points; } updateHUD(); fruits.splice(i, 1); if (lives 0) { endGame(); return; } } } } } function drawBackground() { const grad ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, #87ceeb); grad.addColorStop(0.6, #ffe6b3); grad.addColorStop(1, #4ade80); ctx.fillStyle grad; ctx.fillRect(0, 0, W, H); ctx.fillStyle rgba(255,255,255,0.6); ctx.beginPath(); ctx.arc(70, 80, 24, 0, Math.PI * 2); ctx.arc(100, 70, 30, 0, Math.PI * 2); ctx.arc(130, 84, 22, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(360, 130, 22, 0, Math.PI * 2); ctx.arc(390, 120, 28, 0, Math.PI * 2); ctx.arc(420, 134, 20, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle #22c55e; ctx.fillRect(0, H - 46, W, 46); ctx.fillStyle #16a34a; ctx.fillRect(0, H - 20, W, 20); } function drawBasket() { ctx.save(); ctx.fillStyle #d97706; ctx.fillRect(basket.x, BASKET_Y, BASKET_W, BASKET_H); ctx.fillStyle #92400e; ctx.fillRect(basket.x 8, BASKET_Y - 5, BASKET_W - 16, 7); ctx.restore(); } function drawFruit(f) { ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.rotation); if (f.bomb) { ctx.fillStyle f.color; ctx.beginPath(); ctx.arc(0, 0, f.r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle #222; ctx.lineWidth 2; ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -f.r); ctx.lineTo(4, -f.r - 10); ctx.stroke(); ctx.fillStyle #ffdd55; ctx.beginPath(); ctx.arc(4, -f.r - 10, 3, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillStyle f.color; ctx.beginPath(); ctx.arc(0, 0, f.r, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle rgba(255,255,255,0.45); ctx.beginPath(); ctx.arc(-f.r * 0.3, -f.r * 0.35, f.r * 0.35, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle #2f9e44; ctx.lineWidth 2; ctx.beginPath(); ctx.moveTo(0, -f.r); ctx.quadraticCurveTo(f.r * 0.45, -f.r * 1.3, f.r * 0.7, -f.r * 0.5); ctx.stroke(); } ctx.restore(); } function render() { ctx.clearRect(0, 0, W, H); drawBackground(); for (let i 0; i fruits.length; i) { drawFruit(fruits[i]); } drawBasket(); } function gameLoop(timestamp) { if (!lastTime) { lastTime timestamp; } const dt Math.min(timestamp - lastTime, 50); lastTime timestamp; if (state playing) { update(dt); } render(); requestAnimationFrame(gameLoop); } function clampBasket() { basket.x Math.max(0, Math.min(W - BASKET_W, basket.x)); } canvas.addEventListener(mousemove, function (e) { const rect canvas.getBoundingClientRect(); const scaleX W / rect.width; const x (e.clientX - rect.left) * scaleX; basket.x x - BASKET_W / 2; clampBasket(); }); document.addEventListener(keydown, function (e) { if (e.key ArrowLeft || e.key a || e.key A) { basket.x - 12; } else if (e.key ArrowRight || e.key d || e.key D) { basket.x 12; } clampBasket(); }); canvas.addEventListener(touchmove, function (e) { e.preventDefault(); const rect canvas.getBoundingClientRect(); const scaleX W / rect.width; const x (e.touches[0].clientX - rect.left) * scaleX; basket.x x - BASKET_W / 2; clampBasket(); }, { passive: false }); startBtn.addEventListener(click, startGame); restartBtn.addEventListener(click, startGame); resetGame(); requestAnimationFrame(gameLoop); })(); /script /body /html写完之后我把整个代码文件过了一遍HTML 结构里外层容器是相对定位所有 overlay 都绝对定位在容器内覆盖层始终展示在最前面。JavaScript 部分用了一个立即执行函数把内部变量隔离起来避免和页面上其他脚本产生冲突。帧循环从页面加载就开始运行但只有当 state 为 playing 时才调用 update所以开始画面和结束画面都是静止的。4. 常见问题与排查技巧实录4.1 画面卡顿和掉帧如果你看到水果掉落一卡一卡的优先检查是不是绘制了过多的东西或者每一帧里做了太多无意义的操作。有次我为了增加视觉效果给每个水果都加了投影结果投影的计算量明显拖累了整体帧率。Canvas 的阴影和滤镜在移动端尤其贵能用简单的圆形加高光解决的问题就不要上 shadowBlur。另一个常见原因是创建了大量临时对象。比如每帧都 new 一个数组然后立刻丢掉次数多了会让垃圾回收频繁触发导致画面突然停顿。在循环里尽量复用变量不要随手 new。还有一个我在代码里特意处理过的点dt 上限。如果你在浏览器里切走标签页再切回来rAF 会暂停一段时间重新恢复时第一帧的 dt 可能长达几秒钟。没有 Math.min 限制的话水果会瞬间飞出屏幕游戏直接结束。这个问题在移动端切应用时特别容易出现一定要做保护。4.2 鼠标和触摸坐标不对我见过不少人在 Canvas 游戏里点击和移动总是差一截然后怀疑自己数学有问题。其实问题多半出在缩放换算上。canvas 标签上写的 width 和 height 是画布的逻辑尺寸而 CSS 里设置的 width 和 height 是它在页面中实际显示的尺寸。如果这两个尺寸不一样就必须按比例换算。比如画布逻辑宽度是 480但 CSS 里它显示成了 300 像素宽比例就是 480 / 300 1.6鼠标相对页面左上角的横向偏移量要先乘以这个比例才能得到正确的画布坐标。触摸事件同理。但触摸事件多了一个坑手指滑动时浏览器默认会滚动页面导致坐标采集不稳定所以需要调用 e.preventDefault()。而 preventDefault 在 passive 模式下会被浏览器忽略因此监听 touchmove 时要把第三个参数写成 { passive: false }。这个细节你在桌面浏览器上测不出来一上真机就露馅。4.3 碰撞漏判和误判碰撞漏判最典型的原因是移动速度过快造成了“穿透”。比如某个极慢帧里 dt 很大水果一帧往下移动了 30 像素而篮子的可视高度只有 18 像素它可能直接跨过了碰撞区间被当成漏接处理了。解决思路有几种一是限制 dt不让单帧位移过大这是成本最低的方案。二是做连续碰撞检测把上一帧和当前帧位置之间的线段与篮子矩形做相交测试理论上更严谨但对这个游戏来说有点杀鸡用牛刀。三是把碰撞区间放宽比如竖直方向不要只查一条高度线而是让“水果圆心是否落在篮子高度区间附近”作为条件。我的代码里采用了第三种思路加上 dt 上限实测下来已经非常稳了。还有误判的一类情况视觉效果和判定区域不匹配。比如水果画得很大但判定半径很小或者篮子的绘制范围比判定矩形小玩家明明看到碰到了却没被接住。这种体验问题通常可以通过把判定区域可视化来调试临时画一个半透明的矩形框显示篮子的真实判定范围改完再隐藏掉是排查这类问题的快速手段。4.4 数组遍历中删除元素的问题这个坑对新手来说特别隐蔽。你正循环一个数组循环体里又有 splice 删掉了当前元素按理说没问题但如果用的是正序遍历 for (let i 0; i fruits.length; i)删掉第 i 个元素后原来第 i1 个元素会移到第 i 个位置而下一轮循环 i 又变成了 i1这个元素就被跳过了。解决办法是倒序遍历像我代码里写的那样for (let i fruits.length - 1; i 0; i--)。删除任意位置的元素时前面的元素下标不受影响逻辑上天然安全。这种方法在做筛选、重置类逻辑的时候同样好用建议直接养成习惯。下面把常见问题整理成一个速查表方便你以后快速对照现象常见原因解决办法水果瞬间飞出去切后台后 dt 异常Math.min 限制单帧时间差画面卡顿Canvas 阴影、滤镜过多用基础图形代替减少每帧对象创建鼠标位置偏差画布逻辑尺寸和显示尺寸不一致用 getBoundingClientRect 加比例换算手指移动时页面跟着滚动touchmove 没有阻止默认行为监听时设置 passive: false 并 preventDefault偶尔漏接明明碰到的东西单帧位移跨过了判定区间放宽竖直碰撞判定并限制 dt水果总是少生成几个正序遍历数组删除元素导致跳过改用倒序遍历或使用 filter 生成新数组开始按钮点了没反应overlay 挡住了 canvas 事件确认 overlay 层级按钮事件绑定正确5. 扩展方向与个人经验写完基础版之后这个项目还有很大的扩展空间。我建议感兴趣的朋友可以按下面的顺序继续往上加东西第一游戏结束后把最高分存到 localStorage 里下次打开时显示“历史最高分”这个需求能练习数据持久化。第二接住水果时加一个简单的粒子效果让水果碎裂成小圆点训练你对粒子的创建、运动、销毁全过程的理解。第三把普通的水果圆形替换成真实的图片资源这会让你主动去学习图片预加载和绘制知识。第四给碰撞和得分加上音效Web Audio API 不需要外部音频文件几行代码就能生成短促的提示音。就我个人的实际体验来说写这样一个看起来毫不起眼的小游戏给我带来的收获比写十个静态页面还要大。因为它迫使你同时思考“逻辑状态”和“视觉表现”之间的关系你在脑子里会渐渐形成一种拆解问题的习惯先想明白对象有哪些属性对象之间会发生什么关系再考虑怎么把它们画出来。这种思维方式转移到日常的业务开发中一样管用不管是复杂的表单交互还是动态图表底层都是类似的路子。如果你照着代码写完一遍我强烈建议你手动做一次“重构实验”把水果从圆形改成三角形观察碰撞判定为什么依然生效把难度递增规则从每 100 分升一级改成每 200 分升一级感受一下难度曲线变化再把篮子从固定底部改成可以通过鼠标上下移动体验一下这个改动会带来哪些新的问题。这些实验都做一遍之后这个小游戏就不再是代码抄写而会变成你自己的东西了。