ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零实现单文件 HTML 贪吃蛇:网格、游戏循环与输入缓冲

从零实现单文件 HTML 贪吃蛇:网格、游戏循环与输入缓冲

上周有人拿了一段不到一百行的网页小游戏代码来问,说蛇跑着跑着就自己撞死,按得快一点更是直接原地暴毙。我打开一看,问题不在逻辑,在于输入和循环被揉成了一大坨。顺着这个由头,我把 HTML 贪吃蛇游戏 从头到尾重新捋了一遍,做成一个真正意义上的简易版:单文件、零依赖、复制进 .html 就能玩。这篇就把整套思路和踩过的坑摊开讲,从网格尺寸怎么定、蛇身数组往哪头长、游戏循环为什么不能用定时器,一直讲到点击手机屏能不能滑动操作。适合两类人:一类是刚学完 JavaScript 基础语法、想找个小项目把手感练熟的新手;另一类是做过不少业务页面、但没怎么碰过实时渲染循环,想搞清楚 requestAnimationFrame 到底该怎么用的人。代码我会拆开给,每一段都说明为什么这么写,不给"抄完能跑但不知道为什么"的东西。

1. 定死 20×20 网格和 120 毫秒步长,是刻意的减法

1.1 为什么不用自由坐标,而用整数网格

贪吃蛇最容易被写歪的地方,是让蛇在画布上以像素为单位自由移动。看着挺顺滑,实际上所有判定都会变成浮点数比较:两个圆心距离小于半径算不算撞上?速度 3.7 像素时,蛇头会不会正好卡在两个格子中间导致"擦着边过去"?这类问题一旦出现,debug 的成本高得离谱。

整数网格把这些问题一次性消灭。整张画布切成 20×20 个格子,每格 20 像素,蛇头的坐标永远是{x: 0..19, y: 0..19}这样的整数对。撞墙判定变成x < 0 || x >= 20,撞自己判定变成两个整数对是否相等。没有容差,没有精度误差,判定结果只有"是"和"否"两种,不存在模糊地带。

代价是视觉上会有点"格子感",蛇是一格一格跳着走的。但这个代价完全可以用渲染层补回来——这点我在第 4 节会展开,靠圆角、渐变和插值,画面可以做到不像"方块在蹦"。而逻辑层的干净,是后面所有扩展的前提。

尺寸参数我是这么定的:

参数取值定这个值的理由
列数 COLS20少于 15 走位空间太挤,超过 25 单屏看着累
行数 ROWS20保持正方形,方便手机竖屏等比缩放
单格 CELL20 px400×400 的画布,桌面端不用滚动就能看全
基础步长120 ms每秒 8.3 格,新手反应得过来,又不至于太拖
最短步长70 ms再快人的手指就跟不上了,属于无效难度

有人会问 400×400 在高分屏上是不是糊。确实会,canvas的 CSS 尺寸和位图尺寸是两回事,位图只有 400×400 个像素点,铺到 800 个物理像素上就是拉伸。解决方式我在第 4 节末尾给,一行代码的事。

1.2 画布初始化与高分屏的那一行补偿

先把骨架搭出来。整个项目就一个 HTML 文件,样式内联,脚本内联,没有任何外部资源,断网也能玩:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>贪吃蛇</title> <style> html, body { margin: 0; height: 100%; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #1b1f24; color: #e6e6e6; font-family: system-ui, -apple-system, sans-serif; user-select: none; -webkit-tap-highlight-color: transparent; } #hud { display: flex; gap: 28px; margin-bottom: 14px; font-size: 14px; letter-spacing: .5px; } #hud b { color: #6ee7a8; font-weight: 600; } canvas { display: block; border-radius: 10px; background: #12161a; touch-action: none; } #tip { margin-top: 14px; font-size: 12px; color: #78828c; } </style> </head> <body> <div id="hud"> <span>得分 <b id="score">0</b></span> <span>最高 <b id="best">0</b></span> </div> <canvas id="game" width="400" height="400"></canvas> <div id="tip">方向键 / WASD 移动,空格暂停,回车重开</div> <script> // 后面所有代码都放这里 </script> </body> </html>

touch-action: none这个属性千万别漏。移动端浏览器默认会把画布上的触摸手势解释成页面滚动或缩放,你不关掉它,手指一划整个页面跟着动,游戏根本没法玩。user-select: none和-webkit-tap-highlight-color同理,前者防止连点时选中文字,后者去掉手机上那个难看的灰色点击高亮。

高分屏补偿在这儿加:

const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); // 高分屏适配:位图放大但逻辑坐标不变 const dpr = window.devicePixelRatio || 1; const CSS_W = 400, CSS_H = 400; canvas.style.width = CSS_W + 'px'; canvas.style.height = CSS_H + 'px'; canvas.width = CSS_W * dpr; canvas.height = CSS_H * dpr; ctx.scale(dpr, dpr);

关键在于ctx.scale(dpr, dpr)。这行执行完之后,你后面所有fillRect(0, 0, 20, 20)这类调用,用的还是 400×400 的逻辑坐标系,但底层真正落笔的物理像素被放大了。代码里不需要到处乘 dpr,只在初始化时做一次变换即可。这是最省心的写法,也是我试过好几种方案之后留下的唯一一种。

2. 蛇身数组的头尾方向,决定了代码好不好读

2.1 头元素放末尾,避开 unshift 的思维负担

蛇身我用一个普通数组存,每个元素是{x, y}。争议点在于:蛇头放在数组的第 0 位还是最后一位。

两种我都写过。头在 0 位的写法是snake.unshift(next); snake.pop();——前进一步,往头上塞一个,把尾巴删掉。头在末尾的写法是snake.push(next); snake.shift();。从性能上讲,unshift和shift都要移动整个数组的内存,在 20×20 这种规模下差异可以忽略不计,所以性能不能作为决策依据。

真正的差别是可读性。"最后一个是蛇头"这个约定,在读代码时更符合直觉:蛇是朝着数组末端生长的,新的格子永远 appended 在后面。写渲染循环时从 0 遍历到length - 1,天然就是从尾巴画到头,颜色渐变从暗到亮,头部自然就画在最上层。而头在 0 位的写法里,绘制顺序是反的,你得额外记住"第 0 个是头",每次读都要在脑子里翻一次。

所以最终约定是:数组尾部是蛇头,数组头部是蛇尾。初始状态[{x:9,y:10}, {x:8,y:10}, {x:7,y:10}],长度 3,横躺在中间,头朝右。

let snake, dirQueue, currentDir, food, score, alive, paused, step, acc, last; const COLS = 20, ROWS = 20, CELL = 20; const BASE_STEP = 120; // 基础步长(毫秒) const MIN_STEP = 70; // 速度上限 const STEP_DECAY = 4; // 每吃一个食物减少的步长 function reset() { snake = [{ x: 9, y: 10 }, { x: 8, y: 10 }, { x: 7, y: 10 }]; currentDir = { x: 1, y: 0 }; // 初始朝右 dirQueue = []; // 输入缓冲队列,第 5 节详细说 score = 0; alive = true; paused = false; step = BASE_STEP; acc = 0; food = spawnFood(); document.getElementById('score').textContent = '0'; last = performance.now(); }

注意spawnFood()必须放在snake赋值之后。它需要遍历蛇身找出空格子,如果蛇还没初始化就去调,直接报错。这个顺序坑我踩过一次,因为初始化函数里字段很多,很容易顺手把食物生成写在最前面。

2.2 食物生成:先收集空格子再随机,而不是随机撞运气

最省事的食物生成写法是do { x = randInt(20); y = randInt(20); } while (occupied(x, y))。这写法在蛇短的时候没问题,但蛇长到 350 格以上(总共 400 格),随机撞到空格的期望次数会迅速上升,运气差的时候能卡住几百毫秒,表现就是画面一顿。虽然简易版一般玩不到那么长,但改一遍成本很低,不如一次写对。

function spawnFood() { const empty = []; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { let hit = false; for (let i = 0; i < snake.length; i++) { if (snake[i].x === x && snake[i].y === y) { hit = true; break; } } if (!hit) empty.push({ x, y }); } } if (empty.length === 0) return null; // 满屏了,通关 return empty[Math.floor(Math.random() * empty.length)]; }

先全量收集可用格子,再一次性随机取一个。时间复杂度恒定为 O(格数 × 蛇长),400 × 400 顶天,随便跑。empty.length === 0那个分支也别省,蛇占满全屏时如果不处理,Math.random()取下标会拿到NaN,食物位置变成undefined,后面判定会莫名其妙炸掉。实际表现应该是"通关"——我在这里就直接把food设成null,让tick里判断一下,有食物才吃。

2.3 一个容易忽视的细节:食物不能直接落在蛇头上

有一种情况是开局第一帧,食物正好生成在蛇头要走的下一格。这不算 bug,属于正常吃到。真正会出问题的是"食物生成在蛇头正下方那一格"——视觉上食物被蛇头压住,玩家看不到,以为没食物可吃。

简易版我选择不处理这个问题,因为概率极低,且不影响正确性。但如果要做成正式产品,可以在spawnFood里加一条过滤条件,排除蛇头周围 8 邻格,让食物不会紧贴着头出现。这条规则不需要写进判定逻辑,只影响生成,属于纯粹的体验优化。

3. 拿 setInterval 做游戏循环,是我见过的第一大坑

3.1 定时器漂移会和输入采集抢时间

新手写贪吃蛇,八成会这么干:setInterval(step, 120),每次回调里移动一步。跑起来看着没问题,但几个隐患会随着游戏时长逐渐暴露。

第一个隐患是定时器漂移。setInterval只保证"经过至少 120 毫秒后触发",不保证精确的 120 毫秒。浏览器主线程一忙(比如垃圾回收、页面有别的动画、标签页刚切回来),回调就会延后。延后的回调补偿不了之前丢的时间,于是整体节奏会越来越慢。玩久了明显感觉蛇变迟钝,但代码里的120又没动过,非常迷惑。

第二个隐患是定时器和输入不同步。setInterval的回调什么时候执行是浏览器决定的,而keydown什么时候触发是用户决定的。如果你在定时器回调里直接读currentDir,会出现"用户按了键但这一帧已经算完了方向"的情况,手感就是延迟半拍,或者连按两个方向时只识别到一个。这就是开头提到那个"按快了原地暴毙"的元凶。

第三个隐患是暂停和重开难以控制。得保存setInterval返回的 id,clearInterval掉再重建,一个不小心就是两个定时器同时跑,蛇以双倍速前进。

3.2 requestAnimationFrame 加时间累加器的标准写法

正确的做法是把"渲染"和"逻辑推进"拆成两个频率。渲染跟着显示器的刷新率走,用requestAnimationFrame,一秒 60 帧(或者 120 帧)。逻辑推进按固定步长走,用时间累加器控制。两者用同一个循环串起来:

function loop(now) { // 关键:单帧时间差封顶,防止切后台回来一次补几百步 const dt = Math.min(now - last, 200); last = now; if (alive && !paused) { acc += dt; // 用 while 而不是 if:低帧率时一帧可能要推进多步 while (acc >= step) { acc -= step; tick(); if (!alive) break; // 撞死了就别继续推进 } } draw(now); requestAnimationFrame(loop); }

这段代码有五处值得单独说明。

第一,Math.min(now - last, 200)这个封顶。用户切到别的标签页待了十秒再回来,now - last会是 10000 左右,如果不封顶,while循环会一口气执行 80 多次tick(),蛇瞬间冲到墙上撞死。封顶 200 毫秒意味着最多补两步,视觉上几乎看不出来。

第二,while而不是if。虽然正常 60 帧下dt是 16 毫秒左右,一次循环一般只进一次tick,但如果页面卡了一下,dt到了 300 毫秒,你只推进一步的话游戏节奏就变慢了。用while保证逻辑时间始终和真实时间对齐。

第三,acc -= step而不是acc = 0。这是累加器的核心。减去步长,余数留给下一帧,时间就不会漏。如果直接清零,每一帧都会丢掉一点时间,累积起来又是一次漂移。

第四,if (!alive) break。撞死之后再跑tick会访问已经出界的坐标,可能报错,也可能让分数继续涨。加了这个 break 就干净了。

第五,requestAnimationFrame(loop)放在最后,而不是最前面。这样即使draw里抛了异常,也是下一帧才断,不会立刻进入无限递归。

提示:last在reset()里要重新赋值成performance.now()。如果不重置,重开时第一帧的dt会从上一局结束的时间点开始算,可能直接封顶 200 毫秒,蛇一开局连跳两步。

3.3 步长和时间的关系,顺手算一下

有人看到step = 120会误以为是"速度",其实它是"每步耗时",单位毫秒。速度 = 1000 / 120 ≈ 8.3 格/秒。这个换算关系在调难度时很重要。

假设我设定每吃一个食物step减少 4 毫秒,从 120 减到下限 70,需要 (120 - 70) / 4 = 12.5 个食物。第 1 个食物后是 116 毫秒,第 12 个之后是 72 毫秒,第 13 个之后被Math.max钳到 70。也就是说吃到第 13 个食物左右,难度到达上限。

这个曲线我觉得偏陡。12 个食物在 20×20 的地图上大概两分钟就能吃到,之后就是恒定最高速。如果想让难度上升更平缓,两个办法:一是把STEP_DECAY改成 2,需要 25 个食物才到顶;二是改成"每吃 3 个食物减一次速",这样前期的友好期更长。我给初学者的建议是用STEP_DECAY = 3起步,玩起来会舒服不少。

4. 渲染分三层画,画面才不至于像一块塑料板

4.1 棋盘底纹:用棋盘格把"格子"变成视觉语言

如果背景是纯色,玩家其实感觉不到"网格"的存在,只能看到蛇在跳。一旦给背景加上浅浅的棋盘格,玩家的脑子会自动把网格理解成"棋盘",蛇的跳跃感就变成了棋子在落位,观感立刻不同。

function drawBoard() { for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { if ((x + y) % 2 === 0) { ctx.fillStyle = '#161b20'; ctx.fillRect(x * CELL, y * CELL, CELL, CELL); } } } }

(x + y) % 2 === 0这个判断是标准棋盘格算法。之所以用加法而不是 XOR,是因为加法对负数也成立,万一以后改成允许蛇穿屏幕的无限地图,坐标可能是负的。当然现在不会出现负数,但习惯性地写健壮一点没坏处。

画布底色的处理有两种:一是在 CSS 里给canvas设background,另一种是在draw里ctx.fillRect铺满。我用的是 CSS 方案,配合ctx.clearRect每帧清空。这样底色变化不需要重绘,只在 CSS 里改一个色值就行。注意clearRect清出来是透明的,透出的是 CSS 背景色,所以棋盘格的深色和 CSS 底色要协调好,差值不要太大,否则格子感太强,会分散对蛇的注意力。

4.2 食物脉动:用一个正弦波制造"活着"的错觉

静止的圆形食物和静止的方块,视觉上没什么区别。让食物轻微脉动,玩家的注意力会被自然吸引过去,这是很廉价但很有效的引导。

function drawFood(now) { if (!food) return; // period 约 1.6 秒的正弦波,映射到 0~1 const t = (Math.sin(now / 260) + 1) / 2; const r = CELL * (0.26 + 0.07 * t); const cx = food.x * CELL + CELL / 2; const cy = food.y * CELL + CELL / 2; // 外圈柔光 ctx.beginPath(); ctx.arc(cx, cy, r + 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(240, 90, 90, 0.16)'; ctx.fill(); // 主球体 ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = '#f05a5a'; ctx.fill(); }

Math.sin(now / 260)里的now是requestAnimationFrame传进来的高精度时间戳,单位毫秒。除 260 是调节周期:sin 的完整周期是 2π,所以周期 = 2π × 260 ≈ 1634 毫秒,一秒半多一点脉动一次,节奏很舒服。如果改成除 100,就是 628 毫秒一次,快得像心跳过速,看着烦。

(sin + 1) / 2是把 -1 到 1 映射到 0 到 1。半径在CELL * 0.26到CELL * 0.33之间摆动,幅度大概是 1.4 像素。这个幅度是有讲究的——太大就变成"呼吸"感,玩家的边缘视觉会不断被吸引,玩久了累;太小又看不出来。1 到 2 像素是最合适的区间。

用now而不是performance.now()是刻意的。now在同一帧里是固定值,如果蛇头和食物都用performance.now(),同一帧内两次调用可能得到不同的值,虽然差异极小,但严格来说同一帧的画面应该基于同一时刻。这种细节不影响功能,但会让画面更"整"。

4.3 蛇身三要素:圆角、渐变、头部眼睛

蛇是画面的主角,值得多花点笔墨。

function roundRect(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function drawSnake() { const n = snake.length; for (let i = 0; i < n; i++) { const s = snake[i]; const isHead = (i === n - 1); // 从尾(#3fa86a 偏暗)到头(#7df3a5 偏亮)做插值 const k = n === 1 ? 1 : i / (n - 1); const hue = 145; const light = 38 + 22 * k; ctx.fillStyle = `hsl(${hue}, 62%, ${light}%)`; const pad = 1.2; // 留一点缝,避免蛇身连成一根实心条 const size = CELL - pad * 2; roundRect( s.x * CELL + pad, s.y * CELL + pad, size, size, isHead ? 6 : 4 ); ctx.fill(); if (isHead) { drawEyes(s); } } }

roundRect这个方法现在浏览器普遍原生支持了(ctx.roundRect),但我还是手写了一个。一是老环境不一定有,二是手写版本可以自己控制细节,比如以后想做"只有外侧圆角"的蛇身,改起来方便。arcTo四个角各画一次,是标准的圆角矩形画法,比用quadraticCurveTo算控制点要少想很多。

pad = 1.2这个缝隙很关键。如果不留缝,相邻两格的蛇身会严丝合缝地贴在一起,整条蛇变成一根连续的粗条,你完全看不出它有几节。留 1 到 1.5 像素的缝,每一节就独立出来了,视觉上像一串珠子,而且蛇转弯时能明显看到关节。

亮度插值38 + 22 * k让尾巴暗、头部亮。这不只是为了好看,还有实际功能:蛇很长的时候,玩家容易分不清哪端是头。头部亮、还画了眼睛,方向一目了然。

眼睛的画法要根据当前方向来,否则蛇往左走眼睛还在右边,非常出戏:

function drawEyes(head) { const cx = head.x * CELL + CELL / 2; const cy = head.y * CELL + CELL / 2; const d = currentDir; // 沿前进方向的垂直方向偏移,两只眼睛分别在左右 const px = -d.y, py = d.x; // 垂直向量 const fwd = 3; // 往前推一点 const side = 3.6; // 左右各偏一点 for (const sign of [1, -1]) { ctx.beginPath(); ctx.arc( cx + d.x * fwd + px * side * sign, cy + d.y * fwd + py * side * sign, 1.9, 0, Math.PI * 2 ); ctx.fillStyle = '#0e1a12'; ctx.fill(); } }

这里用了一个二维向量的小技巧:(-d.y, d.x)是方向向量(d.x, d.y)逆时针旋转 90 度得到的垂直向量。有了它,"沿方向往前、往左右各偏一点"就变成了两个乘加运算,不管蛇朝哪个方向,眼睛的位置都对。这是游戏开发里非常通用的写法,值得记住。

4.4 每帧的绘制顺序不能乱

把所有绘制组装起来,顺序是固定的:

function draw(now) { ctx.clearRect(0, 0, CSS_W, CSS_H); drawBoard(); drawFood(now); drawSnake(); drawOverlay(); }

clearRect必须第一个。忘了清屏,画面会越叠越乱,蛇走过的地方留下彩色的拖影,一分钟不到屏幕就花了。

棋盘在食物和蛇之下,因为它是背景。食物在蛇之下,因为蛇头吃到食物的那一帧,蛇头应该盖住食物,看起来像"咬进去了"。如果反过来,食物盖在蛇头上,视觉上就是食物吃蛇。

drawOverlay负责画暂停和结束的文字。把它放最后,是因为它要盖在一切之上,而且完全透明地展示底下的游戏画面,比切一整张纯色遮罩更有质感:

function drawOverlay() { if (alive && !paused) return; ctx.fillStyle = 'rgba(10, 14, 18, 0.72)'; ctx.fillRect(0, 0, CSS_W, CSS_H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#e6e6e6'; ctx.font = '600 26px system-ui, sans-serif'; ctx.fillText(paused ? '已暂停' : '游戏结束', CSS_W / 2, CSS_H / 2 - 18); ctx.font = '14px system-ui, sans-serif'; ctx.fillStyle = '#8d97a1'; ctx.fillText( paused ? '按空格继续' : '按回车重新开始', CSS_W / 2, CSS_H / 2 + 20 ); }

textBaseline = 'middle'加上textAlign = 'center',之后所有fillText的坐标就是文字的正中心,不用再手动减半宽。这两个属性一旦设置会一直生效,所以放在一次绘制的开头设置一次就行,但要注意别的绘制代码如果也用了fillText,会继承这个设置。

5. 方向键连按的处理,是手感好坏的分水岭

5.1 直接改 currentDir 会漏掉快速连续输入

最直观的输入处理写法是这样:

document.addEventListener('keydown', e => { if (e.key === 'ArrowUp') currentDir = { x: 0, y: -1 }; if (e.key === 'ArrowDown') currentDir = { x: 0, y: 1 }; // ... });

问题在于,keydown的触发频率和游戏循环的推进频率完全脱钩。假设蛇正在往右走,玩家想快速做一个"上→左"的转弯,两次按键间隔 80 毫秒,而游戏步长是 120 毫秒。这 80 毫秒内两次keydown都触发了,currentDir被覆盖成左。等到下一个tick执行时,蛇从朝右直接变成朝左——瞬间掉头,一头撞进自己身体里。

这就是很多人抱怨的"我明明拐了弯就死了"。玩家的意图是"先上一格,再向左",但代码只记住了最后一个方向,把中间那一步吞掉了。

第二个问题是反向自杀。蛇往右走,玩家按左,如果代码不做拦截,蛇头会直接撞进自己脖子。这不是玩家的错,是代码允许了非法输入。游戏设计上应该直接忽略这个按键,玩家会觉得"按了没反应,我知道不行",这是合理的反馈。

5.2 用长度上限为 2 的队列,把输入和逻辑解耦

解法是把输入先收进队列,每次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(nd) { // 和"队列末尾的方向"比较,而不是和 currentDir 比较 const ref = dirQueue.length ? dirQueue[dirQueue.length - 1] : currentDir; // 反向:忽略 if (nd.x === -ref.x && nd.y === -ref.y) return; // 同向:忽略(避免队列被同方向占满) if (nd.x === ref.x && nd.y === ref.y) return; // 最多缓存两步,再多就丢 if (dirQueue.length >= 2) return; dirQueue.push(nd); }

这里的关键决策是ref取"队列最后一个方向"而不是currentDir。假设蛇当前向右,队列里已经排了一个"上"。这时候玩家按"下",如果和currentDir(右)比,下不是右的反向,会被放行;但实际上队列里已经有"上"了,再排一个"下",等到两个都被消费,蛇从上又拐回下,等于原地掉头撞自己。

和队列末尾比就解决了:队列末尾是"上","下"是"上"的反向,直接拒绝。玩家的第二次按键被吞掉,但这是正确的——他的意图本来就是矛盾的。

队列上限设成 2 而不是无上限,是因为输入缓冲的目的是"接住快速的两连击",不是"记住玩家所有的按键"。如果限制到 5,玩家慌乱中乱按一通,蛇会按照那些早已过时的输入走,手感反而更糟。2 步大概是 240 毫秒内的操作,覆盖了绝大多数有意识的连续转弯。

消费的代码放在tick的最开头:

function tick() { if (dirQueue.length) { currentDir = dirQueue.shift(); } const head = snake[snake.length - 1]; const next = { x: head.x + currentDir.x, y: head.y + currentDir.y }; // ... 后面的判定 }

5.3 阻止方向键滚页面,以及失焦时自动暂停

有个细节很容易忘:方向键在浏览器里默认是滚动页面。如果页面比视口高(比如手机竖屏),按上下键整页会跟着滚,玩起来极其难受。

document.addEventListener('keydown', e => { if (e.key === ' ' || e.key === 'Enter') return; // 先放行,后面单独处理 const d = DIRS[e.key] || DIRS[e.key.toLowerCase()]; if (!d) return; e.preventDefault(); // 只拦截方向键和 WASD if (alive && !paused) pushDir(d); });

要注意只对游戏的按键调preventDefault,别一上来就无差别拦截所有按键,那样用户按 F5 刷新、按 Ctrl+F 搜索都被拦了,体验很差。

失焦暂停是我加的额外一条,非常实用:

window.addEventListener('blur', () => { if (alive && !paused) paused = true; });

用户切到别的窗口,游戏自动暂停。不做这个的话,切走的时候蛇还在跑,切回来发现已经撞死了,非常莫名其妙。blur事件在窗口失去焦点时触发,比visibilitychange覆盖面更广——后者只在标签页切换时触发,前者在点击别的应用窗口时也会触发。

6. 撞墙、咬尾、吃食物的判定顺序,决定了你要改多少 bug

6.1 先算新头,再做三次判定

tick的核心就三件事:算出下一步的头部坐标,判断这一步合不合法,然后决定蛇怎么变。判定顺序必须是:越界 → 撞身 → 吃食物。

function tick() { if (dirQueue.length) currentDir = dirQueue.shift(); const head = snake[snake.length - 1]; const next = { x: head.x + currentDir.x, y: head.y + currentDir.y }; // 1. 撞墙 if (next.x < 0 || next.x >= COLS || next.y < 0 || next.y >= ROWS) { alive = false; return; } // 2. 判断这一步会不会吃到食物(影响撞身判定) const willGrow = food !== null && next.x === food.x && next.y === food.y; // 3. 撞身:如果不会长,尾巴会让开,比的时候要排除尾格 const body = willGrow ? snake : snake.slice(0, -1); for (let i = 0; i < body.length; i++) { if (body[i].x === next.x && body[i].y === next.y) { alive = false; return; } } // 4. 移动 snake.push(next); if (willGrow) { score += 10; document.getElementById('score').textContent = score; step = Math.max(MIN_STEP, step - STEP_DECAY); food = spawnFood(); if (food === null) { alive = false; /* 满屏,算通关 */ } } else { snake.shift(); } }

第 3 步里snake.slice(0, -1)那行是整个逻辑里最容易被忽略的地方。蛇往前走一步,尾巴同时往后收一格。所以尾巴当前占的那一格,在这一步结束时会空出来,蛇头完全可以走进去。如果不排除尾格,你会发现"蛇头贴着尾巴走"这种合法操作被判定成撞死,手感非常怪。

而吃到食物的时候不一样,尾巴不收,所以尾格不能走。这就是willGrow变量存在的意义——它同时影响两处:撞身要比较的范围,以及移动完要不要shift。

6.2 为什么不是先移再判,而是先判再移

另一种写法是先无条件把新头push进去,然后再检查撞没撞,撞了就把整个数组回滚。这种写法看起来更"直觉",但问题是一旦判定逻辑里有任何一处提前return,状态就脏了——蛇头已经加进去了,但尾巴没删,长度变了。下一帧再跑,数据就彻底乱套。

我坚持"先判后移",因为它的状态变更只有一次,而且发生在所有检查都通过之后。这样如果中途return,snake完全保持原样,只是alive变成false,画面停住,这是干净的状态。调试的时候看snake数组,能明确知道死之前是什么样子,很有帮助。

6.3 独立出来的一个:连续通过地图边界的问题

有一种需求是"穿墙"——蛇从右边出去,从左边进来。这个改动看起来很小,但如果你只在越界判定里把坐标钳回来,会踩到一个坑:蛇穿过边界的时候,头在右边,尾巴在左边,中间那一段是完全断开的。视觉上就是一条蛇凭空断成两截。

真要实现穿墙,正确做法是判定时允许越界,然后取模:

// 穿墙版本 next.x = (next.x + COLS) % COLS; next.y = (next.y + ROWS) % ROWS;

注意要先加COLS再取模,因为 JavaScript 的%对负数会返回负数(-1 % 20 === -1),加了COLS就能保证结果落在 0 到 19 之间。

但视觉上"断开"的问题取模解决不了。要解决得在渲染层做处理:当头在 x=0 的时候,同时在 x=400 的位置再画一个头;尾巴同理。这是"环绕地图"的标准做法,代码量翻倍。

简易版我建议直接不做穿墙,撞墙即死。理由很实际:穿墙版的调试复杂度至少是撞墙版的两倍,而对于一个练手项目,那个复杂度带来的收益并不值得。等你把撞墙版的所有坑都踩明白了,再上穿墙,这时候你已经知道该在哪儿加逻辑。

7. 暂停、重开、失焦这三个状态,最容易写出脏代码

7.1 用一个 paused 布尔值,而不是两个定时器

很多人的暂停是这么实现的:暂停时clearInterval,继续时重新setInterval。这种写法在定时器方案里是必要的,但一旦换成requestAnimationFrame加累加器,就完全不需要了。循环永远在跑,只是推进逻辑被跳过:

if (alive && !paused) { acc += dt; while (acc >= step) { ... } }

暂停期间acc完全不累加。恢复的瞬间acc还是暂停前的值,可能是 0 到 120 之间的任意数。这意味着恢复后第一步可能只走了 10 毫秒就触发,观感上会有一点突兀。

严格的做法是在暂停时不累加、恢复时把last重置为当前时间戳,这样第一步的间隔是从恢复那一刻算起的完整步长:

function togglePause() { if (!alive) return; paused = !paused; if (!paused) last = performance.now(); // 恢复时重设基准 }

这行last = performance.now()是踩过坑才加上的。不加的话,暂停几分钟再恢复,dt会是几万毫秒,虽然有Math.min(200)兜底不会崩,但恢复的第一帧会突然"跳"两格,看着像卡了一下。

7.2 重开必须重置全部状态,包括累加器

reset()里最容易漏的是acc和last。这两个变量的值在重开后如果不重置,会带着上一局的残留进来。acc残留可能是 100 多,重开第一帧就立刻推进两步,蛇一开局就离初始位置很远,看起来像"瞬移"。

所以reset里必须包含这几个:

function reset() { snake = [{ x: 9, y: 10 }, { x: 8, y: 10 }, { x: 7, y: 10 }]; currentDir = { x: 1, y: 0 }; dirQueue = []; score = 0; alive = true; paused = false; step = BASE_STEP; acc = 0; // 必须重置 food = spawnFood(); document.getElementById('score').textContent = '0'; last = performance.now(); // 必须重置 }

我的经验是:凡是和"时间"有关的变量,在重置函数里一定会被忘掉。所以每次写重置逻辑,我都会专门过一遍所有声明过的变量,问自己"这个需要归零吗"。score、snake这些一眼就能想到的反而不会漏,漏的都是acc、last、step这类衍生状态。

7.3 键盘事件里别把空格和回车漏给浏览器

空格除了暂停,还有"滚动页面"的默认行为;回车在某些场景会触发表单提交。虽然这个页面没有表单,但空格滚动页面是实打实会发生的:

document.addEventListener('keydown', e => { if (e.key === ' ') { e.preventDefault(); togglePause(); return; } if (e.key === 'Enter') { e.preventDefault(); if (!alive) reset(); return; } // 方向处理 });

注意togglePause内部已经判断了alive,所以死掉之后按空格不会有反应。这是合理的——死亡状态下空格没有语义,只有回车才重开。这种"按键语义在不同状态下不同"的设计,比让每个键只干一件事要更符合玩家直觉。

8. 加难度、存最高分、支持手指滑动

8.1 最高分用 localStorage,但要包一层容错

localStorage在某些环境下会抛异常:隐私模式下配额为 0,某些嵌入的 iframe 中被禁用,用户手动清了站点数据。直接调用不包try/catch,一旦抛异常整个脚本就停了,游戏直接白屏。

const BEST_KEY = 'snake_best_score'; function loadBest() { try { return parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0; } catch (err) { return 0; } } function saveBest(v) { try { localStorage.setItem(BEST_KEY, String(v)); } catch (err) { // 存不进去就算了,不影响游戏 } }

parseInt(...) || 0这层兜底也不能省。如果存进去的是空字符串或者被用户手动改成乱码,parseInt会返回NaN,NaN参与后续比较永远为false,最高分就永远不更新了。加个|| 0把NaN转成 0,问题消失。

调用时机放在死亡那一帧,而不是每吃一个食物就存一次。每吃一次就写localStorage会有同步 IO 开销,虽然量小,但完全没必要。死亡时写一次就够了:

function onDeath() { alive = false; const best = loadBest(); if (score > best) saveBest(score); document.getElementById('best').textContent = Math.max(score, best); }

drawOverlay里能显示出"新纪录"字样,靠的就是这个判断。

8.2 难度曲线我做了一版,最后又改回去了

前面说过STEP_DECAY = 4会让难度在第 13 个食物时到顶。我试过几种不同的曲线,记录一下实际手感:

方案参数实测手感
恒定速度step 固定 120太简单,吃到 20 个也不紧张
每食物减 4ms120 → 70前 5 个食物几乎没变化,第 10 个突然变难
每食物减 2ms120 → 70过渡最平滑,但要吃到 25 个才到顶
每 5 分减 4ms120 → 70变化节奏可预期,但不够连续
按蛇长比例step = 120 * 0.97^len后期衰减指数级,第 40 格左右几乎瞬移

最终我用的是"每食物减 2ms"。理由很朴素:这是给新手玩的简易版,难度的目的是让游戏不至于太无聊,而不是真的想把人拦住。减 2ms 意味着前 10 个食物只快了 20 毫秒,玩家几乎感觉不到压力,可以专心熟悉操作;等玩到第 20 个食物,速度才真正开始有咬合感。

至于按蛇长比例那种指数衰减,看起来数学上优雅,实测非常糟糕。它前期几乎不变,后期突然加速,玩家没有任何适应过程。我试了一次就删了。

8.3 触摸滑动:24 像素的判定阈值是怎么定的

手机上没有方向键,得靠滑动。实现起来不复杂,但阈值要调。

let touchStart = null; canvas.addEventListener('touchstart', e => { const t = e.changedTouches[0]; touchStart = { x: t.clientX, y: t.clientY }; e.preventDefault(); }, { passive: false }); canvas.addEventListener('touchend', e => { if (!touchStart) return; const t = e.changedTouches[0]; const dx = t.clientX - touchStart.x; const dy = t.clientY - touchStart.y; touchStart = null; // 位移太小当成误触,不响应 if (Math.hypot(dx, dy) < 24) return; if (Math.abs(dx) > Math.abs(dy)) { pushDir(dx > 0 ? DIRS.ArrowRight : DIRS.ArrowLeft); } else { pushDir(dy > 0 ? DIRS.ArrowDown : DIRS.ArrowUp); } e.preventDefault(); }, { passive: false });

Math.hypot(dx, dy) < 24这个阈值是试出来的。太小(比如 10)的话,手指轻微抖动就会被识别成滑动,经常误触;太大(比如 60)的话,用户得划一大段才响应,短距离的快速小滑动会被忽略,手感很迟钝。24 像素大概是一个成年人拇指自然移动的最小有意识距离,我用下来误触率最低。

{ passive: false }也是必须的。现代浏览器默认把触摸事件监听器当成被动的(passive: true),意思是"我承诺不阻止默认行为",这样可以提前开始滚动,性能更好。但我们需要调preventDefault来阻止页面滚动,所以必须显式声明成非被动,否则preventDefault会被静默忽略,控制台还会警告。

判断方向用的是"绝对值谁大"而不是Math.atan2求角度。因为角度计算需要处理各种边界情况,而绝对值比较天然就把八个方向归成了"横"和"纵"两类,代码短,行为稳定。斜着划的时候会按主方向算,这也符合预期。

8.4 启动顺序和一个收尾的小细节

最后把启动逻辑串起来:

document.getElementById('best').textContent = loadBest(); reset(); requestAnimationFrame(loop);

reset()必须在requestAnimationFrame之前调用,因为它初始化了snake、food这些draw需要的数据。如果顺序反了,第一帧draw就会访问undefined。

loop的第一帧now是requestAnimationFrame传进来的时间戳,和reset里调的performance.now()可能差几毫秒,这没问题,dt会是几毫秒,acc加进去也不够触发一步。

另外一个小细节:requestAnimationFrame的回调里我传的是loop而不是loop()。传函数引用,浏览器会在下一帧调用它,并把时间戳作为参数传进去。写成loop()就变成立即执行了,而且返回值(undefined)会被当成下一次的回调,直接报错。这个错误很常见,尤其对刚接触 rAF 的人。

前后大概三百来行,一个能玩、能暂停、能存最高分、手机也能滑的贪吃蛇就成型了。我个人在实际写这类小游戏时的体会是:逻辑层越简单越好,所有"聪明"的东西都往渲染层放。网格化坐标、整数比较、先判后移、队列缓冲,这些都是为了把逻辑压到最扁;而圆角、渐变、脉动、柔光这些让画面好看的东西,全部在绘制函数里,改它们完全不会影响任何一条游戏规则。这两层分得越干净,你后面想加东西就越容易——想加个音效,在吃食物那行加个振荡器就完事了;想加个穿墙模式,只改判定那几行就行;想加个排行榜,把saveBest换成接口调用,其余代码一行不用动。手上有闲的话,下一步可以试试把每节的蛇身做成带一点弧度连接的,或者加一个吃到食物时屏幕轻微震动的效果,都是二三十行的事。

返回列表