news 2026/10/8 9:07:50

用HTML做贪吃蛇:从零实现CSS Grid与JS游戏逻辑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用HTML做贪吃蛇:从零实现CSS Grid与JS游戏逻辑

简介:这是一份面向网页开发初学者与前端练习者的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-50150ms
普通51-100120ms
困难101-20090ms
地狱200+60ms

这张表可以直接抄,改数值就行。注意间隔不要低于 60 毫秒,再快人眼和手指都跟不上,体验反而差。

最后说个习惯:每次改完逻辑,先用手动单步模式跑一遍边界情况——蛇长到填满棋盘、食物刷在角落、蛇头贴着尾巴走。这些场景自动化测试不好写,但手动跑两分钟就能发现大部分 bug。贪吃蛇代码量不大,但边界不少,别等玩家反馈才去修。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 9:07:47

C#与.NET实现PACS源码:DICOM通信、影像显示与存储检索全解析

简介&#xff1a;全套PACS源码是一套面向医疗信息化开发者的图像存档与通信系统完整项目&#xff0c;采用C#与.NET框架编写&#xff0c;结合SQL数据库管理患者信息、影像数据及元数据。代码覆盖图像采集设备接入、DICOM协议处理、数据库服务器和工作站显示等核心模块&#xff0…

作者头像 李华
网站建设 2026/10/8 9:07:25

SolidWorks 2025安装全攻略:从环境准备到报错排查一次搞定

如果是第一次接触 SolidWorks 2025&#xff0c;你会发现这个版本的安装逻辑和以往有很大不同。它不再只是“下一步下一步”那么简单&#xff0c;从系统环境检测到下载源选择&#xff0c;再到安装完成后的服务配置&#xff0c;每一步都可能直接影响你能不能顺利用起来。我自己从…

作者头像 李华
网站建设 2026/10/8 9:06:48

消息队列选型实战:从RabbitMQ到Kafka的决策路径

先说个可能有点反直觉的结论&#xff1a;选MQ这件事&#xff0c;真正难的从来不是“哪个功能多”&#xff0c;而是“你到底要解决什么问题”。我把同一套消息队列方案从日志管道搬到订单交易场景&#xff0c;线上直接丢过消息&#xff1b;也在本该用流式管道的项目里硬塞了Rabb…

作者头像 李华
网站建设 2026/10/8 9:05:54

回归模型与置信区间:从线性回归到集成模型的全解析

今天是我这套学习复盘计划的第18天&#xff0c;主题是回归问题与置信区间。市面上讲回归的教程一抓一大把&#xff0c;什么lightgbm回归模型、xgboost回归预测模型、随机森林回归算法&#xff0c;随便搜都是&#xff0c;但大部分内容都停留在"跑通代码、看R"这个层面…

作者头像 李华
网站建设 2026/10/8 9:04:45

iptables 防火墙原理与实战:从数据包路径到规则配置

记得刚上手服务器那会儿&#xff0c;我第一次配置 Linux iptables 防火墙&#xff0c;顺手把默认策略设成了 DROP&#xff0c;紧接着 SSH 就断了。那一刻我坐在机房门口&#xff0c;看着黑掉的窗口&#xff0c;才真正意识到&#xff1a;防火墙规则不是写给评审看的&#xff0c;…

作者头像 李华
网站建设 2026/10/8 9:04:42

幻尔串口总线舵机Python SDK控制实战:从单舵机到机械臂动作组

做惯了小舵机玩具项目的人&#xff0c;第一次接触幻尔&#xff08;Hiwonder&#xff09;串口总线舵机控制Python SDK时&#xff0c;可能会有点不适应&#xff1a;以前一根PWM信号线驱动一个舵机&#xff0c;现在换成一根串口线挂一串舵机&#xff0c;代码也从“写脉宽”变成了“…

作者头像 李华