网页版贪吃蛇从零实现:JavaScript+Canvas核心逻辑拆解
发布时间:2026/9/15 20:50:45来源:尧图网络
先说点实在的贪吃蛇这个项目几乎是每个学编程的人绕不过去的第一道坎。它看起来简单却把坐标计算、数据结构、碰撞检测、用户响应与定时刷新这些基础又核心的知识点全串起来了。很多教材喜欢拿C语言做控制台版后台也常有人问“贪吃蛇C语言代码怎么改”但我的建议是如果你只是想快速看到一个能跑、能玩、还能拿给朋友炫耀的成果网页版才是性价比最高的起点。你不需要装Visual Studio不需要折腾编译器的中文字符乱码也不用面对控制台窗口里一闪一闪的光标——只需要一个浏览器和记事本把代码一贴刷新页面就能玩。这篇文章就把我实践过的完整流程拆开来讲照着做一小时之内你就能拥有一个属于自己、可二次改造的贪吃蛇。1. 项目整体设计与思路拆解1.1 贪吃蛇的玩法本质状态、网格与移动规则先别急着写代码要把规则想透。贪吃蛇本质上是一个“状态不断更新”的网格游戏。整个游戏区域被切分成若干个大小相等的格子——通常取20×20或者25×25——蛇的每一节身体都占据一个格子食物也占据一个格子。蛇每“走”一步本质上就是蛇头从当前格子移动到上、下、左、右其中一个相邻格子然后身体跟着移动。这里有一个新手特别容易绕晕的地方蛇身体是怎么跟着头走的很多人第一反应是用循环把所有身体节点都往前推一格像排队一样。这种做法不是不行但代码写起来麻烦还容易因为索引顺序出错导致身体乱跳。更优雅的思路是“头进尾出”每走一步在蛇头方向新增一个格子同时把蛇尾的格子删掉保持总长度不变。吃到食物的时候只在头部新增一格、不删尾部蛇的长度自然就加一了。这个思路理解了后面所有逻辑都顺了。另外要明确两个循环一个是游戏逻辑循环负责定时更新蛇的位置、判断是否吃到食物、是否碰到墙壁或自己另一个是渲染循环负责把最新的游戏状态画到屏幕上。网页版里这两个循环可以由同一个定时器触发但心里要清楚它们分别是干什么的。1.2 为什么选用网页版实现而不直接上C语言版后台搜“贪吃蛇C语言代码”的人很多但我的建议很直接新手先做网页版做明白之后想迁移到C语言也就半天的事。原因是网页版有三个C语言给不了的优势。第一环境成本几乎为零。浏览器人人都有HTML文件的书写也不需要任何IDE记事本就能搞定写完保存成.html文件双击就是游戏。C语言虽然也可以写但涉及图形库初始化、键盘监听、控制台清屏与重绘环境配置步骤就要劝退一批人。第二网页版的“反馈感”极强。浏览器里刷新就能重新开始界面在Canvas上绘制颜色、样式、速度参数想怎么调就怎么调所见即所得。C语言控制台版虽然也能做但黑白字符天然缺少视觉反馈玩起来的成就感差一截。第三网页版的技术栈覆盖面更宽。HTML负责页面结构CSS负责样式JavaScript负责逻辑。等你把贪吃蛇做出来后Canvas绘图、事件监听、定时器、数组操作这些知识点全都会了再往后做俄罗斯方块、2048这类小游戏可以直接复用这套思路。1.3 技术选型清单与整体架构我用的是最标准的一套组合HTML CSS JavaScript游戏画面用Canvas绘制。这是目前实现网页小游戏最简洁、跨平台最稳定的方案不需要引入任何第三方库。画布大小我习惯用400×400像素格子数20×20每个格子20像素。这个尺寸在手机上可能偏小但做教学演示和电脑端体验刚好后期想适配大屏改两个常量的值就行。整体架构分四块数据层蛇的坐标数组、方向、分数、食物坐标、逻辑层移动、碰撞、吃食物、渲染层清屏、画网格、画蛇、画食物、画分数、控制层键盘事件。新手最容易犯的错误是把所有代码堆在一个函数里比如边移动边画图还边判断碰撞这样后期改一个参数就得牵一发而动全身。我写小项目也习惯分层——不是说要搞什么框架而是让每一块职责清晰出了问题能快速定位。2. 核心细节解析与实操要点2.1 Canvas坐标系从像素到网格的换算Canvas的坐标系和数学里的坐标系不一样它的原点在左上角x轴向右y轴向下。这一点一定要在脑子里刻清楚否则画出来的蛇很容易上下颠倒、左右翻转。我们的网格是20×20每个格子20像素所以格子的行列坐标范围是0到19。实际写代码时蛇身和食物存的不是像素值而是格子的行列坐标。比如蛇头是(3, 5)表示第3列第5行的格子。只有真正画到Canvas上时才把格子坐标换算成像素坐标x乘以格子大小、y乘以格子大小。这就是像素坐标与网格坐标的换算关系。换算起来也非常简单用乘法即可const CANVAS_SIZE 400; const GRID_COUNT 20; const CELL_SIZE CANVAS_SIZE / GRID_COUNT; // 20这种“逻辑坐标与像素坐标分离”的设计习惯非常值得养成。如果直接拿像素坐标当逻辑坐标蛇的移动就会陷入“每次该走几步”的麻烦里而且很容易出现蛇头画在半个格子上、对不齐网格的情况。先定格子再走格子每一个状态都是整齐的。2.2 蛇的数据结构设计与移动算法本质前文提到“头进尾出”具体到代码数据结构上我推荐直接用数组每个元素是一个包含x和y的坐标对象。数组的第一个元素是蛇头最后一个元素是蛇尾。比如初始蛇长为3脑袋在网格中间身体往左边伸直对应的数组就是let snake [ {x: 9, y: 10}, // 头 {x: 8, y: 10}, // 身体 {x: 7, y: 10} // 尾 ];每次移动时先根据当前方向算出新的蛇头坐标然后用unshift方法把它插到数组最前面再根据是否吃到食物决定是否用pop方法删除尾部。没吃到食物就弹掉尾部蛇看起来就是在“平移”吃到食物就不弹蛇的长度就加一了。核心代码只有两行snake.unshift(newHead); // 新头加进来 if (!isEating) snake.pop(); // 没吃到食物才丢尾巴这个技巧看着简单但背后是数组操作和时间复杂度的考量。如果用循环把每个身体节点都往前移每一次移动都是O(n)操作用头进尾出则是O(1)不管蛇多长都很快。对于20×20的网格来说性能差异不大但代码简洁度差异非常大。新手常常在这卡住我专门把这一点放前面说就是希望你从一开始就用对。2.3 食物生成与碰撞检测细节决定体验食物的生成逻辑不复杂随机找一个空格子坐标把食物画上去。但这里有一个新手很容易忽略的坑——食物不能生成在蛇身上否则就会出现“蛇身上凭空多了个苹果”的诡异画面。处理方式非常简单粗暴但有效生成一个随机坐标后遍历蛇身数组如果坐标冲突了就重新生成。当然极端情况下随机次数会多但对于20×20的网格来说蛇再长也很难占满所有格子这种暴力办法足够了。碰撞检测则是整个游戏胜负判定的核心分两种一是撞墙二是撞自己。撞墙很简单只需判断新蛇头坐标是否小于0或大于等于GRID_COUNT即可撞自己则是遍历蛇身数组的每个节点看有没有和蛇头坐标重合的。需要注意的是遍历蛇身时要从头往后遍历并且跳过或允许末尾的“假碰撞”——因为蛇每次移动尾巴会移开所以蛇头移动到原来尾巴的位置是合法的不算撞自己。这个问题稍不注意就会导致游戏误判特别是蛇比较长、转弯频繁的时候特别容易出BUG。判断的逻辑可以这样写function hasSelfCollision(head, body) { for (let i 0; i body.length - 1; i) { if (body[i].x head.x body[i].y head.y) return true; } return false; }循环条件里的“i body.length - 1”就是刻意排除身体最后一个元素也就是本次移动即将移开的那个尾巴格子。这个细节听起来小但恰恰是坑过很多新手的地方。2.4 定时刷新机制setInterval与requestAnimationFrame怎么选游戏的核心节奏靠定时器驱动。大多数贪吃蛇教程用setInterval每100毫秒刷新一次这样每秒走10格速度适中。但你有没有遇到过这样的情况页面切到别的标签页再切回来蛇瞬间冲出去好远这就是setInterval在后台标签页被降频回来之后补执行造成的。我自己的方案是用requestAnimationFrame作为渲染驱动再用一个时间戳差值来控制逻辑更新的频率。rAF的优点是浏览器会自动优化渲染时机页面不显示时自动暂停不会出现“补帧冲出去”的诡异现象。具体做法是每一帧计算距离上次逻辑更新的时间超过设定的interval就执行一次moveSnake逻辑同时把“上次更新时刻”重置。这样画面渲染跟随浏览器频率逻辑更新保持稳定节奏。简单来说setInterval适合快速实现的版本rAF适合体验更稳的版本。后面我会给出两种写法二选一即可新手建议先用setInterval跑通再试着改成rAF体会一下区别。3. 实操过程与核心环节实现3.1 第一步搭出页面最小骨架打开你的代码编辑器新建一个文件命名为snake.html。先写一个干净的HTML结构一个用于显示游戏画面的Canvas画布、一个显示分数的区域、一个重新开始的按钮。样式别急着写复杂先把结构搭出来能显示内容最重要。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title贪吃蛇小游戏/title style body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #1e272e; font-family: sans-serif; } .game-wrap { text-align: center; color: #ecf0f1; } canvas { background: #2f3640; border-radius: 8px; display: block; margin: 12px auto; } #score { font-size: 20px; margin-bottom: 8px; } button { padding: 8px 20px; font-size: 16px; border: none; border-radius: 6px; background: #4bcffa; color: #1e272e; cursor: pointer; } /style /head body div classgame-wrap h2贪吃蛇/h2 p idscore当前分数0/p canvas idgameCanvas width400 height400/canvas button idrestartBtn重新开始/button /div script // 游戏代码写在这里 /script /body /html这个骨架里canvas的width和height是画布的实际像素尺寸和CSS里设置的显示尺寸最好保持一致避免模糊。背景用深色是为了让鲜绿的蛇和红色食物都突出这个配色可以后续随便换。3.2 第二步初始化全局变量与游戏状态在script标签里第一步就是定义全局变量。我习惯把所有可调参数集中在最上面方便后面改。用英文命名清晰易读。const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const GRID_COUNT 20; const CELL_SIZE 20; let snake []; let direction {x: 1, y: 0}; // 当前移动方向 let nextDirection {x: 1, y: 0}; // 缓存的下一次方向 let food {x: 15, y: 10}; let score 0; let gameOver false; let gameInterval null; const BASE_INTERVAL 120; // 毫秒间隔越小蛇跑得越快注意direction和nextDirection是两个对象。经验不足的人可能会问直接用一个方向变量不行吗我试过不行。原因是键盘事件和定时器事件是交错触发的如果只在keydown事件里直接改direction就可能出现一个刷新周期内方向被连改两次导致蛇头刚向右转完身体还在原地又立刻要向左转直接反向穿过身体。把用户输入先存在nextDirection里每次逻辑刷新时才把nextDirection的数据安全地同步给direction就不会出这种问题。初始化蛇的位置可以让它从场地中间往右出发function initGame() { snake []; for (let i 0; i 3; i) { snake.push({x: 10 - i, y: 10}); } direction {x: 1, y: 0}; nextDirection {x: 1, y: 0}; score 0; gameOver false; generateFood(); updateScore(); clearInterval(gameInterval); gameInterval setInterval(gameLoop, BASE_INTERVAL); }这里的for循环是为了让蛇“头在右、身体往左”比如蛇头在(10,10)身体在(9,10)尾巴在(8,10)。恰好和数组的“头进尾出”结构对应。3.3 第三步绘制网格、蛇与食物绘制是整个游戏最直观的反馈。我的习惯是每次逻辑更新后先把整个画布清空再依次绘制背景网格、食物、蛇身、蛇头。这样虽然简单但能保证画面始终是最新状态不会出现残影。function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 画网格浅色细线 ctx.strokeStyle #3e4a56; ctx.lineWidth 1; for (let i 1; i GRID_COUNT; i) { ctx.beginPath(); ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(canvas.width, i * CELL_SIZE); ctx.stroke(); } // 画食物 ctx.fillStyle #e74c3c; ctx.beginPath(); ctx.arc( food.x * CELL_SIZE CELL_SIZE / 2, food.y * CELL_SIZE CELL_SIZE / 2, CELL_SIZE / 2 - 2, 0, Math.PI * 2 ); ctx.fill(); // 画蛇 for (let i 0; i snake.length; i) { const seg snake[i]; ctx.fillStyle i 0 ? #2ecc71 : #27ae60; const padding i 0 ? 2 : 3; ctx.fillRect( seg.x * CELL_SIZE padding, seg.y * CELL_SIZE padding, CELL_SIZE - padding * 2, CELL_SIZE - padding * 2 ); } }食物画成圆形是为了和方方正正的蛇身形成视觉区分蛇头颜色更亮、尺寸稍微大一点方便玩家第一时间定位头在哪。这些细节都属于“不加也不影响功能加了一点体验更好”的范畴非常适合练手时逐步打磨。3.4 第四步实现移动逻辑与吃食物移动逻辑是游戏的心脏必须严谨。先把nextDirection安全地同步给direction然后根据direction计算出新蛇头坐标。function moveSnake() { direction {...nextDirection}; const head snake[0]; const newHead { x: head.x direction.x, y: head.y direction.y }; // 撞墙判断 if (newHead.x 0 || newHead.x GRID_COUNT || newHead.y 0 || newHead.y GRID_COUNT) { stopGame(); return; } // 是否吃到食物 const isEating (newHead.x food.x newHead.y food.y); snake.unshift(newHead); if (isEating) { score 10; updateScore(); generateFood(); } else { snake.pop(); } // 撞自身判断必须在加减长度之后做 if (hasSelfCollision(newHead, snake)) { stopGame(); return; } }这里特别说明一下“撞自身”判断放的位置。先unshift新头蛇的数组里此时包含新头然后遍历时如果发现新头和身体某格重叠就说明撞了。但为什么还要排除最后一个元素因为如果本次没吃到食物尾巴已经在pop里移开了所以此时数组最后一个元素是倒数的第二节身体它不会和新头碰一起如果你在unshift之前就做碰撞判断反而容易把即将移开的尾巴算进去造成误判。我就是在这里调试了好一阵后来把顺序固定为“先加头、再视情况丢尾、最后检查碰撞”逻辑才彻底清晰。3.5 第五步键盘控制、计分和重新开始键盘控制模块要注意兼容小写字母、大写字母和方向键。很多笔记本用户习惯用方向键也有人喜欢WASD两个都支持最省事。document.addEventListener(keydown, (e) { if (gameOver) return; const key e.key; if (key ArrowUp || key w || key W) { if (direction.y ! 1) nextDirection {x: 0, y: -1}; } else if (key ArrowDown || key s || key S) { if (direction.y ! -1) nextDirection {x: 0, y: 1}; } else if (key ArrowLeft || key a || key A) { if (direction.x ! 1) nextDirection {x: -1, y: 0}; } else if (key ArrowRight || key d || key D) { if (direction.x ! -1) nextDirection {x: 1, y: 0}; } });为什么要判断“当前方向的反向不能转向”因为蛇不能180度掉头——一条向右走的蛇如果直接按左键会立刻撞到自己身上。但是这里不能简单用nextDirection来判断因为nextDirection可能还是上一次的缓存还没生效所以要用direction来判断才能确保不会和本次实际移动方向冲突。游戏结束时的表现也很重要不要只是白屏。我把结束状态写出提示文字并停掉定时器function stopGame() { gameOver true; clearInterval(gameInterval); ctx.fillStyle #ecf0f1; ctx.font bold 36px sans-serif; ctx.textAlign center; ctx.fillText(游戏结束, canvas.width / 2, canvas.height / 2 - 15); ctx.font 18px sans-serif; ctx.fillText(点击按钮重新开始, canvas.width / 2, canvas.height / 2 25); } document.getElementById(restartBtn).addEventListener(click, () { initGame(); });代码写到这里一个能跑的贪吃蛇已经成型了。把完整的代码整合到一个HTML文件里保存后双击打开方向键控制按按钮重开。想调难度就改BASE_INTERVAL想改画面尺寸就改GRID_COUNT和CELL_SIZE非常方便。4. 常见问题与排查技巧实录4.1 蛇身“漂移”或对不齐网格多半是坐标换算问题我见过不少初学者改代码时把蛇身的坐标直接按像素存比如(x: 45, y: 35)然后画的时候又没有做除以格子大小的取整处理结果蛇越来越歪最后脱离网格。这个问题的根源就是没有遵守“逻辑坐标和像素坐标分离”的原则。我的建议是所有游戏状态的存储一律用格子坐标整数只有在draw函数里才做换算。如果你发现自己哪个地方写了“x * CELL_SIZE 一半格子大小”之类算来算去的代码就该停下来想想是不是数据存错了。4.2 按键过快导致蛇反向钻入自己身体这个问题很典型蛇向右移动你快速按了“上”再按“左”理论上蛇应该先向上走再向左转但因为两个keydown事件都在同一个逻辑帧内被接收最后一次输入直接覆盖了前一次导致蛇头还没向上走就向左转了直接撞进自己身体。解决办法就是我前面说的nextDirection缓存机制。如果只用nextDirection一个缓存还不够稳可以把输入事件改成一个队列每次逻辑帧时只取队头方向其它方向留到下一帧。我在极速操作测试中试过队列方案的容错率更高但对新手来说单缓存已经能解决98%的情况了。4.3 吃到食物但蛇没变长或分数不变优先检查判断食物条件的代码。常见错误有两种一是用“”比较食物和蛇头的格子坐标但放食物时生成的是像素坐标导致永远相等不了二是食物生成在蛇身上蛇走过去的时候其实已经碰撞到了但食物画的位置不对视觉上看着像是吃到了实际没触发逻辑。第二种情况我排查过很久后来发现是generateFood的随机数写成了(0, 1)之间的小数而不是格子的整数索引。记住随机坐标一定要取整到0到GRID_COUNT-1之间。4.4 游戏越来越卡当心定时器叠加还有一个高频问题点“重新开始”之后蛇的速度越来越快、越来越卡。这多半是因为每次点击重新开始都调用了一次setInterval而没有把上一个定时器清除掉。多个定时器同时运行moveSnake被反复执行游戏节奏直接失控。解决办法是在initGame的最前面也就是重新设置interval之前先clearInterval一次把旧的定时器彻底销毁。这个坑在很多人写的到后面加“难度递增”功能时也会出现所以先养成“新建前先清理”的习惯能省不少调试时间。4.5 从网页版迁移到C语言版时核心逻辑几乎不用改既然后台很多人搜“贪吃蛇C语言代码”这里多提一句迁移思路。游戏的核心逻辑——用数组存蛇身、头进尾出、碰撞检测——和编程语言无关直接照搬。 C语言版的不同点在于画布用二维字符数组或控制台坐标函数替代键盘监听用kbhit()或getch()轮询替代事件监听定时刷新用Sleep()函数加一个主循环替代setInterval而随机食物生成依然是用rand()取模定位。如果网页版已经把逻辑搞透了迁移到C语言版唯一需要重新学的是“控制台如何画字符”其它都不算难事。我从最早只会照着教程敲代码到现在能随手改参数、加功能贪吃蛇这个小项目对我的帮助真的很大。像“头进尾出”这种思路后来做各种列表类功能时都用得上遇到按键过快这类输入竞争的问题时也学会了用“缓存待处理指令”的方式去化解。如果你照着本文做完了建议继续加两个小功能试试一是让蛇穿过墙壁从另一边钻出来二是吃掉一个食物后随机加速一次。这两个改动会逼着你再动一遍大脑把逻辑彻底吃透。玩游戏是消遣做游戏才是真正的快乐加油。
网站建设高端定制企业官网