上个月在某前端交流群里有个刚学完前端三件套的朋友贴了一段代码问我为什么我的蛇一吃到食物就闪退我打开一看问题不大就是食物生成时没排除蛇身占用的格子。但这让我想起来一个事实几乎每个学前端写游戏的人第一站都是贪吃蛇。这个看起来只有十几个函数的小项目其实把游戏的骨架全装进去了输入、状态管理、游戏循环、碰撞检测、渲染、计分。用htmlcssjs制作一款贪吃蛇小游戏最妙的地方在于代码量不大却能让你把所有网页游戏开发的基本盘都过一遍。如果你正卡在学了HTML/CSS/JS却不知道做什么项目或者想练一下对Canvas和事件处理的理解这篇文章直接给你一套能跑通的完整实现还会把那些只有跑起来才知道的坑一个一个说清楚。1. 为什么是贪吃蛇小玩具里装着网页游戏的完整骨架1.1 麻雀虽小五脏俱全大多数初学者写完的第一个项目是个人主页、计算器、待办清单。这些项目当然有练习价值但它们有一个共同点页面是静止的或者只是点了按钮才动一下。你的交互逻辑是离散的做完一步就停了浏览器等着你下一次点击。游戏不一样。游戏是持续运行的哪怕玩家什么都不做画面上的一切也必须在按帧推进。这个持续运行的机制恰恰是前端开发里很容易被忽略的部分。贪吃蛇恰好站在两个世界中间。它不像俄罗斯方块需要处理复杂的方块旋转和落点判定也不像打飞机那样要同时管理大量子弹和敌人的生命周期。它的核心逻辑非常收敛一条蛇、一个食物、一个计分器。但收敛不等于简单它必须解决所有游戏开发都要面对的问题捕获玩家输入键盘方向键移动端则是触摸手势维持和更新游戏状态蛇身坐标数组、方向、得分、是否结束一个稳定且可调速的游戏循环碰撞检测撞墙、撞自己、吃到食物把状态渲染到屏幕清屏、画蛇、画食物把这五点理清楚你后面去碰任何更复杂的网页游戏逻辑都是相通的。差别只在于状态更复杂、循环更精细、渲染更讲究。1.2 为什么不直接上游戏引擎有人可能会问现在做网页游戏直接用Phaser、Cocos这类引擎或者找个开源的贪吃蛇模板改一改不就行了何必用原生js从头写答案是用引擎当然可以但引擎替你解决的东西恰好是新手最需要理解的东西。引擎封装了场景管理、精灵渲染、物理碰撞你拖拖拽拽就能做出一个看起来很唬人的demo但一旦跑出问题根本无从排查因为你不知道底层发生了什么。原生Canvas加js写贪吃蛇代码总量也就两三百行所有逻辑摊开在你面前。哪里卡了、哪里撞了、哪里分数没加上一眼就能看到。对于学习来说这种一览无余的透明感比什么都值钱。2. 核心机制拆解一条蛇如何在网页里动起来2.1 网格坐标系把连续运动变成离散状态很多人一开始有个误区觉得蛇的移动是像素级连续滑动就像动画片一样每秒60帧平滑前进。如果真这么做贪吃蛇的碰撞检测会变得非常麻烦因为蛇头和食物中心点基本不可能精确重合你还要做距离小于多少算吃到的容差判断。所以正规的做法是把整个画布划分成网格蛇的每一步都在网格间跳转。我习惯把画布设为400x400每个格子20像素于是横向有20个格子纵向也有20个格子。蛇的位置不存像素坐标存网格坐标。比如蛇头在{x: 10, y: 10}就意味着它位于画布上(200, 200)到(220, 220)这个方块区域。这样做的好处太多了。碰撞检测变成了整数比较蛇头的x等于食物x且蛇头的y等于食物y就是吃到了。撞墙检测也变成范围判断x小于0或x大于等于20直接结束。没有浮点误差没有模糊地带。渲染的时候再把网格坐标乘以格子尺寸换算成像素坐标画出来。坐标系统一逻辑和显示各管各的思路非常清晰。2.2 蛇身跟随的数组操作蛇的移动是贪吃蛇里最容易绕晕的部分。你可能会想蛇头往前挪一格蛇身第二节往前挪到原来蛇头的位置第三节挪到原来第二节的位置每一节都要逐个更新。这个思路没错但如果真用循环逐帧去搬每一节的位置代码会啰嗦还容易出错。实际上的做法简洁得多蛇身数据用一个数组存数组里第一个元素是蛇头后面依次是身体。移动时往数组头部塞一个新坐标这是新的蛇头再把数组尾部弹掉一个坐标这是蛇尾往前收了一格。举个例子。初始蛇身是[{x:5,y:5},{x:4,y:5},{x:3,y:5}]蛇头在(5,5)。向右移动一步后snake.unshift({ x: 6, y: 5 }); // 新蛇头 snake.pop(); // 去掉尾部数组变成[{x:6,y:5},{x:5,y:5},{x:4,y:5}]。你看蛇身长度没变但整体往右平移了一格。如果这一步吃到了食物尾部就不弹出数组变成[{x:6,y:5},{x:5,y:5},{x:4,y:5},{x:3,y:5}]长度加一。整条蛇就这么自然地变长了。这个头进尾出的设计是贪吃蛇逻辑的精髓值得写下来反复看。2.3 渲染方案选Canvas还是DOM实现贪吃蛇有两种主流的渲染方式用Canvas每帧重绘或者用DOM节点拼出一条蛇。很多教程会直接推荐Canvas但我想花点时间把两者的差异说透。维度CanvasDOM元素实现思路每帧清空画布重画整个场景蛇身用多个div移动时改left/top性能蛇身几百格也没有压力重构范围明确div数量一多浏览器布局计算压力上升调试难度无法直接选中画布里的元素要靠console输出开发者工具里能直接看到蛇身节点结构视觉效果方便做圆角、渐变、阴影、粒子效果想画复杂形状比较费劲得堆CSS就这个项目而言两者都能跑。但如果你后续想给蛇增加头部渐变、食物发光、吃到食物时的小爆破效果Canvas会让你舒服得多。所以我的完整代码用Canvas来写。另外也提醒一句canvas.getContext(2d)在极少情况下会返回null比如环境不支持当然现代浏览器基本不会出现不过养成先判断一下的习惯也不坏。2.4 游戏循环定时器驱动的正确姿势游戏循环是整条蛇的心脏。你希望蛇每隔固定时间走一格而不是每帧都走。最常见的直觉是用setIntervalsetInterval(() { update(); draw(); }, 150);这个写法在贪吃蛇里会碰到两个实际问题。第一如果速度变了游戏越来越快你必须保存旧的intervalID然后clearInterval再重新setInterval代码很啰嗦一个不小心就会出现两个计时器同时跑的情况。第二setInterval不关心上一次回调有没有执行完如果某一帧逻辑变得很重回调就有可能在队列里堆积。更稳的是用setTimeout递归或者直接用requestAnimationFrame配合时间戳。我推荐后者因为requestAnimationFrame与浏览器刷新率同步不会出现撕裂感还能让JavaScript在标签页切到后台时自动降低损耗。方案是每帧都调用自己但只在距离上次移动超过规定间隔时才更新逻辑let lastMoveTime 0; let speed 150; function gameLoop(timestamp) { if (!gameOver timestamp - lastMoveTime speed) { lastMoveTime timestamp; update(); draw(); } requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这样speed随便改不需要重建循环天然支持变速。这一点我觉得比setInterval方案优雅太多。3. 手把手搭项目一步步写出可运行的贪吃蛇3.1 页面骨架与样式先搭页面结构。一个画布、一个分数栏、一个状态提示文本、一个重新开始按钮全部收进一个容器里居中显示。div classgame-wrap div classscore-bar span得分span idscore0/span/span span最高分span idbestScore0/span/span /div canvas idgameCanvas width400 height400/canvas div idstatus/div button idrestartBtn重新开始/button /divCSS不需要太复杂核心是让画布居中、给个深色背景衬托游戏区域、再加一个圆角边框。这里有个容易被忽视的点canvas在移动端可能超出屏幕所以要给它加max-width: 100%和height: auto。注意这只改变画布的显示尺寸不影响内部逻辑坐标游戏行为完全一致。* { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1e2233; font-family: system-ui, -apple-system, sans-serif; } .game-wrap { text-align: center; color: #e6e6e6; } canvas { border: 2px solid #3a4560; border-radius: 12px; background: #0f1220; max-width: 100%; height: auto; display: block; margin: 12px auto; } .score-bar { display: flex; justify-content: space-between; font-size: 18px; padding: 8px 12px; } #status { min-height: 24px; color: #ffb86b; margin-top: 8px; } button { padding: 10px 24px; border: none; border-radius: 8px; background: #4ecdc4; color: #0f1220; font-size: 16px; cursor: pointer; } button:hover { background: #2f9e8f; }3.2 初始化蛇、食物、方向状态进入JavaScript部分。先拿到画布和上下文定义网格常量再初始化全局状态。const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const bestScoreEl document.getElementById(bestScore); const statusEl document.getElementById(status); const restartBtn document.getElementById(restartBtn); const GRID_SIZE 20; const COLS Math.floor(canvas.width / GRID_SIZE); const ROWS Math.floor(canvas.height / GRID_SIZE); let snake []; let direction { x: 1, y: 0 }; // 当前实际移动方向 let nextDirection { x: 1, y: 0 }; // 玩家最新输入方向 let food null; let score 0; let bestScore 0; let gameOver false; let speed 150;初始化函数会把蛇放在画布中央默认向右移动。食物用spawnFood生成。function init() { const centerX Math.floor(COLS / 2); const centerY Math.floor(ROWS / 2); snake [ { x: centerX, y: centerY }, { x: centerX - 1, y: centerY }, { x: centerX - 2, y: centerY } ]; direction { x: 1, y: 0 }; nextDirection { x: 1, y: 0 }; score 0; gameOver false; speed 150; scoreEl.textContent 0; statusEl.textContent ; spawnFood(); lastMoveTime performance.now(); }要不要把lastMoveTime重置为当前时间要。否则重开一局后下一帧立刻满足时间差大于150ms的条件蛇会直接冲出去一格视觉上像瞬移了一下。3.3 食物生成避免死循环的正确写法很多教程写食物生成是这样的do { food { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; } while (snake.some(s s.x food.x s.y food.y));道理没错但有个隐藏问题如果蛇身很长随机撞到蛇身上的概率就高循环会重复很多次。极端情况下蛇占满整个画布这个while判断永远不会成立页面直接卡死。更稳的做法是把所有空闲格子收集到数组里再一次性随机挑选。这样既不会有死循环也保证了每次生成的食物一定不在蛇身上效率还高。function spawnFood() { const emptyCells []; for (let x 0; x COLS; x) { for (let y 0; y ROWS; y) { if (!snake.some(segment segment.x x segment.y y)) { emptyCells.push({ x, y }); } } } if (emptyCells.length 0) { // 蛇占满全屏玩家赢了 gameOver true; statusEl.textContent 你赢了; return; } const randomCell emptyCells[Math.floor(Math.random() * emptyCells.length)]; food randomCell; }这个版本在平时和极端情况下都稳妥不需要额外判断。3.4 移动与碰撞蛇头进、蛇尾出的完整逻辑update函数负责一帧的逻辑更新按顺序做四件事应用方向、计算新蛇头、判断碰撞、更新蛇身数组。function update() { // 将玩家最新输入应用到这一帧 direction { ...nextDirection }; const head snake[0]; const newHead { x: head.x direction.x, y: head.y direction.y }; // 撞墙检测 if (newHead.x 0 || newHead.x COLS || newHead.y 0 || newHead.y ROWS) { endGame(); return; } // 撞自己检测注意排除尾部因为尾部这一帧会移动 const bodyWithoutTail snake.slice(0, -1); if (bodyWithoutTail.some(segment segment.x newHead.x segment.y newHead.y)) { endGame(); return; } // 蛇头进 snake.unshift(newHead); // 判断是否吃到食物 if (food newHead.x food.x newHead.y food.y) { score 10; scoreEl.textContent score; if (score bestScore) { bestScore score; bestScoreEl.textContent bestScore; } spawnFood(); // 吃到了就不弹尾部蛇身变长 } else { snake.pop(); } } function endGame() { gameOver true; statusEl.textContent 游戏结束按空格或点击按钮重新开始; if (score bestScore) { bestScore score; bestScoreEl.textContent bestScore; } }碰撞自己检测这里有个细节容易踩坑新蛇头撞到了当前蛇尾那一格到底算不算撞答案是不算。因为这一帧蛇尾会移动新蛇头和旧蛇尾不会真的重叠。所以判断时要先把尾部从蛇身数组里临时排除。3.5 键盘控制与防止180度掉头键盘监听是贪吃蛇最容易出错的地方之一。直接改direction会出事我后面会专门讲。这里先给出正确版本用nextDirection做缓冲并且在判断反向时以nextDirection为准。document.addEventListener(keydown, (e) { // 方向键和空格会滚动页面必须阻止默认行为 if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ].includes(e.key)) { e.preventDefault(); } if (e.key gameOver) { init(); return; } if (gameOver) return; const keyMap { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 } }; const newDir keyMap[e.key]; if (!newDir) return; // 不能180度掉头反向判断以nextDirection为准 if (newDir.x -nextDirection.x newDir.y -nextDirection.y) { return; } nextDirection newDir; });preventDefault这一句很多人会漏。不写的话你在浏览器里按方向键时页面会跟着滚动空格键更是会直接把整个页面往下拖游戏体验极其糟糕。这属于那种你没踩过就想象不到的隐形坑。重新开始按钮的点击事件也要绑定一下效果跟空格重开一样restartBtn.addEventListener(click, () { if (gameOver) init(); });3.6 绘制画面画蛇、画食物、画网格线draw函数负责把当前状态画到画布上。每次先清空画布再画网格线、食物、蛇身。蛇头用亮色蛇身用略深的同色系食物用红色圆形视觉上比方块好看很多。function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 网格线 ctx.strokeStyle rgba(255, 255, 255, 0.06); ctx.lineWidth 1; for (let i 1; i COLS; i) { ctx.beginPath(); ctx.moveTo(i * GRID_SIZE, 0); ctx.lineTo(i * GRID_SIZE, canvas.height); ctx.stroke(); } for (let j 1; j ROWS; j) { ctx.beginPath(); ctx.moveTo(0, j * GRID_SIZE); ctx.lineTo(canvas.width, j * GRID_SIZE); ctx.stroke(); } // 食物 if (food) { ctx.fillStyle #ff6b6b; ctx.beginPath(); ctx.arc( food.x * GRID_SIZE GRID_SIZE / 2, food.y * GRID_SIZE GRID_SIZE / 2, GRID_SIZE / 2 - 4, 0, Math.PI * 2 ); ctx.fill(); } // 蛇身 for (let i 0; i snake.length; i) { const segment snake[i]; const padding i 0 ? 1 : 2; ctx.fillStyle i 0 ? #4ecdc4 : #2f9e8f; ctx.fillRect( segment.x * GRID_SIZE padding, segment.y * GRID_SIZE padding, GRID_SIZE - padding * 2, GRID_SIZE - padding * 2 ); } }这里有个顺序问题绘制的时候要先画网格线和食物再画蛇。因为蛇要盖在网格线上方清晰度更高。如果反过来网格线会把蛇身切成一块块视觉上很碎。3.7 完整源码汇总下面是完整的单文件版本。把HTML、CSS、JS放进一个.html文件里双击就能玩。这个版本已经包含了所有修正过的逻辑可以直接作为基线。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title贪吃蛇/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1e2233; font-family: system-ui, -apple-system, sans-serif; } .game-wrap { text-align: center; color: #e6e6e6; } canvas { border: 2px solid #3a4560; border-radius: 12px; background: #0f1220; max-width: 100%; height: auto; display: block; margin: 12px auto; } .score-bar { display: flex; justify-content: space-between; font-size: 18px; padding: 8px 12px; max-width: 400px; margin: 0 auto; } #status { min-height: 24px; color: #ffb86b; margin-top: 8px; } button { padding: 10px 24px; border: none; border-radius: 8px; background: #4ecdc4; color: #0f1220; font-size: 16px; cursor: pointer; margin-top: 10px; } button:hover { background: #2f9e8f; } /style /head body div classgame-wrap div classscore-bar span得分span idscore0/span/span span最高分span idbestScore0/span/span /div canvas idgameCanvas width400 height400/canvas div idstatus/div button idrestartBtn重新开始/button /div script const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const bestScoreEl document.getElementById(bestScore); const statusEl document.getElementById(status); const restartBtn document.getElementById(restartBtn); const GRID_SIZE 20; const COLS Math.floor(canvas.width / GRID_SIZE); const ROWS Math.floor(canvas.height / GRID_SIZE); let snake []; let direction { x: 1, y: 0 }; let nextDirection { x: 1, y: 0 }; let food null; let score 0; let bestScore 0; let gameOver false; let speed 150; let lastMoveTime 0; function init() { const centerX Math.floor(COLS / 2); const centerY Math.floor(ROWS / 2); snake [ { x: centerX, y: centerY }, { x: centerX - 1, y: centerY }, { x: centerX - 2, y: centerY } ]; direction { x: 1, y: 0 }; nextDirection { x: 1, y: 0 }; score 0; gameOver false; speed 150; scoreEl.textContent 0; statusEl.textContent ; spawnFood(); lastMoveTime performance.now(); } function spawnFood() { const emptyCells []; for (let x 0; x COLS; x) { for (let y 0; y ROWS; y) { if (!snake.some(segment segment.x x segment.y y)) { emptyCells.push({ x, y }); } } } if (emptyCells.length 0) { gameOver true; statusEl.textContent 你赢了; return; } const randomCell emptyCells[Math.floor(Math.random() * emptyCells.length)]; food randomCell; } function update() { direction { ...nextDirection }; const head snake[0]; const newHead { x: head.x direction.x, y: head.y direction.y }; if (newHead.x 0 || newHead.x COLS || newHead.y 0 || newHead.y ROWS) { endGame(); return; } const bodyWithoutTail snake.slice(0, -1); if (bodyWithoutTail.some(segment segment.x newHead.x segment.y newHead.y)) { endGame(); return; } snake.unshift(newHead); if (food newHead.x food.x newHead.y food.y) { score 10; scoreEl.textContent score; if (score bestScore) { bestScore score; bestScoreEl.textContent bestScore; } spawnFood(); } else { snake.pop(); } } function endGame() { gameOver true; statusEl.textContent 游戏结束按空格或点击按钮重新开始; if (score bestScore) { bestScore score; bestScoreEl.textContent bestScore; } } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle rgba(255, 255, 255, 0.06); ctx.lineWidth 1; for (let i 1; i COLS; i) { ctx.beginPath(); ctx.moveTo(i * GRID_SIZE, 0); ctx.lineTo(i * GRID_SIZE, canvas.height); ctx.stroke(); } for (let j 1; j ROWS; j) { ctx.beginPath(); ctx.moveTo(0, j * GRID_SIZE); ctx.lineTo(canvas.width, j * GRID_SIZE); ctx.stroke(); } if (food) { ctx.fillStyle #ff6b6b; ctx.beginPath(); ctx.arc( food.x * GRID_SIZE GRID_SIZE / 2, food.y * GRID_SIZE GRID_SIZE / 2, GRID_SIZE / 2 - 4, 0, Math.PI * 2 ); ctx.fill(); } for (let i 0; i snake.length; i) { const segment snake[i]; const padding i 0 ? 1 : 2; ctx.fillStyle i 0 ? #4ecdc4 : #2f9e8f; ctx.fillRect( segment.x * GRID_SIZE padding, segment.y * GRID_SIZE padding, GRID_SIZE - padding * 2, GRID_SIZE - padding * 2 ); } } document.addEventListener(keydown, (e) { if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ].includes(e.key)) { e.preventDefault(); } if (e.key gameOver) { init(); return; } if (gameOver) return; const keyMap { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 } }; const newDir keyMap[e.key]; if (!newDir) return; if (newDir.x -nextDirection.x newDir.y -nextDirection.y) { return; } nextDirection newDir; }); restartBtn.addEventListener(click, () { if (gameOver) init(); }); function gameLoop(timestamp) { if (!gameOver timestamp - lastMoveTime speed) { lastMoveTime timestamp; update(); draw(); } requestAnimationFrame(gameLoop); } init(); requestAnimationFrame(gameLoop); /script /body /html4. 只有跑起来才明白的四个坑4.1 快速连按方向键导致蛇180度掉头这个坑几乎每个写贪吃蛇的人都会遇到表现是蛇往右走你快速按下上再按下左蛇没有先向上转而是直接向左一头撞上自己的身体。问题出在输入缓冲的粒度。如果你在keydown里直接修改direction那么上这个输入会被立刻消费掉还是延迟到下一帧取决于你的循环策略。用requestAnimationFrame时两个按键可能都发生在同一帧间隔内上被覆盖留在内存里的是左。而左和当前的实际方向右正好构成180度反转蛇就直接掉头了。解决方案就是我上面代码里的两层设计第一永远不要直接改direction而是改nextDirection在update每帧开始时才把nextDirection复制给direction第二判断是否反向时拿新方向和nextDirection比较而不是和direction比较。这样玩家在极短时间内连续按两个键第二个合法转向会被保留操作手感顺滑很多。4.2 蛇身很长时食物生成卡死用do...while循环生成食物在蛇很短时没有任何感知但蛇一旦长到二三十格随机命中的概率就直线下降。蛇身长到几十格时这个循环可能要跑几十次甚至几百次才能找到空格子。如果你把画布做成30x30、蛇身占到大部分区域页面几乎就是肉眼可见地卡住。更极端的场景是蛇满屏这时候while条件永远不会为假程序彻底死循环。很多人反馈蛇吃到一定长度页面就白屏多半就是这个原因。所以我坚持用收集所有空格子再随机挑一个的方案。代码量没增加多少但无论蛇多长生成效率都是O(网格总数)恒定且快速。这个思路以后在任何需要随机生成不重复位置的场景里都能复用。4.3 给setInterval调速时出现双计时器假设你一开始用setInterval写了游戏循环打算每吃三个食物让速度提高一点。最直觉的写法是speed speed - 10; clearInterval(timer); timer setInterval(gameTick, speed);看起来没问题但如果你某次忘了clearInterval或者因为某个分支提前return了旧计时器还在跑新的计时器又启动了。两个计时器都在调update蛇每次移动两格速度翻倍而且随着你反复调速计时器会越积越多到最后蛇直接变成瞬移。setTimeout递归虽然写法没那么标准但它天然只有一个链每次执行完再安排下一次不需要手动清除变速也只需要改变量。requestAnimationFrame加时间戳判断的思路也一样只是更贴近浏览器的渲染节奏。这两者都比setInterval省心强烈建议直接用后者。4.4 方向键让页面跟着滚动把页面打开按空格或者方向键如果页面高度超过视口你就会看到页面在滚动。尤其是空格按一下就往下滚一大截。这是因为浏览器对方向键和空格有默认的滚动行为。解决办法是在keydown监听器里调用preventDefault()。但注意要针对那些需要拦住的键做判断不能每个键都拦。我代码里是判断ArrowUp、ArrowDown、ArrowLeft、ArrowRight和空格这五个键因为游戏里会用到它们。其他的像F12、Esc、字母键都不影响没必要拦。如果你以后把游戏做成全屏模式可能还要在touchmove上做preventDefault来阻止移动端的默认缩放这是另一个话题放在后面讲。5. 还能怎么改难度、手势、视觉和玩法的升级路线5.1 难度曲线别让速度无脑加快默认速度是150ms走一格也就是每秒大概6.7格对新手来说刚好。如果每吃一个食物就加速一次很快会超过人类的反应极限游戏变成必死局体验非常差。我建议按得分区间分段加速或者每吃三个食物加速一次每次减少8到10ms下限设到80ms左右。写起来很简洁// 每吃两个食物加速一次速度下限80ms speed Math.max(80, 150 - Math.floor((score / 10) / 2) * 8);这样前期提升明显后期趋于平缓玩家能感受到挑战逐步增加但又不至于突然失控。实际调参的时候建议自己玩几局找到手心微微出汗的那个临界值那就是这套难度曲线最适合的区间。5.2 移动端手势支持手机上没有键盘所以必须加触摸手势。原理很简单记录触摸开始的位置在触摸结束时计算横纵位移位移更大的那个方向就是最新的移动方向。为了避免手指轻轻一抖就误触可以加一个阈值判断。let touchStartX 0; let touchStartY 0; canvas.addEventListener(touchstart, (e) { touchStartX e.touches[0].clientX; touchStartY e.touches[0].clientY; e.preventDefault(); }, { passive: false }); canvas.addEventListener(touchend, (e) { const dx e.changedTouches[0].clientX - touchStartX; const dy e.changedTouches[0].clientY - touchStartY; if (Math.abs(dx) 20 Math.abs(dy) 20) return; // 忽略微小滑动 let newDir; if (Math.abs(dx) Math.abs(dy)) { newDir dx 0 ? { x: 1, y: 0 } : { x: -1, y: 0 }; } else { newDir dy 0 ? { x: 0, y: 1 } : { x: 0, y: -1 }; } if (newDir.x -nextDirection.x newDir.y -nextDirection.y) return; nextDirection newDir; e.preventDefault(); }, { passive: false });注意touchstart和touchend里我加了preventDefault目的是阻止浏览器在滑动时缩放页面。如果你希望保留双指缩放就需要更精细的判断不过对于贪吃蛇这种单指操控的游戏全部拦截是比较安心的做法。5.3 视觉和玩法的进阶方向如果你还想继续打磨可以从几个方向挑一个入手。视觉层面可以给蛇头画两个眼睛让它看起来有方向感食物可以做呼吸动画用requestAnimationFrame控制半径变化吃到食物瞬间在蛇头位置画一圈扩散圆环做小爆破效果分数变化时给数字加上缩放动画。这些都不难但对Canvas的理解会加深不少。玩法层面可以加暂停功能按P暂停再按P继续实现方式是维护一个paused标志在游戏循环里跳过update但保留requestAnimationFrame。最高分可以存在localStorage里刷新页面也不丢。想更刺激一点可以加障碍物地图、穿墙模式、双蛇对战甚至让食物随机漂移。我最推荐先做两件事一是像上面代码那样把最高分用localStorage存起来这个技术几乎百试百灵二是给蛇加上眼睛和吃东西音效让游戏变得活起来。做完这两个这个项目就不只是练手作业而是一个可以拿给朋友玩的小成品了。我在写完这版贪吃蛇之后最大的感受是它看着简单但把它打磨到操作顺手、没有边界bug需要思考的细节一点都不少。输入缓冲、尾部碰撞排除、空位生成食物、请求动画帧调速每一个点单独拿出来都不难但串起来就会遇到各种各样的意外。强烈建议你把代码自己敲一遍然后试着加上一个自己的功能。到做完那一刻你会发现贪吃蛇带给你的根本不是我做出了一个游戏的满足感而是你真的开始理解浏览器里那些持续运转的画面究竟是怎么跑起来的。