免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

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

用HTML+Canvas做贪吃蛇:单文件源码与前端基本功 简介面向Web前端初学者以及想用项目练手JavaScript的读者这是一套基于HTML实现的贪吃蛇游戏入门源码包。资源围绕经典玩法展开完整演示了页面布局、样式装饰与游戏逻辑的整合过程HTML搭建游戏区域与分数显示CSS负责视觉风格JavaScript则处理按键方向控制、食物随机生成、蛇身增长以及边界与自身碰撞检测此外还涉及setInterval定时刷新与DOM操作更新界面能够帮助理解网页交互的基本套路。压缩包共3个文件包含1个HTML核心文件另含JPG与PNG图片素材各1张整体仅86KB轻量易用适合下载后直接打开体验或二次修改。目前该资源已有1450人学习下载无论自学入门、备课演示还是完成课程设计都可以借此快速掌握HTMLCSSJavaScript三者配合开发小游戏的方法。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 宽高是 400fillRect铺满整个画布这是每一帧重绘的起点。注意顺序先清整张画布再画网格线、蛇、食物否则蛇身后会拖残影。GRID_SIZE决定场地大小也决定蛇的极限长度。CELL_SIZE决定画面大小改它不改网格蛇会整体变大变小。canvas.width/height必须和GRID_SIZE * CELL_SIZE一致否则坐标映射全错位。这两组数字是所有坐标计算的根第 4 章的完整源码里也是靠它们控制整套逻辑。想快速验证换算对不对可以先只画网格线再手动放几节蛇身看格子是否对上。这个自检不到一分钟能省掉后面大量调试时间。2.2 帧循环选型setInterval 适合贪吃蛇requestAnimationFrame 适合什么游戏循环有两种常见写法setInterval和requestAnimationFrame。贪吃蛇不是连续移动而是一格一格跳变移动频率通常是 100200ms 一次用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()再交给setDirectiondocument.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而网格坐标合法范围是 019食物直接画到画布外面。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 langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 idwrapper div idtopBar span得分span idscore0/span/span button idrestartBtn onclickrestartGame()重新开始/button /div canvas idgameBoard width400 height400/canvas p idtip方向键或 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 三道自检改完参数后先这样验证每次改完速度或穿墙配置我习惯按这三步自检基本能覆盖九成问题按住一个方向键循环转圈确认蛇不会因为重复按键绕出螺旋。把WALL_MODE改成true让蛇从右边界穿出再从左边穿回确认头坐标没有偏移一格。切到后台停留 10 秒再回来确认游戏自动暂停而不是直接撞死。这三步跑完这份源码在你机器上就算稳了。从那以后我每次给别人发贪吃蛇源码都会强制自己先跑一遍穿墙和后台切换再出手这个习惯帮我挡掉了好几个“在我电脑上明明没问题”的尴尬。希望这套摸爬滚打总结出的坑位记录能帮到你少走点我走过的弯路。本文还有配套的精品资源点击获取
返回列表