1. 项目概述:为什么经典游戏依然值得动手做一遍贪吃蛇游戏,几乎每个开发者都写过一版,或者至少在心里盘算过。它看似简单,但背后牵扯到的数据结构、状态管理、碰撞检测、渲染循环、事件处理,几乎是所有游戏乃至前端应用开发的缩影。正因为如此,它才常被用来练手、教学、甚至面试考察。我这次的模拟项目X,目标是实现一个可直接运行的贪吃蛇Demo,要求包含完整玩法——方向控制、食物生成、死亡判定、计分与速度提升——同时代码结构清晰、可扩展。这个项目适合三类人:刚学完基础语法、想找一个小项目串联知识点的学习者;准备面试、需要快速构建一个能讲清楚的实战项目的人;以及想研究游戏循环和状态管理细节的开发者。实际上,贪吃蛇这类单机小游戏的复杂度刚好踩在“能驾驭”和“有挑战”之间。我第一次写的时候自以为半小时搞定,结果在“蛇是否允许掉头”这个细节上栽了跟头,之后又连着踩了定时器重入、方向键缓冲、渲染闪烁几个坑。这篇文章就是把这些坑和最终方案完整记录下来,希望对你有参考价值。2. 整体设计与思路拆解2.1 技术选型:就地取材,不引复杂框架我选用了纯HTML + CSS + JavaScript的实现方案,渲染层用Canvas画布,逻辑层用面向对象的模块化写法。没有引入任何框架或UI库,原因很简单:这个项目的重点在于游戏逻辑本身,引入框架反而会干扰核心逻辑的清晰度。Canvas渲染在这个场景下是天然合适的选择。贪吃蛇的地图本质是一个网格,每个格子要么是蛇身、要么是食物、要么是空地。用Canvas的fillRect绘制格子即可,性能和代码量都优于DOM操作方案——如果在浏览器里用DOM方式渲染蛇身,每移动一步就要改一次所有蛇身节点的位置样式,代码繁琐且费性能。逻辑层我拆出了三个功能模块,结构化的好处到后期调试和加功能时会非常明显:Game类:负责游戏循环、状态机切换(待开始、运行中、暂停、结束)Snake类:负责蛇的身体数据与移动规则Food类:负责食物的生成与渲染这里我想特别强调一点,游戏循环不要用递归的setTimeout链,也不要在一开始就用什么游戏引擎提供的requestAnimationFrame封装。最朴素的做法是用setInterval驱动定时器,然后通过requestAnimationFrame做渲染。原因后面细说——这涉及掉帧和定时重置两个常见故障的根因。2.2 开发节奏:先跑起来,再谈完善我在动手前定的节奏是四个阶段:五分钟跑通“蛇能动”:不做界面,不做计分,不做碰撞,先把蛇的移动做出来,用键盘方向键控制,让它能在画布上走补齐核心玩法:食物随机生成、吃到食物蛇身变长、撞墙和撞自身判定完善交互体验:计分、暂停/继续、重新开始、游戏速度随分数提升打磨代码细节:把面向过程的写法改成清晰的类封装,补充注释,同时处理方向键快速切换、移动端触控兼容等细节很多初学者容易一上来就追求完美,把UI画得很漂亮,结果核心逻辑还没通。我的经验恰恰相反,先让核心逻辑以最粗糙的形式跑起来——哪怕丑得像命令行一样——然后再层层补充。这背后是一个很实际的道理:游戏逻辑的调试成本是最高的,UI可以后补,但逻辑问题越早暴露越好修。2.3 值得注意的设计决策有几个设计决策在实际编码中特别关键,我列一下当时的思考过程:地图坐标系。我使用的是网格坐标系而非像素坐标系,每个单元格大小为20×20像素。蛇的位置用数组存储,每个节点存{x, y}坐标值,x和y是网格坐标而非像素坐标。渲染时才乘以格子尺寸换算为像素坐标。这样做的最大好处是:碰撞检测、食物生成都基于整数坐标比较,完全避开浮点数误差问题。蛇身的数据结构。经典做法是用一个数组,头在数组末尾或者头部。我采用的是“蛇头固定为数组尾部”的方案:// 蛇身节点:nodes[0]是蛇尾,nodes[nodes.length-1]是蛇头 this.nodes = [{x: 7, y: 7}];每次移动,先把新蛇头坐标用push追加到数组末端,如果本步没吃到食物则shift移除蛇尾节点。这种“先插头、判断是否吃食物、再决定要不要砍尾巴”的顺序,是整个游戏逻辑最核心的地方,后面会详细讲。速度提升机制。我没有无上限地加快速度,而是设置了最小移动间隔上限(100ms),并把速度提升设计为阶梯式。这样可以防止速度过快之后人类根本无法操作,也防止setInterval的时间间隔过小导致定时器异常。2.4 为什么不用现成的游戏框架这个项目完全可以用框架来做,网络上也有不少现成的贪吃蛇游戏源码可以直接抄。但我的建议是,第一遍务必手写核心逻辑。理由很实际:如果你是个新手,抄代码的时候你根本不知道哪句话是关键;如果你是个老手,用框架做项目其实更像在验证框架API,而不是在体会游戏逻辑本身的乐趣。另外一点很反直觉的体会是:贪吃蛇项目的经典实现里,最难的部分不是“蛇怎么动”,而是“蛇怎么不动”。这里面的弯弯绕绕下面专门展开说。3. 核心逻辑拆解与实现要点3.1 蛇移动的真相:你不是在“移动”,而是在“重建”很多人第一次写贪吃蛇时容易把蛇的移动理解为“整条蛇都向前挪一格”,于是写出这样的代码:遍历所有蛇身节点,每个节点往自己的前方移动一格。这样写调试时会出现可怕的蛇身折断现象——因为当你更新第二个节点时,它前方节点已经移动了,拿到的前方节点位置总是不稳定的。正确思路是:蛇的移动,不是每条蛇身都在动,而是蛇头新增一格,蛇尾砍掉一格,中间的部分全都是“保留原样”。只有在吃到食物时,砍蛇尾这步才被跳过——因为要变长。这个思路用生活类比解释就是:一条火车在轨道上行驶,我们不把每节车厢挨个往前拉,而是把最后一节车厢摘下,接到最前面一节车厢的前方。火车就“看起来”前进了一格。这就是为什么用数组结构天然适配这个逻辑——数组末尾push一个新蛇头,数组开头shift掉旧蛇尾,整个蛇身就整体前进了一格。这是我第一次重构时得到的最大启发。把移动理解为“增头减尾”,所有后续的问题——是否吃到食物、是否撞墙、是否撞自己——都变成了对这个新蛇头的坐标判断。3.2 方向控制与“禁止掉头”的细节方向控制的实现很简单,监听键盘事件,根据按键更新方向值。难点在于两个细节:细节一:禁止掉头。假设当前蛇头向右移动,用户在极短时间内按下左箭头,如果程序直接响应,蛇头会直接掉头撞向自己脖子——这在游戏里通常判定为死亡,体验非常差。正确做法是:只有当新方向与当前移动方向的x或y坐标不互为相反数时,才允许更新方向。// 判断新方向是否和当前方向相反 // 如果当前方向是右(1, 0),那么新方向不能是左(-1, 0) const isOpposite = (currentDir, newDir) => { return currentDir.x + newDir.x === 0 && currentDir.y + newDir.y === 0; };细节二:按键缓冲。这是另一个容易踩的坑。如果直接在keydown事件里立即修改移动方向,会出现一套“幽灵操作”效应。举例说明:蛇正在向右移动,玩家先快速按下“上”再快速按下“左”,在下一帧移动来临时,如果程序只是读取“最后一次按下的方向键”,蛇将会向左走——但此时蛇头实际上还在向右移动,向左走意味着蛇头瞬间横穿到左侧位置,这在游戏中看起来像是蛇身体卡了一下,甚至直接判定撞墙。数据层面的根因在于:移动只发生在定时器触发的下一帧,但按键事件随时都在发生;如果每次按键都立即改变方向,帧与帧之间的方向变化就可能被高频按键“跳跃式”覆盖。解决方案是引入方向队列,把每次合法按键的方向推入队列尾部,每帧移动时只从队列头部取一个方向作为本次移动的位移方向。这相当于给方向输入加了个缓冲池,让操作按顺序逐帧生效。// 方向队列:keydown时push,每帧移动时shift取出一个 this.dirQueue = []; window.addEventListener('keydown', (e) => { const newDir = getDirFromKey(e.key); if (newDir && !isOpposite(this.currentDir, newDir)) { this.dirQueue.push(newDir); } });3.3 碰撞检测的三个边界碰撞检测是贪吃蛇项目的重头戏。我归纳为三种典型碰撞,每种的处理方式略有不同:撞墙:判断新蛇头的网格坐标是否超出地图边界。地图大小为20×20网格,所以x和y的有效范围是0到19。超出即死亡。撞自己:判断新蛇头的坐标是否与现有蛇身节点(不包括蛇尾)重合。这里有个精妙的细节:如果蛇尾节点本轮即将移除,新蛇头就算踩到蛇尾的位置也是不会真正撞上的——因为蛇尾马上就消失了。所以遍历时应该排除蛇尾。吃食物:判断新蛇头坐标是否等于食物坐标。相等则本步不移除蛇尾节点,同时重新生成食物、增加分数。这层的实现过程中建议不要用“潜在”的坐标重叠判断(比如预测下下步会不会撞),因为游戏规则本身具有时效性,玩家就是要到最后一刻才能看到结果。判断只基于当前帧数据,够纯粹。3.4 食物生成的避坑原则食物生成看似随机一放就行,但有个隐藏条件:不能生成在蛇身上。否则食物会直接出现在蛇身内部,玩家很难吃到,甚至看起来像渲染Bug。我的实现是:随机生成坐标后,遍历蛇身节点集合判断是否有重叠,如果重叠则重新生成。但这里要提一个可能会被忽略的场景:当蛇身几乎占满整个地图时,随机生成再判断是否重叠的效率会非常低,甚至陷入死循环。虽然对贪吃蛇这个场景来说地图不大、蛇身的极限也有限,但严谨的做法是:在食物生成函数里加一个最大尝试次数(比如1000次),超过上限就遍历所有空地格随机选一个。我实测这个边界情况在正常游戏中几乎不会触发,但既然想到了,就一并处理好。3.5 状态机设计:开始、暂停、结束游戏的交互状态有四种:待开始、运行中、暂停、结束。我用一个状态字符串来管理,避免多个布尔标志相互纠缠。为什么要用状态机?新手容易用isRunning、isPaused、isOver三个布尔值来管理,但很快会发现组合状态极多,比如“暂停且结束”这种非法状态也会出现。用单一状态值之后,每个状态对应一组允许的Action,Action在非法状态下直接不响应。这比多个布尔值清爽得多。this.state = 'ready'; // ready | running | paused | gameover // 状态机迁移表 const transitions = { ready: { start: 'running' }, running: { pause: 'paused', gameover: 'gameover' }, paused: { resume: 'running', restart: 'ready' }, gameover: { restart: 'ready' } };这个设计的优点在UI按钮和键盘快捷键较多时特别明显——你可以集中管理什么状态下能做什么事,而不是在事件回调里到处写if判断。4. 实操过程与核心实现4.1 项目搭建:从零到可运行我用最简单的目录结构,不引入构建工具、不引入npm包,只要一个浏览器就能跑起来:snake-game/ ├── index.html ├── style.css └── js/ ├── main.js // 入口与游戏循环 ├── snake.js // 蛇的逻辑 ├── food.js // 食物逻辑 └── game.js // 游戏控制器index.html里只需要一个Canvas元素和几个按钮,骨架如下:<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>贪吃蛇</title> <style> /* 精简的样式 */ </style> </head> <body> <div class="game-panel"> <canvas id="gameCanvas" width="400" height="400"></canvas> <div class="status-bar"> <span id="score">0</span> <button id="startBtn">开始</button> <button id="pauseBtn">暂停</button> </div> </div> <script src="js/snake.js"></script> <script src="js/food.js"></script> <script src="js/game.js"></script> <script src="js/main.js"></script> </body> </html>为什么拆四个JS文件而不是一个?单文件也能写,但拆开的好处是文件职责清晰,尤其在我不使用打包工具时,浏览器的文件加载顺序也能暴露依赖关系——例如snake.js必须先于game.js加载,因为后者实例化前者。当你调整结构时这种顺序感会约束你写出依赖关系更明确的代码。4.2 Snake类:核心移动逻辑的完整代码我把Snake类设计成不依赖任何DOM和Canvas的纯逻辑类,这样便于单独测试。移动逻辑的核心代码如下:class Snake { constructor(startX, startY, initialLength = 3) { this.reset(startX, startY, initialLength); } reset(startX, startY, initialLength) { this.nodes = []; for (let i = 0; i < initialLength; i++) { this.nodes.push({ x: startX - i, y: startY }); } this.direction = { x: 1, y: 0 }; // 默认向右移动 this.dirQueue = []; this.grew = false; } // 关键:计算移动后的新蛇头位置 getNextHead() { const head = this.nodes[this.nodes.length - 1]; return { x: head.x + this.direction.x, y: head.y + this.direction.y }; } // 执行一次移动,返回是否吃到食物 move(eatFood) { const newHead = this.getNextHead(); this.nodes.push(newHead); if (!eatFood) { this.nodes.shift(); } } isCollidingSelf() { const head = this.nodes[this.nodes.length - 1]; // 遍历除蛇尾外的所有节点 for (let i = 0; i < this.nodes.length - 1; i++) { if (this.nodes[i].x === head.x && this.nodes[i].y === head.y) { return true; } } return false; } // 取出方向队列中的下一个方向并应用 updateDirection() { if (this.dirQueue.length > 0) { const nextDir = this.dirQueue.shift(); this.direction = nextDir; } } }这段代码里我特别想讲的是isCollidingSelf中的循环范围。很多教程会把撞自身写成遍历this.nodes的全部节点,这会导致一个边界问题:假设蛇头准备进入当前蛇尾所在格,而蛇尾在本轮预计会被移除,那么本次移动其实是安全的,但如果判断时包含了蛇尾节点,就会误判为撞上。这个问题我在实际测试中遇到过两次,起因分别是两个不同版本的源码,最终我选择了排除蛇尾的判断逻辑。注意,如果蛇刚好吃到了食物(不吃尾巴),而新蛇头又恰好移动到上一帧蛇尾的位置,这时蛇尾并不会消失,撞上应该判定为死亡。因此更精确的判断是:如果本轮吃到食物,蛇尾不移动,撞蛇尾算死;如果没吃到食物,蛇尾即将移动,撞蛇尾不算死。我最终的实现是先用eatFood参数预判是否移除蛇尾,再决定遍历节点范围。感兴趣的话可以自己深入验证一下。4.3 Game类:游戏循环与状态切换Game类负责总控。它的核心是一个setInterval驱动的游戏循环,以及一个requestAnimationFrame驱动的渲染循环。我把这两个循环分开,是为了让“逻辑更新”和“画面重绘”的节奏解耦。class Game { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.gridSize = 20; this.gridCols = 20; this.gridRows = 20; this.state = 'ready'; this.snake = new Snake(7, 7, 3); this.food = new Food(this.gridCols, this.gridRows); this.score = 0; this.speedInterval = 200; // 初始移动间隔250ms this.minInterval = 100; this.gameLoop = null; this.rafId = null; this.food.generate(this.snake.nodes); } start() { if (this.state === 'running') return; this.state = 'running'; // 清理旧定时器,防止重复启动 if (this.gameLoop) clearInterval(this.gameLoop); const tick = () => { this.update(); if (this.state === 'gameover') return; }; this.gameLoop = setInterval(tick, this.speedInterval); this.renderLoop(); } update() { if (this.state !== 'running') return; this.snake.updateDirection(); const head = this.snake.getNextHead(); if (this.isCollidingWall(head)) { this.gameOver(); return; } const eatFood = (head.x === this.food.x && head.y === this.food.y); this.snake.move(eatFood); if (this.snake.isCollidingSelf(this.snake.grew)) { this.gameOver(); return; } if (eatFood) { this.score++; // 每隔5分提升一次速度 if (this.score % 5 === 0 && this.speedInterval > this.minInterval) { this.speedInterval -= 20; this.restartTimer(); } this.food.generate(this.snake.nodes); this.updateScoreUI(); } this.render(); } isCollidingWall(head) { return head.x < 0 || head.x >= this.gridCols || head.y < 0 || head.y >= this.gridRows; } restartTimer() { clearInterval(this.gameLoop); this.gameLoop = setInterval(() => this.update(), this.speedInterval); } }这段代码里的一个关键细节是移动间隔和渲染间隔的分离。update()中修改的是网格逻辑坐标,修改频率是setInterval控制的逻辑帧率;而render()内部用requestAnimationFrame实现高频率重绘,这样在浏览器上画面会更平滑。如果你把渲染也放在setInterval里,尤其是当setInterval的间隔和显示器的刷新率不匹配时,会出现肉眼可见的闪烁或跳帧。4.4 渲染细节:如何画出干净的格子渲染部分我总结了三点经验:第一,绘制前先清空画布,不必担心性能浪费——区域不大,渲染20×20个格子在现代浏览器上开销低于0.1ms。第二,蛇身的颜色要有层次。蛇头用一个较深的颜色,蛇身用较浅的颜色,每次渲染时遍历蛇身节点依次绘制,这样玩家看方向更清楚。如果所有格子同色,视觉上很难快速判断蛇头方向。第三,食物要做成圆角或圆形效果,让它和方形的蛇身形成视觉对比。用arc画圆而不是fillRect画方,代码就多写一行,但视觉效果会好很多。render() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 绘制网格背景(淡灰色线) this.ctx.strokeStyle = '#ddd'; for (let i = 0; i <= this.gridCols; i++) { this.ctx.beginPath(); this.ctx.moveTo(i * this.gridSize, 0); this.ctx.lineTo(i * this.gridSize, this.canvas.height); this.ctx.stroke(); } for (let i = 0; i <= this.gridRows; i++) { this.ctx.beginPath(); this.ctx.moveTo(0, i * this.gridSize); this.ctx.lineTo(this.canvas.width, i * this.gridSize); this.ctx.stroke(); } // 绘制蛇身 const nodes = this.snake.nodes; for (let i = 0; i < nodes.length; i++) { const x = nodes[i].x * this.gridSize; const y = nodes[i].y * this.gridSize; this.ctx.fillStyle = (i === nodes.length - 1) ? '#4CAF50' : '#8BC34A'; this.ctx.fillRect(x + 1, y + 1, this.gridSize - 2, this.gridSize - 2); } // 绘制食物 this.ctx.fillStyle = '#FF5722'; this.ctx.beginPath(); this.ctx.arc(this.food.x * this.gridSize + this.gridSize / 2, this.food.y * this.gridSize + this.gridSize / 2, this.gridSize / 2 - 2, 0, Math.PI * 2); this.ctx.fill(); }给每个格子留出1像素的内边距是一个看起来很微小但实际效果很好的细节——蛇身格子之间会有清晰的黑缝分割,视觉上就是一个个独立的块,而不是一团糊在一起的色块。4.5 线程(定时器)管理与重启逻辑一个我调试了很久的Bug是“游戏结束后重启,居然会越走越快”。排查后发现原因很简单:start方法里每次点击“开始”按钮都会开启一个新的setInterval,如果没有在方法开头清理掉旧的定时器,旧的还在跑,新的又叠加进去,这导致“每次重启,定时器并行增加一个”,速度自然越来越快。解决方案就是在start()开头先clearInterval清一次,同时重置speedInterval到初始值。这个Bug非常典型,任何带定时器的游戏都可能遇到,写代码时务必把这层“收敛”逻辑前置。另外,移动间隔调整时也要注意:不能直接修改setInterval的时间参数,正确的做法是清除现有定时器,再以新间隔创建新的定时器。这个操作我用restartTimer()方法封装了,避免在update()里到处散落clearInterval逻辑。4.6 调优记录:初始速度与加速曲线我实测了几组不同参数的玩家体验,最终方案是:初始移动间隔:200ms最低移动间隔:100ms加速规则:每得5分,移动间隔减少20ms这样最开始的蛇移动速度相对缓慢,给新手充足的反应时间;到第25分之后,速度逐渐接近极限,操作难度显著上升。这个曲线设计可以参考,实际使用中可以根据目标玩家群体调整。如果做给小朋友玩,可以把初始间隔调到300ms,最低间隔提高到160ms;如果做极限挑战模式,可以把最低间隔压到80ms。5. 常见问题与排查技巧实录5.1 蛇身断裂问题这是新手最容易遇见的Bug,表现为蛇头已经转向,但蛇身还是沿着原来的方向走,看起来像是身体被撕成两半。根因通常是“移动逻辑写成了每个蛇身节点都各自向自己的前方移动一格”,而不是“增头减尾”。我用一个最笨但有效的排查方法:在代码里临时加一个日志,每次移动后打印所有节点的坐标,肉眼对比坐标差,很快就能发现节点之间出现断层。5.2 按键失灵或方向相反这个问题多发于未正确禁用页面默认行为。当你按方向键时,浏览器默认会滚动页面,尤其页面内容较长时,方向键一按就滚走了,游戏画布根本没收到正确的事件序列。解决方法是监听keydown时调用e.preventDefault()。还有一个方向相反的经典原因:你把keydown事件绑到了document上,但页面里某个按钮获得了焦点,此时方向键的焦点行为与预期不一致。最稳妥的做法是给window绑定事件,并在处理函数里主动防止默认行为。5.3 定时器叠加速率越来越快上文已说明,这是setInterval未清理导致的。排查方法很简单:在update()入口处打印每次执行时的时间戳,如果发现两次之间的间隔越来越短,基本就是定时器叠加了。5.4 食物生成在蛇身上一方面要在生成时检查与蛇身节点的重叠,另一方面要留意生成坐标使用随机数后的边界值。比如我一开始写随机数时没注意是左闭右开区间生成0到cols-1,结果某次测试食物生成在了地图外,导致永远吃不到,还一度以为是渲染问题。仔细排查后发现是随机数范围没有对齐网格索引。5.5 高分之后游戏卡顿通常不是逻辑问题,而是setInterval的间隔过小后,浏览器事件循环无法保证毫秒级的精确触发,出现了“看起来卡顿但实际是逻辑帧率已经接近浏览器可调度极限”。我不建议把间隔压到80ms以下,如果真的想要更快的极限速度,可以考虑用requestAnimationFrame实现基于时间的移动逻辑,而不是继续压榨定时器。6. 扩展与后续优化方向项目跑通之后,我强烈建议你别急着收手。贪吃蛇的可扩展方向非常多,我列几个已经验证过有实际价值的:穿墙模式:改变撞墙判定逻辑,蛇头出界后从另一侧进入。这个改动只需要在isCollidingWall中做坐标取模运算:// 穿墙模式:坐标超出边界后绕回 getNextHead() { const head = this.snake.nodes[this.snake.nodes.length - 1]; let x = head.x + this.direction.x; let y = head.y + this.direction.y; if (x < 0) x = this.gridCols - 1; if (x >= this.gridCols) x = 0; if (y < 0) y = this.gridRows - 1; if (y >= this.gridRows) y = 0; return { x, y }; }穿墙模式会让游戏节奏完全不同,适合做成另一种玩法选项。障碍物模式:在地图内随机放置若干固定障碍格,蛇撞上即死。实现时新增一个障碍物数组,移动逻辑里加一个障碍物碰撞判断即可,切记食物生成时也要排除障碍物所在格。AI自动寻路:写一个简易AI,让蛇自动朝食物方向移动,同时避开自身。这个方向比较有挑战性,但做完之后你对路径规划和状态搜索的理解会上升一个台阶。双人模式:两张地图或一张地图上两条蛇,彼此碰撞都会死亡。这个扩展会涉及更多输入处理和多游戏实例管理,适合想进一步挑战的朋友。我个人觉得贪吃蛇这个项目的价值不取决于它有多难、有多炫,而在于它把游戏开发中最基础也最核心的几件事——状态管理、碰撞检测、渲染循环、事件处理——全部用最小篇幅暴露了出来。你在别的项目中遇到的许多问题,都能在这个小项目里找到同构的影子。最后再分享一个小技巧:如果你在这类小游戏里遇到难以定位的Bug,试着把每一步的“逻辑数据”打印出来,而不是盯着画面猜。画面是表象,数据才是真相。我在调试蛇身断裂、方向异常这类问题时,这种方法基本是秒杀级别的效率提升。如果你也在改自己的贪吃蛇,希望你也能体会到这种把问题从“灵异现象”变成“明显错误”的快乐。
1. 项目概述:为什么经典游戏依然值得动手做一遍贪吃蛇游戏,几乎每个开发者都写过一版,或者至少在心里盘算过。它看似简单,但背后牵扯到的数据结构、状态管理、碰撞检测、渲染循环、事件处理,几乎是所有游戏乃至前端应用开发的缩影。正因为如此,它才常被用来练手、教学、甚至面试考察。我这次的模拟项目X,目标是实现一个可直接运行的贪吃蛇Demo,要求包含完整玩法——方向控制、食物生成、死亡判定、计分与速度提升——同时代码结构清晰、可扩展。这个项目适合三类人:刚学完基础语法、想找一个小项目串联知识点的学习者;准备面试、需要快速构建一个能讲清楚的实战项目的人;以及想研究游戏循环和状态管理细节的开发者。实际上,贪吃蛇这类单机小游戏的复杂度刚好踩在“能驾驭”和“有挑战”之间。我第一次写的时候自以为半小时搞定,结果在“蛇是否允许掉头”这个细节上栽了跟头,之后又连着踩了定时器重入、方向键缓冲、渲染闪烁几个坑。这篇文章就是把这些坑和最终方案完整记录下来,希望对你有参考价值。2. 整体设计与思路拆解2.1 技术选型:就地取材,不引复杂框架我选用了纯HTML + CSS + JavaScript的实现方案,渲染层用Canvas画布,逻辑层用面向对象的模块化写法。没有引入任何框架或UI库,原因很简单:这个项目的重点在于游戏逻辑本身,引入框架反而会干扰核心逻辑的清晰度。Canvas渲染在这个场景下是天然合适的选择。贪吃蛇的地图本质是一个网格,每个格子要么是蛇身、要么是食物、要么是空地。用Canvas的fillRect绘制格子即可,性能和代码量都优于DOM操作方案——如果在浏览器里用DOM方式渲染蛇身,每移动一步就要改一次所有蛇身节点的位置样式,代码繁琐且费性能。逻辑层我拆出了三个功能模块,结构化的好处到后期调试和加功能时会非常明显:Game类:负责游戏循环、状态机切换(待开始、运行中、暂停、结束)Snake类:负责蛇的身体数据与移动规则Food类:负责食物的生成与渲染这里我想特别强调一点,游戏循环不要用递归的setTimeout链,也不要在一开始就用什么游戏引擎提供的requestAnimationFrame封装。最朴素的做法是用setInterval驱动定时器,然后通过requestAnimationFrame做渲染。原因后面细说——这涉及掉帧和定时重置两个常见故障的根因。2.2 开发节奏:先跑起来,再谈完善我在动手前定的节奏是四个阶段:五分钟跑通“蛇能动”:不做界面,不做计分,不做碰撞,先把蛇的移动做出来,用键盘方向键控制,让它能在画布上走补齐核心玩法:食物随机生成、吃到食物蛇身变长、撞墙和撞自身判定完善交互体验:计分、暂停/继续、重新开始、游戏速度随分数提升打磨代码细节:把面向过程的写法改成清晰的类封装,补充注释,同时处理方向键快速切换、移动端触控兼容等细节很多初学者容易一上来就追求完美,把UI画得很漂亮,结果核心逻辑还没通。我的经验恰恰相反,先让核心逻辑以最粗糙的形式跑起来——哪怕丑得像命令行一样——然后再层层补充。这背后是一个很实际的道理:游戏逻辑的调试成本是最高的,UI可以后补,但逻辑问题越早暴露越好修。2.3 值得注意的设计决策有几个设计决策在实际编码中特别关键,我列一下当时的思考过程:地图坐标系。我使用的是网格坐标系而非像素坐标系,每个单元格大小为20×20像素。蛇的位置用数组存储,每个节点存{x, y}坐标值,x和y是网格坐标而非像素坐标。渲染时才乘以格子尺寸换算为像素坐标。这样做的最大好处是:碰撞检测、食物生成都基于整数坐标比较,完全避开浮点数误差问题。蛇身的数据结构。经典做法是用一个数组,头在数组末尾或者头部。我采用的是“蛇头固定为数组尾部”的方案:// 蛇身节点:nodes[0]是蛇尾,nodes[nodes.length-1]是蛇头 this.nodes = [{x: 7, y: 7}];每次移动,先把新蛇头坐标用push追加到数组末端,如果本步没吃到食物则shift移除蛇尾节点。这种“先插头、判断是否吃食物、再决定要不要砍尾巴”的顺序,是整个游戏逻辑最核心的地方,后面会详细讲。速度提升机制。我没有无上限地加快速度,而是设置了最小移动间隔上限(100ms),并把速度提升设计为阶梯式。这样可以防止速度过快之后人类根本无法操作,也防止setInterval的时间间隔过小导致定时器异常。2.4 为什么不用现成的游戏框架这个项目完全可以用框架来做,网络上也有不少现成的贪吃蛇游戏源码可以直接抄。但我的建议是,第一遍务必手写核心逻辑。理由很实际:如果你是个新手,抄代码的时候你根本不知道哪句话是关键;如果你是个老手,用框架做项目其实更像在验证框架API,而不是在体会游戏逻辑本身的乐趣。另外一点很反直觉的体会是:贪吃蛇项目的经典实现里,最难的部分不是“蛇怎么动”,而是“蛇怎么不动”。这里面的弯弯绕绕下面专门展开说。3. 核心逻辑拆解与实现要点3.1 蛇移动的真相:你不是在“移动”,而是在“重建”很多人第一次写贪吃蛇时容易把蛇的移动理解为“整条蛇都向前挪一格”,于是写出这样的代码:遍历所有蛇身节点,每个节点往自己的前方移动一格。这样写调试时会出现可怕的蛇身折断现象——因为当你更新第二个节点时,它前方节点已经移动了,拿到的前方节点位置总是不稳定的。正确思路是:蛇的移动,不是每条蛇身都在动,而是蛇头新增一格,蛇尾砍掉一格,中间的部分全都是“保留原样”。只有在吃到食物时,砍蛇尾这步才被跳过——因为要变长。这个思路用生活类比解释就是:一条火车在轨道上行驶,我们不把每节车厢挨个往前拉,而是把最后一节车厢摘下,接到最前面一节车厢的前方。火车就“看起来”前进了一格。这就是为什么用数组结构天然适配这个逻辑——数组末尾push一个新蛇头,数组开头shift掉旧蛇尾,整个蛇身就整体前进了一格。这是我第一次重构时得到的最大启发。把移动理解为“增头减尾”,所有后续的问题——是否吃到食物、是否撞墙、是否撞自己——都变成了对这个新蛇头的坐标判断。3.2 方向控制与“禁止掉头”的细节方向控制的实现很简单,监听键盘事件,根据按键更新方向值。难点在于两个细节:细节一:禁止掉头。假设当前蛇头向右移动,用户在极短时间内按下左箭头,如果程序直接响应,蛇头会直接掉头撞向自己脖子——这在游戏里通常判定为死亡,体验非常差。正确做法是:只有当新方向与当前移动方向的x或y坐标不互为相反数时,才允许更新方向。// 判断新方向是否和当前方向相反 // 如果当前方向是右(1, 0),那么新方向不能是左(-1, 0) const isOpposite = (currentDir, newDir) => { return currentDir.x + newDir.x === 0 && currentDir.y + newDir.y === 0; };细节二:按键缓冲。这是另一个容易踩的坑。如果直接在keydown事件里立即修改移动方向,会出现一套“幽灵操作”效应。举例说明:蛇正在向右移动,玩家先快速按下“上”再快速按下“左”,在下一帧移动来临时,如果程序只是读取“最后一次按下的方向键”,蛇将会向左走——但此时蛇头实际上还在向右移动,向左走意味着蛇头瞬间横穿到左侧位置,这在游戏中看起来像是蛇身体卡了一下,甚至直接判定撞墙。数据层面的根因在于:移动只发生在定时器触发的下一帧,但按键事件随时都在发生;如果每次按键都立即改变方向,帧与帧之间的方向变化就可能被高频按键“跳跃式”覆盖。解决方案是引入方向队列,把每次合法按键的方向推入队列尾部,每帧移动时只从队列头部取一个方向作为本次移动的位移方向。这相当于给方向输入加了个缓冲池,让操作按顺序逐帧生效。// 方向队列:keydown时push,每帧移动时shift取出一个 this.dirQueue = []; window.addEventListener('keydown', (e) => { const newDir = getDirFromKey(e.key); if (newDir && !isOpposite(this.currentDir, newDir)) { this.dirQueue.push(newDir); } });3.3 碰撞检测的三个边界碰撞检测是贪吃蛇项目的重头戏。我归纳为三种典型碰撞,每种的处理方式略有不同:撞墙:判断新蛇头的网格坐标是否超出地图边界。地图大小为20×20网格,所以x和y的有效范围是0到19。超出即死亡。撞自己:判断新蛇头的坐标是否与现有蛇身节点(不包括蛇尾)重合。这里有个精妙的细节:如果蛇尾节点本轮即将移除,新蛇头就算踩到蛇尾的位置也是不会真正撞上的——因为蛇尾马上就消失了。所以遍历时应该排除蛇尾。吃食物:判断新蛇头坐标是否等于食物坐标。相等则本步不移除蛇尾节点,同时重新生成食物、增加分数。这层的实现过程中建议不要用“潜在”的坐标重叠判断(比如预测下下步会不会撞),因为游戏规则本身具有时效性,玩家就是要到最后一刻才能看到结果。判断只基于当前帧数据,够纯粹。3.4 食物生成的避坑原则食物生成看似随机一放就行,但有个隐藏条件:不能生成在蛇身上。否则食物会直接出现在蛇身内部,玩家很难吃到,甚至看起来像渲染Bug。我的实现是:随机生成坐标后,遍历蛇身节点集合判断是否有重叠,如果重叠则重新生成。但这里要提一个可能会被忽略的场景:当蛇身几乎占满整个地图时,随机生成再判断是否重叠的效率会非常低,甚至陷入死循环。虽然对贪吃蛇这个场景来说地图不大、蛇身的极限也有限,但严谨的做法是:在食物生成函数里加一个最大尝试次数(比如1000次),超过上限就遍历所有空地格随机选一个。我实测这个边界情况在正常游戏中几乎不会触发,但既然想到了,就一并处理好。3.5 状态机设计:开始、暂停、结束游戏的交互状态有四种:待开始、运行中、暂停、结束。我用一个状态字符串来管理,避免多个布尔标志相互纠缠。为什么要用状态机?新手容易用isRunning、isPaused、isOver三个布尔值来管理,但很快会发现组合状态极多,比如“暂停且结束”这种非法状态也会出现。用单一状态值之后,每个状态对应一组允许的Action,Action在非法状态下直接不响应。这比多个布尔值清爽得多。this.state = 'ready'; // ready | running | paused | gameover // 状态机迁移表 const transitions = { ready: { start: 'running' }, running: { pause: 'paused', gameover: 'gameover' }, paused: { resume: 'running', restart: 'ready' }, gameover: { restart: 'ready' } };这个设计的优点在UI按钮和键盘快捷键较多时特别明显——你可以集中管理什么状态下能做什么事,而不是在事件回调里到处写if判断。4. 实操过程与核心实现4.1 项目搭建:从零到可运行我用最简单的目录结构,不引入构建工具、不引入npm包,只要一个浏览器就能跑起来:snake-game/ ├── index.html ├── style.css └── js/ ├── main.js // 入口与游戏循环 ├── snake.js // 蛇的逻辑 ├── food.js // 食物逻辑 └── game.js // 游戏控制器index.html里只需要一个Canvas元素和几个按钮,骨架如下:<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>贪吃蛇</title> <style> /* 精简的样式 */ </style> </head> <body> <div class="game-panel"> <canvas id="gameCanvas" width="400" height="400"></canvas> <div class="status-bar"> <span id="score">0</span> <button id="startBtn">开始</button> <button id="pauseBtn">暂停</button> </div> </div> <script src="js/snake.js"></script> <script src="js/food.js"></script> <script src="js/game.js"></script> <script src="js/main.js"></script> </body> </html>为什么拆四个JS文件而不是一个?单文件也能写,但拆开的好处是文件职责清晰,尤其在我不使用打包工具时,浏览器的文件加载顺序也能暴露依赖关系——例如snake.js必须先于game.js加载,因为后者实例化前者。当你调整结构时这种顺序感会约束你写出依赖关系更明确的代码。4.2 Snake类:核心移动逻辑的完整代码我把Snake类设计成不依赖任何DOM和Canvas的纯逻辑类,这样便于单独测试。移动逻辑的核心代码如下:class Snake { constructor(startX, startY, initialLength = 3) { this.reset(startX, startY, initialLength); } reset(startX, startY, initialLength) { this.nodes = []; for (let i = 0; i < initialLength; i++) { this.nodes.push({ x: startX - i, y: startY }); } this.direction = { x: 1, y: 0 }; // 默认向右移动 this.dirQueue = []; this.grew = false; } // 关键:计算移动后的新蛇头位置 getNextHead() { const head = this.nodes[this.nodes.length - 1]; return { x: head.x + this.direction.x, y: head.y + this.direction.y }; } // 执行一次移动,返回是否吃到食物 move(eatFood) { const newHead = this.getNextHead(); this.nodes.push(newHead); if (!eatFood) { this.nodes.shift(); } } isCollidingSelf() { const head = this.nodes[this.nodes.length - 1]; // 遍历除蛇尾外的所有节点 for (let i = 0; i < this.nodes.length - 1; i++) { if (this.nodes[i].x === head.x && this.nodes[i].y === head.y) { return true; } } return false; } // 取出方向队列中的下一个方向并应用 updateDirection() { if (this.dirQueue.length > 0) { const nextDir = this.dirQueue.shift(); this.direction = nextDir; } } }这段代码里我特别想讲的是isCollidingSelf中的循环范围。很多教程会把撞自身写成遍历this.nodes的全部节点,这会导致一个边界问题:假设蛇头准备进入当前蛇尾所在格,而蛇尾在本轮预计会被移除,那么本次移动其实是安全的,但如果判断时包含了蛇尾节点,就会误判为撞上。这个问题我在实际测试中遇到过两次,起因分别是两个不同版本的源码,最终我选择了排除蛇尾的判断逻辑。注意,如果蛇刚好吃到了食物(不吃尾巴),而新蛇头又恰好移动到上一帧蛇尾的位置,这时蛇尾并不会消失,撞上应该判定为死亡。因此更精确的判断是:如果本轮吃到食物,蛇尾不移动,撞蛇尾算死;如果没吃到食物,蛇尾即将移动,撞蛇尾不算死。我最终的实现是先用eatFood参数预判是否移除蛇尾,再决定遍历节点范围。感兴趣的话可以自己深入验证一下。4.3 Game类:游戏循环与状态切换Game类负责总控。它的核心是一个setInterval驱动的游戏循环,以及一个requestAnimationFrame驱动的渲染循环。我把这两个循环分开,是为了让“逻辑更新”和“画面重绘”的节奏解耦。class Game { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.gridSize = 20; this.gridCols = 20; this.gridRows = 20; this.state = 'ready'; this.snake = new Snake(7, 7, 3); this.food = new Food(this.gridCols, this.gridRows); this.score = 0; this.speedInterval = 200; // 初始移动间隔250ms this.minInterval = 100; this.gameLoop = null; this.rafId = null; this.food.generate(this.snake.nodes); } start() { if (this.state === 'running') return; this.state = 'running'; // 清理旧定时器,防止重复启动 if (this.gameLoop) clearInterval(this.gameLoop); const tick = () => { this.update(); if (this.state === 'gameover') return; }; this.gameLoop = setInterval(tick, this.speedInterval); this.renderLoop(); } update() { if (this.state !== 'running') return; this.snake.updateDirection(); const head = this.snake.getNextHead(); if (this.isCollidingWall(head)) { this.gameOver(); return; } const eatFood = (head.x === this.food.x && head.y === this.food.y); this.snake.move(eatFood); if (this.snake.isCollidingSelf(this.snake.grew)) { this.gameOver(); return; } if (eatFood) { this.score++; // 每隔5分提升一次速度 if (this.score % 5 === 0 && this.speedInterval > this.minInterval) { this.speedInterval -= 20; this.restartTimer(); } this.food.generate(this.snake.nodes); this.updateScoreUI(); } this.render(); } isCollidingWall(head) { return head.x < 0 || head.x >= this.gridCols || head.y < 0 || head.y >= this.gridRows; } restartTimer() { clearInterval(this.gameLoop); this.gameLoop = setInterval(() => this.update(), this.speedInterval); } }这段代码里的一个关键细节是移动间隔和渲染间隔的分离。update()中修改的是网格逻辑坐标,修改频率是setInterval控制的逻辑帧率;而render()内部用requestAnimationFrame实现高频率重绘,这样在浏览器上画面会更平滑。如果你把渲染也放在setInterval里,尤其是当setInterval的间隔和显示器的刷新率不匹配时,会出现肉眼可见的闪烁或跳帧。4.4 渲染细节:如何画出干净的格子渲染部分我总结了三点经验:第一,绘制前先清空画布,不必担心性能浪费——区域不大,渲染20×20个格子在现代浏览器上开销低于0.1ms。第二,蛇身的颜色要有层次。蛇头用一个较深的颜色,蛇身用较浅的颜色,每次渲染时遍历蛇身节点依次绘制,这样玩家看方向更清楚。如果所有格子同色,视觉上很难快速判断蛇头方向。第三,食物要做成圆角或圆形效果,让它和方形的蛇身形成视觉对比。用arc画圆而不是fillRect画方,代码就多写一行,但视觉效果会好很多。render() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 绘制网格背景(淡灰色线) this.ctx.strokeStyle = '#ddd'; for (let i = 0; i <= this.gridCols; i++) { this.ctx.beginPath(); this.ctx.moveTo(i * this.gridSize, 0); this.ctx.lineTo(i * this.gridSize, this.canvas.height); this.ctx.stroke(); } for (let i = 0; i <= this.gridRows; i++) { this.ctx.beginPath(); this.ctx.moveTo(0, i * this.gridSize); this.ctx.lineTo(this.canvas.width, i * this.gridSize); this.ctx.stroke(); } // 绘制蛇身 const nodes = this.snake.nodes; for (let i = 0; i < nodes.length; i++) { const x = nodes[i].x * this.gridSize; const y = nodes[i].y * this.gridSize; this.ctx.fillStyle = (i === nodes.length - 1) ? '#4CAF50' : '#8BC34A'; this.ctx.fillRect(x + 1, y + 1, this.gridSize - 2, this.gridSize - 2); } // 绘制食物 this.ctx.fillStyle = '#FF5722'; this.ctx.beginPath(); this.ctx.arc(this.food.x * this.gridSize + this.gridSize / 2, this.food.y * this.gridSize + this.gridSize / 2, this.gridSize / 2 - 2, 0, Math.PI * 2); this.ctx.fill(); }给每个格子留出1像素的内边距是一个看起来很微小但实际效果很好的细节——蛇身格子之间会有清晰的黑缝分割,视觉上就是一个个独立的块,而不是一团糊在一起的色块。4.5 线程(定时器)管理与重启逻辑一个我调试了很久的Bug是“游戏结束后重启,居然会越走越快”。排查后发现原因很简单:start方法里每次点击“开始”按钮都会开启一个新的setInterval,如果没有在方法开头清理掉旧的定时器,旧的还在跑,新的又叠加进去,这导致“每次重启,定时器并行增加一个”,速度自然越来越快。解决方案就是在start()开头先clearInterval清一次,同时重置speedInterval到初始值。这个Bug非常典型,任何带定时器的游戏都可能遇到,写代码时务必把这层“收敛”逻辑前置。另外,移动间隔调整时也要注意:不能直接修改setInterval的时间参数,正确的做法是清除现有定时器,再以新间隔创建新的定时器。这个操作我用restartTimer()方法封装了,避免在update()里到处散落clearInterval逻辑。4.6 调优记录:初始速度与加速曲线我实测了几组不同参数的玩家体验,最终方案是:初始移动间隔:200ms最低移动间隔:100ms加速规则:每得5分,移动间隔减少20ms这样最开始的蛇移动速度相对缓慢,给新手充足的反应时间;到第25分之后,速度逐渐接近极限,操作难度显著上升。这个曲线设计可以参考,实际使用中可以根据目标玩家群体调整。如果做给小朋友玩,可以把初始间隔调到300ms,最低间隔提高到160ms;如果做极限挑战模式,可以把最低间隔压到80ms。5. 常见问题与排查技巧实录5.1 蛇身断裂问题这是新手最容易遇见的Bug,表现为蛇头已经转向,但蛇身还是沿着原来的方向走,看起来像是身体被撕成两半。根因通常是“移动逻辑写成了每个蛇身节点都各自向自己的前方移动一格”,而不是“增头减尾”。我用一个最笨但有效的排查方法:在代码里临时加一个日志,每次移动后打印所有节点的坐标,肉眼对比坐标差,很快就能发现节点之间出现断层。5.2 按键失灵或方向相反这个问题多发于未正确禁用页面默认行为。当你按方向键时,浏览器默认会滚动页面,尤其页面内容较长时,方向键一按就滚走了,游戏画布根本没收到正确的事件序列。解决方法是监听keydown时调用e.preventDefault()。还有一个方向相反的经典原因:你把keydown事件绑到了document上,但页面里某个按钮获得了焦点,此时方向键的焦点行为与预期不一致。最稳妥的做法是给window绑定事件,并在处理函数里主动防止默认行为。5.3 定时器叠加速率越来越快上文已说明,这是setInterval未清理导致的。排查方法很简单:在update()入口处打印每次执行时的时间戳,如果发现两次之间的间隔越来越短,基本就是定时器叠加了。5.4 食物生成在蛇身上一方面要在生成时检查与蛇身节点的重叠,另一方面要留意生成坐标使用随机数后的边界值。比如我一开始写随机数时没注意是左闭右开区间生成0到cols-1,结果某次测试食物生成在了地图外,导致永远吃不到,还一度以为是渲染问题。仔细排查后发现是随机数范围没有对齐网格索引。5.5 高分之后游戏卡顿通常不是逻辑问题,而是setInterval的间隔过小后,浏览器事件循环无法保证毫秒级的精确触发,出现了“看起来卡顿但实际是逻辑帧率已经接近浏览器可调度极限”。我不建议把间隔压到80ms以下,如果真的想要更快的极限速度,可以考虑用requestAnimationFrame实现基于时间的移动逻辑,而不是继续压榨定时器。6. 扩展与后续优化方向项目跑通之后,我强烈建议你别急着收手。贪吃蛇的可扩展方向非常多,我列几个已经验证过有实际价值的:穿墙模式:改变撞墙判定逻辑,蛇头出界后从另一侧进入。这个改动只需要在isCollidingWall中做坐标取模运算:// 穿墙模式:坐标超出边界后绕回 getNextHead() { const head = this.snake.nodes[this.snake.nodes.length - 1]; let x = head.x + this.direction.x; let y = head.y + this.direction.y; if (x < 0) x = this.gridCols - 1; if (x >= this.gridCols) x = 0; if (y < 0) y = this.gridRows - 1; if (y >= this.gridRows) y = 0; return { x, y }; }穿墙模式会让游戏节奏完全不同,适合做成另一种玩法选项。障碍物模式:在地图内随机放置若干固定障碍格,蛇撞上即死。实现时新增一个障碍物数组,移动逻辑里加一个障碍物碰撞判断即可,切记食物生成时也要排除障碍物所在格。AI自动寻路:写一个简易AI,让蛇自动朝食物方向移动,同时避开自身。这个方向比较有挑战性,但做完之后你对路径规划和状态搜索的理解会上升一个台阶。双人模式:两张地图或一张地图上两条蛇,彼此碰撞都会死亡。这个扩展会涉及更多输入处理和多游戏实例管理,适合想进一步挑战的朋友。我个人觉得贪吃蛇这个项目的价值不取决于它有多难、有多炫,而在于它把游戏开发中最基础也最核心的几件事——状态管理、碰撞检测、渲染循环、事件处理——全部用最小篇幅暴露了出来。你在别的项目中遇到的许多问题,都能在这个小项目里找到同构的影子。最后再分享一个小技巧:如果你在这类小游戏里遇到难以定位的Bug,试着把每一步的“逻辑数据”打印出来,而不是盯着画面猜。画面是表象,数据才是真相。我在调试蛇身断裂、方向异常这类问题时,这种方法基本是秒杀级别的效率提升。如果你也在改自己的贪吃蛇,希望你也能体会到这种把问题从“灵异现象”变成“明显错误”的快乐。