简介:这是一份面向网页开发初学者与前端练习者的HTML贪吃蛇游戏实战资源,围绕HTML、CSS与JavaScript三者的协同工作展开,帮助读者理解如何用标准标记语言搭建游戏界面,并借助脚本实现蛇的移动、食物生成、碰撞检测与分数更新等核心逻辑。压缩包共3个文件,包含1个html主页面、1张jpg背景图与1张png素材图,整体约86KB,体积轻巧,便于直接运行与二次修改。资源以静态页面结构为基础,配合外部脚本完成键盘事件监听、定时器状态刷新与DOM动态更新,同时预留了CSS3动画优化与难度扩展空间。目前已有1452人学习下载,适合希望从零完成一个可交互小项目、巩固前端基础与逻辑思维训练的读者参考。
1. 用 HTML 做贪吃蛇:为什么我劝你先别急着写 canvas
很多人第一次听到「用 html 做贪吃蛇」,脑子里蹦出来的画面是打开记事本、敲几行<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8">,然后蛇就动起来了。真动手才发现,蛇要么穿墙、要么吃到食物不变长、要么按一下方向键直接反向撞死自己。问题不在 HTML,HTML 只负责搭骨架,真正让贪吃蛇跑起来的是 CSS 的网格布局和 JavaScript 的定时循环。这篇笔记讲的就是怎么用最朴素的三件套——html➕css➕js 基础语法——把一个能玩、能改、能扩展的贪吃蛇做出来,顺带把新手最容易翻车的地方一次说清。适合刚学完 DOM 操作、想找个完整小项目练手的人,也适合想拿它当教学案例的培训场景。读完你能拿到一份可复现的代码结构,知道每个参数为什么这么设,以及哪些坑我替你踩过了。
2. 贪吃蛇的骨架:网格、状态与渲染循环怎么定
2.1 为什么用 CSS Grid 而不是 canvas
选型这件事决定了后面写代码的手感。canvas 画贪吃蛇当然可以,一个fillRect就能画一格,性能也好,但它的代价是所有东西都得自己画:网格线、蛇身、食物、分数,全是一堆坐标计算。对刚接触 html网页制作 的人来说,canvas 的坐标系和重绘逻辑会额外增加一层心智负担,调试时你看到的是一张画布,不是一个个 DOM 节点,想单独改某一格的颜色都费劲。
CSS Grid 的思路不一样。它把游戏区域切成固定数量的格子,蛇身和食物都是真实的 DOM 元素,加个 class 就能改样式,浏览器 DevTools 里能直接点中某一节蛇身看它的位置。代价是格子多了以后 DOM 节点数量上升,性能不如 canvas。但对一个 20×20 的棋盘来说,最多也就 400 个格子,现代浏览器完全扛得住。常见做法是棋盘用 Grid,蛇和食物用绝对定位或 Grid 单元格填充,这样既保留了可调试性,又不用手写一堆坐标。
我一般会这样定结构:外层一个#board容器,用display: grid加grid-template-columns: repeat(20, 1fr)切成 20 列,行数同理。每个格子是一个div,但不需要一开始就生成 400 个,而是用 JS 动态生成,方便后面根据难度调整棋盘大小。蛇和食物不直接操作格子,而是维护一份坐标数组,渲染时把对应坐标的格子标记出来。这样逻辑和视图就分开了,改渲染方式不影响游戏规则。
2.2 游戏状态该存哪些字段
状态设计是贪吃蛇的核心。很多人写着写着发现蛇身对不上、食物刷在蛇身上、方向键连按出问题,根源都是状态没设计好。我一般会维护这么几个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
snake | 数组 | 蛇身坐标,[{x, y}, ...],索引 0 是蛇头 |
direction | 对象 | 当前移动方向,{x: 1, y: 0}表示向右 |
nextDirection | 对象 | 缓冲方向,避免同一帧内连按导致反向 |
food | 对象 | 食物坐标{x, y} |
score | 数字 | 当前得分 |
speed | 数字 | 每步间隔毫秒数 |
timer | 定时器 ID | 用于暂停和重置 |
isRunning | 布尔 | 游戏是否进行中 |
这里最关键的是direction和nextDirection分开。如果只用一个方向变量,玩家在一步之内快速按「上」再按「左」,当蛇当前向右时,按「上」合法,但紧接着按「左」时如果直接改方向,蛇就会在同一帧内先转上再转左,等于原地掉头撞自己。用nextDirection做缓冲,每步开始时才把nextDirection赋给direction,就能避免这个问题。这是血泪经验,不分开写迟早会遇到。
2.3 渲染循环:setInterval 还是 requestAnimationFrame
贪吃蛇的移动是离散的,一格一格跳,所以用setInterval最直观。requestAnimationFrame更适合连续动画,用它做贪吃蛇反而要自己算时间累积,绕远了。我一般用setInterval,间隔就是speed,默认 150 毫秒一步,难度高了调到 80 甚至 60。
但setInterval有个坑:如果某一步的逻辑执行时间超过了间隔,定时器会堆积,蛇会突然连跳好几格。贪吃蛇逻辑很轻,一般不会超时,但如果你在里面做了复杂计算或频繁 DOM 操作,就要注意。稳妥做法是每步开始时先clearInterval再重新设,或者用递归setTimeout代替。我倾向后者,因为递归setTimeout能保证上一步执行完才排下一步,不会堆积。
function gameLoop() { if (!state.isRunning) return; step(); // 执行一步移动逻辑 render(); // 重新渲染棋盘 state.timer = setTimeout(gameLoop, state.speed); }这段代码里step()负责更新蛇的坐标、判断吃食物和碰撞,render()负责把状态映射到 DOM。state.speed就是每步间隔,改这个值就能调难度。注意gameLoop开头判断isRunning,暂停时直接返回,不会继续排定时器。恢复时重新调用gameLoop即可。这种写法比setInterval多一层函数调用,但换来了不会堆积的确定性,值得。
3. 从零写一个能跑的贪吃蛇:HTML 结构、CSS 网格与 JS 逻辑
3.1 HTML 骨架与 meta 该写什么
先看 HTML 部分。一个完整的贪吃蛇页面不需要多复杂,但<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8">这几行必须写对。charset="utf-8"保证中文不乱码,lang="zh-cn"对屏幕阅读器和搜索引擎友好。viewport那行别漏,不然手机上棋盘会缩成一团。
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>贪吃蛇</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="game"> <div class="hud"> <span>得分:<b id="score">0</b></span> <button id="pauseBtn">暂停</button> <button id="resetBtn">重开</button> </div> <div id="board" class="board"></div> </div> <script src="game.js"></script> </body> </html>结构就三块:计分板、棋盘、脚本。棋盘不预生成格子,留给 JS 动态创建,这样改棋盘尺寸只改一个常量。按钮用原生button,不引框架,保持零依赖。hud里放得分和两个按钮,暂停和重开是最常用的操作,放显眼位置。
3.2 CSS Grid 把棋盘切成格子
CSS 部分的核心是棋盘。用 Grid 切格子,每个格子用aspect-ratio: 1保证正方形,这样棋盘不管屏幕多宽都能自适应。
.board { display: grid; grid-template-columns: repeat(20, 1fr); grid-template-rows: repeat(20, 1fr); width: min(90vw, 480px); aspect-ratio: 1; background: #1a1a2e; border: 2px solid #333; gap: 1px; } .cell { background: #16213e; } .cell.snake { background: #4ade80; border-radius: 3px; } .cell.head { background: #22c55e; } .cell.food { background: #ef4444; border-radius: 50%; }grid-template-columns: repeat(20, 1fr)把宽度均分 20 份,行同理。width: min(90vw, 480px)让棋盘在手机上占 90% 宽度,桌面上最大 480px,配合aspect-ratio: 1保持正方形。gap: 1px让格子之间有细缝,视觉上能看清网格。蛇身用绿色,蛇头深一点,食物红色圆形,对比明显。这些颜色值可以随便改,不影响逻辑。
注意.cell的background是默认底色,蛇和食物通过追加 class 覆盖。这样渲染时只需要增删 class,不用改内联样式,性能更好,也方便用 CSS 过渡做简单动画。
3.3 JS 初始化:生成棋盘、放蛇、刷食物
JS 部分从初始化开始。先把常量定好,棋盘尺寸、初始蛇长、初始速度都放这里,后面改难度只动这几个数。
const COLS = 20; const ROWS = 20; const INIT_LENGTH = 3; const INIT_SPEED = 150; const boardEl = document.getElementById('board'); const scoreEl = document.getElementById('score'); const state = { snake: [], direction: { x: 1, y: 0 }, nextDirection: { x: 1, y: 0 }, food: null, score: 0, speed: INIT_SPEED, timer: null, isRunning: false, }; function initBoard() { boardEl.innerHTML = ''; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { const cell = document.createElement('div'); cell.className = 'cell'; cell.dataset.x = x; cell.dataset.y = y; boardEl.appendChild(cell); } } } function initSnake() { state.snake = []; const startY = Math.floor(ROWS / 2); for (let i = 0; i < INIT_LENGTH; i++) { state.snake.push({ x: INIT_LENGTH - 1 - i, y: startY }); } state.direction = { x: 1, y: 0 }; state.nextDirection = { x: 1, y: 0 }; } function spawnFood() { const empty = []; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if (!state.snake.some(s => s.x === x && s.y === y)) { empty.push({ x, y }); } } } if (empty.length === 0) return null; state.food = empty[Math.floor(Math.random() * empty.length)]; return state.food; }initBoard生成 400 个格子,每个格子用dataset存坐标,方便后面查找。initSnake把蛇放在中间行,蛇头在右,初始长度 3。spawnFood先收集所有空格子,再随机选一个,这样食物绝不会刷在蛇身上。如果空格子为零说明蛇填满了棋盘,返回null表示胜利,这个边界要处理。
state.snake索引 0 是蛇头,移动时在头部插入新坐标,尾部根据是否吃到食物决定是否删除。这个设计让「吃食物变长」变成「不删尾部」,逻辑很干净。
3.4 一步移动:碰撞检测与吃食物判定
step()是游戏的核心,每调用一次蛇前进一格。要处理三件事:根据方向算出新蛇头、判断撞墙撞自己、判断吃食物。
function step() { state.direction = state.nextDirection; const head = state.snake[0]; const newHead = { x: head.x + state.direction.x, y: head.y + state.direction.y, }; // 撞墙判定 if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) { return gameOver(); } // 撞自己判定,注意尾巴这一格在没吃食物时会移走,可以不算 const willEat = state.food && newHead.x === state.food.x && newHead.y === state.food.y; const bodyToCheck = willEat ? state.snake : state.snake.slice(0, -1); if (bodyToCheck.some(s => s.x === newHead.x && s.y === newHead.y)) { return gameOver(); } state.snake.unshift(newHead); if (willEat) { state.score += 10; scoreEl.textContent = state.score; if (state.speed > 60) state.speed -= 5; spawnFood(); } else { state.snake.pop(); } }撞墙判定直接看新蛇头坐标是否越界。撞自己判定有个细节:如果这一步不吃食物,尾巴会移走,所以新蛇头撞到当前尾巴的位置是合法的,检查时要把尾巴排除。willEat为真时尾巴不移,才需要检查整条蛇。这个边界很多人写错,导致蛇贴着尾巴走时莫名死亡。
吃到食物后加分、提速、刷食物。提速每次减 5 毫秒,最低 60,避免快到没法玩。分数每次加 10,简单直接。spawnFood在蛇变长后调用,保证食物不刷在蛇身上。
3.5 渲染与键盘控制:把状态映射到格子
渲染函数负责把state映射到 DOM。先清掉所有格子的蛇和食物 class,再按当前状态重新标记。
function render() { const cells = boardEl.children; for (let i = 0; i < cells.length; i++) { cells[i].classList.remove('snake', 'head', 'food'); } state.snake.forEach((seg, i) => { const idx = seg.y * COLS + seg.x; cells[idx].classList.add('snake'); if (i === 0) cells[idx].classList.add('head'); }); if (state.food) { const idx = state.food.y * COLS + state.food.x; cells[idx].classList.add('food'); } }格子是按行优先生成的,所以索引是y * COLS + x。先全部清 class,再标记蛇和食物。蛇头额外加headclass 区分颜色。这种全量重绘在 400 格下完全没问题,不用做增量更新。
键盘控制要处理方向键和 WASD,同时防止反向。
const keyMap = { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 }, w: { x: 0, y: -1 }, s: { x: 0, y: 1 }, a: { x: -1, y: 0 }, d: { x: 1, y: 0 }, }; document.addEventListener('keydown', e => { const dir = keyMap[e.key]; if (!dir) return; e.preventDefault(); // 不能反向:新方向与当前方向相反时忽略 if (dir.x === -state.direction.x && dir.y === -state.direction.y) return; state.nextDirection = dir; });keyMap把按键映射到方向向量,方向键和 WASD 都支持。preventDefault阻止方向键滚动页面。反向判定用当前direction而不是nextDirection,因为nextDirection可能还没生效。这样连按也不会掉头。
启动和暂停逻辑:
function start() { if (state.isRunning) return; state.isRunning = true; gameLoop(); } function pause() { state.isRunning = false; clearTimeout(state.timer); } function reset() { pause(); state.score = 0; state.speed = INIT_SPEED; scoreEl.textContent = '0'; initSnake(); spawnFood(); render(); start(); }start里判断isRunning防止重复启动。pause清掉定时器。reset重置所有状态后重新开始。初始化时调用initBoard、initSnake、spawnFood、render,再绑定按钮事件即可。
4. 贪吃蛇最容易翻车的 5 个地方:排查与避坑
4.1 蛇一按方向键就反向撞死
现象是蛇正常向右走,玩家按左键,蛇立刻掉头撞到自己。原因是方向判断直接改了direction,没有缓冲。解决方法是引入nextDirection,按键只改nextDirection,每步开始时才赋给direction,并且反向判定用direction而不是nextDirection。这样同一帧内连按也不会出问题。
4.2 食物刷在蛇身上导致蛇瞬间变长
现象是食物偶尔出现在蛇身中间,蛇经过时直接吃掉,或者食物根本看不见。原因是spawnFood只随机了坐标,没排除蛇身。解决方法是先遍历所有格子,收集不在蛇身上的空格,再从空格里随机选。棋盘快满时还要处理空格为零的情况,返回null并判定胜利。
4.3 定时器堆积导致蛇突然连跳
现象是游戏运行一段时间后,蛇会突然加速连跳好几格,或者暂停后恢复时蛇跳了一段。原因是setInterval在逻辑执行超时时会堆积回调。解决方法是改用递归setTimeout,每步执行完再排下一步,保证不会堆积。暂停时clearTimeout,恢复时重新调用gameLoop。
4.4 撞自己判定把尾巴算进去导致误杀
现象是蛇贴着尾巴走时莫名死亡,明明尾巴下一步就移走了。原因是撞自己判定检查了整条蛇,包括即将移走的尾巴。解决方法是先判断这一步是否吃食物,不吃的话检查时排除最后一节,吃的话才检查整条蛇。这个边界在蛇长度较短时不容易发现,蛇长了以后必现。
4.5 手机上没有方向键玩不了
现象是页面在手机上能打开,但没法控制蛇。原因是只绑了键盘事件。解决方法是加触摸滑动检测,记录touchstart和touchend的坐标差,判断滑动方向,映射到nextDirection。或者加四个屏幕按钮,点击触发方向。滑动体验更好,但要注意阻止页面滚动。
5. 让贪吃蛇更耐玩:难度曲线、存档与一个调试技巧
基础版跑通后,真正决定这个项目值不值得继续投入的,是难度曲线和可扩展性。我一般会做三件事:把速度调整从「每吃一个减 5 毫秒」改成按分数分段,比如 0 到 50 分保持 150 毫秒,50 到 100 分降到 120,100 分以上降到 90,这样前期不会太难,后期有挑战。分段表放在一个数组里,step里根据score查表设speed,比线性递减更可控。
第二件事是加本地存档。用localStorage存最高分,页面加载时读出来显示在计分板上。贪吃蛇这种小游戏,最高分是唯一的长期激励,没有它玩家玩两把就关了。存的时候用localStorage.setItem('snake_high', score),读的时候Number(localStorage.getItem('snake_high')) || 0。注意localStorage在隐私模式下可能不可用,包一层try/catch更稳。
第三件事是一个调试技巧:加一个「慢速模式」开关,按住某个键时把speed临时调到 500 毫秒,松开恢复。这样调试碰撞逻辑时能看清每一步,不用改代码重启。实现上在keydown里判断按键,改state.speed,keyup时恢复。这个技巧帮我省了很多次「蛇死得莫名其妙」的排查时间。
| 难度 | 分数区间 | 每步间隔 |
|---|---|---|
| 简单 | 0-50 | 150ms |
| 普通 | 51-100 | 120ms |
| 困难 | 101-200 | 90ms |
| 地狱 | 200+ | 60ms |
这张表可以直接抄,改数值就行。注意间隔不要低于 60 毫秒,再快人眼和手指都跟不上,体验反而差。
最后说个习惯:每次改完逻辑,先用手动单步模式跑一遍边界情况——蛇长到填满棋盘、食物刷在角落、蛇头贴着尾巴走。这些场景自动化测试不好写,但手动跑两分钟就能发现大部分 bug。贪吃蛇代码量不大,但边界不少,别等玩家反馈才去修。希望帮到你。
本文还有配套的精品资源,点击获取