用HTML做贪吃蛇:前端新手必学的完整项目实战
简介这是一份面向网页开发初学者与前端练习者的HTML贪吃蛇游戏实战资源围绕HTML、CSS与JavaScript三者的协同工作展开帮助读者理解如何用标准标记语言搭建游戏界面并借助脚本实现交互逻辑。压缩包共3个文件包含1个html主页面、1张jpg背景图与1张png素材图整体约86KB体积轻巧便于直接运行与二次修改。资源以HTML结构定义游戏区域、分数显示与控制按钮配合JavaScript完成键盘事件监听、蛇身位置更新、碰撞检测与食物随机生成等核心机制同时可借助CSS3动画优化移动流畅度。目前已有1452人学习下载适合希望从零实践DOM操作、定时器与条件判断的读者通过完整可运行的小项目巩固前端基础锻炼逻辑思维与问题解决能力。1. 用 html 做贪吃蛇为什么它是前端新手最值得啃下的第一个完整项目很多人第一次接触 html 网页制作都是从静态页面开始的写几个 div、调调颜色、放张图片然后就没有然后了。页面能看但不会动更不会响应你的操作。而「用 html 做贪吃蛇」恰好卡在一个微妙的位置上——它看起来是个游戏实际上是一道把 html、css、js 基础语法全部串起来的最小闭环题。你需要一块画布、一个循环、一套输入响应、一份状态管理缺一个都跑不起来。我见过太多人卡在「蛇怎么动起来」这一步。他们能写出canvas标签也能画出一个方块但一旦要让方块按方向持续移动、吃到食物变长、撞墙就结束代码就变成了一团乱麻。问题不在语法在于没有建立起「游戏循环 状态快照」的思维模型。贪吃蛇解析开来核心只有三件事网格坐标、定时器驱动的状态更新、碰撞检测。把这三件事拆清楚剩下的就是体力活。这篇文章面向两类人一是刚学完 htmlcssjs 基础语法、想找个项目练手的前端新手二是想用最短路径理解「浏览器里怎么跑一个实时交互程序」的开发者。我不会只给你一段能跑的代码就完事而是把每一步的选型理由、参数含义、翻车点都摊开讲。你跟着走一遍能拿到一个可玩的贪吃蛇你多走一遍能把这套模式迁移到其他小游戏上。2. 先把画布和坐标系定下来canvas 尺寸、网格与渲染循环2.1 为什么选 canvas 而不是 DOM 拼格子用 html 做贪吃蛇第一道选择题就是渲染方式。常见做法有两种一种是用几十个 div 拼成网格通过改 class 来控制哪个格子亮另一种是用canvas统一绘制。我一般会直接推荐 canvas理由很实在。DOM 方案在蛇身只有十几节时还能跑但每次移动都要增删或切换 class浏览器要重排重绘蛇一长、速度一快掉帧就来了。而且 DOM 方案里每个格子是独立元素你要维护一个二维数组去映射 DOM 节点代码量反而更大。canvas 是一块位图所有绘制都在一个元素里完成清屏、画蛇、画食物就是三次 API 调用性能稳定逻辑也干净。代价是 canvas 里的东西不是 DOM你不能给它绑点击事件也不能用 css 直接改样式。但对贪吃蛇来说这恰恰不是问题——我们只需要键盘输入和定时刷新。2.2 网格尺寸怎么算才不糊canvas 的宽高和网格数量必须提前定死否则后面坐标换算会乱。我习惯用 20x20 的网格每格 20px这样画布就是 400x400。这个尺寸在手机和桌面上都不至于溢出格子也够大肉眼能看清。!DOCTYPE html html langzh-cn head meta charsetutf-8 title贪吃蛇/title style /* 画布居中去掉默认边距避免出现滚动条 */ body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #1a1a1a; } canvas { background: #111; border: 2px solid #333; } /style /head body canvas idgame width400 height400/canvas script // 全局常量网格大小和格子像素 const GRID 20; // 20x20 个格子 const CELL 20; // 每格 20px const canvas document.getElementById(game); const ctx canvas.getContext(2d); /script /body /html这段代码里GRID和CELL是两个必须提前锁死的参数。GRID决定逻辑坐标的范围CELL决定渲染时一个逻辑单位对应多少像素。后面所有坐标计算都基于这两个值改一个就要全盘检查。ctx是 2d 上下文所有绘制都靠它。提示canvas 的 width 和 height 属性不要用 css 去改否则会被拉伸模糊。要改尺寸就改属性值或者用 devicePixelRatio 做高清适配新手阶段先保持 1:1。2.3 游戏循环用 setInterval 还是 requestAnimationFrame贪吃蛇的「动」本质上是每隔一段时间把蛇头往前推一格。这里有两种驱动方式setInterval和requestAnimationFrame。setInterval简单直接你给它一个毫秒数它就按这个间隔反复执行。贪吃蛇对帧率要求不高150ms 一格完全够用所以setInterval是性价比最高的选择。requestAnimationFrame跟着屏幕刷新率走通常 60fps你需要自己累加时间来判断什么时候该走一格代码更绕适合需要平滑动画的场景。我一般会用一个变量存定时器 ID方便后面暂停和重启let timer null; const SPEED 150; // 每 150ms 走一格 function startLoop() { if (timer) return; // 防止重复启动 timer setInterval(tick, SPEED); } function stopLoop() { clearInterval(timer); timer null; }SPEED这个参数直接决定游戏难度。150ms 是偏慢的适合新手熟悉操作调到 80ms 就有明显压迫感低于 50ms 基本靠预判人手反应跟不上。后面做加速机制时也是改这个值。3. 蛇的状态怎么存数组、方向队列与碰撞检测3.1 用数组存蛇身头在数组末尾蛇的状态本质上是一串坐标。最自然的存法就是一个数组每个元素是{x, y}。关键约定是数组最后一个元素是蛇头第一个元素是蛇尾。为什么这么定因为移动时你只需要在末尾 push 一个新头再根据是否吃到食物决定要不要 shift 掉尾巴。头尾操作都是 O(1)非常顺手。let snake [ { x: 10, y: 10 }, // 尾 { x: 11, y: 10 }, { x: 12, y: 10 } // 头 ]; let dir { x: 1, y: 0 }; // 当前方向向右 let nextDir { x: 1, y: 0 }; // 下一帧要用的方向 let food { x: 5, y: 5 }; let score 0;这里有个血泪经验方向和「下一帧方向」必须分开存。如果你直接在键盘事件里改dir玩家在 150ms 内快速按两次方向键就可能出现蛇头直接反向撞自己脖子。用nextDir做缓冲在每次 tick 开始时才把nextDir赋给dir能挡掉大部分误操作。3.2 方向输入为什么不能直接反向键盘事件里要做两件事把按键映射成方向向量然后判断这个方向是否和当前方向相反。相反就忽略否则更新nextDir。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; // 反向判断当前方向和目标方向相加为零向量即为反向 if (d.x -dir.x d.y -dir.y) return; nextDir d; });反向判断用的是向量加法如果新方向和当前方向互为相反数和就是{x:0, y:0}。这个判断必须基于dir而不是nextDir否则连续快速按键时会出现绕过检测的情况。比如蛇向右走玩家快速按上再按左如果基于nextDir判断上会覆盖nextDir左再和上比不是反向就通过了结果蛇直接向左撞自己。3.3 碰撞检测撞墙和撞自己分开处理每次 tick 里先算出新蛇头坐标然后依次检查三件事是否撞墙、是否撞自己、是否吃到食物。function tick() { dir nextDir; // 锁定本帧方向 const head snake[snake.length - 1]; const newHead { x: head.x dir.x, y: head.y dir.y }; // 撞墙超出 0~GRID-1 范围 if (newHead.x 0 || newHead.x GRID || newHead.y 0 || newHead.y GRID) { return gameOver(); } // 撞自己新头坐标和蛇身任意一节重合 // 注意尾巴这一帧会移走所以可以排除最后一节未吃食物时 const willEat newHead.x food.x newHead.y food.y; const bodyToCheck willEat ? snake : snake.slice(0, -1); if (bodyToCheck.some(seg seg.x newHead.x seg.y newHead.y)) { return gameOver(); } snake.push(newHead); if (willEat) { score 10; placeFood(); } else { snake.shift(); // 没吃到就移除尾巴 } draw(); }这段逻辑里最容易被忽略的是「撞自己时要不要检查尾巴」。如果这一帧没吃到食物尾巴会移走新头落在原来尾巴的位置是合法的。所以bodyToCheck要排除最后一节。如果吃到了食物尾巴不移那就要全量检查。这个细节不处理会出现「明明没撞到却死了」的玄学 bug。placeFood负责在空白格子随机放食物要排除蛇身占用的格子function placeFood() { const empty []; for (let x 0; x GRID; x) { for (let y 0; y GRID; y) { if (!snake.some(s s.x x s.y y)) { empty.push({ x, y }); } } } food empty[Math.floor(Math.random() * empty.length)]; }这种「先收集再随机」的写法在蛇很长时也不会死循环比「随机试到不冲突为止」稳得多。4. 渲染与节奏控制draw 函数、计分与暂停重启4.1 draw 函数清屏、画食物、画蛇顺序不能乱canvas 是即时模式每一帧都要从头画。顺序是先清屏再画食物最后画蛇。食物画在蛇下面避免蛇头盖住食物时看不清。function draw() { // 清屏 ctx.fillStyle #111; ctx.fillRect(0, 0, canvas.width, canvas.height); // 画食物 ctx.fillStyle #e74c3c; ctx.fillRect(food.x * CELL, food.y * CELL, CELL - 2, CELL - 2); // 画蛇头亮尾暗 snake.forEach((seg, i) { const isHead i snake.length - 1; ctx.fillStyle isHead ? #2ecc71 : #27ae60; ctx.fillRect(seg.x * CELL, seg.y * CELL, CELL - 2, CELL - 2); }); // 画分数 ctx.fillStyle #fff; ctx.font 16px monospace; ctx.fillText(Score: score, 10, 24); }CELL - 2是给每个格子留 2px 间隙视觉上能区分蛇身各节。如果不留缝整条蛇会糊成一块。头的颜色比身体亮方便玩家一眼找到方向。4.2 暂停与重启状态重置要彻底暂停就是stopLoop重启要把蛇、方向、分数、食物全部重置再startLoop。这里常见的翻车是只重置了蛇忘了重置dir和nextDir结果重启后蛇直接往上次的方向冲。function reset() { snake [{ x: 10, y: 10 }, { x: 11, y: 10 }, { x: 12, y: 10 }]; dir { x: 1, y: 0 }; nextDir { x: 1, y: 0 }; score 0; placeFood(); draw(); } function gameOver() { stopLoop(); ctx.fillStyle rgba(0,0,0,0.7); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #fff; ctx.font 24px monospace; ctx.fillText(Game Over, 130, 190); ctx.font 16px monospace; ctx.fillText(按空格重新开始, 130, 220); } document.addEventListener(keydown, (e) { if (e.key ) { reset(); startLoop(); } });空格键同时承担「开始」和「重开」两个职责逻辑上不冲突因为游戏结束时循环已经停了重置后重新启动即可。4.3 速度曲线什么时候该加速固定 150ms 玩久了会腻。一个自然的进阶是随着分数提高逐步缩短间隔。但注意setInterval的间隔不能直接改必须先clearInterval再用新间隔重建。function updateSpeed() { const newSpeed Math.max(60, 150 - Math.floor(score / 50) * 10); if (newSpeed ! SPEED) { SPEED newSpeed; stopLoop(); startLoop(); } }Math.max(60, ...)是下限保护防止速度无限加快到人类无法操作。每吃 50 分提速 10ms到 60ms 封顶这个曲线在实测中比较舒服。5. 避坑与排查贪吃蛇跑不起来时先看这几处5.1 蛇一开局就死现象按下方向键或游戏刚开始蛇立刻判定撞墙或撞自己。原因通常是初始蛇身坐标和初始方向不匹配。比如蛇头在{x:12, y:10}方向却是向左{x:-1, y:0}下一帧新头就是{x:11, y:10}正好撞上第二节。解决初始方向必须和蛇身延伸方向一致头在右就向右头在上就向上。5.2 按方向键页面跟着滚动现象用方向键操作时整个网页上下滚动蛇没反应或反应迟钝。原因是方向键默认会触发页面滚动。解决在 keydown 里对方向键调用e.preventDefault()。document.addEventListener(keydown, (e) { if ([ArrowUp,ArrowDown,ArrowLeft,ArrowRight, ].includes(e.key)) { e.preventDefault(); } // ... 后续方向处理 });5.3 食物刷在蛇身上现象蛇吃到食物后没有变长或者食物直接消失。原因是placeFood没有排除蛇身格子随机到了蛇身上。解决用前面「先收集空格再随机」的写法不要用 while 循环硬试。蛇很长时硬试可能循环很多次极端情况下卡死。5.4 快速连按导致反向自杀现象蛇向右走玩家快速按上再按左蛇直接向左撞自己。原因是方向判断基于了已经被覆盖的nextDir。解决反向判断永远基于当前dirnextDir只做缓冲。这个坑我在早期项目里踩过不止一次后来固定成「dir 锁帧、nextDir 缓冲」的模式就再没出过。5.5 重启后蛇不动或方向错乱现象游戏结束后按空格蛇出现了但不动或者往奇怪的方向走。原因是reset没有重置dir和nextDir或者timer没有被正确清理导致有两个循环在跑。解决reset里把方向、分数、蛇身全部显式赋值startLoop里加if (timer) return防止重复启动。6. 进阶技巧把贪吃蛇改成可配置的网格游戏框架玩通基础版之后我建议你做一件事把GRID、CELL、SPEED、初始蛇长这些参数抽成一个配置对象放在代码最顶部。这样你改一个数字就能得到不同难度的版本也方便后面加穿墙模式、障碍物、双人模式。const CONFIG { grid: 20, cell: 20, speed: 150, minSpeed: 60, speedStep: 10, scorePerSpeed: 50, initialSnake: [{x:10,y:10},{x:11,y:10},{x:12,y:10}], initialDir: {x:1,y:0}, wrap: false // 穿墙模式开关 };穿墙模式的实现只需要改碰撞检测那一段撞墙时不 gameOver而是把新头坐标对GRID取模。if (CONFIG.wrap) { newHead.x (newHead.x CONFIG.grid) % CONFIG.grid; newHead.y (newHead.y CONFIG.grid) % CONFIG.grid; } else { if (newHead.x 0 || newHead.x CONFIG.grid || newHead.y 0 || newHead.y CONFIG.grid) { return gameOver(); } }加 CONFIG.grid再取模是为了处理负数因为 js 的%对负数返回负值直接取模会得到负坐标。验证你的实现是否健壮我常用三个检查点一是蛇长到占满大半屏幕时是否还流畅二是快速连按方向键是否会出现反向自杀三是暂停后重启分数和蛇身是否完全归零。这三个过了基本就没有隐藏 bug 了。最后说个习惯我写这类小游戏时会先把tick函数里的逻辑用注释写成步骤再往里填代码。贪吃蛇的 tick 无非就是「锁方向 → 算新头 → 判撞墙 → 判撞自己 → 判吃食物 → 更新蛇身 → 重绘」。步骤写清楚代码就不会乱。希望帮到你。本文还有配套的精品资源点击获取