☰
用Canvas写极简贪吃蛇:一百多行JavaScript代码搞定完整游戏
2026/10/15 4:41:05 网站建设 项目流程

做贪吃蛇,大概是每个前端开发者绕不开的练手项目。网上搜一圈,很多教程代码动辄三四百行,功能却只有“移动、吃食物、撞墙死”三件套;方向键连按会反向自杀、定时器越跑越快、暂停失灵这些坑,更是要等自己踩一遍才明白。我整理旧项目时翻出自己写的一个版本,核心逻辑压到一百多行,暂停、计分、最高分记录、速度递增、游戏结束重开全都齐活,一个 HTML 文件直接塞浏览器就能跑。这篇文章就把这个版本背后的取舍、完整代码、扩展思路和几次翻车现场一起交代清楚。目标很简单:让没写过贪吃蛇的人也能在半小时内复现,同时搞明白每行代码到底在干什么,而不是照着抄一遍就完事。

1. 先想清楚:功能这么多,代码凭什么能省下来?

1.1 技术选型:Canvas 为什么比 DOM 更适合贪吃蛇

写贪吃蛇最常见的技术方案有三个:DOM 节点、SVG、Canvas。我一开始也试过 DOM 方案——每个蛇身是一个<div>,用 CSS 绝对定位摆在画布里。第一版写下来我印象很深:光是“移动”就要遍历所有蛇身格子、逐个改坐标,吃到食物还要 createElement 插入节点,一局下来 DOM 操作几十次。代码行数倒是其次,关键是逻辑被“操作节点”这件事扯得乱七八糟。

后来换成 Canvas,整个思路瞬间清爽。贪吃蛇本质是一个不断重绘的画面:每一帧,根据蛇头和食物的坐标画方块就行,不需要维护一堆节点。Canvas 2D 上下文里的fillRect一次调用画一个格子,蛇有二十节就循环二十次,吃到食物再重绘下一帧,新格子自然出现。所有游戏状态都保存在 JavaScript 变量里,渲染层只负责“根据状态画图”,职责单一,代码自然短。

SVG 我也简单试过。它比 DOM 好在可以复用图形元素,但缺点是元素数量会随着蛇身增长而膨胀,性能不如 Canvas,而且逐帧更新需要操作setAttribute,代码并没有比 Canvas 少。所以最后选型就是 Canvas + 2D 上下文:一个<canvas>标签,一个getContext('2d'),后面所有绘制全在这一个平面上完成。

1.2 数据结构:用数组和方向向量替代一堆类

网上很多贪吃蛇教程会引入 class Snake、class Food、class Game 这样的面向对象设计。听起来很正规,但对一个核心逻辑只有几十行的小游戏来说,这属于典型的“过度设计”。你想想,蛇本质上是什么?是一连串坐标点,头部一个坐标,后面身体跟着一个坐标。这种结构,JavaScript 原生数组就是最贴切的数据结构,根本不需要自建链表或者对象节点。

具体来说,我用一个数组存蛇身,数组第 0 项是蛇头,后面依次是身体、尾巴。每次移动,往数组头部unshift一个新坐标作为蛇头;如果没吃到食物,再pop掉数组尾部一个坐标,蛇的身体长度保持不变。吃到食物时就不pop,尾巴不动,长度自然加一。这个设计是整个游戏能“极简”的基石——移动、增长、缩短全部被数组原生方法承接了,你不需要为这些逻辑写任何循环。

方向也用数据结构省掉了一堆代码。很多教程会写switch(direction) { case 'up': y--; break; ... },四个方向四种分支,冗长还容易漏。我直接用方向向量{x, y}:向右是{x:1, y:0},向左是{x:-1, y:0},向上是{x:0, y:-1},向下是{x:0, y:1}。这样移动逻辑就统一成了坐标加法:新蛇头 = 旧蛇头坐标 + 方向向量。打游戏时你按方向键,其实只是改这个向量的值,移动那一行代码从头到尾不用变。用一个统一公式处理四种情况,比四个分支省了至少五到六行代码,还减少出错概率。

food 用一个{x, y}坐标对象表示,best 分数存localStorage,这些都按最小粒度的数据结构来,不套对象壳。现在回头看,整个游戏需要维护的状态就十个左右,用一组全局变量完全够用。这不代表“全局变量好”,而是说:当变量之间没有复杂的继承关系时,平铺直叙反而是最好维护的。

1.3 极简不是删代码,而是删掉多余的复杂度

有一件事必须说清楚:极简代码不等于把代码压缩成一行让人看不懂的“炫技码”。我见过有人把整个贪吃蛇逻辑写成三行,用一堆?:和逗号表达式,看起来很短,改一行就崩,那是“压缩”不是“极简”。

真正的极简,是去掉那些本来就不需要的复杂度。这段代码里没有类、没有继承、没有状态机,原因是它们在这个项目里没有发挥空间。如果你把同一个项目交给一个大型团队做,需要加在线排行榜、多房间对战、用户账号体系,那我当然会建议拆模块、引入状态管理。但一个单机贪吃蛇,用函数加共享状态表,就是最直接的路径。

代价是要小心函数的调用顺序。比如tick()里必须先更新方向、再算新蛇头、再判定碰撞、最后更新数组和重绘,顺序错了,游戏立刻出 bug。这种风险在复杂系统里不可接受,但在核心逻辑只有五个步骤的小游戏里完全可控。所以“极简”是有适用边界的,用的时候想清楚边界在哪。

2. 核心逻辑拆解:贪吃蛇最关键的 5 段代码

2.1 蛇的移动:unshift 加 pop,一行完成前进与伸缩

先看移动这段,它可以说是整个游戏的心脏:

snake.unshift(head); // 新蛇头插到数组最前面 if (head.x === food.x && head.y === food.y) { // 吃到食物,尾巴不删除,长度加一 spawnFood(); } else { snake.pop(); // 没吃到食物,尾巴删除,长度不变 }

用生活里的话说,蛇的运动就是队伍在走:队头往前迈一步,队尾看情况跟不跟。unshift是队头迈的那一步,pop是队尾是否跟上。吃到食物时队尾原地不动,整个队伍就长了一节;没吃到时队尾跟上,队伍长度就不变。这套操作完全不依赖“蛇从哪来、往哪去”,只要有数组头和尾就行。我在最开始用链表想过这个问题——每个节点保存下一个节点的引用,移动时要改头尾和中间节点的引用关系,写起来比数组啰嗦得多。数组在这里是“上帝视角”,因为蛇的每一节的坐标都明摆着,你想查哪节在哪,直接snake[i]就完事。

这里还有个小细节:判断“吃到食物”用的是蛇头坐标和食物坐标相等,因为格子是离散的,坐标完全一致就代表踩在同一个格子上。不需要“距离小于多少”这种模糊判断,格子的天然像素尺寸已经帮你做了碰撞。

2.2 方向锁:一个变量拦住“反向自杀”

新手写贪吃蛇几乎必踩的一个坑是:蛇正在向右走,你快速按了一下上、又按了一下左,结果蛇没有往上走,而是直接掉头向左,撞进自己身体,一局秒死。原因是两个按键发生在同一次 tick 的间隔之内,方向变量被第二个键覆盖成了“左”,蛇下一帧真的就向左了。

解法是引入两个方向变量:dir是当前帧真正生效的方向,nextDir是你希望下一帧生效的方向。每次 tick 开始时才把dir = nextDir,按键时只修改nextDir。这样就算你在一帧间隔内按了三次键,最终生效的也只是最后一次有效输入,不会在同一帧里叠加。

代码上对应这一句:

if (d.x !== -nextDir.x || d.y !== -nextDir.y) { nextDir = d; }

这句的含义是:如果新方向与“下一帧将使用的方向”完全相反,就忽略这次输入。比如当前有效方向是右,你按左,d.x是 -1,nextDir.x是 1,d.x === -nextDir.x成立,于是整条判断失败,按键被忽略。注意,这里比较的是nextDir而不是dir,这一点很关键,后面专门讲。

2.3 碰撞判定:越界和撞自己都在这一个 if 里

贪吃蛇的死亡条件只有两类:撞墙、撞自己。用坐标表示,就是蛇头坐标超出了画布范围,或者蛇头坐标和身体任意一节坐标重合。这两类条件可以写进同一个 if 里。

const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; if (head.x < 0 || head.y < 0 || head.x >= COLS || head.y >= ROWS || snake.some(s => s.x === head.x && s.y === head.y)) { gameOver(); return; }

第一行先算出“如果蛇头往前走一步”的新坐标。这里有个顺序上的重要细节:必须先算新蛇头、再判定、最后unshift。你要是先snake.unshift(head)再判定碰撞,新蛇头已经进入数组,snake.some(...)会在数组里找到蛇头自己,判定为“撞到自己”,蛇一移动就死。

snake.some(s => ...)是极简代码里经常用到的一招:遍历数组,只要有一个元素满足条件就返回 true。用它检测“蛇头是否和身体任意一节重叠”,一行代码替代了整个 for 循环。同理,后面生成食物时检查“食物是否在蛇身上”,也是用some一行搞定。数组原生方法,能帮你把大量手写循环压缩成一行函数调用。

2.4 食物生成:do...while 让食物永远不出现在蛇身上

食物坐标必须满足两个条件:落在画布格子内,不和蛇的任何一节重合。随机数生成坐标很容易,但“不和蛇重合”需要检查:

function spawnFood() { do { food = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; } while (snake.some(s => s.x === food.x && s.y === food.y)); }

Math.random()产生 0 到 1 的浮点数,乘上COLS(20)再取整,就得到 0 到 19 的整数坐标。do...while保证至少执行一次,如果生成的坐标落在蛇身上,就再随机一次,直到不在蛇身上为止。

这个写法的理论边界是:如果蛇身铺满整个画布,这个循环会永远找不到空地,程序直接卡死。实际游戏里蛇身要占满 20×20 的全部格子几乎不可能(你早撞墙死了),但我还是在后面补了一个次数上限的兜底,严谨起见。做一个可以一直玩下去的游戏,边界情况的防御还是不能省。

2.5 游戏循环:用 setInterval 搭建节拍器

贪吃蛇和很多动作游戏不一样,它不需要每帧都刷新,而是“每隔一段时间动一格”。这个“每隔一段时间”就是游戏速度。用setInterval最直接:

function loop() { clearInterval(timer); timer = setInterval(tick, speed); }

speed是毫秒数,180 表示每 180 毫秒执行一次tick(),一秒钟约走 5.5 格。每吃到 5 个食物,我把speed减 15,让蛇越走越快,给玩家制造压迫感。

这里有一个大多数人踩过的大坑:改速度时不能只是“再 setInterval 一次”,必须先把旧的定时器清掉。否则旧定时器还在跑,新定时器又叠加,tick 被同时执行两次,蛇直接翻倍加速,而且越来越乱。loop()函数里第一行先clearInterval(timer),然后再开新定时器,就是这个原因。用clearInterval加setInterval的组合,就是“节拍器重新校准”的过程。

3. 完整代码实现:一百多行搞定一个能玩的游戏

3.1 一个 HTML 文件直接跑:完整代码清单

把上面的逻辑拼起来,就是一个可直接运行的完整 HTML 文件。保存成.html,双击用浏览器打开就能玩。方向键控制移动,空格键暂停或继续,游戏结束时按空格重新开始。分数实时显示在上方,最高分会存进浏览器 localStorage,关掉页面再打开依然在。

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>极简贪吃蛇</title> <style> body{ background:#222; color:#fff; text-align:center; font-family:system-ui,sans-serif; } canvas{ background:#111; border:2px solid #333; } .info{ margin:10px; font-size:18px; } </style> </head> <body> <div class="info"> 得分 <span id="score">0</span> &nbsp;|&nbsp; 最高 <span id="best">0</span> &nbsp;|&nbsp; 空格暂停 </div> <canvas id="game" width="400" height="400"></canvas> <script> const GRID = 20; const CW = 400, CH = 400; const COLS = CW / GRID; const ROWS = CH / GRID; const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const scoreDom = document.getElementById('score'); const bestDom = document.getElementById('best'); let snake, food, dir, nextDir; let speed, timer, running, over, score; let best = Number(localStorage.getItem('snake-best')) || 0; bestDom.textContent = best; function start() { snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}]; dir = {x:1,y:0}; nextDir = {x:1,y:0}; speed = 180; running = true; over = false; score = 0; scoreDom.textContent = score; spawnFood(); loop(); draw(); } function loop() { clearInterval(timer); timer = setInterval(tick, speed); } function spawnFood() { do { food = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; } while (snake.some(s => s.x === food.x && s.y === food.y)); } function tick() { dir = nextDir; const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; if (head.x < 0 || head.y < 0 || head.x >= COLS || head.y >= ROWS || snake.some(s => s.x === head.x && s.y === head.y)) { gameOver(); return; } snake.unshift(head); if (head.x === food.x && head.y === food.y) { score++; scoreDom.textContent = score; if (score % 5 === 0 && speed > 90) { speed -= 15; loop(); } if (score > best) { best = score; bestDom.textContent = best; localStorage.setItem('snake-best', best); } spawnFood(); } else { snake.pop(); } draw(); } function draw() { ctx.clearRect(0, 0, CW, CH); ctx.strokeStyle = '#202830'; ctx.lineWidth = 1; for (let i = 1; i < COLS; i++) { ctx.beginPath(); ctx.moveTo(i * GRID, 0); ctx.lineTo(i * GRID, CH); ctx.stroke(); } for (let j = 1; j < ROWS; j++) { ctx.beginPath(); ctx.moveTo(0, j * GRID); ctx.lineTo(CW, j * GRID); ctx.stroke(); } ctx.fillStyle = '#e65c55'; ctx.fillRect(food.x * GRID + 4, food.y * GRID + 4, GRID - 8, GRID - 8); snake.forEach((s, i) => { ctx.fillStyle = i === 0 ? '#7bed8b' : '#2e8b57'; ctx.fillRect(s.x * GRID + 2, s.y * GRID + 2, GRID - 4, GRID - 4); }); if (!running && !over) { ctx.fillStyle = 'rgba(0, 0, 0, .55)'; ctx.fillRect(0, 0, CW, CH); ctx.fillStyle = '#fff'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('已暂停', CW / 2, CH / 2); } } function gameOver() { over = true; running = false; clearInterval(timer); ctx.fillStyle = 'rgba(0, 0, 0, .65)'; ctx.fillRect(0, 0, CW, CH); ctx.fillStyle = '#fff'; ctx.font = '22px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('游戏结束', CW / 2, CH / 2 - 8); ctx.font = '14px sans-serif'; ctx.fillText('按空格重新开始', CW / 2, CH / 2 + 24); } document.addEventListener('keydown', (e) => { if (e.key.startsWith('Arrow')) { e.preventDefault(); const map = { ArrowUp: {x: 0, y: -1}, ArrowDown: {x: 0, y: 1}, ArrowLeft: {x: -1, y: 0}, ArrowRight: {x: 1, y: 0} }; const d = map[e.key]; if (!running) return; if (d.x !== -nextDir.x || d.y !== -nextDir.y) { nextDir = d; } } if (e.code === 'Space') { e.preventDefault(); if (over) { start(); } else { running = !running; if (running) { loop(); } else { clearInterval(timer); draw(); } } } }); start(); </script> </body> </html>

数一下,整个文件大约 170 行,其中 HTML 和 CSS 占将近 40 行,真正干活的 JavaScript 在 130 行左右。对一个包含暂停、计分、最高分、加速、重开的完整游戏来说,这个体量真的算克制了。

3.2 全局状态与 start():每一局为什么必须“干净”

代码里所有游戏状态都挂在全局变量上:snake是蛇身数组,food是食物坐标,dir和nextDir是当前与下一帧方向,speed是定时器间隔,timer是定时器句柄,running表示是否运行中,over表示是否结束,score是当前分数,best是历史最高分。

其中running和over是两个不同概念,这一点稍微解释一下。running是“游戏是否在持续推进”,暂停时它为 false,但游戏并没有结束;over是“这一局是否已经结束”,结束时它为 true。两个布尔值组合,才能区分“运行中”“暂停中”“已结束”三种状态。如果只用一个值,暂停和结束就会混在一起,按空格时你根本不知道该重启还是恢复。

start()函数的作用,是把所有状态恢复到一局开局的初始值,然后启动定时器、画第一帧。为什么要这么郑重地写?因为游戏结束后你会再次调用start(),如果有一两个状态忘了重置,就会出现“新一局蛇还留着上一局的位置”“分数没归零”“定时器没清理”之类的诡异问题。把所有状态放在同一个函数里统一初始化,是我写这类小游戏养成的习惯,省得在重开逻辑里到处找遗漏。

3.3 tick() 的执行顺序:方向、蛇头、判定、绘制

tick()是每帧的核心,它的执行顺序非常关键。第一步,dir = nextDir,把这一帧真正要用的方向定下来。第二步,基于dir计算新蛇头坐标。第三步,做碰撞判定,如果撞墙或撞到自己,直接进入gameOver()。第四步,snake.unshift(head)把新蛇头放进数组。第五步,根据有没有吃到食物,决定pop还是加分、加速、生成新食物。第六步,draw()重绘画布。

一个容易忽略的细节是:为什么把dir = nextDir放在 tick 的开头,而不是按键时直接改dir?因为如果你直接改dir,那么同一次 tick 的间隔里,第一次按键把dir设为上,第二次按键读取到的就是“当前方向为上”,它判断“向左不是反向”,于是允许;但如果第二次按键是“下”,它会认为“向下也不是上,不反向”,也允许了。结果就出现瞬间掉头。所以方向锁必须基于nextDir做判断,真正生效的方向交给tick()开头统一赋值。这样即使在一次间隔里连按“上、下”,第二次按键也会被拦下。

3.4 渲染细节:网格、蛇头高亮和结束遮罩

draw()的流程也尽量节省代码。第一行clearRect清空画布,然后把格子网络画上去。其实格子不是必须的,加上它主要是让玩家看清蛇移动的界限,观感更舒服。画网格时我没有手动指定每条线的坐标,而是用for循环从 1 画到COLS - 1,横竖各一遍。两段 5 行代码换来一个清晰的网格背景,性价比很高。

蛇的绘制用forEach遍历蛇身,索引为 0 的蛇头用一种亮绿色,身体用深绿色。这样蛇头一眼就能看出来,操作时更容易盯住目标。食物用方形红色块,比圆形好画,fillRect一行就完事,不需要弧线。为了视觉上有“食物是实体”的感觉,我在食物方块四周留了 4 像素空白,等于画一个小一点的方块,成本为零。

每当暂停时,draw()还会在画布中央叠一层半透明遮罩,写上“已暂停”三个字。这里的技巧是:暂停时没有tick()触发,所以我在键盘事件里手动调用了一次draw(),让遮罩立即出现。结束遮罩同理:gameOver()里直接用 Canvas 把“游戏结束”画在画布上,比另外创建一个 DOM 元素显示文案要省事得多,也避免了额外 CSS 定位的问题。

4. 功能扩展实录:极简代码的“弹力”检验

4.1 穿墙模式:三行代码改变死亡逻辑

判断一套代码设计得好不好,最好的办法是给它加需求。我拿“穿墙模式”试了一下——所谓穿墙,就是蛇头碰到边界时不死亡,而是从对面的边界钻出来。修改点在tick()的碰撞判定里之前,把“越界即死亡”换成“越界即传送”:

head.x = (head.x + COLS) % COLS; head.y = (head.y + ROWS) % ROWS;

只需要这两行,放在撞墙判断之前,蛇头就会从对面出现。这里有个大坑必须提醒:JavaScript 的%对负数取模结果是负数,和 Python 不一样。如果蛇头 x 是 -1,(-1) % 20的结果是 -1,不是 19。所以一定要先加COLS再取模,也就是(head.x + COLS) % COLS。只写head.x % COLS,蛇会在左边卡住,看起来像撞了隐形墙。这样一个我以为五分钟搞定的“小功能”,实际试了三次才跑对,教训很深刻。

穿墙之后,游戏策略完全变了:你不用再绕开边界,而是可以利用边界快速穿行。整个代码只花三行,就改变了核心玩法,说明原来的逻辑边界划分是合理的——移动和判定是分离的,改判定不影响移动。

4.2 障碍物:在画布上多加一层数据

再往游戏里加“障碍物”这个功能。所谓障碍物,就是地图上一些蛇不能碰的固定方块。我把它实现成一组静态坐标:

let obstacles = []; function setupObstacles() { obstacles = []; while (obstacles.length < 5) { const o = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) }; const onSnake = snake.some(s => s.x === o.x && s.y === o.y); const onFood = o.x === food.x && o.y === food.y; const onObstacle = obstacles.some(ob => ob.x === o.x && ob.y === o.y); if (!onSnake && !onFood && !onObstacle) obstacles.push(o); } }

然后tick()里加一个判定:

if (obstacles.some(o => o.x === head.x && o.y === head.y)) { gameOver(); return; }

draw()里加一段渲染:

ctx.fillStyle = '#5a6a7a'; obstacles.forEach(o => ctx.fillRect(o.x * GRID + 2, o.y * GRID + 2, GRID - 4, GRID - 4));

前前后后增加约 15 行代码,一个障碍物系统就有了。为什么这么顺?因为整个游戏里的实体本质都是“带坐标的东西”,蛇是坐标数组,食物是坐标点,障碍物无非是一组固定的坐标点。你不需要为障碍物发明新机制,它只是进入“碰撞检测”和“绘制”两条流水线的一个新数据源。极简代码没做过度抽象,反而让同类概念共享同一套处理流程,这就是它的弹力。

4.3 难度档位与手机虚拟按键:零散功能如何顺手并入

接下来是“难度选择”。加三个按钮本质上只是改变start()里speed的初始值。你可以在 HTML 里放三个按钮,JS 里监听点击,把speed设为 220(简单)、180(普通)、130(困难),然后调用start()重新开局。这里没有引入任何新结构,因为speed本来就是全局状态,改初始值就是改难度。

手机触屏支持也类似。在touchstart里记录起始坐标,touchend时算出手指的横向和纵向位移,取绝对值大的一方作为滑动方向,映射成方向向量之后,交给和键盘事件完全相同的nextDir赋值逻辑。因为输入层的接口都是“给我一个方向向量”,键盘和触屏只是方向来源不同,核心的方向锁逻辑可以原封不动复用。这套“输入层统一输出方向向量”的设计,一开始写键盘事件时并没有刻意规划,纯粹是因为方向向量的数据结构选对了,后面所有新输入方式都变得很顺。

做完这几个扩展之后你再看,代码的体积仍然不太夸张,而且每一个新功能都是“数据层面多加一个角色”或者“输入方式换一个来源”,没有一次需要推倒重写。

5. 常见问题与避坑实录:贪吃蛇最容易翻车的几个地方

5.1 定时器叠加,蛇突然失控加速

这是我在演示这个游戏时被朋友吐槽最多的问题:玩着玩着蛇突然像打了鸡血一样,一次走好几格。原因八成出在加速逻辑上——很多人是这样写加速的:

if (score % 5 === 0) { speed -= 15; setInterval(tick, speed); // 错误示范 }

旧定时器根本没清,新的又开了一个,于是两个tick同时在跑,蛇自然走一步等于走两步。再加速一次变成三个定时器,越跑越疯。

我的习惯是写一个loop()函数统一管理定时器。不管什么时候要改变速度或者重启游戏,都先clearInterval(timer),再setInterval新的。你可以把定时器看成节拍器——改速度不是“再加一个节拍器”,而是“把旧的收起来,新的调好速度再放上去”。

5.2 方向锁失效,快速操作还是会“自杀”

代码里方向锁用的是nextDir做反向判断,但如果你在按键处理里不小心用了dir,就会出现一种隐蔽的 bug:蛇向右走时快速按“上”再按“下”。第一次按键把nextDir设为上;第二次按键时,dir仍然是右,判断“下不是右的反向”,于是nextDir被改成下。等tick()一执行,方向从右直接跳成下,这一下就把蛇头送到了身体旁边,立刻“自杀”。

我第一次遇到这个 bug 时死活想不通——明明加了方向锁,怎么还会反向?后来打印日志才发现比较的变量选错了。方向锁锁的应该是“即将生效的方向”nextDir,不是“当前方向”dir。因为两次按键发生在同一帧间隔内,此时当前方向还没变,真正会变的恰恰是nextDir。用dir判断等于永远慢半拍,锁不住第二下。

5.3 页面滚动、焦点问题与 localStorage 意外值

玩贪吃蛇时方向键会触发页面滚动,这是纯前端小游戏最常见的体验槽点。如果你的页面内容超过一屏,按方向键页面就上下左右乱滚,游戏画面也跟着晃。解决办法很简单:在keydown事件里对方向键调用e.preventDefault(),阻止浏览器默认行为。空格键也一样,要e.preventDefault(),否则空格会触发页面上获得焦点的按钮,比如你点过某个按钮后,空格等于“再点一次”,游戏行为就乱了。

localStorage 的读取也要小心。localStorage.getItem('snake-best')如果之前没存过,返回 null,Number(null)是 0,看起来没问题。但如果你哪天手滑存了个非数字字符串,Number('abc')变成 NaN,分数栏就显示 “NaN”。我习惯写成Number(getItem(...)) || 0,这样任何非数字都会兜底成 0。写入时则只用数字类型,避免出这种幺蛾子。

5.4 食物生成死循环和 canvas 模糊

spawnFood()里的do...while在蛇身快要占满全屏时会陷入死循环,因为随机坐标每次都可能落在蛇身上,而空地越来越少。虽然实际情况很难触发,但严谨的程序员会防一手:

let retry = 0; do { food = { x: ..., y: ... }; retry++; } while (snake.some(...) && retry < 1000); if (retry >= 1000) { gameOver(); return; }

这样真到了极端情况,游戏会主动结束而不是浏览器卡死。对我这种把代码给别人玩的人来说,防一手就是防一个“为什么你的页面打开就卡住”的售后问题。

canvas 模糊则是一个视觉效果问题。很多教程会在 CSS 里设canvas { width: 400px; height: 400px; },但 HTML 属性里没写宽高,或者写的是别的尺寸,画布就会被拉伸模糊。实际上 canvas 的显示尺寸和绘图缓冲区尺寸是两回事。我这版是直接在<canvas>标签上写死width="400" height="400",CSS 不再设置尺寸,这样渲染出来的画面始终是 1:1 的物理像素,干净锐利。高分辨率屏上如果想更清晰,需要按devicePixelRatio做缩放,但对这种休闲小游戏完全没必要,反而会引入缩放矩阵一堆新代码,违背了极简的初衷。

6. 写在最后:一点实际操作心得

这个项目让我最深的体会是:代码少不等于难懂,关键在于把复杂度的源头掐掉。我后来把这个文件发给同事看,大家最感兴趣的反而不是“怎么画出蛇头”,而是那段方向锁——几乎人人都被“反向自杀”坑过,但很少有人去深究为什么锁不住。用nextDir替代dir,只是一个变量名的差别,背后是对“帧”和“输入时机”的理解。写短代码的时候,注释打在这种最容易踩坑的地方,比到处写“这里是移动”有用得多。

另外一个体会是:真正省代码的不是语法技巧,而是数据结构选对了。贪吃蛇用数组和方向向量,移动统一成加法和数组操作,很多分支就自然消失了。后来的俄罗斯方块、扫雷这些小游戏,我也是先用类似思路把数据流画出来再动手写,每写完一个都明显比网上模板精简。如果你也想体验一下极简代码的乐趣,建议先不要去看别人的大而全框架,试着只用数组、对象、几个函数把这个游戏拼出来,你会惊讶于原来一个完整的小游戏只需要这么点代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询