☰
从零手写贪吃蛇:HTML+CSS+JS与Canvas核心机制解析
2026/10/18 12:20:10 网站建设 项目流程

上个月在某前端交流群里,有个刚学完前端三件套的朋友贴了一段代码问我:为什么我的蛇一吃到食物就闪退?我打开一看,问题不大,就是食物生成时没排除蛇身占用的格子。但这让我想起来一个事实,几乎每个学前端写游戏的人,第一站都是贪吃蛇。这个看起来只有十几个函数的小项目,其实把游戏的骨架全装进去了:输入、状态管理、游戏循环、碰撞检测、渲染、计分。用html+css+js制作一款贪吃蛇小游戏,最妙的地方在于代码量不大,却能让你把所有网页游戏开发的基本盘都过一遍。如果你正卡在学了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 游戏循环:定时器驱动的正确姿势

游戏循环是整条蛇的心脏。你希望蛇每隔固定时间走一格,而不是每帧都走。最常见的直觉是用setInterval:

setInterval(() => { 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 class="game-wrap"> <div class="score-bar"> <span>得分:<span id="score">0</span></span> <span>最高分:<span id="bestScore">0</span></span> </div> <canvas id="gameCanvas" width="400" height="400"></canvas> <div id="status"></div> <button id="restartBtn">重新开始</button> </div>

CSS不需要太复杂,核心是让画布居中、给个深色背景衬托游戏区域、再加一个圆角边框。这里有个容易被忽视的点: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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 class="game-wrap"> <div class="score-bar"> <span>得分:<span id="score">0</span></span> <span>最高分:<span id="bestScore">0</span></span> </div> <canvas id="gameCanvas" width="400" height="400"></canvas> <div id="status"></div> <button id="restartBtn">重新开始</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> </html>

4. 只有跑起来才明白的四个坑

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,需要思考的细节一点都不少。输入缓冲、尾部碰撞排除、空位生成食物、请求动画帧调速,每一个点单独拿出来都不难,但串起来就会遇到各种各样的意外。强烈建议你把代码自己敲一遍,然后试着加上一个自己的功能。到做完那一刻你会发现,贪吃蛇带给你的根本不是"我做出了一个游戏"的满足感,而是你真的开始理解浏览器里那些持续运转的画面,究竟是怎么跑起来的。

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

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

立即咨询