原生JS+Canvas贪吃蛇:游戏循环、碰撞检测与输入队列
简介面向网页开发初学者的HTML贪吃蛇实践项目围绕用HTML搭建游戏区域与分数显示结合JavaScript处理键盘事件、蛇身坐标更新、食物随机生成、碰撞检测和分数刷新帮助读者把页面结构、样式布局与交互逻辑串联起来理解。压缩包共3个文件包含1个HTML入口页面及JPG、PNG图片素材整体约86KB体积轻巧便于快速查看页面组织与素材引用关系也可作为课堂演示或课后练习参考。已有1455人学习说明该案例在入门阶段具有一定参考价值适合对照常见实现思路查漏补缺。读者可从中获得一份可运行的贪吃蛇页面雏形、基础界面布局思路以及事件监听与setInterval定时刷新配合的代码组织方式并据此继续扩展难度等级、音效或CSS3动画效果在理解核心循环后锻炼问题拆解与逻辑实现能力。1. 用纯 HTML 写贪吃蛇不装框架、不跑构建双击就能玩第一次用 html 语言写游戏的人八成都是从贪吃蛇下手的。原因很实际它不需要素材、不需要物理引擎、不需要网络请求一个 canvas 加一个键盘监听就能闭环。但真正写完之后最容易碰到的两个问题是蛇在快速连按两下方向键之后原地掉头一头撞进自己的身体以及刷新页面时食物正好叠在蛇身上导致“看不见的食物”永远吃不到。这两个问题都不是逻辑写错了而是状态更新的时序没想清楚。下面这份单页实现围绕一个 20×20 的网格、约 200 行原生 JavaScript 展开不依赖任何库双击 index.html 就能跑适合刚学完 DOM 和数组、想找一个完整闭环练手的人也适合拿它当教学 Demo 或者给孩子演示“代码能做出东西”的素材。2. 网格与坐标系把 20×20 的格子映射成 canvas 像素2.1 页面骨架一个 canvas 加三个信息位先把 HTML 骨架固定下来后面所有逻辑都挂在这上面。这里刻意不用 div 拼格子——20×20 是 400 个节点虽然现代浏览器扛得住但每次重绘都要改 400 个元素的 class写法上远不如 canvas 一次 clearRect 干净。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title贪吃蛇/title style :root { color-scheme: dark; } body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0b1220; color: #e2e8f0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; } .wrap { display: flex; flex-direction: column; gap: 12px; align-items: center; } .hud { display: flex; gap: 24px; font-size: 14px; font-variant-numeric: tabular-nums; letter-spacing: .04em; } .hud b { color: #38bdf8; font-weight: 600; } /* 关键canvas 的 CSS 尺寸和像素尺寸分离后面 6.1 会用到 */ canvas { display: block; border-radius: 10px; background: #0f172a; box-shadow: 0 0 0 1px #1e293b, 0 18px 40px -20px #000; touch-action: none; } .tip { margin: 0; font-size: 12px; color: #64748b; } /style /head body div classwrap div classhud span得分 b idscore0/b/span span最高 b idbest0/b/span span速度 b idspeed1.0/bx/span /div canvas idstage width400 height400/canvas p classtip方向键 / WASD 控制 · 空格暂停 · R 重开/p /div script srcgame.js/script /body /html逻辑说明canvas idstage width400 height400里的 width/height 是绘图缓冲区尺寸不是显示尺寸这一点后面会反复用到。HUD 三块分别显示当前分、本地最高分、当前速度倍率都是纯文本节点用textContent更新即可不要用innerHTML拼字符串——每帧都拼会白白触发解析。参数说明400×400 配合 20 的格子边长正好是 20×20 个格子这是全网贪吃蛇最常见的比例改动时三个数要一起改否则会出现半格蛇。touch-action: none是给移动端滑动手势留的位不加的话手指在画布上滑动会触发页面滚动方向刚按下就被浏览器抢走了。2.2 蛇的数据结构为什么用对象数组而不是两个坐标数组数据结构决定了后面每一行代码的写法。常见的三种存法一是两个数组xs[]、ys[]平行存放二是一个一维索引数组值等于y * cols x三是一个{x, y}对象数组。我一般会选第三种理由是它让“取蛇头”“弹出蛇尾”“判断某格是否在蛇身”这三件事都写成一行而平行数组在插入删除时必须同步操作两个数组一旦某次忘了同步就会慢慢错位——这种错位不会立刻报错会表现为“蛇身少了一节”属于典型的血泪经验。// game.js —— 全局状态只留这几个避免散落各处 const canvas document.getElementById(stage); const ctx canvas.getContext(2d); const CFG { cols: 20, // 横向格子数 rows: 20, // 纵向格子数 cell: 20, // 每格边长像素 baseStep: 160, // 初始每走一格耗时毫秒 minStep: 70, // 速度上限越小越快 speedUp: 5 // 每吃一个食物减少的毫秒数 }; const boardW CFG.cols * CFG.cell; const boardH CFG.rows * CFG.cell; let snake; // [{x,y}, ...] 下标 0 是蛇头 let dir; // {x,y} 当前生效方向 let queue; // 待消费的方向指令 let food; // {x,y} 或 null let score; let stepMs; // 当前每格耗时 let running; // 是否在推进 let over; // 是否已死 // 两个小工具全篇高频复用 const same (a, b) a.x b.x a.y b.y; function reset() { snake [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }]; dir { x: 1, y: 0 }; // 初始向右 queue []; score 0; stepMs CFG.baseStep; running true; over false; food pickFood(snake); syncHud(); }逻辑说明蛇头放在数组下标 0意味着“前进”就是unshift一个新头“不生长”就是pop掉尾巴。这个方向比“尾巴在下标 0”要顺手因为碰撞检测永远从蛇头开始看而渲染时从尾往头画能保证蛇头压在最上层。参数说明baseStep: 160意味着每秒约走 6.25 格这个速度对新手刚好低于 100 会明显手忙脚乱。minStep: 70是硬性下限避免吃多了之后快到人手无法响应——很多人写加速时忘了设下限最后蛇速变成每帧一格直接变成不可玩。speedUp: 5表示每吃一个食物每格耗时减 5 毫秒吃到第 18 个时速度见顶。2.3 渲染顺序清屏、底纹、食物、蛇一步都不能省canvas 是“画上去就留在那”的位图不清屏的话上一帧的蛇尾会留在画面上看起来像蛇在拖着影子走。function draw() { // 1) 整块清掉不能只清蛇身因为蛇会移动 ctx.clearRect(0, 0, boardW, boardH); // 2) 棋盘底纹只画极淡的交叉线别画满格子否则蛇看不清 ctx.strokeStyle rgba(148, 163, 184, 0.08); ctx.lineWidth 1; for (let i 1; i CFG.cols; i) { const p i * CFG.cell 0.5; // 0.5 让 1px 线落在像素中心避免发虚 ctx.beginPath(); ctx.moveTo(p, 0); ctx.lineTo(p, boardH); ctx.stroke(); } for (let j 1; j CFG.rows; j) { const p j * CFG.cell 0.5; ctx.beginPath(); ctx.moveTo(0, p); ctx.lineTo(boardW, p); ctx.stroke(); } // 3) 食物画成圆形和方形蛇身区分开 if (food) { const cx food.x * CFG.cell CFG.cell / 2; const cy food.y * CFG.cell CFG.cell / 2; ctx.fillStyle #f97316; ctx.beginPath(); ctx.arc(cx, cy, CFG.cell * 0.34, 0, Math.PI * 2); ctx.fill(); } // 4) 蛇从尾到头画保证蛇头覆盖在最上面 for (let i snake.length - 1; i 0; i--) { const seg snake[i]; const x seg.x * CFG.cell; const y seg.y * CFG.cell; const pad i 0 ? 1 : 2; // 蛇头稍大视觉上有方向感 ctx.fillStyle i 0 ? #38bdf8 : #1d7fb8; ctx.fillRect(x pad, y pad, CFG.cell - pad * 2, CFG.cell - pad * 2); } }逻辑说明四步顺序不能乱。先 clearRect 是整个画布清空如果只在蛇身位置画背景色蛇加速后会出现残影叠加。底纹循环里加0.5是老生常谈的像素对齐技巧1px 的描边落在整数坐标上会跨两个物理像素渲染出来是灰蒙蒙的两像素线看着就像“画面有点糊”。参数说明pad是每节之间的缝隙取 1 到 2 像素即可取大了蛇会变成一串孤立的方块取 0 则整条蛇糊成一根粗条看不出关节。食物半径取格子边长的 0.34 倍刚好填满但留一圈空隙视觉上比贴边更好认。3. 游戏循环与输入队列让蛇按 160 毫秒一格稳定推进3.1 setInterval 和 requestAnimationFrame 该怎么选最省事的写法是setInterval(tick, 150)改速度就清掉再建一个新的定时器。能跑但有两个隐患一是后台标签页里定时器会被浏览器节流切回来时可能一次性补执行好几次回调蛇瞬间窜出去好几格直接撞墙二是改速度时要反复 clearInterval某个分支忘了清就会有两个定时器同时在推表现是“速度突然翻倍”。更稳的做法是requestAnimationFrame加一个时间累加器rAF 负责“每帧问我一次”累加器负责“攒够一个步长才走一格”。这样渲染帧率和逻辑帧率解耦120Hz 屏幕上动画更顺同时逻辑速度完全由stepMs控制。let acc 0; // 已累积的时间 let last 0; // 上一帧时间戳 function loop(ts) { if (!last) last ts; // 第一帧没有参照只记时间不走格 const dt ts - last; last ts; // 单帧最长按 100ms 算防止切标签页回来时 dt 过大导致连走十几格 const safeDt Math.min(dt, 100); if (running !over) { acc safeDt; // while 而不是 if低帧率时一帧可能要推进多格 while (acc stepMs) { acc - stepMs; tick(); if (over) break; } } draw(); requestAnimationFrame(loop); } requestAnimationFrame(loop);逻辑说明acc stepMs用 while 而不是 if是为了在设备掉帧时补上欠下的逻辑步数——否则低端机上蛇的移动速度会变慢玩家会感觉“卡”。但 while 必须配if (over) break否则死亡瞬间还欠着两步会在 gameOver 之后继续 tick把蛇推出边界。参数说明safeDt的 100ms 上限是必须加的护栏。不加的话用户切到别的标签页 30 秒再切回来第一帧 dt 就是 30000while 循环要跑将近 200 次 tick蛇会在你看不见的一瞬间连撞好几次。取 100 是因为它至少能覆盖 1 到 2 个正常步长视觉上不会有明显跳帧。3.2 方向队列为什么不能直接改 dir这是贪吃蛇最经典的翻车点。如果键盘回调里直接dir newDir玩家在蛇向右走时快速按“上”再按“左”两个事件都在同一帧内被处理等下一帧 tick 执行时 dir 已经是“左”于是蛇头直接从 (10,10) 走到 (9,10)正好是它自己身体所在的格子——判死。正确做法是把按键收集到一个队列里每个 tick 只消费一个并且做“反向过滤”。const DIRS { 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 } }; function pushDir(next) { if (!next) return; // 队列里最后一个方向才是蛇走到那一步时的方向拿它做反向判断 const ref queue.length ? queue[queue.length - 1] : dir; if (ref.x next.x 0 ref.y next.y 0) return; // 180° 掉头丢弃 if (queue.length 2) return; // 最多缓冲两步防止按太快攒一堆 queue.push(next); } window.addEventListener(keydown, (e) { const key e.key.length 1 ? e.key.toLowerCase() : e.key; if (DIRS[key]) { e.preventDefault(); // 阻止方向键滚动页面 pushDir(DIRS[key]); return; } if (key !over) { e.preventDefault(); running !running; if (running) last 0; // 恢复时重置时间基准否则会瞬间补帧 return; } if (key r) { reset(); last 0; } });逻辑说明关键在于pushDir里的参照方向取的是“队列末尾”而不是“当前 dir”。假设蛇正在向右、队列为空玩家按上入队、再按左此时参照是上左和上不构成 180°允许入队。两个 tick 之后蛇先后执行“上”“左”是一条合法的 L 形路线。而如果参照取的是当前 dir还是右按左就会被误判成掉头而丢弃玩家会觉得“按了没反应”。参数说明queue.length 2这个上限是为了防止玩家疯狂连按时攒出十几步松手之后蛇还会自动驾驶一段时间体验很差。缓冲两步足够覆盖人类手速极限的连续拐弯。空格暂停恢复时把last置 0是因为暂停期间 rAF 仍在跑last一直更新其实没问题但暂停如果做成了停止 rAF恢复的第一帧 dt 会非常大置 0 是统一兜底。3.3 tick 里到底发生了什么把一件事说清楚tick 只负责“推进一格”不负责渲染也不负责判断输赢之外的事。职责单一带来的是排错时能一眼定位——蛇走错格子就看 tick画面不对就看 draw。function tick() { // 1) 消费一个方向指令 if (queue.length) dir queue.shift(); const head snake[0]; const next { x: head.x dir.x, y: head.y dir.y }; // 2) 撞墙判定 if (next.x 0 || next.x CFG.cols || next.y 0 || next.y CFG.rows) { return gameOver(); } // 3) 判断这一步是否吃到食物 const growing food ! null same(next, food); // 4) 撞自己没吃到食物时尾巴这一步会腾出来不能算碰撞 const body growing ? snake : snake.slice(0, -1); if (body.some((seg) same(seg, next))) { return gameOver(); } // 5) 落子 snake.unshift(next); if (growing) { score 10; stepMs Math.max(CFG.minStep, stepMs - CFG.speedUp); food pickFood(snake); if (food null) return win(); // 整块棋盘被填满 } else { snake.pop(); } syncHud(); }逻辑说明第 4 步的slice(0, -1)是整个函数里最容易写漏的一行。考虑蛇长度为 4、正在吃食物的情况——尾巴不 pop那么尾巴占的格子仍然属于身体必须参与碰撞而在普通移动时尾巴会在同一 tick 内离开蛇头进入尾格是合法的这是贪吃蛇的经典“擦尾”操作。少了这个区分游戏会出现“明明能过却死”的玄学判定玩家百分百会给差评。参数说明score 10只是为了让分数好看改成 1也行但要在 HUD 上保持一致别出现“吃十个食物分数只有 2”的错位。stepMs用Math.max卡住下限配合CFG.minStep 70让游戏在吃到第 18 个食物后速度封顶避免手速跟不上直接劝退。4. 碰撞、食物与计分把“吃到”和“撞死”的判断写对4.1 撞墙的边界值写 还是 墙的判定只有四个比较但写错方向的人不少。坐标系统的定义是合法格子下标范围0 .. cols-1所以next.x 0或next.x CFG.cols都是撞墙。写成 CFG.cols就会多出一列“墙外可以走”的隐形格子蛇头会跑到画布外面画面上看不见蛇但游戏还在跑看起来就像程序卡死了。如果你想让撞墙变成“穿墙”从左边出去右边回来只需把判定换成取模// 穿墙模式把越界坐标折回棋盘其余逻辑不变 next.x (next.x CFG.cols) % CFG.cols; next.y (next.y CFG.rows) % CFG.rows; // 此时不再需要撞墙的 return gameOver()逻辑说明JavaScript 的%对负数返回负值-1 % 20等于-1而不是 19所以必须先 CFG.cols再取模。这个坑非常常见表现是蛇从左边出去之后没有从右边出现而是直接消失。参数说明穿墙和撞墙是两种玩法建议做成一个CFG.wrap true/false开关默认关掉。带穿墙的版本难度低不少适合给小孩玩关闭穿墙的经典模式更适合当练手目标。4.2 食物生成从空位池采样别用重试循环新手最常写的食物生成是这样// 不推荐的写法 do { food { x: Math.floor(Math.random() * CFG.cols), y: Math.floor(Math.random() * CFG.rows) }; } while (snake.some((s) same(s, food)));蛇短的时候没问题等蛇占到 380 格以上这个循环平均要随机 20 次才命中一个空位极端情况下会卡住一瞬间画面上表现为“吃到食物后停顿一下”。更糟的是当棋盘只剩 1 个空位时概率上仍能命中但如果是只剩 0 个空位蛇填满棋盘这就是一个死循环页面直接失去响应。改成从空位池里等概率采样复杂度是线性的且天然处理棋盘填满的情况function pickFood(body) { const total CFG.cols * CFG.rows; const occupied new Set(body.map((s) s.y * CFG.cols s.x)); const free []; for (let i 0; i total; i) { if (!occupied.has(i)) free.push(i); } if (free.length 0) return null; // 棋盘被填满交给 win() 处理 const k free[Math.floor(Math.random() * free.length)]; return { x: k % CFG.cols, y: Math.floor(k / CFG.cols) }; }逻辑说明把二维坐标压成一维索引y * cols x存进 Set判断重复就是 O(1)比用some逐个比对对象快得多。再把所有空闲索引收集成数组随机取一个下标取中的概率严格均匀——这一点比“随机重试”更公平重试法在蛇很长时会让靠近蛇的空位被选中的概率偏高。参数说明total是 400这个循环每吃一个食物跑一次性能上完全无感。但如果把格子改成 100×10010000 格每次吃食物都要遍历一万次此时应该改成“维护一个空闲 Set吃食物时删一个、蛇尾移动时加回一个”的增量写法这是另一个量级的优化等真做到大棋盘再考虑。4.3 分数、速度与最高分的本地存档分数只是展示真正影响手感的是速度曲线。线性递减每吃一个减固定毫秒最直观也最容易调指数递减stepMs * 0.97前期变化小、后期骤降容易在某一个食物之后突然变得不可玩。我一般给学生用线性参数就三个起始步长、每步递减量、下限。最高分用 localStorage 存注意读写都要做异常兜底——某些隐私模式或者存储配额满的情况下setItem会抛异常不 try 住的话游戏会在破纪录那一刻直接崩掉。const BEST_KEY snake.best.v1; function readBest() { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (err) { return 0; // 隐私模式或禁用存储时静默降级 } } function syncHud() { document.getElementById(score).textContent String(score); document.getElementById(speed).textContent (CFG.baseStep / stepMs).toFixed(1); const best readBest(); if (score best) { try { localStorage.setItem(BEST_KEY, String(score)); } catch (err) {} document.getElementById(best).textContent String(score); } else { document.getElementById(best).textContent String(best); } } function gameOver() { over true; running false; syncHud(); // 简单的死亡提示用画布画不弹 alertalert 会打断 rAF 节奏 ctx.fillStyle rgba(8, 15, 30, 0.78); ctx.fillRect(0, 0, boardW, boardH); ctx.fillStyle #e2e8f0; ctx.font 600 20px system-ui, sans-serif; ctx.textAlign center; ctx.fillText(游戏结束, boardW / 2, boardH / 2 - 10); ctx.font 13px system-ui, sans-serif; ctx.fillStyle #94a3b8; ctx.fillText(按 R 重新开始, boardW / 2, boardH / 2 20); }逻辑说明syncHud里每次都重新读一遍 best看着有点浪费但它保证了多标签页同时开着两个游戏时两个页面的最高分不会互相覆盖成旧值。速度显示用baseStep / stepMs换算成倍率比直接显示毫秒数对玩家友好得多——没人关心“每格 120 毫秒”是什么意思“1.3 倍速”一眼就懂。参数说明BEST_KEY带.v1后缀是个习惯将来改了数据结构比如从纯数字改成{score, date}直接升到.v2老数据不会被解析成 NaN。gameOver 用画布绘制提示而不是 alert是因为 alert 是阻塞式的弹出时 rAF 队列会停住某些浏览器关掉弹窗后会补一帧超大的 dt——虽然前面有 safeDt 兜底但体验上仍然是明显的卡顿。5. 排查清单五类让贪吃蛇“玄学”卡死的坑5.1 输入类原地掉头与长按乱窜现象一蛇在某些走位下会直接撞死自己而且死亡位置明显是前一格的身体。原因方向键回调里直接赋值dir两个方向在同一个 tick 之间被先后按下中间缺少“反向过滤”和“逐帧消费”导致蛇头反向移动。这类 bug 在慢速时不明显加速到 2 倍以上后必现。解决改成方向队列 参照队列末尾做 180° 过滤也就是 3.2 里那套pushDir。判断反向的条件是两向量相加为零向量写ref.x next.x 0 ref.y next.y 0比判断四个枚举值更省事将来加了斜向也不容易漏。现象二按住方向键不放蛇像打了鸡血一样往前冲或者方向乱跳。原因keydown事件在按住时会以系统重复速率反复触发通常每秒 30 次左右。这些重复事件全被塞进队列虽然queue.length 2挡住了大部分但按住不放时队列永远不空玩家松手后蛇还会自己走一阵。解决在回调开头加一行if (e.repeat) return;。这个属性是浏览器原生提供的“这是不是按住产生的重复事件”比自己做时间戳去重干净得多。注意它要在所有逻辑之前判断包括 preventDefault 之前也无所谓因为重复事件本来就不需要再拦一次。5.2 状态类食物叠在蛇身、擦尾被误判现象三吃到食物后分数加了但画面上看不到新食物或者新食物出现在蛇身体下面。原因用了 4.2 里那个do...while重试写法但循环条件写漏了或者条件里的比较函数用了引用比较snake.includes(food)永远返回 false因为对象地址不同。也有人是先生成食物、后移动蛇导致生成时用的蛇身快照是旧的。解决换成空位池采样并且严格保证调用顺序是“先 unshift 新头、再 pop 尾巴、最后 pickFood(snake)”。顺序错了生成食物时算的占用集合就少一节蛇食物就有概率叠上去。现象四蛇贴着尾巴走的时候判定死亡但尾格明明空着。原因碰撞检测直接拿整个snake数组去比没有排除“这一 tick 会离开的尾格”。这是贪吃蛇最经典的边界问题。解决判碰撞前先算growing没吃到食物就snake.slice(0, -1)之后再比对。注意这里的判断必须在落子之前一旦先 unshift 再判断新头会被自己算进去永远判定为撞。5.3 渲染与设备类画面发虚、高分屏错位、后台跳帧现象五在高分屏笔记本上蛇的边缘发虚、格子线糊成一片或者在移动端点了画布坐标整体偏了一段。原因canvas 有两个尺寸——width/height属性是绘图缓冲区CSS 的width/height是显示尺寸。默认两者相等但在 devicePixelRatio 2 的屏幕上一个 400×400 的缓冲区被拉伸到 800 个物理像素显示浏览器做插值边缘自然发虚。如果只用 CSS 缩放而没有同步缓冲区鼠标或触摸坐标换算就会差一个 dpr 倍数。解决按 dpr 放大缓冲区再用ctx.scale把逻辑坐标拉回来CSS 尺寸保持逻辑值。这样所有绘图代码仍然按 400×400 写不用改一行。function fitCanvas() { const dpr window.devicePixelRatio || 1; canvas.width boardW * dpr; canvas.height boardH * dpr; canvas.style.width boardW px; canvas.style.height boardH px; // 每次改 width/height 都会重置上下文所以 scale 必须放在这之后 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } fitCanvas(); window.addEventListener(resize, fitCanvas);逻辑说明给canvas.width赋值会清空整块画布并重置所有上下文状态包括变换、填充色、字体所以setTransform必须写在赋值之后放在之前会被无声抹掉。用setTransform而不是scale是因为 resize 会被反复触发scale会不断累乘最后画出来的蛇越来越大。参数说明window.devicePixelRatio在老设备上可能不存在|| 1兜底。如果想让窗口变化时画布跟着缩放可以把canvas.style.width改成百分比但那样就要额外维护一个“逻辑坐标到 CSS 坐标”的比例对入门版本不值得固定 400px 反而更好控。6. 把参数抽出来难度、帧率、存档三处可调点的收尾打法代码跑通只是及格线真正决定这份东西能不能拿去用、能不能改成别的游戏是参数有没有抽干净。我习惯在最后做一次“参数体检”把所有能在不破坏逻辑的前提下调整的数字全部收进CFG然后拿几个极端值跑一遍。参数位置常用取值调大会怎样调小会怎样cols/rowsCFG1530棋盘变大单局时间拉长太挤蛇很快就没空间cellCFG1624画布变大需同步 CSS蛇身细节看不清baseStepCFG120200起步慢适合新手起步就快容错低minStepCFG6090后期不够刺激快到无法操作speedUpCFG38一局内速度跨度大全程速度几乎不变queue上限pushDir13连续拐弯更跟手但易失控快拐时丢指令调参时有个容易忽略的联动cols、rows、cell三个数必须同时改因为画布尺寸是cols * cell。只改cols不改cell画布会变成非正方形而 HTML 里写死的width400又会把画面拉伸回方形蛇看起来像被压扁了。所以更好的做法是在 JS 里初始化时动态写死画布尺寸别在 HTML 上写死数值。// 初始化顺序先定尺寸再重置状态最后启动循环 fitCanvas(); // 内部已经根据 CFG 写入 canvas.width/style.width reset(); requestAnimationFrame(loop);另一个值得加的收尾技巧是“死亡后不立刻重开先冻结 800 毫秒”。原因是玩家死亡瞬间手指往往还在按方向键如果 R 键或者点击能立刻重开很容易误触一局刚开局就被自己的惯性操作搞死。冻结的做法很简单在gameOver里记一个overAt performance.now()重开入口判断performance.now() - overAt 800才放行。这属于体验细节但它决定了这个东西是“能跑”还是“能给人玩”。主流浏览器Chromium 内核、Gecko 内核、WebKit 内核的现代版本对requestAnimationFrame、canvas.setTransform、Set的支持都没有差异这套代码不需要任何兼容分支。真正需要留意的只有两处e.key在极老的浏览器上可能拿不到devicePixelRatio在缩放 125% 的 Windows 上会返回 1.25画布尺寸会变成小数canvas.width会自动取整导致右侧或底部多出一条 1px 的缝——如果你介意把boardW * dpr外面套一层Math.round就干净了。从那以后我每次写完这类小游戏都会强制走一遍“极值三连”把minStep设成 20 跑一局看 while 循环会不会把蛇一次推穿墙把cols设成 3 跑一局看食物池空了会不会死循环把浏览器切到后台三十秒再切回来看safeDt有没有真的挡住补帧。这三步加起来不到两分钟但能挡掉九成上线后才被发现的翻车。希望帮到你。本文还有配套的精品资源点击获取