最近整理硬盘上的旧文件夹,翻出一个好几年前写的JavaScript贪吃蛇。当时是某段时间练手用的,代码写得很粗糙,注释也不全,但核心逻辑基本完整,跑起来没有问题。这几天花时间把代码理清爽、补上注释、顺手修掉几个当年没注意的边界问题,然后打包传到了百度网盘,方便需要的人直接下载。这个项目说白了就是用原生JavaScript实现经典贪吃蛇小游戏,整个游戏只有一个HTML文件,没有任何框架依赖,打开浏览器就能玩。如果你刚学完JS基础,想找一个能逐行读懂的小项目,或者正在准备课程设计和面试作品,这份代码可以拿来直接参考。我写这篇博文不只是为了发一个网盘链接,而是想把贪吃蛇在实现上的每个关键决策都摊开讲清楚:蛇身为什么要用这个数据结构、移动逻辑为什么这样写、碰撞检测有哪些顺序上的坑、Canvas和DOM渲染该怎么选,以及我实测过程中踩到的几个典型问题。代码包放在文末,链接失效了评论区喊我补。下面按我实际开发的顺序一步一步说。1. 为什么值得把贪吃蛇再仔细做一遍很多人觉得贪吃蛇太简单,是新手才玩的东西。这种想法我理解,但不太同意。贪吃蛇麻雀虽小,五脏俱全,它几乎包含了游戏开发中最核心的几块拼图:输入处理、状态管理、循环调度、碰撞检测、随机生成、变量平衡。这些概念放到任何大型游戏里都是同一个味道,只是包装更复杂而已。我当年写这个项目的时候,其实是处于"能看懂语法、但不知道怎么组织一个完整程序"的阶段。教科书里的JS知识都是零散的:这里有函数,那里有数组,但怎么把这些东西拼成一个能互动的应用,完全没有概念。贪吃蛇恰好是"完整程序"的最小单位——几百行代码,十几个函数,一个主循环,你不需要理解框架、网络、数据库这些外围概念,只需要盯着游戏逻辑本身。做这个项目还有一个实际价值:它是一个非常合适的面试手写题。很多公司在招前端实习生或初级岗位时,会让人现场写一个贪吃蛇或者类似的Canvas小游戏,考察的不只是API熟不熟,而是候选人能不能在半小时内把一个模糊需求拆成清晰的状态和函数。如果你之前完整写过一遍,面试时即使紧张,核心思路也不会丢。这份代码我尽量保持了"教学级"的可读性,没有做花哨的封装,没有使用ES6的炫技写法,变量命名也偏向直白。这样做的原因是降低阅读门槛。等你完全理解之后,再去看看那些用模块化、对象实现的高级版本,会有一种"原来还能这样组织"的收获感。2. 贪吃蛇的核心逻辑:动手前先把状态模型想明白很多人在写贪吃蛇时的第一反应是打开编辑器开始敲代码,这个顺序其实是反的。贪吃蛇是典型的"状态驱动"游戏,先把状态模型想清楚,写代码只是翻译的过程。下面是我在动手之前梳理的几个核心问题。2.1 蛇身用数组还是用链表我最终选择了数组,而且是最朴素的数组。蛇的每一节坐标是一个包含x和y的对象,整个蛇就是这些坐标对象的数组。数组的尾部是蛇头,数组的头部是蛇尾,这个方向很多人会搞反,但实际上"尾在前、头在后"的排列方式有两个好处。第一,移动逻辑很直观。正常移动一帧时,需要同时做两件事:删掉蛇尾一格、在蛇头方向增加一格。用数组操作对应就是shift()删除尾部坐标,push()添加新蛇头坐标。这两个操作一组合,蛇看起来就整体向前走了一格。第二,增长逻辑自然的对称。吃到食物时,仍然push()新蛇头坐标,但跳过shift()这一步,蛇的长度就加了一。虽然也可以用unshift()和pop()的组合,但从阅读直觉上,"把数组最后一个是蛇头"配合"push表示头前进"更对称。链表在理论上也适合这个场景,但对一个最大长度不会超过几百个节点的游戏,数组完全够用,而且更容易理解和调试。2.2 移动的本质是"删尾加头",不是"整条蛇平移"新手最容易犯的错误,是以为蛇的每一节需要单独更新坐标。如果按这个思路,你得遍历整条蛇,把每一节的坐标挪到前一节的位置上。这样做的最大问题是坐标更新的顺序容易出错,比如你先更新了蛇头坐标,再处理第二节时它参照的"前一节坐标"已经被改过了。正确的做法是删尾加头,不需要遍历整条蛇。因为蛇在网格上移动,每次一定是一格,不会出现半格的情况。那么每一帧的变化就是:尾部消失一个格子,头部方向多出一个格子,中间所有部分原封不动。这个"不变式"一旦想通,代码就非常简单。还有一个细节值得注意:移动和碰撞检测的顺序。正确的顺序是"先删尾、再加头、再检测碰撞"。为什么不能点加头之前就检测?因为当蛇头移动到原本蛇尾所在的位置时,如果先检测碰撞,数组里还残留旧蛇尾坐标,会误判为撞到自己。实际上蛇尾那一格在移动瞬间已经被释放了。这个顺序错位,是很多初版贪吃蛇出现"莫名其妙死亡"的头号原因。2.3 食物与增长:吃下去的瞬间发生了什么食物只需要存一个坐标对象,每次吃到食物时,新蛇头坐标照常push()进去,但跳过shift()删尾操作,蛇的长度就增加了一格。我代码里有一段稍微取巧的写法:在统一执行了shift()之后,如果检测到吃到食物,再手动把旧蛇尾坐标复制一份unshift()回数组头部,相当于把已经删掉的尾巴补了回来。效果是相同的,但如果你直接看代码,可能会觉得这行很多余。我在注释里特别标注了原因。食物生成的位置需要避开蛇身和边界。最简单的方式是死循环随机生成,直到坐标不与蛇的任何一节重合:function generateFood() { do { food.x = Math.floor(Math.random() * GRID_SIZE); food.y = Math.floor(Math.random() * GRID_SIZE); } while (snake.some(segment => segment.x === food.x && segment.y === food.y)); }在蛇很短时,这个冲突概率很低,循环最多跑几次。蛇很长时可能多跑几次,但考虑实际游戏的游玩场景,这个实现足够用。如果你想做得更严谨,可以维护一个"空白坐标集合"来避免死循环,但代价是代码复杂度明显上升。2.4 碰撞检测的三条规则碰撞检测包含三部分:撞墙、撞自己、吃食物。撞墙的判断在移动后立刻做。新蛇头坐标一旦超出0到GRID_SIZE之间的范围,游戏结束。这里我建议把边界值统一用常量表示,比如GRID_SIZE = 20,而不是散落在各处写死数字20,这样后期调整网格大小时只改一处就可以。撞自己的判断顺序上面提过,在shift()和push()完成之后遍历蛇身数组,检查蛇头坐标是否与其他任何一节重合。这个遍历的复杂度是O(n),蛇的长度最大也就几百个点,每帧跑一次完全没问题。吃食物本质也是一种碰撞——新蛇头坐标和食物坐标是否相等。这个判断放在撞墙和撞自己之后,因为如果蛇已经死亡,吃食物就没有意义了。如果判断顺序反过来,可能出现蛇撞墙的同一帧还吃到了食物,导致分数增加之后才弹出"游戏结束",体验上会有点怪。3. 渲染方案选型:Canvas与DOM,我为什么选了前者贪吃蛇这类网格游戏的渲染层有两条路:用DOM节点铺格子,或者用Canvas画图。很多教程默认用Canvas,但我是在对比过两个方案的优缺点后才做的决定,因为选错后期会很痛苦。3.1 DOM方案存在的问题DOM方案的思路是:游戏区域是一个div容器,蛇的每一节是div,食物是div,每次移动时更新这些div的CSS定位。对于20x20的网格,蛇最长可能占据上百个节点,浏览器频繁操作这些节点的样式,桌面端性能其实勉强能接受。但放弃这个方案有几个理由。第一,DOM节点会有自身的边框、圆角、阴影等样式干扰,要做到像素级对齐很费劲,稍有不慎蛇身就对不齐网格。第二,频繁更新style的left和top,在移动端设备上容易出现抖动,因为CSS布局计算和绘制并不总在同一帧完成。第三,如果你想给画面加分——蛇身渐变、食物光环、背景格线——DOM会让这些效果变得非常麻烦。3.2 Canvas的优势和代价Canvas是一块位图画布,所有绘制由开发者自己控制。绘制几百个小矩形在Canvas上耗时是微秒级别,性能上完全没有压力。蛇身每格就是fillRect()画一个矩形,食物可以画成圆形,分数文字也可以直接绘制在画布上,不需要额外的HTML元素。所有颜色、尺寸、透明度都能精确控制,视觉效果更干净。代价是什么?Canvas没有"节点"概念,所有物体都得自己记录坐标、自己绘制、自己在游戏循环里管理。这意味着"状态"和"表现"之间的映射关系完全由代码负责。对于贪吃蛇这种状态简单的游戏,这个代价完全可以接受。3.3 画布尺寸、网格数与高分屏适配我用的网格是20x20,画布逻辑尺寸400x400像素。网格数量直接影响游戏难度:网格太密蛇移动显得慢且需要更多次移动才能穿越屏幕,网格太疏则游戏太简单。20x20是一个平衡点。高分屏适配是很容易被忽略的环节。在Retina屏上,Cavas如果逻辑尺寸是400x400但CSS显示尺寸也是400x400,画面会明显模糊。修复方式是读取window.devicePixelRatio(简称DPR),把Canvas实际像素尺寸乘以DPR,然后通过样式把显示尺寸固定为400:const DPR = window.devicePixelRatio || 1; const canvas = document.getElementById('gameCanvas'); const BASE_SIZE = 400; canvas.width = BASE_SIZE * DPR; canvas.height = BASE_SIZE * DPR; canvas.style.width = BASE_SIZE + 'px'; canvas.style.height = BASE_SIZE + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(DPR, DPR);ctx.scale(DPR, DPR)这行是精髓,它让所有后续绘制代码继续使用逻辑坐标400x400,不需要在每次绘制时手动乘DPR。这个细节对于只在开发者自己电脑上跑的项目可能无所谓,但一旦分享给别人或放到移动端打开,画面清晰度会立刻暴露问题。4. 代码怎么写:从骨架到完整可玩版本本来准备直接贴全部代码,但200多行一次贴出来阅读效果不好,我把拆成几个功能块来说明。网盘里的文件是完整版,本章节只讲每块关键逻辑的思路和局部代码。4.1 全局状态:所有关键信息集中在游戏对象里我用一个game对象承载全部可变状态,这样重置游戏时只需要重新初始化这个对象,不会遗漏任何一个角落。其中包括:const game = { snake: [{x: 6, y: 10}, {x: 5, y: 10}, {x: 4, y: 10}], direction: {x: 1, y: 0}, directionQueue: [], food: {x: 15, y: 10}, score: 0, interval: 150, running: false, timer: null };初始蛇的长度是3,位于网格中部附近,方向向右。directionQueue的方向队列是为了解决4.3节要讲到的输入问题。interval是蛇移动的间隔毫秒数,数值越小速度越快。4.2 主循环:setInterval和requestAnimationFrame的配合游戏循环我采用双机制:setInterval控制"逻辑更新"的节奏,requestAnimationFrame负责"绘制"。为什么要分开?因为setInterval只保证按间隔调用函数,但如果页面同时在做其他重任务,绘制可能跟不上,画面会出现闪烁。而requestAnimationFrame会自动匹配显示器刷新率,在合适的时间执行绘制。启动游戏时这样处理:function startGame() { if (game.timer) clearInterval(game.timer); resetGame(); game.timer = setInterval(update, game.interval); } function update() { moveSnake(); checkCollision(); if (game.running) { requestAnimationFrame(draw); } }移动和碰撞检测的逻辑在同一次setInterval回调中完成,绘制交给下一帧。这样即便setInterval的间隔和显示器刷新率不完全同步,绘制也不会被逻辑更新打乱。一个常见的问题是:加速时忘记清理旧定时器。假设你直接写setInterval(update, game.interval)而之前已经有定时器在跑,就会有两个甚至多个循环同时调用update,蛇的速度瞬间翻倍甚至更快。正确做法每次启动或调整间隔前,先clearInterval上一次的定时器。4.3 方向输入与防呆设计键盘控制本身很简单,keydown事件里判断方向键,更新方向。真正的坑在于"180度掉头"。举个例子:蛇正在向右移动,你快速按下"上"再按"左",如果两个按键事件落在同一帧间隔内,按"上"时方向已经变成上,按"左"时方向又变成左,最终蛇头从向右变成了向左,直接穿过自己的身体。这在正常游戏中是致命的,因为蛇不可能瞬间掉头。解决方案是维护一个方向队列。按键事件只负责把合法的方向对象放入队列,不直接修改当前方向。每次移动前,从队列里取出最新的一个方向,并且检查它是否与当前方向相反,不相反才应用:document.addEventListener('keydown', (e) => { const dirMap = { 'ArrowUp': {x: 0, y: -1}, 'ArrowDown': {x: 0, y: 1}, 'ArrowLeft': {x: -1, y: 0}, 'ArrowRight': {x: 1, y: 0} }; const newDir = dirMap[e.key]; if (newDir) { game.directionQueue.push(newDir); e.preventDefault(); } }); function applyDirection() { while (game.directionQueue.length > 0) { const candidate = game.directionQueue.pop(); if (candidate.x !== -game.direction.x || candidate.y !== -game.direction.y) { game.direction = candidate; break; } } }applyDirection在每次移动逻辑最前面调用。这样即使玩家手速很快,同一帧内也只会应用一个方向,其他输入会排队到下一帧再考虑。这一个小改动让手感稳定了许多。4.4 蛇的移动与生长逻辑移动逻辑的核心是moveSnake。它做几件事:先从队列中取出合法方向,计算新蛇头坐标,然后根据是否吃到食物决定是否执行删尾操作。function moveSnake() { applyDirection(); const head = game.snake[game.snake.length - 1]; const newHead = { x: head.x + game.direction.x, y: head.y + game.direction.y }; // 边界判断 if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) { gameOver(); return; } game.snake.push(newHead); if (newHead.x === game.food.x && newHead.y === game.food.y) { game.score += 10; generateFood(); speedUp(); } else { game.snake.shift(); } // 自碰判断(在删尾之后做,蛇头移到原蛇尾位置不算死) for (let i = 0; i < game.snake.length - 1; i++) { if (game.snake[i].x === newHead.x && game.snake[i].y === newHead.y) { gameOver(); return; } } }我特意把shift()放在食物判断之后,这样吃到食物时不会删尾,长度增加;没吃到食物时删尾,长度保持不变。注意自碰判断的顺序:加头之后、且已经删尾之后再检查。如果蛇头恰好移动到原本蛇尾的位置,不会误判碰撞。4.5 绘制函数:用Canvas画出一整局游戏draw函数负责把游戏状态渲染到画布上。绘制分三层:背景网格、蛇身、食物。这里展示核心绘制代码:function draw() { ctx.clearRect(0, 0, BASE_SIZE, BASE_SIZE); // 背景网格,淡灰色细线 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; for (let i = 0; i <= GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, BASE_SIZE); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(BASE_SIZE, i * CELL_SIZE); ctx.stroke(); } // 食物,画成一个小圆 ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(game.food.x * CELL_SIZE + CELL_SIZE / 2, game.food.y * CELL_SIZE + CELL_SIZE / 2, CELL_SIZE / 2 - 2, 0, Math.PI * 2); ctx.fill(); // 蛇身 game.snake.forEach((segment, index) => { const isHead = index === game.snake.length - 1; ctx.fillStyle = isHead ? '#2c3e50' : '#27ae60'; ctx.fillRect(segment.x * CELL_SIZE + 1, segment.y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); }); // 分数 ctx.fillStyle = '#333'; ctx.font = '14px monospace'; ctx.fillText('Score: ' + game.score, 10, 20); }CELL_SIZE = BASE_SIZE / GRID_SIZE,也就是20像素。绘制时每个格子之间留1像素的间隙,用填充色形成类似"网格卡片"的视觉效果,比纯色块看起来更精致。蛇头和蛇身用不同颜色区分,这样玩家能快速定位蛇头方向。5. 实测中的坑:这几处不修,体验直接崩代码能跑通和体验过关是两回事。我在测试中踩到了几个典型问题,每个都有具体表现、成因和修复方案。这些经验在教程里不一定看得到,但实际操作中几乎必然遇到。5.1 定时器叠加:蛇速记忆性翻倍初版代码的加速逻辑是:每吃到食物,间隔减少20ms,并且重新setInterval。问题出现在"重开游戏"时:如果重开逻辑只重置了蛇、分数、食物,但clearInterval被遗漏或调用时机不对,上一次游戏的定时器还在跑。新游戏又启动一个新的定时器,两个定时器同时触发update,蛇速直接翻倍,而且每次重开都会叠加。修复并不难,关键是"先清理,后开启"的顺序,以及把所有初始化统一放到一个函数里:function startGame() { if (game.timer) { clearInterval(game.timer); game.timer = null; } resetGame(); game.timer = setInterval(update, game.interval); }再补一个保险:用running标志位配合锁,确保update不会重入。虽然定时器叠加已经修复,但万一浏览器因为卡顿把多个回调积压在一起,这个锁能保证一次只处理一个逻辑帧。5.2 快速连续按键导致蛇头"漂移"这个问题在4.3已经提出,这里补充几个真实表现。没有方向队列时,快速按"上+左",蛇头几乎在两帧内完成两个方向的转向,蛇身看起来像"漂"过去了一截。更严重的情况是,如果按键顺序是"上+下",正好构成180度掉头,蛇会直接穿过自己的身体——虽然最终会因为自碰而死亡,但那个穿模的画面非常影响体验。加入方向队列后,同一帧内只会读取一个方向,其他输入排队等待。这样即使玩家在同一瞬间按了多个方向键,也只会有序合并,不会出现穿模。调低interval后,键盘输入的"拥挤"更容易发生,队列的必要性也更加明显。5.3 移动端触控是从零补上的我没在一开始就考虑移动端,但当我试着在手机上打开时,发现点击屏幕毫无反应。于是给Canvas加了一套最基础的滑动手势:监听touchstart和touchmove,计算滑动向量,超过阈值就把方向压入队列。let touchStart = null; canvas.addEventListener('touchstart', (e) => { const t = e.touches[0]; touchStart = {x: t.clientX, y: t.clientY}; e.preventDefault(); }); canvas.addEventListener('touchmove', (e) => { if (!touchStart) return; const t = e.touches[0]; const dx = t.clientX - touchStart.x; const dy = t.clientY - touchStart.y; if (Math.abs(dx) > 30 || Math.abs(dy) > 30) { if (Math.abs(dx) > Math.abs(dy)) { game.directionQueue.push({x: dx > 0 ? 1 : -1, y: 0}); } else { game.directionQueue.push({x: 0, y: dy > 0 ? 1 : -1}); } touchStart = {x: t.clientX, y: t.clientY}; } e.preventDefault(); }, {passive: false});阈值30像素是为了防止手指在屏幕上轻微晃动时就误触发方向。滑动之后重置touchStart,这样不抬手连续滑动也能连续触发方向指令。如果接下来你想支持点按方向按钮,逻辑也是一样的:把按钮事件映射到directionQueue。5.4 高分屏模糊和文字锯齿这个问题在3.3讲过,这里补充一个测试时的感受:没有DPR适配时,在普通Windows屏幕上看起来还行,一到MacBook的Retina屏,整个画面明显"肉"了,蛇身边缘像隔着一层雾。修复后视觉立刻锐利。如果你在手机上调试,很多手机屏幕DPR是2或3,同样需要这个适配。加上ctx.scale(DPR, DPR)之后,所有绘制坐标都不用改动,非常方便。6. 代码包里有什么,下载后怎么继续改最后说一下分享的代码包结构,以及下一步可以怎么扩展。这些都是我实际玩下来觉得"再改一点会更好玩"的方向。6.1 文件结构一览代码包很小,压缩之后不到10KB,里面没有node_modules,没有构建工具配置,没有框架依赖,就是一个纯粹的静态页面项目。文件清单如下:文件作用index.html页面入口,包含Canvas节点和基础样式snake.js全部游戏逻辑,约260行,含注释README.txt操作说明、参数调整说明、版本记录下载后直接用浏览器打开index.html就能运行,不需要启动任何服务器或安装依赖。如果你使用VS Code的Live Server插件,也可以起一个本地服务来跑,但这不是必须的。6.2 换皮肤、加音效、调难度的实战提示如果你拿到代码后想自己改着玩,我建议从这三个方向入手,工作量不大但对理解代码非常有帮助。第一个是换皮肤。蛇身颜色、食物样式、背景网格都是draw函数里的几行代码。想改成渐变效果,可以按蛇的索引位置调整颜色深浅,做一个简单的从深到浅的蛇身过渡。这个练习能帮你理解Canvas的逐帧绘制和着色流程。第二个是加音效。用AudioContext生成极简的短声,吃到食物时播放上升音阶,死亡时播放下行音效,不需要任何音频文件。这个方向会带你接触Web Audio API,代码量很小但"完整感"提升巨大。第三个是调难度曲线。现在的逻辑是每吃固定数量的食物加速一次,而且速度下限在60ms。你可以尝试让加速曲线更平滑,或者加入"每吃一个食物,随机改变下一次食物的颜色和分值"的机制,让游戏策略更丰富。这些改动都不会影响核心代码结构,但能让你的版本和别人不一样。6.3 下载说明和遇到问题的处理方式代码包以压缩包形式放在百度网盘,下载之后解压即可。如果你在运行中遇到画面模糊、速度异常、控制失灵之类的问题,先打开浏览器控制台看有没有红色报错。如果控制台显示语法错误,多半是因为浏览器版本太旧——代码里用到了let、const、箭头函数、Canvas 2D相关API,理论上2016年之后的浏览器都支持。你可以先升级浏览器版本再试,必要时换Chrome或Edge打开。网盘链接如果失效,在评论区留言说明,我看到后会补上新的分享。这只代码我测试过桌面端和移动端的运行,移动端目前支持滑动手势,不支持同时多点触控,因为贪吃蛇本身也不需要多点操作。如果你照着代码写了一遍,又动手改了一两个功能,那这个项目对你的价值就真正落地了。贪吃蛇虽然老,但作为理解"状态驱动游戏"的最小完整范例,它依然是性价比极高的练手项目。我在这次重新整理过程中,还顺手把当年没想通的两个细节——碰撞检测顺序和定时器清理——彻底搞明白了,这种"回看旧代码发现新东西"的体验,也是写代码的一种乐趣。
最近整理硬盘上的旧文件夹,翻出一个好几年前写的JavaScript贪吃蛇。当时是某段时间练手用的,代码写得很粗糙,注释也不全,但核心逻辑基本完整,跑起来没有问题。这几天花时间把代码理清爽、补上注释、顺手修掉几个当年没注意的边界问题,然后打包传到了百度网盘,方便需要的人直接下载。这个项目说白了就是用原生JavaScript实现经典贪吃蛇小游戏,整个游戏只有一个HTML文件,没有任何框架依赖,打开浏览器就能玩。如果你刚学完JS基础,想找一个能逐行读懂的小项目,或者正在准备课程设计和面试作品,这份代码可以拿来直接参考。我写这篇博文不只是为了发一个网盘链接,而是想把贪吃蛇在实现上的每个关键决策都摊开讲清楚:蛇身为什么要用这个数据结构、移动逻辑为什么这样写、碰撞检测有哪些顺序上的坑、Canvas和DOM渲染该怎么选,以及我实测过程中踩到的几个典型问题。代码包放在文末,链接失效了评论区喊我补。下面按我实际开发的顺序一步一步说。1. 为什么值得把贪吃蛇再仔细做一遍很多人觉得贪吃蛇太简单,是新手才玩的东西。这种想法我理解,但不太同意。贪吃蛇麻雀虽小,五脏俱全,它几乎包含了游戏开发中最核心的几块拼图:输入处理、状态管理、循环调度、碰撞检测、随机生成、变量平衡。这些概念放到任何大型游戏里都是同一个味道,只是包装更复杂而已。我当年写这个项目的时候,其实是处于"能看懂语法、但不知道怎么组织一个完整程序"的阶段。教科书里的JS知识都是零散的:这里有函数,那里有数组,但怎么把这些东西拼成一个能互动的应用,完全没有概念。贪吃蛇恰好是"完整程序"的最小单位——几百行代码,十几个函数,一个主循环,你不需要理解框架、网络、数据库这些外围概念,只需要盯着游戏逻辑本身。做这个项目还有一个实际价值:它是一个非常合适的面试手写题。很多公司在招前端实习生或初级岗位时,会让人现场写一个贪吃蛇或者类似的Canvas小游戏,考察的不只是API熟不熟,而是候选人能不能在半小时内把一个模糊需求拆成清晰的状态和函数。如果你之前完整写过一遍,面试时即使紧张,核心思路也不会丢。这份代码我尽量保持了"教学级"的可读性,没有做花哨的封装,没有使用ES6的炫技写法,变量命名也偏向直白。这样做的原因是降低阅读门槛。等你完全理解之后,再去看看那些用模块化、对象实现的高级版本,会有一种"原来还能这样组织"的收获感。2. 贪吃蛇的核心逻辑:动手前先把状态模型想明白很多人在写贪吃蛇时的第一反应是打开编辑器开始敲代码,这个顺序其实是反的。贪吃蛇是典型的"状态驱动"游戏,先把状态模型想清楚,写代码只是翻译的过程。下面是我在动手之前梳理的几个核心问题。2.1 蛇身用数组还是用链表我最终选择了数组,而且是最朴素的数组。蛇的每一节坐标是一个包含x和y的对象,整个蛇就是这些坐标对象的数组。数组的尾部是蛇头,数组的头部是蛇尾,这个方向很多人会搞反,但实际上"尾在前、头在后"的排列方式有两个好处。第一,移动逻辑很直观。正常移动一帧时,需要同时做两件事:删掉蛇尾一格、在蛇头方向增加一格。用数组操作对应就是shift()删除尾部坐标,push()添加新蛇头坐标。这两个操作一组合,蛇看起来就整体向前走了一格。第二,增长逻辑自然的对称。吃到食物时,仍然push()新蛇头坐标,但跳过shift()这一步,蛇的长度就加了一。虽然也可以用unshift()和pop()的组合,但从阅读直觉上,"把数组最后一个是蛇头"配合"push表示头前进"更对称。链表在理论上也适合这个场景,但对一个最大长度不会超过几百个节点的游戏,数组完全够用,而且更容易理解和调试。2.2 移动的本质是"删尾加头",不是"整条蛇平移"新手最容易犯的错误,是以为蛇的每一节需要单独更新坐标。如果按这个思路,你得遍历整条蛇,把每一节的坐标挪到前一节的位置上。这样做的最大问题是坐标更新的顺序容易出错,比如你先更新了蛇头坐标,再处理第二节时它参照的"前一节坐标"已经被改过了。正确的做法是删尾加头,不需要遍历整条蛇。因为蛇在网格上移动,每次一定是一格,不会出现半格的情况。那么每一帧的变化就是:尾部消失一个格子,头部方向多出一个格子,中间所有部分原封不动。这个"不变式"一旦想通,代码就非常简单。还有一个细节值得注意:移动和碰撞检测的顺序。正确的顺序是"先删尾、再加头、再检测碰撞"。为什么不能点加头之前就检测?因为当蛇头移动到原本蛇尾所在的位置时,如果先检测碰撞,数组里还残留旧蛇尾坐标,会误判为撞到自己。实际上蛇尾那一格在移动瞬间已经被释放了。这个顺序错位,是很多初版贪吃蛇出现"莫名其妙死亡"的头号原因。2.3 食物与增长:吃下去的瞬间发生了什么食物只需要存一个坐标对象,每次吃到食物时,新蛇头坐标照常push()进去,但跳过shift()删尾操作,蛇的长度就增加了一格。我代码里有一段稍微取巧的写法:在统一执行了shift()之后,如果检测到吃到食物,再手动把旧蛇尾坐标复制一份unshift()回数组头部,相当于把已经删掉的尾巴补了回来。效果是相同的,但如果你直接看代码,可能会觉得这行很多余。我在注释里特别标注了原因。食物生成的位置需要避开蛇身和边界。最简单的方式是死循环随机生成,直到坐标不与蛇的任何一节重合:function generateFood() { do { food.x = Math.floor(Math.random() * GRID_SIZE); food.y = Math.floor(Math.random() * GRID_SIZE); } while (snake.some(segment => segment.x === food.x && segment.y === food.y)); }在蛇很短时,这个冲突概率很低,循环最多跑几次。蛇很长时可能多跑几次,但考虑实际游戏的游玩场景,这个实现足够用。如果你想做得更严谨,可以维护一个"空白坐标集合"来避免死循环,但代价是代码复杂度明显上升。2.4 碰撞检测的三条规则碰撞检测包含三部分:撞墙、撞自己、吃食物。撞墙的判断在移动后立刻做。新蛇头坐标一旦超出0到GRID_SIZE之间的范围,游戏结束。这里我建议把边界值统一用常量表示,比如GRID_SIZE = 20,而不是散落在各处写死数字20,这样后期调整网格大小时只改一处就可以。撞自己的判断顺序上面提过,在shift()和push()完成之后遍历蛇身数组,检查蛇头坐标是否与其他任何一节重合。这个遍历的复杂度是O(n),蛇的长度最大也就几百个点,每帧跑一次完全没问题。吃食物本质也是一种碰撞——新蛇头坐标和食物坐标是否相等。这个判断放在撞墙和撞自己之后,因为如果蛇已经死亡,吃食物就没有意义了。如果判断顺序反过来,可能出现蛇撞墙的同一帧还吃到了食物,导致分数增加之后才弹出"游戏结束",体验上会有点怪。3. 渲染方案选型:Canvas与DOM,我为什么选了前者贪吃蛇这类网格游戏的渲染层有两条路:用DOM节点铺格子,或者用Canvas画图。很多教程默认用Canvas,但我是在对比过两个方案的优缺点后才做的决定,因为选错后期会很痛苦。3.1 DOM方案存在的问题DOM方案的思路是:游戏区域是一个div容器,蛇的每一节是div,食物是div,每次移动时更新这些div的CSS定位。对于20x20的网格,蛇最长可能占据上百个节点,浏览器频繁操作这些节点的样式,桌面端性能其实勉强能接受。但放弃这个方案有几个理由。第一,DOM节点会有自身的边框、圆角、阴影等样式干扰,要做到像素级对齐很费劲,稍有不慎蛇身就对不齐网格。第二,频繁更新style的left和top,在移动端设备上容易出现抖动,因为CSS布局计算和绘制并不总在同一帧完成。第三,如果你想给画面加分——蛇身渐变、食物光环、背景格线——DOM会让这些效果变得非常麻烦。3.2 Canvas的优势和代价Canvas是一块位图画布,所有绘制由开发者自己控制。绘制几百个小矩形在Canvas上耗时是微秒级别,性能上完全没有压力。蛇身每格就是fillRect()画一个矩形,食物可以画成圆形,分数文字也可以直接绘制在画布上,不需要额外的HTML元素。所有颜色、尺寸、透明度都能精确控制,视觉效果更干净。代价是什么?Canvas没有"节点"概念,所有物体都得自己记录坐标、自己绘制、自己在游戏循环里管理。这意味着"状态"和"表现"之间的映射关系完全由代码负责。对于贪吃蛇这种状态简单的游戏,这个代价完全可以接受。3.3 画布尺寸、网格数与高分屏适配我用的网格是20x20,画布逻辑尺寸400x400像素。网格数量直接影响游戏难度:网格太密蛇移动显得慢且需要更多次移动才能穿越屏幕,网格太疏则游戏太简单。20x20是一个平衡点。高分屏适配是很容易被忽略的环节。在Retina屏上,Cavas如果逻辑尺寸是400x400但CSS显示尺寸也是400x400,画面会明显模糊。修复方式是读取window.devicePixelRatio(简称DPR),把Canvas实际像素尺寸乘以DPR,然后通过样式把显示尺寸固定为400:const DPR = window.devicePixelRatio || 1; const canvas = document.getElementById('gameCanvas'); const BASE_SIZE = 400; canvas.width = BASE_SIZE * DPR; canvas.height = BASE_SIZE * DPR; canvas.style.width = BASE_SIZE + 'px'; canvas.style.height = BASE_SIZE + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(DPR, DPR);ctx.scale(DPR, DPR)这行是精髓,它让所有后续绘制代码继续使用逻辑坐标400x400,不需要在每次绘制时手动乘DPR。这个细节对于只在开发者自己电脑上跑的项目可能无所谓,但一旦分享给别人或放到移动端打开,画面清晰度会立刻暴露问题。4. 代码怎么写:从骨架到完整可玩版本本来准备直接贴全部代码,但200多行一次贴出来阅读效果不好,我把拆成几个功能块来说明。网盘里的文件是完整版,本章节只讲每块关键逻辑的思路和局部代码。4.1 全局状态:所有关键信息集中在游戏对象里我用一个game对象承载全部可变状态,这样重置游戏时只需要重新初始化这个对象,不会遗漏任何一个角落。其中包括:const game = { snake: [{x: 6, y: 10}, {x: 5, y: 10}, {x: 4, y: 10}], direction: {x: 1, y: 0}, directionQueue: [], food: {x: 15, y: 10}, score: 0, interval: 150, running: false, timer: null };初始蛇的长度是3,位于网格中部附近,方向向右。directionQueue的方向队列是为了解决4.3节要讲到的输入问题。interval是蛇移动的间隔毫秒数,数值越小速度越快。4.2 主循环:setInterval和requestAnimationFrame的配合游戏循环我采用双机制:setInterval控制"逻辑更新"的节奏,requestAnimationFrame负责"绘制"。为什么要分开?因为setInterval只保证按间隔调用函数,但如果页面同时在做其他重任务,绘制可能跟不上,画面会出现闪烁。而requestAnimationFrame会自动匹配显示器刷新率,在合适的时间执行绘制。启动游戏时这样处理:function startGame() { if (game.timer) clearInterval(game.timer); resetGame(); game.timer = setInterval(update, game.interval); } function update() { moveSnake(); checkCollision(); if (game.running) { requestAnimationFrame(draw); } }移动和碰撞检测的逻辑在同一次setInterval回调中完成,绘制交给下一帧。这样即便setInterval的间隔和显示器刷新率不完全同步,绘制也不会被逻辑更新打乱。一个常见的问题是:加速时忘记清理旧定时器。假设你直接写setInterval(update, game.interval)而之前已经有定时器在跑,就会有两个甚至多个循环同时调用update,蛇的速度瞬间翻倍甚至更快。正确做法每次启动或调整间隔前,先clearInterval上一次的定时器。4.3 方向输入与防呆设计键盘控制本身很简单,keydown事件里判断方向键,更新方向。真正的坑在于"180度掉头"。举个例子:蛇正在向右移动,你快速按下"上"再按"左",如果两个按键事件落在同一帧间隔内,按"上"时方向已经变成上,按"左"时方向又变成左,最终蛇头从向右变成了向左,直接穿过自己的身体。这在正常游戏中是致命的,因为蛇不可能瞬间掉头。解决方案是维护一个方向队列。按键事件只负责把合法的方向对象放入队列,不直接修改当前方向。每次移动前,从队列里取出最新的一个方向,并且检查它是否与当前方向相反,不相反才应用:document.addEventListener('keydown', (e) => { const dirMap = { 'ArrowUp': {x: 0, y: -1}, 'ArrowDown': {x: 0, y: 1}, 'ArrowLeft': {x: -1, y: 0}, 'ArrowRight': {x: 1, y: 0} }; const newDir = dirMap[e.key]; if (newDir) { game.directionQueue.push(newDir); e.preventDefault(); } }); function applyDirection() { while (game.directionQueue.length > 0) { const candidate = game.directionQueue.pop(); if (candidate.x !== -game.direction.x || candidate.y !== -game.direction.y) { game.direction = candidate; break; } } }applyDirection在每次移动逻辑最前面调用。这样即使玩家手速很快,同一帧内也只会应用一个方向,其他输入会排队到下一帧再考虑。这一个小改动让手感稳定了许多。4.4 蛇的移动与生长逻辑移动逻辑的核心是moveSnake。它做几件事:先从队列中取出合法方向,计算新蛇头坐标,然后根据是否吃到食物决定是否执行删尾操作。function moveSnake() { applyDirection(); const head = game.snake[game.snake.length - 1]; const newHead = { x: head.x + game.direction.x, y: head.y + game.direction.y }; // 边界判断 if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) { gameOver(); return; } game.snake.push(newHead); if (newHead.x === game.food.x && newHead.y === game.food.y) { game.score += 10; generateFood(); speedUp(); } else { game.snake.shift(); } // 自碰判断(在删尾之后做,蛇头移到原蛇尾位置不算死) for (let i = 0; i < game.snake.length - 1; i++) { if (game.snake[i].x === newHead.x && game.snake[i].y === newHead.y) { gameOver(); return; } } }我特意把shift()放在食物判断之后,这样吃到食物时不会删尾,长度增加;没吃到食物时删尾,长度保持不变。注意自碰判断的顺序:加头之后、且已经删尾之后再检查。如果蛇头恰好移动到原本蛇尾的位置,不会误判碰撞。4.5 绘制函数:用Canvas画出一整局游戏draw函数负责把游戏状态渲染到画布上。绘制分三层:背景网格、蛇身、食物。这里展示核心绘制代码:function draw() { ctx.clearRect(0, 0, BASE_SIZE, BASE_SIZE); // 背景网格,淡灰色细线 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; for (let i = 0; i <= GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, BASE_SIZE); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(BASE_SIZE, i * CELL_SIZE); ctx.stroke(); } // 食物,画成一个小圆 ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(game.food.x * CELL_SIZE + CELL_SIZE / 2, game.food.y * CELL_SIZE + CELL_SIZE / 2, CELL_SIZE / 2 - 2, 0, Math.PI * 2); ctx.fill(); // 蛇身 game.snake.forEach((segment, index) => { const isHead = index === game.snake.length - 1; ctx.fillStyle = isHead ? '#2c3e50' : '#27ae60'; ctx.fillRect(segment.x * CELL_SIZE + 1, segment.y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); }); // 分数 ctx.fillStyle = '#333'; ctx.font = '14px monospace'; ctx.fillText('Score: ' + game.score, 10, 20); }CELL_SIZE = BASE_SIZE / GRID_SIZE,也就是20像素。绘制时每个格子之间留1像素的间隙,用填充色形成类似"网格卡片"的视觉效果,比纯色块看起来更精致。蛇头和蛇身用不同颜色区分,这样玩家能快速定位蛇头方向。5. 实测中的坑:这几处不修,体验直接崩代码能跑通和体验过关是两回事。我在测试中踩到了几个典型问题,每个都有具体表现、成因和修复方案。这些经验在教程里不一定看得到,但实际操作中几乎必然遇到。5.1 定时器叠加:蛇速记忆性翻倍初版代码的加速逻辑是:每吃到食物,间隔减少20ms,并且重新setInterval。问题出现在"重开游戏"时:如果重开逻辑只重置了蛇、分数、食物,但clearInterval被遗漏或调用时机不对,上一次游戏的定时器还在跑。新游戏又启动一个新的定时器,两个定时器同时触发update,蛇速直接翻倍,而且每次重开都会叠加。修复并不难,关键是"先清理,后开启"的顺序,以及把所有初始化统一放到一个函数里:function startGame() { if (game.timer) { clearInterval(game.timer); game.timer = null; } resetGame(); game.timer = setInterval(update, game.interval); }再补一个保险:用running标志位配合锁,确保update不会重入。虽然定时器叠加已经修复,但万一浏览器因为卡顿把多个回调积压在一起,这个锁能保证一次只处理一个逻辑帧。5.2 快速连续按键导致蛇头"漂移"这个问题在4.3已经提出,这里补充几个真实表现。没有方向队列时,快速按"上+左",蛇头几乎在两帧内完成两个方向的转向,蛇身看起来像"漂"过去了一截。更严重的情况是,如果按键顺序是"上+下",正好构成180度掉头,蛇会直接穿过自己的身体——虽然最终会因为自碰而死亡,但那个穿模的画面非常影响体验。加入方向队列后,同一帧内只会读取一个方向,其他输入排队等待。这样即使玩家在同一瞬间按了多个方向键,也只会有序合并,不会出现穿模。调低interval后,键盘输入的"拥挤"更容易发生,队列的必要性也更加明显。5.3 移动端触控是从零补上的我没在一开始就考虑移动端,但当我试着在手机上打开时,发现点击屏幕毫无反应。于是给Canvas加了一套最基础的滑动手势:监听touchstart和touchmove,计算滑动向量,超过阈值就把方向压入队列。let touchStart = null; canvas.addEventListener('touchstart', (e) => { const t = e.touches[0]; touchStart = {x: t.clientX, y: t.clientY}; e.preventDefault(); }); canvas.addEventListener('touchmove', (e) => { if (!touchStart) return; const t = e.touches[0]; const dx = t.clientX - touchStart.x; const dy = t.clientY - touchStart.y; if (Math.abs(dx) > 30 || Math.abs(dy) > 30) { if (Math.abs(dx) > Math.abs(dy)) { game.directionQueue.push({x: dx > 0 ? 1 : -1, y: 0}); } else { game.directionQueue.push({x: 0, y: dy > 0 ? 1 : -1}); } touchStart = {x: t.clientX, y: t.clientY}; } e.preventDefault(); }, {passive: false});阈值30像素是为了防止手指在屏幕上轻微晃动时就误触发方向。滑动之后重置touchStart,这样不抬手连续滑动也能连续触发方向指令。如果接下来你想支持点按方向按钮,逻辑也是一样的:把按钮事件映射到directionQueue。5.4 高分屏模糊和文字锯齿这个问题在3.3讲过,这里补充一个测试时的感受:没有DPR适配时,在普通Windows屏幕上看起来还行,一到MacBook的Retina屏,整个画面明显"肉"了,蛇身边缘像隔着一层雾。修复后视觉立刻锐利。如果你在手机上调试,很多手机屏幕DPR是2或3,同样需要这个适配。加上ctx.scale(DPR, DPR)之后,所有绘制坐标都不用改动,非常方便。6. 代码包里有什么,下载后怎么继续改最后说一下分享的代码包结构,以及下一步可以怎么扩展。这些都是我实际玩下来觉得"再改一点会更好玩"的方向。6.1 文件结构一览代码包很小,压缩之后不到10KB,里面没有node_modules,没有构建工具配置,没有框架依赖,就是一个纯粹的静态页面项目。文件清单如下:文件作用index.html页面入口,包含Canvas节点和基础样式snake.js全部游戏逻辑,约260行,含注释README.txt操作说明、参数调整说明、版本记录下载后直接用浏览器打开index.html就能运行,不需要启动任何服务器或安装依赖。如果你使用VS Code的Live Server插件,也可以起一个本地服务来跑,但这不是必须的。6.2 换皮肤、加音效、调难度的实战提示如果你拿到代码后想自己改着玩,我建议从这三个方向入手,工作量不大但对理解代码非常有帮助。第一个是换皮肤。蛇身颜色、食物样式、背景网格都是draw函数里的几行代码。想改成渐变效果,可以按蛇的索引位置调整颜色深浅,做一个简单的从深到浅的蛇身过渡。这个练习能帮你理解Canvas的逐帧绘制和着色流程。第二个是加音效。用AudioContext生成极简的短声,吃到食物时播放上升音阶,死亡时播放下行音效,不需要任何音频文件。这个方向会带你接触Web Audio API,代码量很小但"完整感"提升巨大。第三个是调难度曲线。现在的逻辑是每吃固定数量的食物加速一次,而且速度下限在60ms。你可以尝试让加速曲线更平滑,或者加入"每吃一个食物,随机改变下一次食物的颜色和分值"的机制,让游戏策略更丰富。这些改动都不会影响核心代码结构,但能让你的版本和别人不一样。6.3 下载说明和遇到问题的处理方式代码包以压缩包形式放在百度网盘,下载之后解压即可。如果你在运行中遇到画面模糊、速度异常、控制失灵之类的问题,先打开浏览器控制台看有没有红色报错。如果控制台显示语法错误,多半是因为浏览器版本太旧——代码里用到了let、const、箭头函数、Canvas 2D相关API,理论上2016年之后的浏览器都支持。你可以先升级浏览器版本再试,必要时换Chrome或Edge打开。网盘链接如果失效,在评论区留言说明,我看到后会补上新的分享。这只代码我测试过桌面端和移动端的运行,移动端目前支持滑动手势,不支持同时多点触控,因为贪吃蛇本身也不需要多点操作。如果你照着代码写了一遍,又动手改了一两个功能,那这个项目对你的价值就真正落地了。贪吃蛇虽然老,但作为理解"状态驱动游戏"的最小完整范例,它依然是性价比极高的练手项目。我在这次重新整理过程中,还顺手把当年没想通的两个细节——碰撞检测顺序和定时器清理——彻底搞明白了,这种"回看旧代码发现新东西"的体验,也是写代码的一种乐趣。