简介:这套基于HTML、CSS与JavaScript实现的网页版贪吃蛇游戏源码,面向前端初学者、Web游戏开发爱好者及想快速体验经典游戏实现的读者。压缩包共8个文件,含1个HTML页面、2个CSS样式表、1个JavaScript逻辑脚本与4个方向控制图标,整体约653KB,结构一目了然,浏览器打开即可直接游玩。目前已有178人学习借鉴,对入门项目而言具有一定参考价值。通过阅读代码,可以清晰掌握键盘事件监听、蛇身移动与增长、食物随机生成、碰撞检测、得分刷新等核心逻辑;同时代码分层简洁,方便在此基础上扩展关卡难度、音效或移动端触控支持,是一份轻量且完整的原生JavaScript游戏开发示例。
1. 网页版贪吃蛇:不是玩具,是前端状态机的最小样本
网页版贪吃蛇听起来是一个已经被写烂的练手项目,但把 index.html、snake.js、base.css、snake.css 以及 down.png、left.png、up.png、right.png 这八个文件从 rar 包里解出来逐行过一遍,你会发现它覆盖了一个前端小游戏从页面结构到交互逻辑的所有环节。和 C 语言控制台版贪吃蛇那种“循环读按键 + 清屏重绘”的模型不同,网页版天然是事件驱动:DOM 负责展示,JS 负责状态迁移,两张 CSS 负责界面表现。
这套资源适合两类人。一类是刚学完 JavaScript DOM 操作,想找一个完整项目练手的前端入门者;另一类是需要在短时间内搭一个 H5 小游戏原型的工程师。整个游戏没有后端依赖,解压后直接用浏览器打开 index.html 就能玩,操作就是 W、A、S、D 四个键,吃食物加分,撞墙或撞到自己结束。
我更想强调的是后面的东西:贪吃蛇的代码量不大,但状态不少,蛇身坐标、当前方向、下一帧方向、食物位置、得分、游戏是否结束,每一项都清清楚楚。把这几个状态之间的关系理清楚,再去做俄罗斯方块、扫雷或者迷宫生成,会发现思路几乎可以平移。这也是这份源码值得拆的原因。
2. 从 index.html 到 CSS:把游戏界面拆成可维护的模块
2.1 解压 rar 后先确认文件清单
拿到网页版贪吃蛇游戏.rar以后,不要急着双击打开,先解压确认目录结构。Linux 或 macOS 下常见做法是用 unrar:
unrar x 网页版贪吃蛇游戏.rar ls -l没有 unrar 就先用 WinRAR 或 7-Zip 解压,效果一样。解压后应该能看到八个文件:index.html、snake.js、base.css、snake.css,以及down.png、left.png、up.png、right.png四张方向箭头图片。看到这个文件列表,基本上能判断出两件事:一是这个项目没有依赖任何 CDN 或前端框架,离线状态下一样能跑;二是作者把方向控制设计成了键盘和图片按钮两套入口,后者是为触屏设备预留的。
文件命名也很有规律,HTML、JS、CSS 按职责分开,图片按方向语义命名,没有混淆。坏习惯是把所有样式写在一个 style 标签里、把所有逻辑塞进一个 script 标签,这个项目没有这个问题,后面改起来会省很多事。
2.2 index.html:DOM 层级与脚本加载顺序
index.html是游戏的骨架。按同类源码的常见结构,大致是这样的:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="base.css"> <link rel="stylesheet" href="snake.css"> </head> <body> <div id="game-wrapper"> <div id="score-board"> 得分:<span id="score-value">0</span> </div> <canvas id="game-canvas" width="400" height="400"></canvas> <div id="direction-panel"> <img src="left.png" alt="left"> <img src="up.png" alt="up"> <img src="right.png" alt="right"> <img src="down.png" alt="down"> </div> </div> <script src="snake.js"></script> </body> </html>这段标记里有两个容易忽略的细节。第一,两个 CSS 文件放在 head 中,snake.js 放在 body 末尾。这样脚本执行时,canvas、得分板和方向按钮已经存在于 DOM 中,可以直接绑定事件。如果因为习惯把 script 塞进 head,运行时经常报Cannot read properties of null,原因就是节点还没解析出来。第二,canvas 的 width 和 height 是画布物理尺寸,不是 CSS 显示尺寸。后期如果做响应式,需要按 canvas 宽高和 GRID_SIZE 的倍数关系缩放,不能直接改 width 属性。
方向面板里的四张 img 对应资源包里的四张 PNG。它们的存在意味着,即使不接键盘,通过点击或触摸图片也能指定方向。这个入口在源码里可能只是静态摆着,但已经给移动端适配留下了接口,第 4 章会把它接上。
2.3 base.css 和 snake.css 的分层思路
两个样式文件的分工从命名就能看出来。base.css 管通用基础样式,snake.css 管游戏专属主题。常见分工如下表:
| 文件 | 职责 | 典型内容 |
|---|---|---|
| base.css | 全局基础样式 | 盒模型重置、页面背景、字体、居中布局 |
| snake.css | 游戏主题样式 | canvas 边框与圆角、得分板排版、方向按钮布局、响应式适配 |
这种拆分的好处是职责边界清楚。想换一套站点皮肤时只动 base.css,想调整游戏配色和按钮尺寸时只动 snake.css,互不影响。单人小项目当然可以合成一个 style.css,但拆开写更符合多人协作时样式隔离的习惯,也方便将来把游戏组件单独抽出去。
方向按钮这里有个值得注意的点。四张图片要组成方向键,常见做法是 2x2 网格或十字布局。用 CSS Grid 或 Flexbox 都能实现,关键是要给 image 设置足够大的点击区域,并在 hover 或 active 状态下给一点视觉反馈,不然玩家按下去不知道有没有生效。另外,移动端要记得在按钮上关闭默认的触摸高亮和双击缩放行为,后面 4.1 会给出具体写法。
2.4 直接打开与放到网站根目录的差异
很多教程说“双击 index.html 就能运行”,对这个项目来说基本是对的。它没有跨域请求,JS 和图片都是相对路径,file:// 协议下能正常工作。但如果后续要加 localStorage 保存最高分,或者要让同一局域网里的手机访问,我一般会先在项目目录下起一个静态文件服务:
python3 -m http.server 8080然后通过http://localhost:8080访问。这样文件协议带来的本地存储限制、浏览器安全策略差异都会消失,模拟出来的环境更接近真实部署。起完服务以后,手机和电脑连同一个网络,再用电脑 IP 加端口访问,就能直接调试触摸方向按钮。
提示:解压后发现方向按钮图片不显示,先检查文件名大小写。资源里是 down.png、left.png、up.png、right.png,HTML 引用路径必须和实际文件完全一致。
3. snake.js:移动、碰撞、得分与 setInterval 的取舍
3.1 用 state 对象管理蛇、方向和得分
snake.js 是整套源码里信息密度最高的文件。贪吃蛇的逻辑不复杂,但直接把蛇身数组、方向变量、得分到处写全局,很容易在加功能时改乱。我一般会用一个 state 对象把游戏状态集中起来:
const state = { snake: [ { x: 10, y: 10 }, // 蛇头 { x: 9, y: 10 }, { x: 8, y: 10 } ], direction: 'right', // 当前生效方向 nextDirection: 'right', // 玩家最新输入方向 food: null, score: 0, status: 'ready' // ready | running | over };数组里第 0 项是蛇头,后面是身体。direction 和 nextDirection 两个字段是分开的,这个设计直接决定了反向保护怎么写,3.3 会细说。status 字段将游戏区分为未开始、运行中、结束三种状态,按键监听和渲染函数都要先判断它,否则游戏结束后按方向键还会继续移动。
这种写法的意义在于数据与渲染分离。moveSnake、checkCollision 只改 state,render 只读 state,两边不互相插队。后面要加暂停、重开、最高分,全部围绕 state 做文章,代码不会越改越乱。
3.2 主循环:为什么是 setInterval 而不是 requestAnimationFrame
刚接触前端动画的人容易直接上 requestAnimationFrame,结果发现蛇跑得飞快。这是因为 rAF 是跟着显示器刷新率走的,每秒 60 次,而贪吃蛇需要的是一格一格离散移动。用固定间隔的定时器更合适:
const SPEED = 200; // 每 200ms 走一格,每秒 5 格 let timer = null; function start() { if (state.status === 'running') return; state.status = 'running'; timer = setInterval(tick, SPEED); } function tick() { moveSnake(); checkCollision(); render(); }SPEED 是核心节奏参数。200ms 对新手刚好,改成 120ms 就是明显加速的困难模式。setInterval 的缺点是间隔在创建时固定,想要动态变化必须先 clearInterval 再重新创建,很容易把 timer 状态搞乱。所以第 4.3 做难度曲线时,我会改用 setTimeout 链。
render 做的事情很单一:清空画布,遍历 state.snake 坐标画方块,再在 state.food 位置画食物。不要在 render 里处理移动和碰撞,那会让逻辑和绘制耦合,出现“明明没按方向键蛇却动了”这类很难查的 bug。
3.3 WASD 键盘控制与方向锁
键盘是这个游戏的主输入,WASD 和方向键都要支持。比较稳妥的监听写法是:
document.addEventListener('keydown', (e) => { const map = { KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right', ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; const dir = map[e.code]; if (dir) { changeDirection(dir); e.preventDefault(); // 阻止方向键滚动页面 } }); function changeDirection(dir) { if (state.status !== 'running') return; state.nextDirection = dir; }这里用 e.code 而不是 keyCode。e.code 代表物理按键,不会因为浏览器语言或输入法状态变成中文后失效;keyCode 已经被标记废弃,在兼容性上也有坑。
真正的方向锁在 changeDirection 里只做了一半,另一半在 tick 的移动阶段。原因是玩家可能会在一个 tick 间隔内快速按两次键,比如蛇向右走时先按 W 再按 A。如果按一次就立刻把 direction 改成上,再按一次又改成左,第二次方向会被直接采纳,蛇在下一个 tick 里就会向左走,相当于向右的蛇瞬间掉头穿进自己的身体。正确的处理是用 nextDirection 先暂存输入,移动时只接受与当前方向不相反的最新方向:
const opposite = { up: 'down', down: 'up', left: 'right', right: 'left' }; if (dir !== opposite[state.direction]) { state.direction = dir; }这样每个 tick 最多转向一次,并且永远不会 180 度掉头,这是经典贪吃蛇手感的关键。
3.4 食物生成与碰撞检测
食物的位置不能和蛇身重合,否则会重叠显示且吃不到。生成逻辑常见做法是随机选坐标,如果落在蛇身上就重选:
function spawnFood() { let pos; do { pos = { x: Math.floor(Math.random() * GRID_SIZE), y: Math.floor(Math.random() * GRID_SIZE) }; } while (state.snake.some(seg => seg.x === pos.x && seg.y === pos.y)); state.food = pos; }do...while 在蛇还短的时候没有问题,但理论上蛇会越吃越长,如果占满整个画布,这个循环就退化成死循环。严谨的写法是先统计空位数量,为空时直接判定胜利。这个边界虽然很难触发,但值得在代码里留一个防御分支。
移动和吃食物是同一段逻辑:
function moveSnake() { const head = state.snake[0]; const newHead = { ...head }; switch (state.direction) { case 'up': newHead.y -= 1; break; case 'down': newHead.y += 1; break; case 'left': newHead.x -= 1; break; case 'right': newHead.x += 1; break; } state.snake.unshift(newHead); if (state.food && newHead.x === state.food.x && newHead.y === state.food.y) { state.score += 10; spawnFood(); } else { state.snake.pop(); } }unshift 把新蛇头插到数组头部,pop 把尾巴移除,身体就自然向前滑动了一格。吃到食物时不 pop,蛇身长度加 1,这就是“变长”的本质。要注意先 unshift 再 pop,顺序反过来会导致蛇永远差一格不移动。
碰撞检测分为两部分,墙和身体:
function checkCollision() { const head = state.snake[0]; if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) { gameOver(); return; } for (let i = 1; i < state.snake.length; i++) { if (state.snake[i].x === head.x && state.snake[i].y === head.y) { gameOver(); return; } } }为什么从索引 1 开始遍历?蛇头自己和自己同坐标不算碰撞,所以要从第二节身体开始比较。反过来,如果从 0 开始,游戏一运行就会判定失败。
3.5 速度与网格参数:改哪里能直接改变难度
源码里影响难度和显示的核心参数通常集中在文件顶部。常用默认值如下:
| 参数 | 建议值 | 作用 |
|---|---|---|
| GRID_SIZE | 20 | 画布 400×400 时被分为 20×20 个格子 |
| SPEED | 200ms | 每次移动的间隔,越小速度越快 |
| INITIAL_LENGTH | 3 | 初始蛇身长度,影响开局压力 |
| SCORE_STEP | 10 | 每吃一个食物增加的分数 |
如果 GRID_SIZE 改成 30,格子变小,蛇的活动精度提高,游戏会简单一些;SPEED 调到 120ms,整体节奏立刻紧张起来。调整时要注意,所有坐标计算都必须依赖同一份 GRID_SIZE,不能某处写 20、某处写 16。更工程化的做法是把这些常量放进一个 CONFIG 对象,关于这个改法放在第 5.4 里具体说。
4. 扩展实战:触屏方向键、localStorage 最高分和难度曲线
4.1 让四张方向图片真正可用
资源包里的 left.png、up.png、right.png、down.png 不是装饰。它们在 index.html 里被放进了 direction-panel,说明原作者预留了鼠标或触摸方向控制。纯 WASD 在手机上没法操作,把这四张图接上事件是移动端的第一步。
常见做法是给每张图片绑定 pointerdown 事件:
document.querySelectorAll('#direction-panel img').forEach(img => { img.addEventListener('pointerdown', (e) => { e.preventDefault(); const dir = img.alt.toLowerCase(); // left/up/right/down changeDirection(dir); }); });用 alt 做映射比较简单,但更严谨的方案是在 HTML 里加>const HIGH_SCORE_KEY = 'snake_high_score'; function gameOver() { state.status = 'over'; const high = Number(localStorage.getItem(HIGH_SCORE_KEY) || 0); if (state.score > high) { localStorage.setItem(HIGH_SCORE_KEY, String(state.score)); document.querySelector('#best-score').textContent = state.score; } }
这段代码有两个注意点。第一,localStorage 存出来的值是字符串,用 Number() 包一层再做比较,否则'30' > 100的结果会不符合预期。第二,显示最高分的位置要在 index.html 里预留一个节点,比如<span id="best-score">0</span>,放在得分板旁边。
如果直接在 file:// 协议下双击运行,个别浏览器会把 localStorage 禁掉,控制台抛 SecurityError。放到第 2.4 的本地 HTTP 服务下就没有这个问题。这也解释了为什么“双击能跑”和“适合正式使用”是两件事。
4.3 动态难度:用 setTimeout 代替 setInterval
固定 200ms 的节奏玩到后面会无聊。把主循环改成 setTimeout 链,就能让蛇随分数逐渐加速:
let speed = 200; function loop() { if (state.status !== 'running') return; moveSnake(); checkCollision(); render(); speed = Math.max(80, 200 - state.score * 2); // 分数越高,间隔越短 timer = setTimeout(loop, speed); } function start() { if (state.status === 'running') return; state.status = 'running'; loop(); }Math.max(80, ...) 是下限保护,防止蛇快到完全无法操作。200 - score * 2 是线性变快:得 10 分时加快 20ms,得 50 分时加快 100ms,曲线比较温和。想要更刺激,可以改成分段函数:
if (state.score > 100) speed = 90; else if (state.score > 50) speed = 110; else speed = 200 - state.score * 2;不同分段的节奏差异可以参考这张表:
| 分数区间 | 间隔 | 体验 |
|---|---|---|
| 0 - 50 | 200ms 递减到 100ms | 前期平稳,新手能适应 |
| 50 - 100 | 110ms 左右 | 开始有压迫感 |
| 100 以上 | 90ms | 接近操作上限,主要考验预判 |
改成 setTimeout 后有一个容易漏的点:游戏结束时必须 clearTimeout(timer)。setInterval 时代也要求 clearInterval,但不同点是 setTimeout 链每次执行都会生成新 timer,如果 gameOver 里忘了清,checkCollision 已经改了 status,loop 下一次进来会因为状态判断而退出,看起来没出问题,实际上多跑了一次没有意义的 tick。提前清掉更干净。
4.4 和 C 语言控制台版贪吃蛇理解对比
搜“贪吃蛇c语言程序代码”会看到大量控制台版本:用二维数组表示地图,用 getch() 阻塞读取键盘,用 system("cls") 清屏重绘。这套网页版和它的本质差别,是输入和渲染模型不同。C 语言版是循环里读键盘、刷新终端;网页版是事件回调里改 state、定时器里改逻辑。如果把 C 语言版移植到浏览器,要做的映射是:把 while 换成 setInterval 回调,把 getch() 换成 keydown 监听,把 printf 换成 canvas 的 fillRect。数据结构是蛇身数组和食物坐标,几乎可以原样搬过来。
理解了这层对应关系,看任何语言的小游戏都能快速定位思路:输入怎么进入系统、状态怎么迁移、画面怎么刷新。这套网页版比控制台版多出来的 CSS 分层和事件机制,恰恰是前端环境里最有价值的部分。
5. 收尾调试技巧:优先看控制台、路径大小写和渲染配置
5.1 先开 DevTools,后动代码
网页版游戏最容易出的问题不是算法,而是脚本压根没执行。双击 index.html 后画面空白,先按 F12 打开开发者工具,切到 Console。看到Uncaught TypeError: Cannot set properties of null,基本就是 snake.js 在查找某个节点时,index.html 里没有对应的 id。对照文件里的 id 逐字检查,尤其注意大小写。Network 面板用来确认四张 PNG 是否全部 200 返回,图片 404 时方向按钮区域会出现明显的占位框。
5.2 路径大小写和编码
HTML 里引用的 snake.js、snake.css 必须和解压出的文件名完全一致。Windows 文件系统默认不区分大小写,问题不明显;一旦部署到 Linux 服务器,Snake.js 和 snake.js 就是两个文件。另一个隐蔽问题是 index.html 缺少<meta charset="UTF-8">,中文注释和得分文字会乱码,看起来像字符集问题,实际和游戏逻辑无关。
5.3 一份可抄的验证清单
改代码前先按这张表过一遍,能快速确认基础行为没被破坏:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 页面加载 | 打开 index.html 并刷新 | 画布与方向按钮正常,控制台无报错 |
| 基本移动 | 按 D 键 | 蛇头向右移动,身体跟随,约 5 格/秒 |
| 反向保护 | 蛇向右时按 A 键再按 W 键 | 蛇不会直接掉头,最多转向上方 |
| 吃食物 | 让蛇头碰到食物 | 分数增加,蛇身长度加 1,新食物生成 |
| 撞墙结束 | 持续向墙移动 | 游戏结束,方向键不再影响蛇 |
这五项分别覆盖初始化、输入、方向锁、增长、碰撞五条主要代码路径。哪一条不对,就顺着 snake.js 里对应的 moveSnake、checkCollision 或 keydown 监听去查。
5.4 把参数收敛到 CONFIG 对象
最后一个我常用的收口改法,是把散落的常量收进一个 CONFIG 对象:
const CONFIG = { canvasSize: 400, gridSize: 20, initSpeed: 200, minSpeed: 80, initLength: 3, scoreStep: 10 };移动和渲染函数里全部用 CONFIG.gridSize、CONFIG.canvasSize 计算,不再写死 20 或 400。以后再开困难模式,只要在代码里定义一组 CONFIG_HARD,覆盖 initSpeed 和 initLength 后传给 start,移动和碰撞函数一行都不用改。
本文还有配套的精品资源,点击获取