用HTML+JavaScript手写贪吃蛇:Canvas游戏开发入门实战

发布时间:2026/9/9 23:43:11
用HTML+JavaScript手写贪吃蛇:Canvas游戏开发入门实战 简介一个基于 HTML、CSS 和 JavaScript 实现的贪吃蛇游戏项目面向网页开发初学者适合用来理解静态页面结构与动态逻辑如何配合。压缩包共 3 个文件包含 1 个 HTML 主文件以及背景图、角色素材各一张jpg、png整包仅 86KB轻量易用可直接在浏览器中打开运行。这份资源已吸引 1450 人学习说明其对于入门阶段确有参考价值。通过学习这份代码可以掌握游戏区域布局、键盘事件监听、蛇身移动与增长、碰撞检测、食物随机生成等关键实现还能了解如何借助 JavaScript 操作 DOM 更新界面以及用 CSS 配合制作简单动画效果。从页面初始化、计时器驱动到得分反馈整个流程都能对照源码逐行阅读是巩固前端基础知识的实用练习案例。用HTML做贪吃蛇从零手写一个能玩的小游戏贪吃蛇大概是很多人接触编程时写的第一个小游戏也是我这些年给新手推荐次数最多的项目之一。用HTMLCSSJavaScript做贪吃蛇性价比确实高玩法规则简单逻辑闭环清晰只用浏览器就能直接运行成品还能发给朋友玩特别适合刚学完前端基础语法、想动手做点东西的人。这个项目麻雀虽小但五脏俱全——有数据存储蛇身的坐标数组、有事件处理键盘监听、有逻辑判断碰撞检测、有界面刷新Canvas绘制。把这一套流程走通你对JavaScript的理解会比刷一百道语法题都管用。这篇文章我会把完整的实现思路、代码、以及实际踩过的坑都写出来保证你把代码复制过去就能跑也能看懂每一行是在干什么。1. 贪吃蛇游戏的核心设计先拆规则再选方案1.1 贪吃蛇的最小规则集写代码之前先把规则拆清楚很多人一上来就写代码写到一半发现逻辑纠缠不清就是因为没做这一步。贪吃蛇的规则其实可以拆成四条第一蛇在一个固定大小的矩形区域里移动每次移动一个格子不能掉头。这个“不能掉头”是新手最容易漏掉的逻辑——你正在向右走按了上键可以按了左键就直接撞到自己了。第二食物会在区域内随机出现蛇头碰到食物后蛇身长度增加一格同时得分增加并重新生成食物。第三蛇头撞到墙壁或者撞到自己的身体游戏结束。第四游戏结束后可以重新开始。就这么点规则。但落实到代码里它涉及的核心问题是你怎么表示蛇、怎么让蛇动起来、怎么判断碰撞。这三个问题想清楚了整个游戏就完成了一半。1.2 技术方案选型为什么选Canvas而不是DOM实现贪吃蛇有两种主流方案一种是纯DOM操作用div来拼蛇身另一种是使用Canvas绘图。我直接说结论用Canvas。我见过不少人第一次写贪吃蛇时用DOM方案——给蛇的每一节创建一个div然后每次移动时更新所有div的坐标。这个方案不是不能用但实践下来问题很多DOM节点一多页面就开始卡样式和逻辑揉在一起很乱而且每次移动要触发浏览器重排手感明显跟不上。Canvas方案就清爽很多整局游戏只需要一个canvas元素蛇和食物全部通过JavaScript绘制上去。你只需要维护一个数据数组每次刷新时重新绘制整个画面就行性能好、逻辑统一、代码也更好维护。还有一个搭配问题要考虑定时驱动用setInterval还是requestAnimationFrame。setInterval实现简单适合新手理解缺点是最小间隔有浏览器限制快速模式下可能不够平滑requestAnimationFrame更流畅但你需要自己计算帧间隔来控制游戏速度。我的建议是先用setInterval跑通逻辑等你想加“逐渐加速”功能时再切换也不迟。下面代码用的就是setInterval逻辑上最好理解。2. 从搭建页面开始HTML骨架与CSS样式2.1 画一个游戏主界面页面的HTML结构非常简单一个容器装计分栏、一快画布、一个重新开始的按钮就这三样。另外在实现时我选择让Canvas的尺寸为400x400像素划分为20x20的网格每个格子边长20像素。这个尺寸在大多数屏幕上看起来都比较合适而且20x20的网格对计算来说也很友好。div classgame-container div classheader span classscore-label得分 span idscore0/span/span button idrestartBtn重新开始/button /div canvas idgameCanvas width400 height400/canvas /div这里有一个细节要提醒canvas的宽高是通过width和height属性设置的不是CSS。很多人习惯在CSS里写width: 400px但这样会把Canvas内部的绘图坐标搞乱导致图形发虚、变形。如果你想在CSS里做缩放应该用width和height属性先设定好画布的真实分辨率再用CSS控制显示尺寸两个都设置时注意保持比例一致。2.2 配色与细节让界面不至于太丑CSS部分我选了一个简洁的深色主题这样色调统一、蛇和食物都能突出出来。蛇头用亮绿色蛇身用较暗的绿色食物用红色。深色背景对长时间盯着屏幕也比较友好。body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1a1a2e; font-family: Microsoft YaHei, PingFang SC, sans-serif; } .game-container { background: #16213e; padding: 20px 24px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; color: #e0e0e0; font-size: 18px; } #restartBtn { background: #e94560; color: #fff; border: none; padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; } #restartBtn:hover { background: #ff6b6b; } #gameCanvas { background: #0f3460; display: block; border-radius: 8px; }其中flex布局让页面整体居中不管在哪个屏幕尺寸下打开游戏面板都会固定在视口中央。这个布局方法很常用做小游戏、落地页、项目demo时都用得上。3. 核心逻辑落地JavaScript让蛇动起来3.1 用数组表示蛇身数据结构的选型决定整段代码的清晰程度。蛇的本质是一个坐标序列所以用一个数组来表示最合适。数组中的每一个元素是一个对象包含x和y两个属性表示蛇的某一节在网格中的位置。const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const restartBtn document.getElementById(restartBtn); const gridSize 20; const tileCount canvas.width / gridSize; // 20 let snake; let direction; let nextDirection; let food; let score; let gameOver; let speed; let gameTimer;我把所有游戏状态变量声明在顶层然后在init函数里统一初始化。这样做的好处是每次重新开始时只需要调用一次init所有状态就会回到初始值不会因为残留旧数据而出bug。方向我用一个向量来表示direction {x: 1, y: 0} 表示向右移动{x: 0, y: -1} 表示向上。用向量而不是字符串的好处是计算蛇头新位置时只需要做一次坐标加法非常直观。3.2 移动逻辑头进尾出蛇移动的本质是“头进尾出”。每次移动时根据当前方向计算出新的蛇头坐标插入到数组头部如果这一步没有吃到食物就把数组尾部弹出保持长度不变如果吃到了食物就不弹出蛇自然就长了一节。这是贪吃蛇最核心的算法理解了这一行代码整个游戏就通了。function move() { direction nextDirection; const head { x: snake[0].x direction.x, y: snake[0].y direction.y }; // 撞墙检测 if (head.x 0 || head.x tileCount || head.y 0 || head.y tileCount) { endGame(); return; } // 撞自己检测 for (let seg of snake) { if (seg.x head.x seg.y head.y) { endGame(); return; } } snake.unshift(head); if (head.x food.x head.y food.y) { score 10; scoreEl.textContent score; generateFood(); // 食用食物后提速每次减5毫秒下限80 if (speed 80) { speed - 5; clearInterval(gameTimer); gameTimer setInterval(gameLoop, speed); } } else { snake.pop(); } }注意这里用了一个细节重新设置定时器时先clearInterval再setInterval。这是因为setInterval的间隔时间在创建后就不能修改了要改速度只能销毁旧的定时器再建一个新的。如果不先清理就创建新定时器会造成多个定时器叠加蛇的移动速度会剧烈加快甚至瞬间跳帧这也是新手最容易踩的坑之一。3.3 键盘控制与方向锁键盘监听本身很简单但这里有一个关键的设计问题方向锁定。如果蛇正在向右移动玩家快速按了上键和左键理论上蛇应该先向上再向左。但如果我们立即响应左键蛇就直接掉头撞上了。所以我要用一个经典的缓冲变量nextDirection。每次按键时不直接修改direction而是把新方向存入nextDirection在move函数里才把它赋给direction。这样能保证同一帧内方向不会冲突。同时还要做一个反向判断——比如当前方向是向右就不能允许改成向左否则蛇会穿过自己的身体。document.addEventListener(keydown, (e) { switch (e.key) { case ArrowUp: if (direction.y ! 1) nextDirection { x: 0, y: -1 }; e.preventDefault(); break; case ArrowDown: if (direction.y ! -1) nextDirection { x: 0, y: 1 }; e.preventDefault(); break; case ArrowLeft: if (direction.x ! 1) nextDirection { x: -1, y: 0 }; e.preventDefault(); break; case ArrowRight: if (direction.x ! -1) nextDirection { x: 1, y: 0 }; e.preventDefault(); break; } });e.preventDefault()是必须写的。如果你不写按方向键时页面会跟着滚动尤其在Mac触控板上体验会非常糟糕。这个细节虽然小但能明显提升体验。3.4 食物生成与碰撞检测食物生成的核心诉求是位置随机但不能出现在蛇身上。生成食物的逻辑我用了递归检查先随机生成一个坐标然后遍历当前蛇身数组如果发现重合就重新生成。贪吃蛇的网格只有20x20总共400个格子随机碰撞的概率很小递归次数也很少性能完全没问题。function generateFood() { food { x: Math.floor(Math.random() * tileCount), y: Math.floor(Math.random() * tileCount) }; for (let seg of snake) { if (seg.x food.x seg.y food.y) { generateFood(); break; } } }碰撞检测则分两种撞墙和撞自己。撞墙检测只需要判断蛇头的坐标是否越界在move函数里已经写了。撞自己的检测我用了遍历数组的方式从蛇头开始逐个比较坐标。因为蛇身最长也就几十节线性遍历的性能损耗可以忽略不计。这里要注意的是遍历时从第0个元素开始比较没问题因为新头部还没有插入数组所以不会出现“头撞自己”的误判。4. 完整代码与实测效果4.1 整合一份能直接跑的完整代码上面把逻辑拆开讲了一遍这里把完整的代码整合出来。你只需要新建一个HTML文件把下面的内容全部复制进去然后用浏览器打开就能直接玩。这段代码已经包含了初始化、绘制、食物生成、碰撞检测、键盘控制、重新开始等全部功能并且做了速度递增和结束画面。!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title贪吃蛇/title style body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1a1a2e; font-family: Microsoft YaHei, PingFang SC, sans-serif; } .game-container { background: #16213e; padding: 20px 24px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; color: #e0e0e0; font-size: 18px; } #restartBtn { background: #e94560; color: #fff; border: none; padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; } #restartBtn:hover { background: #ff6b6b; } #gameCanvas { background: #0f3460; display: block; border-radius: 8px; } /style /head body div classgame-container div classheader span classscore-label得分 span idscore0/span/span button idrestartBtn重新开始/button /div canvas idgameCanvas width400 height400/canvas /div script const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const restartBtn document.getElementById(restartBtn); const gridSize 20; const tileCount canvas.width / gridSize; let snake; let direction; let nextDirection; let food; let score; let gameOver; let speed; let gameTimer; function init() { snake [{ x: 10, y: 10 }]; direction { x: 1, y: 0 }; nextDirection { x: 1, y: 0 }; score 0; gameOver false; speed 150; scoreEl.textContent score; generateFood(); if (gameTimer) clearInterval(gameTimer); gameTimer setInterval(gameLoop, speed); } function generateFood() { food { x: Math.floor(Math.random() * tileCount), y: Math.floor(Math.random() * tileCount) }; for (let seg of snake) { if (seg.x food.x seg.y food.y) { generateFood(); break; } } } function gameLoop() { move(); if (gameOver) return; draw(); } function move() { direction nextDirection; const head { x: snake[0].x direction.x, y: snake[0].y direction.y }; if (head.x 0 || head.x tileCount || head.y 0 || head.y tileCount) { endGame(); return; } for (let seg of snake) { if (seg.x head.x seg.y head.y) { endGame(); return; } } snake.unshift(head); if (head.x food.x head.y food.y) { score 10; scoreEl.textContent score; generateFood(); if (speed 80) { speed - 5; clearInterval(gameTimer); gameTimer setInterval(gameLoop, speed); } } else { snake.pop(); } } function draw() { ctx.fillStyle #0f3460; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制网格线 ctx.strokeStyle rgba(255, 255, 255, 0.05); for (let i 0; i tileCount; i) { ctx.beginPath(); ctx.moveTo(i * gridSize, 0); ctx.lineTo(i * gridSize, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * gridSize); ctx.lineTo(canvas.width, i * gridSize); ctx.stroke(); } // 绘制食物 ctx.fillStyle #e94560; ctx.fillRect(food.x * gridSize 3, food.y * gridSize 3, gridSize - 6, gridSize - 6); // 绘制蛇身 snake.forEach((seg, index) { if (index 0) { ctx.fillStyle #00ff88; } else { ctx.fillStyle #00cc66; } ctx.fillRect(seg.x * gridSize 1, seg.y * gridSize 1, gridSize - 2, gridSize - 2); }); } function endGame() { gameOver true; clearInterval(gameTimer); ctx.fillStyle rgba(0, 0, 0, 0.6); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #ffffff; ctx.font 24px Microsoft YaHei; ctx.textAlign center; ctx.fillText(游戏结束, canvas.width / 2, canvas.height / 2 - 10); ctx.font 16px Microsoft YaHei; ctx.fillText(得分: score, canvas.width / 2, canvas.height / 2 24); } document.addEventListener(keydown, (e) { switch (e.key) { case ArrowUp: if (direction.y ! 1) nextDirection { x: 0, y: -1 }; e.preventDefault(); break; case ArrowDown: if (direction.y ! -1) nextDirection { x: 0, y: 1 }; e.preventDefault(); break; case ArrowLeft: if (direction.x ! 1) nextDirection { x: -1, y: 0 }; e.preventDefault(); break; case ArrowRight: if (direction.x ! -1) nextDirection { x: 1, y: 0 }; e.preventDefault(); break; } }); restartBtn.addEventListener(click, init); init(); /script /body /html4.2 运行效果与手感调优把文件拖到浏览器里打开游戏就直接开始了。初始速度为150毫秒一步这个速度对新手来说刚好不会快到手忙脚乱也不会慢到无聊。吃到食物后每次加速5毫秒下限是80毫秒游戏会随着时间逐渐变得有挑战性。手感上有一个很重要的细节初始蛇身长度为1也就是只有一个头。如果你想让游戏一开始就有点难度可以初始化蛇身为3节比如[{x: 10, y: 10}, {x: 9, y: 10}, {x: 8, y: 10}]这样一个新的游戏开始时就有一定的长度不至于太简单。我测试下来初始长度为3更符合小时候玩的贪吃蛇的感觉。另外食物的大小是14x14像素在20x20的格子里留了3px的边距蛇身是18x18像素留了1px的间距。这样蛇和食物在视觉上不会完全占满格子观感更舒服也更容易区分边界。5. 常见问题与排查技巧5.1 按方向键页面跟着滚动这个我之前说过了解决方法是按方向键时调用e.preventDefault()。如果在你的代码里已经写了这个函数但页面还是滚动检查一下是不是绑定keydown事件时监听的是window而不是document或者是在别的库的事件后面绑定的可能会被覆盖。5.2 蛇突然变短或闪断这种情况绝大多数是因为在移动逻辑里每次移动都同时执行了unshift和pop导致蛇身长度维持在1不变——看起来就像蛇一直是断开的一截。解决办法就是严格按照“吃到食物就不pop没吃到食物才pop”的方式来处理。5.3 游戏加速后定时器错乱加速功能如果实现不当会在点击重新开始后出现定时器越跑越快的问题。原因就是每次加速都新建了一个定时器而没有把旧的清掉。正确的做法是在创建新定时器之前先调用clearInterval销毁旧定时器。为了更加稳妥也可以在init开头全局清理一次function init() { if (gameTimer) clearInterval(gameTimer); // 其余初始化代码... }5.4 Canvas图像发虚Canvas发虚的根源是CSS尺寸和Canvas分辨率不一致。浏览器在缩放Canvas时会按照CSS尺寸对Canvas画布进行拉伸分辨率不匹配就会模糊。解决办法是在实际开发中建议把Canvas的分辨率设得比显示尺寸大比如显示400x400Canvas设800x800用devicePixelRatio适配然后通过CSS控制显示尺寸。对于这个贪吃蛇项目400x400的分辨率已经足够清晰了保持width和height与CSS一致即可。6. 还能怎么玩扩展方向参考写完一个能跑的贪吃蛇只是第一步这个项目的价值在于你可以无限扩展。我根据自己的实践给你几个方向参考第一加障碍物。在网格里预置一些障碍块蛇头撞上去就游戏结束。障碍可以随机生成也可以根据关卡手动配置。这样难度会显著提升。第二增加最高分记录。使用localStorage把最高分存到浏览器本地刷新页面后仍然保留这样你再玩的时候就有了目标和动力。function saveHighScore() { const high localStorage.getItem(snakeHighScore); if (!high || score Number(high)) { localStorage.setItem(snakeHighScore, score); } }第三移动端触控支持。给页面加上四个方向按钮绑定touchstart事件逻辑和键盘控制一样。这样手机浏览器打开也能玩顺便练一练移动端适配。第四穿墙模式。把撞墙检测改成头从另一边出来难度立刻下降很多人玩起来反而更爽。穿墙模式实现起来就一句话if (head.x 0) head.x tileCount - 1; if (head.x tileCount) head.x 0; if (head.y 0) head.y tileCount - 1; if (head.y tileCount) head.y 0;第五加音效。用Web Audio API在吃食物时播放一个简短的提示音。不需要加载外部音频文件两行代码就能生成一个声音很有意思。我这段时间反复调这个项目最大的体会是一个看似简单的游戏真正动手做起来每一步都有值得思考的细节。方向锁、碰撞边界、定时器清理、画布尺寸任何一个环节出了问题游戏体验都会大打折扣。但也正是这些小问题逼着你把JavaScript的基础知识真正吃透。建议你把上面代码敲一遍改一改初始速度、蛇身长度、加速斜率这些参数感受一下手感的变化——玩自己写的游戏和玩别人做好的游戏完全是两种感觉。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询