news 2026/9/29 19:45:44

用HTML+Canvas做贪吃蛇:单文件源码与前端基本功

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用HTML+Canvas做贪吃蛇:单文件源码与前端基本功

简介:面向Web前端初学者以及想用项目练手JavaScript的读者,这是一套基于HTML实现的贪吃蛇游戏入门源码包。资源围绕经典玩法展开,完整演示了页面布局、样式装饰与游戏逻辑的整合过程:HTML搭建游戏区域与分数显示,CSS负责视觉风格,JavaScript则处理按键方向控制、食物随机生成、蛇身增长以及边界与自身碰撞检测,此外还涉及setInterval定时刷新与DOM操作更新界面,能够帮助理解网页交互的基本套路。压缩包共3个文件,包含1个HTML核心文件,另含JPG与PNG图片素材各1张,整体仅86KB,轻量易用,适合下载后直接打开体验或二次修改。目前该资源已有1450人学习下载,无论自学入门、备课演示还是完成课程设计,都可以借此快速掌握HTML+CSS+JavaScript三者配合开发小游戏的方法。

1. 用 HTML 做贪吃蛇:一份单文件源码背后的完整前端基本功

用 HTML 做贪吃蛇,很多人第一反应是课程作业,实际拆开以后你会发现,这一个小项目把 Canvas 绘图、游戏主循环、键盘事件、碰撞检测和状态管理全串起来了,而且只需要一个.html文件就能跑,不需要脚手架也不需要装任何依赖。这也是我带新人练前端时经常让他们先复刻一遍贪吃蛇的原因——HTML 语言本身的表达能力已经足够撑起一个完整游戏。这篇实战笔记会从网格坐标系开始,把蛇身数据结构、方向控制、吃食判定、碰撞边界逐步拆开,最后给出一份完整可运行的 HTML 源码,参数能现场改、改完立刻看到效果。适合刚学完 HTML/CSS 基础、想用一个小项目把 JavaScript 基本功扎实一遍的前端初学者;如果你已经会写页面,可以直接跳到第 5 章看踩坑记录,那里全是血泪经验。

2. Canvas 与游戏循环:先把“画面动起来”这件事拆透

贪吃蛇看起来是连续动画,其实和动画片一个道理,是一帧一帧重绘出来的。每一帧要做三件事:算出蛇头新位置、判断碰撞结果、按最新状态重绘画布。这个“每一帧”就是游戏循环,画布用 Canvas 2D 原生 API 就够了,不需要任何图形库。如果打开页面一片空白,先别急着调逻辑,确认canvas.getContext('2d')返回的不是 null——偶发情况是 canvas 元素还没渲染完就取上下文,虽然不常见,但真遇到会让人误以为是绘制代码写错了。

2.1 网格坐标系:蛇身、食物和画布之间的换算关系

贪吃蛇不用像素坐标直接跑,而是跑在一个逻辑网格上。“格子坐标”和“像素坐标”必须分开看。我一般把行列数GRID_SIZE设为 20,每格像素CELL_SIZE设为 20,画布就是 20 × 20 = 400 像素见方。蛇身的每个节点只存{ x, y }这种网格坐标,比如{ x: 10, y: 10 },绘制时再换算成像素坐标:x * CELL_SIZE, y * CELL_SIZE。

这么设计的好处是逻辑层和绘制层解耦。碰撞检测、随机食物生成都基于网格坐标,整数运算,逻辑简单;绘制层只负责把网格映射到像素。如果反过来直接用像素做移动,很快会被浮点数误差和边界判断折磨到怀疑人生。

const GRID_SIZE = 20; // 一行或一列有多少个格子 const CELL_SIZE = 20; // 每个格子是多少像素 const canvas = document.getElementById('gameBoard'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width, canvas.height);

canvas 宽高是 400,fillRect铺满整个画布,这是每一帧重绘的起点。注意顺序:先清整张画布,再画网格线、蛇、食物,否则蛇身后会拖残影。

  • GRID_SIZE决定场地大小,也决定蛇的极限长度。
  • CELL_SIZE决定画面大小,改它不改网格,蛇会整体变大变小。
  • canvas.width/height必须和GRID_SIZE * CELL_SIZE一致,否则坐标映射全错位。

这两组数字是所有坐标计算的根,第 4 章的完整源码里也是靠它们控制整套逻辑。想快速验证换算对不对,可以先只画网格线,再手动放几节蛇身,看格子是否对上。这个自检不到一分钟,能省掉后面大量调试时间。

2.2 帧循环选型:setInterval 适合贪吃蛇,requestAnimationFrame 适合什么

游戏循环有两种常见写法:setInterval和requestAnimationFrame。贪吃蛇不是连续移动,而是一格一格跳变,移动频率通常是 100~200ms 一次,用setInterval最简单直接。requestAnimationFrame是浏览器每次重绘前回调一次,适合赛车、粒子效果这类需要流畅补帧的连续动画;如果拿它跑贪吃蛇,反而要自己记录时间戳判断“够不够 150ms”,代码绕一圈又回到原点。

我一般把循环封装成两个函数,一套启动、一套停止:

let gameInterval = null; const BASE_SPEED = 150; // 毫秒,数值越大蛇越慢 function startLoop() { stopLoop(); // 防止重复启动产生多个定时器 gameInterval = setInterval(gameStep, BASE_SPEED); } function stopLoop() { if (gameInterval) { clearInterval(gameInterval); gameInterval = null; } }

startLoop()里先调用stopLoop()是个好习惯。很多新人直接setInterval,重开游戏后上一个定时器还在跑,两个循环一起执行,蛇突然变快、食物闪跳,查半天查不出来。gameInterval置 null 也方便后面判断当前是否在运行中。

注意BASE_SPEED单位是毫秒,150 表示每秒约 6.7 步。这个值不是越小越好,浏览器对setInterval有最小间隔限制,后台标签页还会被降频,所以第 5 章会专门讲“切后台再回来蛇瞬移”的坑。真需要更高精度时,再换requestAnimationFrame加时间差计算。

2.3 第一次渲染:画背景、网格线、蛇身和食物

渲染函数要做的其实不多:铺背景、画网格线、画蛇身、画食物。网格线前期调试特别有用,你能直接看到蛇头压在哪个格子里。蛇身用带空隙的fillRect,食物画成圆形,视觉上有区分度,代码也不复杂:

function render() { // 1. 清背景 ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 2. 画浅色网格线 ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; for (let i = 1; i < GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(canvas.width, i * CELL_SIZE); ctx.stroke(); } // 3. 画蛇身:头亮绿,身体深浅交替 snake.forEach((seg, idx) => { ctx.fillStyle = idx === 0 ? '#2ecc71' : (idx % 2 ? '#27ae60' : '#1e8449'); ctx.fillRect(seg.x * CELL_SIZE + 1, seg.y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); }); // 4. 食物画成圆 if (food) { ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc( food.x * CELL_SIZE + CELL_SIZE / 2, food.y * CELL_SIZE + CELL_SIZE / 2, CELL_SIZE / 2 - 3, 0, Math.PI * 2 ); ctx.fill(); } }

步骤顺序有讲究:背景最先画,否则蛇和食物会被盖掉;网格线画在背景之上,便于观察但别太深,防止干扰视觉;蛇身和食物最后画,保证它们是前景。fillRect的四个参数是 x、y、宽、高,这里的+1和-2是留出 1 像素间隔,让相邻蛇节之间有一条细缝,视觉上更像一节一节,而不是一整块矩形。

ctx.arc最后两个参数是起始弧度和结束弧度,0, Math.PI * 2表示一个整圆。食物半径取CELL_SIZE / 2 - 3,也就是 7px,比格子略小一圈,看起来像颗豆子。渲染函数本身不改变任何游戏状态,只负责把当前snake和food画出来。这个约定很重要,新手经常把“移动”和“画”混在一个函数里,最后蛇走一步要重排好几处代码。状态和渲染分离后,调试碰撞、加特效都轻松得多。

3. 核心逻辑:移动、吃食、碰撞这三件事的先后顺序

游戏循环里小逻辑很多,但真正决定“像不像贪吃蛇”的只有三个:蛇身怎么移动、食物怎么生成、碰撞怎么判定。这里的顺序错一步都不行,最常见的问题是碰撞检测放在蛇身插入之前还是之后搞混,结果蛇头刚撞到自己就让游戏结束,看不出是逻辑漏洞还是故意设计。所以这一章把三个环节的顺序单独拿出来讲清楚。

3.1 用数组存蛇身:unshift 加头、pop 去尾

贪吃蛇的移动本质上不是“每条蛇身都往前挪一格”,而是“在头部加一格,在尾部删一格”。用数组实现这个模型非常自然:snake数组第 0 项是头,最后一项是尾。前进时用unshift把新头插到数组头部,没吃到食物就用pop把尾巴去掉;吃到食物时不 pop,长度就增加一节。

let snake = [ { x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 } ]; function moveSnake(head) { snake.unshift(head); if (!ateFood) { snake.pop(); // 没吃到食物才删尾巴 } }

上面是结构示意,真正的代码要在主循环里先算出新头,再决定是否删尾。这里有两个容易踩的细节:

  • unshift会改变整个数组的索引,所以遍历蛇身绘制时,索引 0 永远是头,很适合做头部高亮。
  • 吃食物的判定必须放在unshift之后,和pop放在并列的 if 分支里。如果把判定放移动前,拿到的是上一帧的头,和当前食物坐标对不上。

数组元素只存{ x, y }网格坐标,不存方向,因为移动方向由全局变量direction决定。这样设计的另一个好处是碰撞检测时可以直接遍历蛇身数组逐坐标比较,不需要额外维护一套坐标集合。

3.2 方向控制:阻止 180 度回头的实现

方向控制的难点不是“按哪个方向走”,而是“不能允许蛇直接掉头”。蛇向右走的时候按左,蛇头会立刻钻到自己的脖子里,游戏直接结束。多数实现会维护两个方向变量:direction表示当前移动方向,nextDirection表示下一次 tick 要用的方向。为什么要两个?因为键盘事件可能在gameStep执行到一半时触发,直接改direction会导致当前帧移动方向突变,蛇在视觉上会瞬间“鬼畜”。

let direction = 'right'; let nextDirection = 'right'; function setDirection(newDir) { const opposite = { up: 'down', down: 'up', left: 'right', right: 'left' }; // 禁止 180 度回头 if (opposite[newDir] !== nextDirection) { nextDirection = newDir; } }

反向表opposite把四个方向的反向映射出来,如果新方向和当前将要执行的方向互为反向,直接忽略。这里以nextDirection为比较对象,能拦截“同一帧内连续按键”造成的方向覆盖。实际绑定键盘时,还要注意浏览器对方向键有默认行为,页面会跟着滚动。我习惯在keydown里对方向键和空格先preventDefault(),再交给setDirection:

document.addEventListener('keydown', (e) => { if (e.repeat) return; // 长按自动重复忽略 const key = e.key.toLowerCase(); const keyMap = { arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }; if (key in keyMap) { e.preventDefault(); setDirection(keyMap[key]); } if (key === ' ') { e.preventDefault(); togglePause(); } });

e.repeat用来过滤键盘自动重复事件。按住方向键不松手,浏览器会连续触发多次 keydown,如果不拦截,蛇每帧都在变方向,很容易把自己绕死。这个属性在原生 keyboard 事件里都有支持,不需要自己做节流。

3.3 食物生成与碰撞检测:三块判定,缺一不可

食物生成最简单的方案是随机取0到GRID_SIZE - 1之间的整数作为坐标,但光随机还不够,必须避开蛇身。如果食物刷在蛇身上,玩家永远吃不到,游戏直接卡死。这里用 do-while 循环反复随机,直到落在空白格子上:

function spawnFood() { let pos; do { pos = { x: Math.floor(Math.random() * GRID_SIZE), y: Math.floor(Math.random() * GRID_SIZE) }; } while (snake.some(seg => seg.x === pos.x && seg.y === pos.y)); food = pos; }

Math.random() * GRID_SIZE得到的是带小数的数字,必须用Math.floor转成整数。有人会手滑写Math.ceil,结果偶尔得到 20,而网格坐标合法范围是 0~19,食物直接画到画布外面。Math.floor是这里唯一不容易出错的取整方式。

碰撞检测分两类:撞墙和撞自己。墙边界是 0 和GRID_SIZE - 1,头坐标越界即结束;撞自己则从数组第 1 项开始比较,因为第 0 项是旧头,新头坐标还没插入数组,比较时用的是新头的临时变量:

function checkCollision(head) { if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) { return true; } return snake.some(seg => seg.x === head.x && seg.y === head.y); }

这里snake里存的还是旧蛇身。正确顺序是:算出新头 → 检查墙和自身碰撞 → 没有碰撞再 unshift → 根据是否吃到食物决定 pop。如果先 unshift 再比较,snake[0]已经变成新头,比较时会把新头自己也算进去,等于永远判定碰撞。

这套顺序是整个游戏稳定性的核心。把它写成注释贴在主循环函数上方,比任何口头约定都管用。第 4 章会把完整流程串成一份可运行源码,移动、吃食、碰撞六步走完整闭环。

4. 完整源码:一个能直接运行的贪吃蛇 HTML 文件

这一章把前面拆开的 Canvas、循环、移动、碰撞、渲染整合成一个文件。你可以保存为snake.html,双击就能在浏览器跑,完全不依赖网络资源。代码故意写成“参数集中在顶部、逻辑直白平铺”的风格,方便改参数观察效果,而不是封装成一堆看不出用途的类。

4.1 完整代码与文件结构

整个游戏只有一个文件,结构分三块:<style>管布局样式,<canvas>是游戏画布,<script>是全部逻辑。没有外部库,没有构建步骤,复制到记事本另存为 html 就能玩。

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>贪吃蛇 HTML 单文件版</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Microsoft YaHei", sans-serif; background: #0f0f23; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; } #wrapper { background: #15152e; padding: 20px; border-radius: 12px; box-shadow: 0 0 20px rgba(0,0,0,0.4); } #topBar { display: flex; justify-content: space-between; margin-bottom: 12px; font-size: 16px; } #topBar button { padding: 4px 14px; border: none; border-radius: 6px; background: #00b894; color: #fff; cursor: pointer; font-size: 14px; } #gameBoard { display: block; background: #1a1a2e; border-radius: 8px; } #tip { margin-top: 10px; font-size: 13px; color: #888; text-align: center; } </style> </head> <body> <div id="wrapper"> <div id="topBar"> <span>得分:<span id="score">0</span></span> <button id="restartBtn" onclick="restartGame()">重新开始</button> </div> <canvas id="gameBoard" width="400" height="400"></canvas> <p id="tip">方向键或 WASD 控制移动,空格暂停</p> </div> <script> // ========== 参数配置 ========== const GRID_SIZE = 20; const CELL_SIZE = 20; const BASE_SPEED = 150; const WALL_MODE = false; // 改为 true 开启穿墙模式 // ========== 全局状态 ========== let canvas, ctx; let snake = []; let direction = 'right'; let nextDirection = 'right'; let food = null; let score = 0; let gameInterval = null; let isRunning = false; // ========== 初始化 ========== function init() { canvas = document.getElementById('gameBoard'); ctx = canvas.getContext('2d'); restartGame(); } function restartGame() { snake = [ { x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 } ]; direction = 'right'; nextDirection = 'right'; score = 0; document.getElementById('score').textContent = '0'; isRunning = true; spawnFood(); startLoop(); render(); } // ========== 游戏循环 ========== function startLoop() { stopLoop(); gameInterval = setInterval(gameStep, BASE_SPEED); } function stopLoop() { if (gameInterval) { clearInterval(gameInterval); gameInterval = null; } } // ========== 核心逻辑 ========== function gameStep() { if (!isRunning) return; // 1. 计算新头位置 const head = { x: snake[0].x, y: snake[0].y }; switch (nextDirection) { case 'right': head.x += 1; break; case 'left': head.x -= 1; break; case 'up': head.y -= 1; break; case 'down': head.y += 1; break; } direction = nextDirection; // 2. 穿墙模式修正 if (WALL_MODE) { head.x = (head.x + GRID_SIZE) % GRID_SIZE; head.y = (head.y + GRID_SIZE) % GRID_SIZE; } // 3. 碰撞检测 if (checkCollision(head)) { gameOver(); return; } // 4. 新头插入 snake.unshift(head); // 5. 是否吃到食物 if (food && head.x === food.x && head.y === food.y) { score += 10; document.getElementById('score').textContent = score; spawnFood(); } else { snake.pop(); } // 6. 渲染 render(); } function checkCollision(head) { if (!WALL_MODE) { if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) { return true; } } return snake.some(seg => seg.x === head.x && seg.y === head.y); } // ========== 食物 ========== function spawnFood() { let pos; do { pos = { x: Math.floor(Math.random() * GRID_SIZE), y: Math.floor(Math.random() * GRID_SIZE) }; } while (snake.some(seg => seg.x === pos.x && seg.y === pos.y)); food = pos; } // ========== 渲染 ========== function render() { ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; for (let i = 1; i < GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, canvas.height); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(canvas.width, i * CELL_SIZE); ctx.stroke(); } snake.forEach((seg, idx) => { ctx.fillStyle = idx === 0 ? '#2ecc71' : (idx % 2 ? '#27ae60' : '#1e8449'); ctx.fillRect(seg.x * CELL_SIZE + 1, seg.y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); }); if (food) { ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc( food.x * CELL_SIZE + CELL_SIZE / 2, food.y * CELL_SIZE + CELL_SIZE / 2, CELL_SIZE / 2 - 3, 0, Math.PI * 2 ); ctx.fill(); } } // ========== 键盘控制 ========== document.addEventListener('keydown', function (e) { if (e.repeat) return; const key = e.key.toLowerCase(); const keyMap = { arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }; if (key in keyMap) { e.preventDefault(); setDirection(keyMap[key]); } if (key === ' ') { e.preventDefault(); togglePause(); } }); function setDirection(newDir) { const opposite = { up: 'down', down: 'up', left: 'right', right: 'left' }; if (opposite[newDir] !== nextDirection) { nextDirection = newDir; } } // ========== 暂停 / 结束 ========== function togglePause() { isRunning = !isRunning; if (isRunning) { startLoop(); } else { stopLoop(); } } function gameOver() { isRunning = false; stopLoop(); ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.font = 'bold 28px "Microsoft YaHei"'; ctx.textAlign = 'center'; ctx.fillText('游戏结束', canvas.width / 2, canvas.height / 2 - 10); ctx.font = '16px "Microsoft YaHei"'; ctx.fillText('得分:' + score, canvas.width / 2, canvas.height / 2 + 25); } init(); </script> </body> </html>

这个文件的执行顺序是:页面加载 →init()拿 canvas 上下文 →restartGame()重置蛇、生成食物并启动定时器 → 之后每个 tick 执行一次gameStep。restartGame被按钮点击事件直接调用,所以点“重新开始”不需要刷新页面。

代码里WALL_MODE在最上方参数区。改成true后,checkCollision跳过撞墙判定,同时gameStep里对 head 坐标做了取模修正,蛇从右边出去会从左边回来,逻辑闭环。这里有一个值得注意的点:穿墙后头部坐标必须用(head.x + GRID_SIZE) % GRID_SIZE而不是head.x % GRID_SIZE,因为 JavaScript 的取模对负数返回负值,从左边穿出时head.x是 -1,直接取模会得到 -1,还是越界。

有一处需要提醒:gameInterval = setInterval(gameStep, BASE_SPEED)是固定间隔,分数提高后想加速不能直接改BASE_SPEED常量,第 6 章会给更合理的难度曲线方案。另外e.repeat拦截了长按重复,是为了避免按住方向键时蛇“连打方向盘”,这个细节在真机上体验差距很大。

4.2 参数说明:速度、网格、穿墙模式怎么调

参数集中在文件开头几行,改起来很快。下面这张表列了每个参数的默认值、控制内容和常规调法:

参数默认值控制内容怎么改
GRID_SIZE20场地行列数,值越小空间越挤改成 15 更快遇到墙,改成 25 更耐玩
CELL_SIZE20每个格子的像素大小画布固定 400px 时,改大会溢出,记得同步 canvas 尺寸
BASE_SPEED150每次移动的间隔毫秒数改成 100 蛇变快,改成 200 蛇变慢
WALL_MODEfalse是否开启穿墙改成 true,蛇从边界穿到对面
初始蛇长3开局蛇身长度在restartGame的 snake 数组里增减元素

修改GRID_SIZE时,canvas 的width和height建议同步改成GRID_SIZE * CELL_SIZE,否则场地一半空在画布外面,碰撞边界却仍按网格算,很容易出现“蛇头撞到看不见的墙”。修改初始蛇长只需要在restartGame的 snake 数组里加删对象,但每节坐标必须连续,不能跳着摆,否则蛇一出生就断成两截。

如果你想把这套游戏嵌进现有页面,只需要保留<canvas>和<script>部分,把 body 的 flex 布局删掉,再放一块合适大小的容器。canvas 的坐标计算相对独立,嵌入后不需要改逻辑,但要注意页面其他元素如果抢键盘焦点,方向键监听可能收不到。让 canvas 拿到tabindex属性可以缓解这个问题。

完整源码就是给你直接复制用的。第 5 章的四个坑也都是在它基础上总结出来的,建议先原样跑起来,再动手改参数,避免一上来就改出问题,还分不清是代码缺陷还是预期行为。

5. 避坑与常见问题:四个高频翻车点,每个都有后悔药

网上贪吃蛇教程很多,但真正跑起来以后翻车的往往是边角问题:方向键连按、后台切回来、残影、食物刷新。每一条我都踩过,下面按“现象 → 原因 → 解决”写清楚,方便直接照方抓药。

5.1 长按方向键后蛇开始绕圈,甚至瞬间反向撞死自己

现象:按住“上”不松手,蛇连续向上拐,转弯半径小到避不开身体;快速连按“上”“左”时,蛇会直接反向钻进脖子,屏幕立刻弹出“游戏结束”。

原因:浏览器键盘事件有自动重复机制,按住一个键会连续触发keydown。如果不处理,setDirection会被反复调用,nextDirection正好落在玩家没预料的那个方向;加上反向判断如果只参照上一帧的direction,两个方向连按时就可能绕过回头限制。

解决:在keydown开头加if (e.repeat) return;,只接受第一次按键。同时把反向判断的比较对象改成nextDirection而不是direction,这样同一帧内发生连续按键也无法绕过 180 度检查。

document.addEventListener('keydown', (e) => { if (e.repeat) return; // 其余按键处理逻辑不变 });

改完以后按住方向键绕一整圈试试,蛇只能 90 度转向,不会出现瞬移回头。这个改动还能减少方向键连击时的误触感,后面接触摸控制时会舒服很多。

5.2 切到其他标签页再回来,蛇已经撞墙

现象:游戏过程中切到别的标签页处理消息,切回来时发现蛇已经撞墙结束,或者凭空跳了一大段距离。

原因:浏览器对后台标签页的setInterval有节流策略,切到后台后定时器可能被降到每秒 1 次,有些浏览器甚至直接暂停。等回到前台,积压的 tick 没有按正常节奏执行,蛇的状态还停留在切出前,看起来就是“瞬移”。

解决:监听visibilitychange事件,页面切出时自动暂停游戏,回来再继续。这样做比硬扛后台限制靠谱得多,贪吃蛇本身就不适合看不见的情况下继续跑。

document.addEventListener('visibilitychange', () => { if (document.hidden) { if (isRunning) togglePause(); } });

你可能会问:用requestAnimationFrame能不能解决后台间隔问题?不能完全解决,页面隐藏时 rAF 同样会停。真正需要后台运行的游戏得用 web worker 或服务端推进,但贪吃蛇没必要做到那一步,自动暂停反而是玩家更友好的行为。

5.3 蛇身后拖着一串残影,格子擦不干净

现象:蛇移动后,原先的位置还留有颜色块,画布上越来越多“尾巴”,颜色深浅不一,最后把整个画布糊成一片。

原因:render()里没有在重绘前清空整个画布。Canvas 不是普通 DOM,它是一张位图,不清掉旧像素,新像素只会叠在上面。有人只用ctx.clearRect清出透明区域,有人只画蛇身不画背景,都会造成残留。

解决:在render()最开头先整体填充背景色,再画网格线和蛇。第 4 章源码里render的第一行就是ctx.fillStyle = '#1a1a2e'; ctx.fillRect(...)。如果你用的是clearRect,清出来的区域是透明的,页面 body 背景会透出来,看起来像一块深一块浅,所以推荐统一“先铺背景、再画内容”。

顺便说一句,残影不一定每次都肉眼可见。蛇身颜色和背景接近时,旧的深色块会混在背景里,等到蛇转过弯才突然露出来。所以“清理画布”这件事要放在每次 render 的第一行,不能只在视觉异常时想起来。

5.4 食物刷在蛇身上或被“墙”吃掉

现象:食物突然消失,或者生成后永远捞不到;打开穿墙模式后,食物停在画布外。

原因:Math.random()生成的是[0, 1)区间的浮点数,乘以GRID_SIZE后需要Math.floor取整。如果用Math.round或Math.ceil,边界上会得到GRID_SIZE这个非法坐标,食物画在画布外面。另一个问题是随机生成时没有排除蛇身占用的格子,食物可能生成在蛇的肚子里。

解决:生成食物用 do-while 循环,只要新坐标和蛇身任一节点重合就重新随机。参考第 3 章的spawnFood写法,并坚持用Math.floor。想更严谨一点,可以在蛇身占满棋盘时主动触发胜利结算,避免 do-while 在满盘边界下低效空转:

if (snake.length >= GRID_SIZE * GRID_SIZE) { isRunning = false; stopLoop(); return; }

这行检查放在spawnFood()之前即可。日常游玩基本触发不到,但它能防止极端情况下浏览器卡顿,属于老工程师的自我保护。改完GRID_SIZE后,这类边界问题的概率会变大,因为场地越小,蛇占满棋盘的速度越快。

6. 进阶:速度曲线、穿墙模式与三道自检

这一章讲两个实用优化:难度曲线和穿墙模式体验,再给一个改完参数后的自检清单。它们不改变核心玩法,但对游戏耐玩度影响很大。

6.1 难度曲线:分数越高,间隔怎么减

如果每吃到食物都把间隔减 10ms,初看起来挺线性,但越到后面减速越猛,因为食物间隔本身就是速度的倒数,相当于速度越高加速度还越大,玩家很快会撞上操作极限。我一般按分数段降速,每 50 分降一次,并设速度下限:

let currentSpeed = BASE_SPEED; function updateSpeed() { const level = Math.floor(score / 50); const nextSpeed = Math.max(80, BASE_SPEED - level * 10); if (nextSpeed !== currentSpeed) { currentSpeed = nextSpeed; startLoop(); // 用新间隔重启定时器 } }

调用位置放在gameStep吃到食物的分支里。Math.max(80, ...)是保底方案,80ms 大约是每秒 12 步,对多数玩家已经接近操作极限,再快就变成纯粹考验手速了。

穿墙模式在源码里已经有WALL_MODE开关,开启后蛇头坐标会在每帧做取模修正。要注意的是食物生成不受穿墙影响,它永远在网格范围内随机,不会出现食物生成在墙外的情况。你只需要在restartGame时复用同一套spawnFood逻辑即可。

6.2 三道自检:改完参数后先这样验证

每次改完速度或穿墙配置,我习惯按这三步自检,基本能覆盖九成问题:

  1. 按住一个方向键循环转圈,确认蛇不会因为重复按键绕出螺旋。
  2. 把WALL_MODE改成true,让蛇从右边界穿出再从左边穿回,确认头坐标没有偏移一格。
  3. 切到后台停留 10 秒再回来,确认游戏自动暂停而不是直接撞死。

这三步跑完,这份源码在你机器上就算稳了。从那以后,我每次给别人发贪吃蛇源码,都会强制自己先跑一遍穿墙和后台切换再出手,这个习惯帮我挡掉了好几个“在我电脑上明明没问题”的尴尬。希望这套摸爬滚打总结出的坑位记录能帮到你,少走点我走过的弯路。

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

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

Model-Optimizer本质:AI模型瘦身的三大手术刀

1. 这不是“一键优化”工具&#xff1a;Model-Optimizer的本质是模型瘦身手术台你在网上搜“Model-Optimizer”&#xff0c;十有八九会撞进一堆NVIDIA驱动安装教程、RTX 4060笔记本显卡识别失败的求助帖&#xff0c;甚至还有人问“nvidia dxcache文件夹能不能删”。这恰恰暴露了…

作者头像 李华
网站建设 2026/9/29 19:45:40

工业Agent实时控制是伪命题?拆解技术栈与落地边界

1. 工业Agent的"实时控制"承诺&#xff0c;到底卡在哪一层 先把结论摆在前面&#xff1a; 当前市面上绝大多数号称能做"实时控制"的工业Agent&#xff0c;本质上都是"离线决策人工确认PLC执行"的三段式流程&#xff0c;中间那一段人工确认环节&…

作者头像 李华
网站建设 2026/9/29 19:44:44

qwen3.5-9b长对话上下文管理:从窗口分配到持久化

最近后台收到好几条同类私信&#xff1a;为什么拿 qwen3.5-9b 这类 9B 参数量级的小模型跑长对话&#xff0c;前几十轮还挺正常&#xff0c;后面越聊越像“失忆”&#xff1f;还有人在折腾多 Agent 任务编排时发现&#xff0c;A 智能体说过的话&#xff0c;B 智能体一概不认。这…

作者头像 李华
网站建设 2026/9/29 19:42:34

Kubernetes 上构建 Agentic 运行时:ax 编排实践与避坑指南

1. 从“ax”这个标题说起&#xff1a;一个被低估的运行时编排切口第一次看到“ax”这个标题&#xff0c;很多人会一头雾水。它不像“Kubernetes 入门”那样直白&#xff0c;也不像“Agentic RAG 实战”那样自带场景。但把热搜词摊开看——ax、agentic、orchestration、runtime、…

作者头像 李华
网站建设 2026/9/29 19:41:43

Model-Optimizer:面向边缘部署的模型瘦身方法论体系

1. 项目概述&#xff1a;这不是一个“优化器”&#xff0c;而是一套模型瘦身的手术刀组合“Model-Optimizer”这个名称在当前技术社区里被反复提及&#xff0c;但绝大多数人第一次看到时都会下意识地把它当成某个单一工具、某个开源库的别名&#xff0c;甚至误以为是PyTorch或T…

作者头像 李华
网站建设 2026/9/29 19:41:27

边缘部署模型优化实战:量化、剪枝、蒸馏与图优化全解析

把训练好的模型塞进边缘设备&#xff0c;这件事我做了不下二十次&#xff0c;每次上线前都要失眠——不是因为模型不收敛&#xff0c;而是因为收敛得“刚刚好”的模型&#xff0c;在设备上根本跑不动。两年前我们上线的第一个缺陷检测模型&#xff0c;ResNet-50结构&#xff0c…

作者头像 李华