☰
用HTML做贪吃蛇:单文件实现与核心逻辑解析
2026/10/8 15:12:42 网站建设 项目流程

简介:这份资源是一套面向网页开发初学者的HTML贪吃蛇游戏实战项目,适合刚接触前端、希望把HTML、CSS与JavaScript串联起来练手的学习者。项目围绕游戏区域搭建、分数显示、键盘事件监听、蛇身位置更新、碰撞检测与食物随机生成等核心逻辑展开,能帮助读者理解静态页面结构与脚本逻辑如何协同工作,并在此基础上尝试CSS3动画、音效或难度分级等扩展。压缩包共3个文件,包含1个html主页面、1张jpg背景图与1张png素材图,整体约86KB,体积轻巧,便于直接打开运行和阅读源码。目前已有1452人学习下载,说明该案例在入门练习中具有一定参考价值。通过这份资源,读者可以获得一份可运行的完整小游戏代码,对照理解DOM操作、定时器与条件判断的实际用法,并借助现成素材快速还原游戏界面,适合作为前端基础阶段的综合练习素材。

1. 用 HTML 做贪吃蛇:一个文件就能跑,但 90% 的人卡在坐标系上

很多人第一次听到「用 HTML 做贪吃蛇」,脑子里浮现的是打开记事本、敲几行标签、双击浏览器就能玩。真动手才发现,蛇不动、吃到食物不增长、撞墙判定失灵,甚至键盘按下去页面先滚动了。问题不在 HTML 本身,而在于这个项目本质是「HTML 搭骨架 + CSS 画格子 + JavaScript 跑逻辑」的三件套,HTML 只负责那块画布和页面结构,真正让蛇活起来的是后面两层。这份资源就是围绕这个最小闭环展开的:一个.html文件里同时容纳结构、样式和脚本,不依赖任何构建工具、不装 npm、不引外部库,双击即运行。适合刚学完<!doctype html>、<meta charset="utf-8">这些基础标签、想找个能立刻看到反馈的小项目练手的人,也适合想拿它当教学演示、给学员讲清楚「DOM 操作 + 定时器 + 二维网格」三者怎么配合的从业者。它解决的不是「做一个商业级游戏」,而是「用最短路径把抽象语法变成看得见的交互」。

2. 拆开这个单文件:HTML 骨架、CSS 网格与 JS 循环各管什么

2.1 为什么选 Canvas 而不是一堆 div

用 HTML 做贪吃蛇,绕不开的第一个选型就是「蛇身用什么画」。常见做法有两种:一种是用几十个<div>拼成网格,靠改style.background来显示蛇和食物;另一种是用一个<canvas>元素,所有绘制都在 JS 里通过fillRect完成。前者对新手友好,因为每个格子都是真实 DOM,改颜色直观;但蛇一长,DOM 节点数量上去,重绘和布局计算会拖慢帧率,而且坐标和 DOM 位置容易对不齐。后者把渲染收进一块画布,逻辑坐标和像素坐标一一对应,性能稳定,代价是要理解getContext('2d')这套 API。

我一般会推荐 Canvas 方案,原因很实际:贪吃蛇的移动是「整格跳变」,不是平滑动画,用 Canvas 每帧清空重画反而比操作几十个 div 更简单、更可控。下面这个骨架就是最小可运行版本的结构,注意<meta charset="utf-8">和viewport这两行,少了它们中文会乱码、手机上会缩放异常。

<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>贪吃蛇</title> <style> /* 画布居中,去掉页面默认边距,避免方向键触发滚动 */ body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #1e1e1e; overflow: hidden; } canvas { background: #111; border: 2px solid #333; } </style> </head> <body> <canvas id="game" width="400" height="400"></canvas> <script> // 逻辑全部写在这里,下一节展开 </script> </body> </html>

这段结构里,width和height直接写在<canvas>标签上,而不是用 CSS 控制,是因为 Canvas 的绘制坐标系由这两个属性决定,CSS 只负责显示尺寸。如果两者不一致,画面会被拉伸模糊。overflow: hidden是为了防止方向键按下时页面整体滚动,这是新手最常遇到的「玄学」之一。

2.2 网格坐标与游戏循环:把「移动」翻译成数学

贪吃蛇的核心不是画图,而是「状态更新」。把画布切成 20×20 的格子,每格 20 像素,蛇头坐标用{x, y}表示格子索引,而不是像素。每次移动就是根据当前方向,把蛇头坐标加一或减一,再把新头插入蛇身数组头部,然后判断是否吃到食物:吃到就不删尾部,没吃到就删掉尾部。这个「头插尾删」的队列操作,就是蛇移动的全部秘密。

游戏循环用setInterval或requestAnimationFrame驱动。setInterval简单,固定间隔执行一次更新;requestAnimationFrame跟屏幕刷新率同步,更平滑但需要自己控制时间差。对贪吃蛇这种整格跳变的游戏,setInterval足够,间隔设 120~150 毫秒手感比较稳。下面这段是可直接抄的核心逻辑,注意方向判断里那个「不能 180 度掉头」的检查,少了它蛇会瞬间咬到自己。

const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const CELL = 20; // 每格像素 const COLS = canvas.width / CELL; const ROWS = canvas.height / CELL; let snake = [{x: 10, y: 10}]; // 蛇身数组,索引 0 是头 let dir = {x: 1, y: 0}; // 当前方向,初始向右 let nextDir = {x: 1, y: 0}; // 缓冲方向,避免同一帧内多次转向 let food = {x: 15, y: 10}; let timer = null; function spawnFood() { // 随机生成食物,且不能落在蛇身上 let p; do { p = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; } while (snake.some(s => s.x === p.x && s.y === p.y)); food = p; } function update() { dir = nextDir; // 每帧只采纳一次转向 const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; // 撞墙判定:超出网格即结束 if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) { return gameOver(); } // 撞自己判定:新头坐标与身体重合 if (snake.some(s => s.x === head.x && s.y === head.y)) { return gameOver(); } snake.unshift(head); // 头插 if (head.x === food.x && head.y === food.y) { spawnFood(); // 吃到食物,不删尾,蛇变长 } else { snake.pop(); // 没吃到,删尾,长度不变 } draw(); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#4caf50'; snake.forEach(s => ctx.fillRect(s.x * CELL, s.y * CELL, CELL - 2, CELL - 2)); ctx.fillStyle = '#f44336'; ctx.fillRect(food.x * CELL, food.y * CELL, CELL - 2, CELL - 2); } function gameOver() { clearInterval(timer); ctx.fillStyle = '#fff'; ctx.font = '24px sans-serif'; ctx.fillText('Game Over', 120, 200); } document.addEventListener('keydown', e => { const map = { ArrowUp: {x: 0, y: -1}, ArrowDown: {x: 0, y: 1}, ArrowLeft: {x: -1, y: 0}, ArrowRight: {x: 1, y: 0} }; const d = map[e.key]; if (!d) return; e.preventDefault(); // 阻止方向键滚动页面 // 禁止 180 度掉头:新方向不能与当前方向相反 if (d.x === -dir.x && d.y === -dir.y) return; nextDir = d; }); spawnFood(); draw(); timer = setInterval(update, 130);

参数上,CELL决定格子大小,改小格子更多、蛇更细腻,但视觉上蛇变细;setInterval的 130 毫秒是速度,数值越小蛇跑得越快,低于 80 毫秒多数人反应不过来。nextDir这个缓冲变量是关键,如果直接改dir,玩家在两次更新之间快速按两个方向键,可能在同一帧内先转再转,导致蛇直接反向撞死自己。用nextDir暂存、每帧只采纳一次,就绕开了这个坑。

2.3 键盘事件与移动端适配的取舍

桌面端靠keydown监听方向键,逻辑简单。但这份资源如果只在电脑上跑,价值就少了一半。移动端没有物理方向键,常见做法是加四个屏幕按钮,或者监听touchstart/touchmove判断滑动方向。加按钮最稳,不依赖手势识别,代码量也小;滑动手势体验好,但阈值设不好会误判。我一般会先加按钮保证能玩,再补滑动作为增强。

// 移动端:四个方向按钮,绑定与键盘相同的转向逻辑 ['up','down','left','right'].forEach(id => { document.getElementById(id).addEventListener('touchstart', e => { e.preventDefault(); const d = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} }[id]; if (d.x === -dir.x && d.y === -dir.y) return; nextDir = d; }); });

注意touchstart里也要做反向检查,否则手机上同样会咬到自己。按钮的 HTML 结构需要额外加在<canvas>旁边,用 CSS 固定在底部,这里不展开,思路是「按钮只负责改nextDir,不直接操作蛇」。

3. 从能跑到好玩:分数、加速与暂停的实现细节

3.1 分数显示与难度递增

能跑起来之后,第二个需求通常是「记分」和「越吃越快」。分数就是蛇身长度减初始长度,或者每吃一个加 10 分,直接画在画布上或用一个<span>显示。难度递增的做法是:每吃到一个食物,把setInterval的间隔减少几毫秒,但要用clearInterval先清掉旧的再设新的,否则会同时跑多个定时器,蛇会突然加速到失控。

let score = 0; let speed = 130; function restartTimer() { clearInterval(timer); timer = setInterval(update, speed); } // 在吃到食物的分支里: score += 10; speed = Math.max(60, speed - 4); // 下限 60 毫秒,防止快到无法操作 restartTimer();

speed的下限一定要设,我见过有人不设下限,玩到后面间隔变成 10 毫秒,蛇每帧移动一格,肉眼根本看不清,直接变成「必死局」。60 毫秒是多数人还能反应的极限,再快就失去游戏意义了。

3.2 暂停与重开的边界处理

暂停不是简单地把定时器停掉就完事。停掉之后,dir和nextDir还保留着,恢复时如果玩家在暂停期间按了方向键,nextDir已经变了,恢复瞬间蛇会朝新方向走,可能直接撞墙。稳妥做法是暂停时同时冻结输入,或者恢复时把nextDir重置为dir。重开则要把蛇、分数、速度、食物全部初始化回初始状态,少重置任何一个,下一局都会带着上一局的残留。

let paused = false; function togglePause() { if (paused) { paused = false; nextDir = dir; // 恢复时对齐方向,避免暂停期间的输入生效 restartTimer(); } else { paused = true; clearInterval(timer); } }

重开函数里,snake = [{x:10,y:10}]、dir = {x:1,y:0}、nextDir = dir、score = 0、speed = 130、spawnFood()、restartTimer()这几步一个都不能少。尤其是nextDir,很多人只重置dir,结果重开后第一次按键就反向。

3.3 用 localStorage 存最高分

最高分是让这个小项目「有粘性」的最低成本改动。用localStorage存一个数字,游戏结束时比较并更新,页面加载时读出来显示。注意localStorage存的是字符串,读出来要parseInt,否则比较时会出现"100" > 90这种字符串和数字混比的怪结果。

function updateBest() { const best = parseInt(localStorage.getItem('snake_best') || '0', 10); if (score > best) { localStorage.setItem('snake_best', String(score)); } }

这段逻辑放在gameOver里调用即可。localStorage按域名隔离,本地双击打开的文件(file://)在不同浏览器里行为可能不一致,Chrome 下通常可用,但如果你把它放到某个静态服务器上,表现最稳定。

4. 避坑与排查:蛇不动、穿墙、按键失灵的真实原因

4.1 蛇按了键没反应,或者一按就反向撞死

现象是方向键按下去蛇不动,或者刚按一下蛇就掉头咬到自己。原因通常有两个:一是keydown里没有e.preventDefault(),方向键触发了页面滚动,焦点跑掉了;二是直接修改了dir而没有用nextDir缓冲,导致同一帧内多次转向叠加。解决方式是加上preventDefault,并且坚持「输入只改nextDir,update里才把nextDir赋给dir」这条纪律。另外检查map里的键名是否和e.key完全一致,ArrowUp不是Up,大小写和拼写错一个字符就静默失效。

4.2 蛇从右边出去又从左边冒出来

现象是蛇撞到边界没有结束,而是从对面穿出来。原因是撞墙判定写成了取模运算,比如head.x = (head.x + COLS) % COLS,这是「环绕」逻辑,不是「撞墙」逻辑。贪吃蛇默认应该是撞墙即死,如果你确实想要环绕玩法,那要明确这是另一种规则。排查时直接看update里边界判断那几行,确认用的是< 0和>= COLS这种比较,而不是%。

4.3 吃到食物后蛇变长了两格

现象是吃一个食物,蛇身长度增加了两格甚至更多。原因通常是unshift之后又忘了pop的条件分支写反了,或者在spawnFood里又对蛇做了操作。正确逻辑是:unshift永远执行,pop只在「没吃到」时执行。如果写成「吃到时 unshift 两次」或者「没吃到时也 unshift」,长度就会异常。排查时在update末尾打印snake.length,吃食物前后各看一次,差值应该是 1。

4.4 游戏越玩越卡,蛇身多了以后明显掉帧

现象是蛇短的时候流畅,蛇一长就卡顿。原因多半是每帧都在创建新的 DOM 或者频繁读写localStorage,也可能是draw里用了shadowBlur这类高开销效果。Canvas 方案本身不该卡,如果卡了,先检查draw里有没有在循环内做字符串拼接或 DOM 查询。把ctx.fillStyle的设置提到循环外,snake.forEach里只做fillRect,能省下大量重复赋值。另外确认没有在update里调用setInterval嵌套,那会导致定时器数量指数增长。

4.5 手机上按钮点了没反应,或者页面跟着滑动

现象是移动端按钮触摸无响应,或者一触摸页面就上下滑动。原因是touchstart没有preventDefault,浏览器把触摸当成了页面滚动手势。解决方式是在按钮的touchstart里调用e.preventDefault(),同时给按钮加touch-action: none的 CSS。如果用的是click事件,移动端会有 300 毫秒延迟,手感很差,所以优先用touchstart。另外注意按钮的z-index不要被画布盖住,否则点不到。

5. 进阶技巧:把单文件拆成可维护结构,并用帧同步替代定时器

单文件跑通之后,下一步通常是「怎么让它不那么像玩具」。我的习惯是先把逻辑按职责拆成三个对象:Snake管蛇身和移动,Food管生成和碰撞,Game管循环、输入和状态。拆完之后,update里只调用snake.move()、判断food.eatenBy(snake),可读性立刻不一样。但拆的时候有个边界要注意:不要为了拆而拆,贪吃蛇总共几百行,拆成十个文件反而增加跳转成本,三个对象是甜点区。

第二个进阶点是把setInterval换成基于时间戳的帧同步。setInterval的问题是它不保证精确间隔,浏览器标签页切到后台时会被节流,切回来可能一次性补执行多次update,蛇瞬间移动好几格直接撞死。用requestAnimationFrame配合累积时间差,可以做到「不管帧率多少,逻辑更新频率恒定」。

let lastTime = 0; let acc = 0; const STEP = 130; // 逻辑更新间隔,毫秒 function loop(now) { if (!lastTime) lastTime = now; acc += now - lastTime; lastTime = now; // 累积到 STEP 才更新一次逻辑,避免后台切回时爆发 while (acc >= STEP) { update(); acc -= STEP; } requestAnimationFrame(loop); } requestAnimationFrame(loop);

这段代码里,STEP就是原来的setInterval间隔,acc是时间累加器。关键在while而不是if,它保证即使某一帧间隔偏长,也会按固定步长补上逻辑更新,而不是一次跳一大步。同时acc在每轮减去STEP,不会无限累积。切到后台再回来时,now - lastTime可能很大,但while会把它拆成多次标准步长的更新,蛇的移动节奏保持一致,不会出现「瞬移撞墙」的翻车现场。

验证这套帧同步是否生效,有个简单办法:在update里console.log(performance.now()),正常应该看到相邻两次更新的时间差稳定在 130 毫秒左右,波动不超过十几毫秒。如果差值是 0 或者几百毫秒跳变,说明acc的累加或扣减写错了。另外记得在gameOver里把requestAnimationFrame的循环也停掉,否则游戏结束后update还在跑,分数会继续变。我一般会用一个running标志位,loop开头判断if (!running) return;,重开时再置回true。

从那以后我每次写这类小游戏,都强制先跑一遍「后台切换测试」:切到别的标签页等十秒再切回来,看蛇有没有异常移动。这个习惯帮我省掉了无数次「明明本地跑得好好的,一演示就翻车」的尴尬。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询