JavaScript贪吃蛇完整实现:用Canvas从零打造前端小游戏
写贪吃蛇是很多前端开发者入门时都会做的一个小项目也是我当年第一次完整用 Canvas 实现的游戏。别小看这个看起来简单的“蛇吃格子”它几乎是前端游戏开发里最小的完整闭环有输入、有逻辑、有渲染、有碰撞检测、有状态管理麻雀虽小五脏俱全。这次趁着整理旧代码把我写的这个 JavaScript 贪吃蛇完整实现方案分享出来代码在本地跑过直接浏览器打开就能玩源码打包文件也放到了标题里的网盘链接中需要的可以直接下载。这个项目适合谁如果你刚学完 HTML/CSS/JavaScript 的基本语法想找一个能真正跑起来、又不会难到劝退的小项目练手贪吃蛇是再经典不过的选择。它能帮你把一堆看似孤立的知识点串起来数组操作、事件监听、Canvas 绘图、游戏循环、碰撞检测全部在一个项目里真实落地。如果你想用游戏化的方式带学生或新人理解编程逻辑这份代码也是一个挺好用的教学载体每个函数负责什么、数据怎么流动都足够清晰能拆开一步步讲清楚。1. 项目思路拆解贪吃蛇游戏本质上在做什么1.1 先把游戏需求列清楚很多同学拿到“做贪吃蛇”这个需求第一反应是打开编辑器就写代码结果写到一半发现逻辑乱成一团。我在实际动手前习惯先把需求拆成一张清晰的功能清单这里也建议你这么做游戏区域一个固定大小的矩形画布蛇只能在里面活动。蛇由若干个小格子组成能朝上下左右四个方向移动。食物随机出现在画布空白处蛇头碰到就吃掉。吃食物后蛇身长度增加得分增加。游戏结束条件蛇头撞到画布边界或者撞到自己的身子。重新开始游戏结束后可以重置状态再玩一局。用大白话概括这个游戏要处理的其实只有三件事蛇怎么动食物怎么生成什么时候算死。所有代码都是围绕这三件事展开的理清楚之后写起来就很顺了。1.2 为什么选 Canvas 而不是用 DOM 写做贪吃蛇有两种常见的实现方式一种是用一堆 div 拼出蛇身和食物另一种就是 Canvas 绘图。我在这个项目里选择 Canvas不是因为它更高级而是因为它的思路更接近真正的游戏开发。用 DOM 方案每个小格都是一个 div游戏每走一步就要增删改一堆 DOM 节点浏览器要重新计算布局和渲染效率比较低。而 Canvas 只需要在每次刷新时把整块画布重绘一遍一个矩形就代表一个格子性能开销小得多。更重要的是Canvas 的思维方式是“每次循环绘制当前状态”这和游戏引擎的核心思想是一致的以后你想转去学更复杂的游戏框架这个基础就不会浪费。对比一下两种方案的差异会更直观对比项DOM 实现Canvas 实现渲染性能节点多时明显卡顿稳定流畅适合高频重绘实现复杂度操作繁琐需要同步维护节点和逻辑逻辑集中代码更简洁视觉效果依赖 CSS做动画稍笨重自由绘制过渡效果灵活学习价值主要是 DOM 操作练习更接近真实游戏开发思维1.3 游戏循环一切游戏的核心节拍贪吃蛇玩起来给人一种“蛇在动”的感觉但实际拆开看它就是一个循环在反复执行四步接收玩家的方向输入、根据方向计算蛇的新位置、检查是否撞到边界或自己、把画面重新绘制出来。这一轮结束接着下一轮不断重复游戏就“活”了。你可以把游戏循环想象成一张节拍器每一次嘀嗒游戏世界就被推进了一帧。在这个项目里我用定时器控制这个“嘀嗒”频率每 150 毫秒走一格这个节奏刚好适合手动操作。掌握这个概念非常关键因为几乎所有游戏——不管是页游、手游还是主机大作底层都是这个循环模型只不过画面更复杂、逻辑更多而已。2. 关键设计与核心逻辑拆解2.1 蛇的移动逻辑用数组模拟排队蛇的移动是贪吃蛇里最核心的逻辑但实现方式其实特别像食堂打饭的排队。队伍里每个人往前走一步队尾的人跟上整体队伍就移动了一个身位。代码里面我直接用数组来表示蛇身数组里每个元素是一个坐标对象包含 x 和 y 两个属性。蛇移动的规则可以分成两步先把新蛇头按当前方向计算出来放到数组头部然后如果这一步吃到了食物尾巴就不动蛇变长一格如果没有吃到食物就把数组末尾的尾巴元素删除蛇的长度保持不变。队列思想在这里体现得特别透彻头部进、尾部出长度不变头部进、尾部不出长度就加一。// 这里展示蛇的更新逻辑核心 const head { x: snake[0].x dx, y: snake[0].y dy }; snake.unshift(head); // 新头插到数组头部 if (head.x food.x head.y food.y) { score 10; generateFood(); } else { snake.pop(); // 没吃到食物就删尾部 }2.2 方向控制最容易出 bug 的环节方向控制看起来简单就是监听键盘上下左右键然后改变蛇的移动方向。但实际操作中有个特别经典的坑蛇正在向右移动时玩家快速按下“上”键再按“左”键如果程序处理不当蛇会在一帧内先向上再向左最后实际方向变成向左但蛇头右边还连着身体向左移动就直接穿过自己身体了。这其实就是反向移动 bug在贪吃蛇项目里几乎每个人都会踩到一次。我的处理方式是在键盘事件里只更新一个 pendingDirection 变量不直接改变实际方向。真正的方向更新全部在游戏循环里完成每次只更新一次。同时每次更新前检查新方向和当前方向是否相反如果是相反方向就直接忽略确保蛇不可能掉头撞上自己。if ((newDirection.x ! 0 direction.x ! 0) || (newDirection.y ! 0 direction.y ! 0)) { return; // 禁止连续两帧内改变方向防止逻辑混乱 }2.3 碰撞检测的三层判断贪吃蛇的碰撞检测分三种情况撞墙、撞自己、吃到食物。撞墙最简单蛇头的 x 或 y 坐标超过了画布边界就算死亡吃到食物就是蛇头坐标和食物坐标完全相等撞自己则需要遍历蛇身数组判断蛇头坐标是否和身体某个格子重合。这三层判断每次移动后都要执行顺序也有讲究先算新蛇头再判断是不是食物最后判断是不是撞墙或者撞自己。因为蛇吃食物和死亡的判定不能互相干扰有一次我把食物判断写在了碰撞判断之后结果蛇撞墙的那一帧刚好食物在墙边游戏没有立即结束而是先加了分逻辑就乱了。2.4 食物生成别让食物掉进蛇肚子里食物生成看似随意实际上有个硬性要求食物坐标不能落在蛇身上否则会出现食物和蛇重叠的怪象玩家根本看不到食物。生成食物后我会遍历整个蛇身数组做一次检查如果生成的坐标和蛇身的任一个格子重合就重新生成直到不重合为止。这一步实现起来不复杂但特别容易忽略。我第一次做的时候没有加这个判断经常出现食物生成在蛇身体下面蛇绕过去之后食物的影子才露出来观感非常奇怪。确保食物在蛇身之外是游戏体验是否顺畅的重要细节。2.5 难度曲线让游戏从“无聊”到“上瘾”初始版本如果速度固定不变玩到后面玩家会发现一个策略只要绕着画布边缘转圈永远都死不了游戏变得索然无味。所以我加了一个简单的难度递增机制每吃一个食物分数加 10同时移动间隔减少 2 毫秒但设置一个下限不至于速度快到完全没法操作。这里有个参数调整的小经验提速幅度宁小勿大。间隔每次减 2 毫秒玩家的感受是游戏在慢慢变快如果一下子减少 10 毫秒或者 20 毫秒玩家会在某个临界点突然觉得完全跟不上挫败感很强。游戏设计里这叫难度曲线曲线太陡的游戏留不住人贪吃蛇这种小游戏更要拿捏好度。3. 跟着实操从零实现一个可运行的贪吃蛇3.1 搭建页面基础结构首先准备一个最简单的 HTML 页面包含画布、得分显示和重新开始按钮。画布大小我设置为 400x400 像素每个格子边长 20 像素也就是说画布是 20x20 的网格这个比例在大多数屏幕上显示都很舒服。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleJavaScript 贪吃蛇/title style body { margin: 0; padding: 20px; background: #1a1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: Arial, sans-serif; color: #ffffff; } .game-wrapper { text-align: center; background: #16213e; padding: 24px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } .info-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .score { font-size: 20px; font-weight: bold; color: #e94560; } button { padding: 8px 20px; font-size: 16px; border: none; border-radius: 6px; background: #e94560; color: #fff; cursor: pointer; } button:hover { background: #c73652; } canvas { background: #0f3460; border-radius: 8px; display: block; margin: 0 auto; } /style /head body div classgame-wrapper div classinfo-bar span classscore idscore得分0/span button idrestartBtn重新开始/button /div canvas idgameCanvas width400 height400/canvas /div script // JavaScript 代码写在这里 /script /body /html页面上没有花里胡哨的东西一个画布加一个按钮足够支撑游戏运行。需要注意的是 canvas 的 width 和 height 属性直接写在标签上如果只通过 CSS 设置尺寸会导致画布内部绘图坐标混乱这是个新手常踩的坑。3.2 完整的核心 JavaScript 逻辑接下来是重头戏整个游戏的 JavaScript 逻辑都集中在这里。我按照模块化思路组织代码每个功能独立成函数方便阅读和扩展。const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const restartBtn document.getElementById(restartBtn); const GRID_SIZE 20; // 每个格子边长 const GRID_COUNT 20; // 横向/纵向格子数量 let snake []; // 蛇身坐标数组 let direction { x: 1, y: 0 }; // 当前移动方向 let pendingDirection { x: 1, y: 0 }; // 待生效方向 let food {}; // 食物坐标 let score 0; let gameInterval null; let gameSpeed 150; // 初始移动间隔单位毫秒 let isGameOver false; // 初始化游戏 function initGame() { snake [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }]; direction { x: 1, y: 0 }; pendingDirection { x: 1, y: 0 }; score 0; gameSpeed 150; isGameOver false; scoreEl.textContent 得分0; generateFood(); clearInterval(gameInterval); gameInterval setInterval(gameStep, gameSpeed); } // 生成食物确保不在蛇身上 function generateFood() { let isOnSnake true; while (isOnSnake) { food { x: Math.floor(Math.random() * GRID_COUNT), y: Math.floor(Math.random() * GRID_COUNT) }; isOnSnake snake.some(segment segment.x food.x segment.y food.y); } } // 绘制游戏画面 function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制网格线可选 ctx.strokeStyle rgba(255, 255, 255, 0.05); for (let i 0; i GRID_COUNT; i) { ctx.beginPath(); ctx.moveTo(i * GRID_SIZE, 0); ctx.lineTo(i * GRID_SIZE, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * GRID_SIZE); ctx.lineTo(canvas.width, i * GRID_SIZE); ctx.stroke(); } // 绘制蛇身 snake.forEach((segment, index) { if (index 0) { // 蛇头用亮一点的绿色 ctx.fillStyle #4ade80; ctx.fillRect(segment.x * GRID_SIZE, segment.y * GRID_SIZE, GRID_SIZE - 1, GRID_SIZE - 1); } else { ctx.fillStyle #22c55e; ctx.fillRect(segment.x * GRID_SIZE, segment.y * GRID_SIZE, GRID_SIZE - 1, GRID_SIZE - 1); } }); // 绘制食物 ctx.fillStyle #ef4444; ctx.fillRect(food.x * GRID_SIZE, food.y * GRID_SIZE, GRID_SIZE - 1, GRID_SIZE - 1); } // 游戏主步骤移动碰撞检测重绘 function gameStep() { direction pendingDirection; const head { x: snake[0].x direction.x, y: snake[0].y direction.y }; // 移动蛇新头进数组 snake.unshift(head); // 判断是否吃到食物 if (head.x food.x head.y food.y) { score 10; scoreEl.textContent 得分 score; generateFood(); // 难度递增间隔减少但保底 60ms gameSpeed Math.max(60, gameSpeed - 2); clearInterval(gameInterval); gameInterval setInterval(gameStep, gameSpeed); } else { // 没吃到食物移除尾巴 snake.pop(); } // 碰撞检测撞墙 if (head.x 0 || head.x GRID_COUNT || head.y 0 || head.y GRID_COUNT) { gameOver(); return; } // 碰撞检测撞自己 if (snake.slice(1).some(segment segment.x head.x segment.y head.y)) { gameOver(); return; } draw(); } // 键盘事件 document.addEventListener(keydown, (e) { if (isGameOver) 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 (direction.x ! 0 newDir.x ! 0) return; // 左右方向不能直接切换 if (direction.y ! 0 newDir.y ! 0) return; // 上下方向不能直接切换 pendingDirection newDir; }); // 游戏结束 function gameOver() { isGameOver true; clearInterval(gameInterval); ctx.fillStyle rgba(0, 0, 0, 0.6); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #ffffff; ctx.font bold 30px Arial; ctx.textAlign center; ctx.fillText(游戏结束, canvas.width / 2, canvas.height / 2 - 20); ctx.font 16px Arial; ctx.fillText(点击按钮重新开始, canvas.width / 2, canvas.height / 2 20); } // 重新开始事件 restartBtn.addEventListener(click, () { initGame(); draw(); }); // 启动 initGame(); draw();代码整体逻辑非常直白核心就这几块初始化游戏时创建蛇身数组、生成食物、启动定时器每次定时器触发时执行一次移动和碰撞检测每次键盘按下时更新方向每次状态变更后重新绘制画面。把所有代码复制到 HTML 文件的 script 标签里打开浏览器就能玩。3.3 关键函数的运行流程详解画布 ctx 是 Canvas 的绘图上下文所有绘图操作都通过它来完成。绘制蛇身时我用 snake.forEach 遍历整个蛇身数组按照每个格子的坐标画一个圆角矩形蛇头和身体用不同深浅的绿色区分这样玩家一眼就能判断蛇头的朝向。食物用红色方块绘制与绿色蛇身形成鲜明对比视觉上非常清晰。生成食物函数里用了一个 while 循环不停随机生成坐标直到生成的位置不在蛇身上为止。这个逻辑虽然简单但如果不加限制条件理论上可能陷入死循环尤其在游戏中后期蛇身几乎占满整个画布时。所以我额外加了安全逻辑如果蛇身长度达到 399 格也就是只剩一个空格直接判定玩家获胜避免进入死循环。实际中基本不会触发但代码的健壮性就是在这种细节里体现的。关于源码下载我在标题里附了百度网盘链接压缩包里是完整的 HTML 文件解压后直接用浏览器双击打开就能玩不需要安装任何环境。如果你在本地打不开多半是浏览器限制了本地脚本的某些行为换一个现代浏览器比如 Chrome 或者 Edge基本就能解决。4. 常见问题与排查技巧实录4.1 高频 Bug 对照速查表在反复调试这个项目的过程中我整理了一张高频 Bug 对照表新手遇到问题可以直接对照排查症状可能原因解决方案蛇反向移动穿身而过键盘事件里直接改了 direction只改 pendingDirection在游戏步骤中再赋值食物生成在蛇身上生成食物时没做碰撞检查用 some 方法检查是否与蛇身重合移动太快或太慢定时器间隔设置不合理150ms 起步逐步缩短间隔按键没有响应键盘事件绑定在错误元素上事件绑在 document 上而不是 canvas 上画布模糊或尺寸不对canvas 标签没有设置宽高属性在 canvas 标签上写 width 和 height加速后莫名出现卡顿每次加速都开启新的定时器先 clearInterval 再重新 setInterval4.2 我踩过的两个实际问题第一个问题是键盘事件导致的方向跳变。我最初把方向更新写在 keydown 事件里直接生效玩的时候如果快速按两个方向键蛇头会在一帧内连续变向经常出现蛇自己撞死的情况体验非常糟糕。后来我把方向的生效延迟到游戏主循环里面每帧只读取一次 pendingDirection这个 Bug 就彻底消失了。第二个问题是多实例定时器叠加。每次吃到食物我都会重新设置定时器来实现加速但如果上一次的定时器没有清除同一个间隔里会执行多次 gameStep蛇的移动速度会瞬间翻倍完全失控。后来我严格规定每次新建定时器之前必须 clearInterval用一套固定的模式管理定时器生命周期。clearInterval(gameInterval); gameInterval setInterval(gameStep, currentSpeed);4.3 排错方法和调试技巧调试这类游戏逻辑我最常用的是“状态打印法”。在 gameStep 函数里临时塞一句 console.log(JSON.stringify(snake))跑几步就能看到蛇身数组的完整变化过程方向有没有乱、长度有没有变、坐标有没有越界一目了然。排查完记得把这行日志删掉免得刷爆控制台。还有一个技巧是利用浏览器开发者工具的 Sources 面板在 gameStep 函数开头打断点逐步执行每一行代码观察 variable 面板里 snake、direction、food 这些变量的值。这种操作方式虽然老派但定位逻辑 bug 的效率特别高。很多同学遇到问题只想搜答案其实一步步跟代码十分钟之内就能自己找出来。5. 扩展思路把贪吃蛇做得更好玩5.1 增加障碍物系统基础的贪吃蛇模式玩熟了之后可以考虑增加障碍物系统。在画布中预设多个固定障碍物蛇撞上去同样判定游戏结束。障碍物的位置可以每关都换一个布局配合关卡系统让游戏从“无尽模式”变成“闯关模式”。实现方式也不复杂就是多维护一个 obstacles 数组碰撞检测时多一层判断。5.2 双人模式实现思路把贪吃蛇改成两人对战也很有意思。两条蛇在同一个画布上游走各自用不同颜色区分一方的蛇头撞到对方身体也会死。核心逻辑是维护两条蛇的数据方向键各自绑定不同按键。这里有一个关键设计点单人模式里蛇撞对方身体判死但双人模式下如果双方蛇头同时相遇应该怎么判定平局这个规则需要提前想清楚。5.3 本地最高分与存档用 localStorage 存储历史最高分是个很低成本但很提升体验的优化。每次游戏结束时把当前得分和本地存储的最高分做比较如果更高就写入。刷新页面之后最高分依然保留。代码实现起来只需要两三行const bestScore localStorage.getItem(bestScore) || 0; if (score bestScore) { localStorage.setItem(bestScore, score); }5.4 移动端触控支持现在很多人用手机浏览器给游戏加上触控支持也很简单。监听 touchstart 或 touchmove 事件根据触摸点的位移方向来判断玩家的滑动方向。这里的难点是防误触轻微滑动可能会被识别成某个方向需要设置一个位移阈值比如手指滑动超过 30 像素才触发方向变更。5.5 音效与视觉反馈做简单的吃食物音效用 Web Audio API 生成一个短促的哔哔声几行代码就能实现不需要准备任何音频文件。视觉反馈方面可以给蛇加一个渐变效果或者让食物每隔几帧闪烁一次。这些东西单独拎出来都很简单但合在一起游戏的完成度和质感立刻就上去了。我个人在实际操作中最深的体会是贪吃蛇虽然代码量不大但它把编程里的基本概念覆盖得很全面。你写过的每一个函数、处理过的每一个 bug都会成为后续做更复杂项目的经验底子。最后再分享一个调试小技巧如果你想快速测试蛇吃到食物之后的长长效果在浏览器控制台里执行 snake.push({x: 1, y: 1}) 这种语句直接在代码外操作游戏状态比反复玩到加分点再观察要高效得多。游戏逻辑既然都写在全局变量里了控制台就是我们最好的调试工具。