
上一次我带大家做的是那种纯鼠标点按的网页小游戏这次换一个互动感更强的经典玩法——切水果。别小看这个项目它几乎是前端练手题的“含金量担当”HTML负责页面结构CSS负责界面视觉JavaScript负责从物理运动、碰撞检测到计分的整套游戏逻辑三大件一项不落。很多人收藏过“文后附完整代码”的帖子下载下来跑一下能动但改两个参数就崩根本不知道代码为什么这么写。这篇文章要做的就是把整个项目彻底拆开讲清楚游戏循环怎么跑、水果的抛物线怎么算、刀光粒子怎么加以及最容易让新手翻车的几个坑。适合刚学完前端基础、想动手做一个综合项目的朋友。1. 项目分析与技术选型用前端三大件做切水果的核心思路1.1 切水果的玩法逻辑拆成技术点先别看代码认真想一下这个游戏到底在做什么。屏幕底部或者侧边会不断冒出水果水果飞起来之后又因为重力往下掉玩家的任务是在水果飞行的过程中用鼠标或者手指从它身上划过去把它“切开”。切中的水果会变成两半散出果汁粒子屏幕上方分数增加如果划过炸弹那就直接出局或者扣一条命。把这个玩法拆成前端技术点会得到这样一张表核心玩法环节对应的前端实现手段水果从底部弹出并沿弧线运动用JavaScript维护水果对象的位置与速度每帧更新坐标切割判定监听鼠标/触摸移动把移动轨迹当成线段与水果圆形做相交检测切开的视觉反馈Canvas绘制两半果块、果汁粒子刀光效果保留鼠标轨迹点Canvas每帧重绘半透明拖尾得分、连击、游戏状态DOM操作更新UIJavaScript管理状态机整体界面、按钮、遮罩HTML CSS布局与样式你会发现这个游戏表面上是“切水果”本质上是在练三件事用Canvas做大量的动态图形绘制、用事件系统处理实时交互、用数组加对象来组织一批不断产生和销毁的实体。这三件事几乎是一切前端游戏和复杂交互页面的地基。1.2 画面用Canvas界面用DOM为什么要这么分工一开始有人会问水果用div加上CSS动画不也能做出来吗确实能做我之前也试过用绝对定位的div来移动水果八个十个还没问题一旦水果数量到二十个、再叠加十几颗粒子DOM节点的频繁样式更新会让页面明显卡顿低端设备上更是惨不忍睹。这里面的本质区别在于DOM的每一个元素浏览器都要去维护它的布局、样式、事件、层级关系像给每个水果都配了一个独立的秘书而Canvas完全是一张画布你告诉它哪里涂什么颜色它只负责往像素点上画。大量动态图形实时更新Canvas的绘制效率要远高于操作同等数量的DOM节点。但是Canvas也不是万能的。你得自己管理画面里的每一个对象的坐标、状态、层级、碰撞浏览器不会替你记住“这个是水果”“那个是刀光”。所以这个小项目最稳妥的分工方式是Canvas管动态世界也就是水果、刀光、粒子DOM管静态界面也就是得分数字、开始按钮、结束遮罩。两种思路各干各的活整个项目结构清晰性能也稳。1.3 技术方案的取舍原生JavaScript就够用还有人会问做这个要不要上游戏引擎或者引入某个框架我的答案非常直接不需要一个都不要加。切水果这个体量的游戏用框架反而会掩盖你真正要学的核心逻辑。原生JavaScript能把整个游戏从零到一完整写出来而且你写完之后会看清一件事所谓的“游戏逻辑”核心就是一个循环加一堆对象状态仅此而已。选择原生方案还有一个现实好处你可以直接双击打开一个HTML文件就跑起来不需要配置复杂的工程化工具适合专注研究原理。我建议的事件方案是使用 Pointer Events 来统一处理鼠标和触摸。pointerdown、pointermove、pointerup 这套API在电脑浏览器和手机上都能识别不需要再分别绑定mouse事件和touch事件代码量少兼容性也够稳。后面的实操部分我会提到一个额外的兼容注意点但总体思路是不引入任何依赖。2. 核心机制精讲循环、物理、碰撞与视觉反馈的底层原理2.1 游戏主循环所有水果跟着同一个节拍器走切水果游戏看起来是一个动态世界里面所有对象都在运动。新手最容易犯的错误是每次生成一个水果就给它挂一个独立的定时器让它在自己的时间轴上飞。这样做水果一多定时器满天飞你要暂停游戏时还得一个一个去清理非常痛苦。正确做法是给整个游戏一个统一的“节拍器”——主循环。所有水果的移动、粒子的消散、刀光的淡出都在这个循环里统一更新。主循环每一帧要处理的事情就四件遍历水果数组按速度更新水果位置叠加重力根据鼠标最近两次的坐标与每个水果做切割检测更新粒子系统和刀光轨迹移除已经飞出屏幕或者生命到期的对象清空画布把当前所有可见对象绘制一遍这个循环用什么驱动强烈建议用 requestAnimationFrame。它是浏览器原生提供的帧动画接口会跟屏幕刷新率同步常见是60帧每秒。相比之下setInterval即使设置成16毫秒也没法和屏幕刷新节奏精准对齐而且后台标签页会被浏览器降频游戏会出现忽快忽慢的情况。2.2 水果运动模型用加速度模拟重力一分钟调出弧线水果从底部弹出去之后飞出的弧线本质上就是一个带初始速度和重力加速度的抛体运动。高中物理在这套逻辑里特别管用水平方向速度基本不变垂直方向速度每帧都会因为重力而改变。我在代码里给每个水果对象定义这些字段const fruit { x: 随机水平位置, y: canvas.height 50, // 从画布底部下方冒出来 vx: 随机水平初速度, vy: 随机向上初速度, radius: 随机半径, color: 随机颜色, sliced: false };每一帧更新位置的时候执行这样一个逻辑fruit.x fruit.vx * dt; fruit.y fruit.vy * dt; fruit.vy gravity * dt;就这么几行水果的抛物线就出来了。比较关键的是参数画布如果是常见的800×600左右重力一般取0.18到0.25之间初速度vy取-13到-9之间负号表示向上vx取-2.5到2.5之间随机。这样水果会有一个先上升后下坠的弧线留出足够的时间给玩家去划。调参数的时候有个感觉可以参照如果水果一出底部就笔直往上、然后又笔直掉下来那是vx太小如果还没升到屏幕中部就往下掉是重力太大或者vy的绝对值不够如果水果满屏乱飞根本切不中那就是vx给得太大了。我建议你先把三个参数都调成一个不合理的值比如重力0.5、vx给到8跑一下看效果再慢慢调回来比自己凭空猜要直观得多。2.3 切割判定线段与圆的相交检测解决“穿水果”问题这是整个项目里最值得仔细研究的地方。很多版本的切水果代码用的是“鼠标当前坐标和水果圆心的距离小于半径”来判断切割看起来天经地义但实际用起来会发现一个特别气人的情况鼠标明明从水果中间快速滑过去了水果却没反应。原因在于鼠标事件不是每帧都触发更不是连续不断的。两帧之间鼠标可能已经移动了很长一段距离从水果的左边一下跳到了右边如果你只判断“当前点是否在水果内”水果就被直接穿透了。正确的思路是把上一帧鼠标的位置和当前帧鼠标的位置连成一条线段判断这条线段跟水果的圆形区域是否相交。只要线段穿过了圆的内部就认为切到了。这里要用到点到线段最小距离的计算function isSegmentCircleIntersect(ax, ay, bx, by, cx, cy, r) { const dx bx - ax; const dy by - ay; if (dx 0 dy 0) { // 两点重合的情况直接判断点到圆心距离 return Math.hypot(cx - ax, cy - ay) r; } // 计算圆心在线段上的投影位置并限制在0到1之间 let t ((cx - ax) * dx (cy - ay) * dy) / (dx * dx dy * dy); t Math.max(0, Math.min(1, t)); const px ax t * dx; const py ay t * dy; return Math.hypot(px - cx, py - cy) r; }这段代码里Math.hypot就是求平方和的平方根本质上就是两点间的距离。写清楚之后你会发现它同时兼容了鼠标慢速移动和快速甩动的情况切水果的手感会立刻上一个台阶。2.4 刀光、切开与粒子游戏视觉反馈的三件套玩家判断“这一刀切没切中”靠的不仅仅是得分数字更重要的是画面反馈。三个视觉反馈必须同时做缺一个都会觉得手感干巴巴。刀光是最简单的。用一个数组保存最近10到15个鼠标移动点每帧往数组里推入新坐标同时把最早的那个坐标移除。绘制的时候把这些点连成一条线线条宽度从4像素渐变为0透明度从高到低颜色用白色半透明。这样鼠标划过去就会带出一条刀光拖尾是那种“切割感”的主要来源。切开本身用两半果块来表现。实际项目里不用追求特别精细的“半圆形果片模型”我的做法是切割命中时把当前水果从数组中移除同时在切点附近生成两个较小半径的果块对象给它们一个随机方向的速度让它们分别弹开。果块的颜色沿用原水果的颜色视觉上就有“一分两半”的效果成本很低效果也够。果汁粒子就是生成5到10个彩色小圆点初始速度随机往外射同时受重力作用每一帧透明度衰减。粒子寿命到了就从数组里删掉。粒子系统理论上不复杂唯一的敌人是数量。后面我会专门讲怎么限制粒子数量防止游戏越玩越卡。3. 完整实现步骤从空页面到可玩的切水果游戏3.1 HTML骨架Canvas画布与界面层分离第一步先把HTML结构搭好。页面主体就一个canvasCanvas上面叠几层UI用于显示分数、开始按钮和结束遮罩。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title切水果小游戏/title link relstylesheet hrefstyle.css /head body canvas idgameCanvas/canvas div classui-layer div classscore-panel 得分span idscore0/span button idrestartBtn styledisplay:none;重新开始/button /div /div div classui-layer idstartLayer h1切水果/h1 button idstartBtn开始游戏/button /div div classui-layer idendLayer styledisplay:none; h2游戏结束/h2 p idendScore0/p button idendRestartBtn再来一次/button /div script srcgame.js/script /body /html两个注意点。第一canvas尺寸不能只靠CSS必须在JavaScript里用实际像素设置宽高否则你绘制时的坐标系和CSS显示区域不一致所有水果的位置都会错位。第二UI层和canvas在同一个页面里叠放UI层用绝对定位铺在最上面canvas铺在底层这样水果从屏幕下方“飞出来”时看起来像是从UI后面钻出来的层次感更好。3.2 CSS样式让界面有一点点游戏感CSS这一层不用花太多时间但也不能完全没有。背景可以用深色渐变模拟果盘背景按钮做成圆角、带阴影遮罩层做成半透明黑底。* { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; font-family: Microsoft YaHei, sans-serif; background: linear-gradient(#1a2a3a, #0d1b2a); } #gameCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: block; touch-action: none; } .ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; z-index: 10; } button { padding: 10px 26px; font-size: 18px; border: none; border-radius: 24px; background: #ff8c42; color: #fff; cursor: pointer; box-shadow: 0 4px 14px rgba(255, 140, 66, 0.4); }有一个细节值得提给canvas设置touch-action: none在手机上意味着“在画布区域里的触摸手势由页面自己处理不要触发浏览器默认滚动和缩放”这是切水果这类全屏互动游戏在移动端能正常玩的前提。这个属性和pointer事件配合使用效果最稳。3.3 JavaScript主逻辑状态结构、生成水果、切割与计分游戏核心逻辑全在JavaScript里。我建议先定义全局状态对象把所有可变的量集中管理这样暂停、重开、结束等各种状态切换会非常清晰const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); canvas.width window.innerWidth; canvas.height window.innerHeight; const state { score: 0, lives: 3, running: false, fruits: [], particles: [], trail: [], pointerDown: false, lastX: 0, lastY: 0, gameTime: 0, spawnTimer: 0 }; const fruitColors [#ff4d6d, #ffc300, #6bcb77, #4d96ff, #9c88ff];游戏流程被拆成几个函数startGame负责初始化所有状态、清空数组、开启循环endGame负责停止更新、显示结束界面spawnFruit负责生成一个水果或者炸弹update和render分别负责更新和绘制。生成水果的逻辑里我一般会这样写function spawnFruit() { const isBomb Math.random() 0.1; const base { x: canvas.width * (0.1 Math.random() * 0.8), y: canvas.height 50, vx: (Math.random() * 5 - 2.5), vy: -(Math.random() * 4 9), radius: isBomb ? 26 : 18 Math.random() * 14, sliced: false, isBomb: isBomb }; if (!isBomb) { base.color fruitColors[Math.floor(Math.random() * fruitColors.length)]; } else { base.color #3a3a3a; } state.fruits.push(base); }炸弹的生成概率我控制在10%左右而且要等游戏开局几秒再允许出现不然新手一上来就莫名其妙被炸飞体验会很差。切割检测的时机是在pointermove事件里。每次事件触发时把上一次坐标和当前坐标组成的线段和所有未切水果做相交检测canvas.addEventListener(pointermove, (e) { const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; for (let fruit of state.fruits) { if (fruit.sliced) continue; if (isSegmentCircleIntersect(state.lastX, state.lastY, mx, my, fruit.x, fruit.y, fruit.radius)) { if (fruit.isBomb) { endGame(); return; } fruit.sliced true; state.score 1; scoreEl.textContent state.score; spawnCutEffect(fruit); } } state.lastX mx; state.lastY my; });要注意的是事件里的e.clientX和e.clientY是相对视口的坐标如果canvas不是铺满整个视口需要减去canvas左上角的偏移量。全屏时偏移量是0但养成减一下的习惯更稳。切中的反馈函数spawnCutEffect可以这样实现function spawnCutEffect(fruit) { // 生成两个果块 for (let i 0; i 2; i) { state.fruits.push({ x: fruit.x, y: fruit.y, vx: fruit.vx (Math.random() * 2 - 1), vy: fruit.vy - 2, radius: fruit.radius * 0.7, color: fruit.color, sliced: true, isBomb: false }); } // 生成果汁粒子 for (let i 0; i 8; i) { state.particles.push({ x: fruit.x, y: fruit.y, vx: (Math.random() * 4 - 2), vy: (Math.random() * 4 - 2), alpha: 1, color: fruit.color }); } }果块和粒子的更新逻辑都在update里跑果块飞出界外后会在清理阶段被移除。3.4 帧率无关处理与代码拼装把完整项目组合起来到这里还有一个必须处理的问题帧率不稳定时游戏会忽快忽慢。60Hz的显示器上玩得好好的换到120Hz刷新率的电脑上水果会飞得飞快因为每帧执行的更新次数变多了。解决方法是计算每两帧之间的时间差用时间差来调节运动幅度。我在主循环里是这样处理的let lastTime 0; function loop(timestamp) { const dt Math.min((timestamp - lastTime) / 16.667, 3); lastTime timestamp; update(dt); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);dt代表当前帧和上一帧之间经历的时间以“16.667毫秒”即60帧一帧的时长为基准。如果屏幕是120Hzdt大约是0.5那么所有位移都减半最终水果的运动速度在不同刷新率下保持一致。给dt设一个最大值3是为了防止标签页切到后台再切回来时一两秒的时间差让水果瞬间全部飞出去。把所有代码按照这个结构拼装起来HTML是骨架CSS负责界面JavaScript里的state定义、spawnFruit、update、render、loop、事件监听拼成game.js就是一个完整可运行的项目。你可以边写边测每次改动只在某个函数里发生定位问题也变得容易。4. 问题排查与调优实录让游戏更流畅、手感更稳4.1 切开反应慢、水果穿透的排查方案如果测试时发现“明明滑过水果却没反应”优先检查三点第一切割判定是不是用了“点到点距离”而不是“线段到圆的相交检测”。这是最常见的原因快速滑动时水果会被直接穿过去。用文中的isSegmentCircleIntersect函数基本能解决。第二坐标有没有做偏移换算。如果canvas在页面上位置不是左上角直接用clientX和clientY去碰撞所有判定位置都会偏。统一用canvas.getBoundingClientRect()做一次换算。第三事件是不是绑定错了对象。有些人把pointermove绑在window上坐标拿到的绝对位置和自己画的canvas不是同一套坐标系也会导致判定错位。我习惯绑在canvas元素上并且用canvas自己的坐标。4.2 越玩越卡内存堆积与粒子数量失控游戏玩了三四分钟之后开始掉帧这是切水果项目最高频的性能问题。核心原因是数组里积累了太多无用对象切过的水果还留在数组里、飞出屏幕的果块没人清理、粒子alpha已经衰减为0了还在占用空间。解决办法是在每次更新时统一过滤一次state.fruits state.fruits.filter(fruit { return fruit.y -100 fruit.y canvas.height 100 fruit.x -100 fruit.x canvas.width 100; }); state.particles state.particles.filter(p p.alpha 0);粒子数量本身也要设置上限。一次切割生成8个粒子连切十几刀粒子数量可能冲到几百。我习惯在生成粒子前判断一下if (state.particles.length 500) { return; // 粒子池满了就不继续生成 }这个限制写起来很简单但能避免绝大多数低端设备的性能崩溃。4.3 移动端适配触摸事件、滚动与坐标换算把页面放到手机上最常见的报错现象是手指划过屏幕页面跟着滚来滚去游戏完全没法玩。这就是浏览器默认触摸行为没有阻止导致的。解决办法有三个层级可以同时用第一CSS上给canvas加touch-action: none第二在document的touchmove事件里调用e.preventDefault()注意注册要带passive: false否则现代浏览器会忽略你的preventDefault第三viewport meta里禁用用户缩放避免双指缩放干扰游戏。触摸的坐标拿法也需要注意。如果用的是pointer事件e.clientX和e.clientY在触摸时同样有效和鼠标是一致的不需要特殊处理。这个好处是我前面提到的“Pointer Events统一方案”带来的省掉了很多if判断。4.4 手感细节参数表与调整思路最后分享几个我实测下来让手感明显变好的参数以及它们的影响方向。你可以照着这个表去微调自己的项目参数默认值参考调大效果调小效果重力gravity0.18-0.25水果下坠更快更难切水果飘得久难度降低初始vy-13到-9飞得更高反应时间更长低抛容易没反应过来初始vx-2.5到2.5轨迹更乱不好预判轨迹太直玩法单调水果半径18-32更好切中手感更宽容需要更精确地划中炸弹概率满3秒后约8%-12%紧张感提高死亡风险大更像无脑切水果建议每次只改一个参数跑两分钟感受变化不要同时动好几个因为多个变量互相影响你就搞不清是什么变好的了。有的人看别人的代码时会觉得切水果很简单实际上手改过之后才会发现这里的难点根本不在于“能不能画出一个圆”而在于怎么组织一个持续运转的动态系统对象要生成、要运动、要销毁状态要切换性能还要守住。如果你能把这个项目做完并且把每个函数都能跟别人讲清楚为什么这么写那前端基础这一关就算是真正过了。动手改代码是最快的路径先试试把重力调成0.5看看水果变“重”之后的手感再换一台高刷新率电脑跑一跑直观体会一下帧率无关处理的价值这些东西只有自己踩过一遍才会长记性。