写游戏是检验一个人对前端基础掌握程度的最直接方式,而贪吃蛇大概是所有小游戏里投入产出比最高的那一个——它够小,小到你用一份HTML + Canvas + 原生 JavaScript文件就能跑完整个循环;它又够完整,完整到方向输入、状态机、碰撞判定、帧率控制、数据持久化这些常见套路一个不落。这篇东西不打算把贪吃蛇讲成什么架构范本,就是把我自己从零写这个 HTML 贪吃蛇游戏时踩过的顺序、量过的参数、改过的写法完整摊开,做一个真正意义上的手把手简易版。适合两类人看:刚学完 JS 语法但还没写过完整项目的新手,以及写过 Demo 但做出来手感很怪、蛇会瞬间掉头自杀的老哥。下面的所有代码都是可以整份复制进一个.html文件直接双击打开运行的,没有构建工具,没有依赖,没有框架。
1. 先把设计想清楚:为什么简易版反而更难写
动手前必须先明白一件事:简易不等于随便。把贪吃蛇砍到只剩"会动、能吃、会死"这三件事之后,剩下的每一个决定都会直接体现在手感上。我在这一步花的时间往往比写代码还多,因为方向定错了,后面所有的调参都是在给错误的架构打补丁。
1.1 需求边界:明确写下"这次不做什么"
我习惯在动手前拿张纸把功能列成两栏,一栏是"必须有",一栏是"这次不做"。"必须有"只有六条:蛇按固定节奏前进、方向键能转向、吃到食物变长加分、撞墙或撞自己结束、能一键重开、分数可见。就这六条,多一条都是负担。
"这次不做"那一栏写得更长:不做排行榜、不做关卡难度曲线、不做音效、不做皮肤切换、不做暂停续玩的存档。原因很实在——单人练手项目最大的杀手从来不是技术难度,而是范围蔓延。你今天想加音效,明天想加穿墙模式,后天觉得皮肤不够炫,最后就是一个半成品躺在文件夹里再也没打开过。
把边界写死的另一个好处是,你会被迫思考每条需求的实现成本。比如"分数可见"这条,用 DOM 显示和用 Canvas 绘制文字是两种完全不同的工作量,前者三行代码,后者要算居中、要管字体、还要处理高分屏模糊。明确边界之后,你就能心安理得地选便宜的那条路。
1.2 Canvas 还是 DOM 格子:一个 400 个 div 的教训
我最早的版本是用 DOM 做的:20×20 的网格,铺 400 个<div>,蛇身靠切class来变色。跑起来确实能看到画面,但一旦速度提到 100ms 一格,动画就开始出现明显的拖影和卡顿。原因不复杂——每次切class都会触发浏览器重新计算样式和重排,一秒十次、每次动两个元素,累积起来就是肉眼可见的抖动。
后来换成 Canvas,整个世界清净了。这个对比值得放张表,因为它决定了你后面所有的代码组织方式:
| 维度 | DOM 格子方案 | Canvas 方案 |
|---|---|---|
| 元素数量 | 400 个节点常驻 | 1 个 canvas 节点 |
| 更新方式 | 改 class,触发重排重绘 | 整帧重绘,不碰 DOM |
| 坐标模型 | CSS 像素 + 定位 | 自己维护逻辑坐标,更可控 |
| 圆角/渐变 | CSS 直接写,很爽 | 要手写路径,稍麻烦 |
| 高分屏 | 浏览器自动处理 | 需要自己乘 dpr |
| 适合规模 | 小格子、低频更新 | 任意规模、高频更新 |
结论很直接:只要你的游戏需要"每秒刷新多次",就别跟 DOM 较劲。Canvas 的代价是你得自己管理坐标系和缩放,但这点复杂度换来的是完全可控的渲染节奏,值。
1.3 游戏循环的三种写法,以及为什么选 requestAnimationFrame
游戏循环是这个项目里唯一真正有技术含量的部分。新手最常掉进去的坑是setInterval(tick, 100),看起来简洁,实际有三个隐患:一是时间漂移,setInterval不保证准点,累积几十秒后节奏会明显偏;二是它在前一个回调没执行完时仍会排队,标签页被切到后台之后回来会一次性补跑一堆;三是它和屏幕刷新率完全脱钩,60Hz 屏和 120Hz 屏上体验不一致。
setTimeout递归调用能缓解漂移问题,但同样解决不了后台标签页的问题。真正靠谱的是requestAnimationFrame,它跟着浏览器的渲染节奏走,标签页不可见时会自动降频甚至暂停,切回来也不会爆发式补帧。问题是它每秒跑 60 帧,而你只需要每秒动 6 到 10 次,所以中间必须做节流。
我用的方案是"时间累加器":每帧把实际流逝的毫秒数累加起来,累加值超过当前步长就执行一次逻辑更新,然后减掉一个步长。这样逻辑刷新频率和渲染频率彻底解耦,改速度只改一个常量,渲染依然丝滑。这个模式是所有定时驱动游戏的地基,理解它之后,俄罗斯方块、飞机大战都是一个套路。
2. 核心机制拆解:四个必须想明白的零件
骨架搭完之后,剩下的都是细节。但贪吃蛇有四个地方,细节一旦想歪,代码能跑但玩法是坏的,而且很难通过调试发现——因为它不会报错,只会让玩家觉得"这游戏有点怪"。
2.1 网格与坐标系:别让碰撞判定跑在像素上
新手很自然的写法是:蛇头位置存成{x: 120, y: 240}这样的像素值,每帧加上24(格子大小)。这个方案在第一次写的时候感觉没问题,但它埋了一个隐蔽的雷——当你想加个"半格移动"的过渡动画,或者想做速度平滑插值,坐标就变成浮点数了,而浮点数的相等比较是灾难。你会遇到"明明看着撞上了却没死"或者"离着还有半个格子就判死了"这种玄学 bug。
正确做法是逻辑坐标与渲染坐标分离。蛇的每一节只用格子索引{x: 10, y: 7}表示,范围是0 ≤ x < 20、0 ≤ y < 20,全是整数,比较用===绝对安全。渲染的时候再统一乘格子边长转成像素。这个分离还有个额外好处:撞墙判定变成一句nx < 0 || nx >= COLS,清爽得不行。
格子大小和棋盘尺寸的关系也要算一下。我选的是 20×20 的网格、每格 24 逻辑像素,那么画布逻辑尺寸就是 480×480。为什么是这个数?因为 24 能被 2、3、4、6、8 整除,做圆角和内缩时不会出现半像素导致的边缘发虚;而 20×20 共 400 格,对新手来说棋盘不至于大到让人迷失,也不至于小到两下就撞墙。
2.2 蛇的数据结构:数组就够,但方向要对
蛇用什么存?数组。链表理论上插入更快,但蛇的长度最多也就百来节,数组的unshift和pop在这个量级完全无感,为了一个不存在的性能问题去写链表指针,属于典型的过度设计。
关键在于顺序。我把蛇头放在数组索引 0,蛇尾放在末尾。这个约定不是随便定的,因为游戏里所有高频查询都指向蛇头——碰撞检测查snake[0]、渲染时给蛇头单独上色、方向判断基于蛇头位置。放在 0 位意味着这些操作全是 O(1) 的直接索引。
移动逻辑因此变得极简:每次前进时unshift一个新头,没吃到食物就pop掉尾巴,长度不变;吃到食物就跳过pop,长度加一。整段逻辑就三行,没有任何分支嵌套。我第一次写的时候是反着来的(头在数组末尾),结果每个操作都要snake[snake.length - 1],写到最后自己都看晕了。
还有个小细节值得单独拎出来说:判断蛇头会不会撞到自己时,尾巴那一格其实不算。因为这一步移动之后,如果没吃到食物,尾巴会同时移走,蛇头进入的是即将空出来的格子。我的处理是先算出willGrow(这一步是否吃到食物),然后取willGrow ? snake : snake.slice(0, -1)作为碰撞检测的身体范围。这一点在很多教程里被忽略了,导致贴着尾巴走会莫名其妙死亡,玩家会觉得游戏"不讲理"。
2.3 方向输入:为什么必须用队列而不是直接赋值
这是整个项目里最容易写错的地方,没有之一。初学者通常这么写:按下上箭头就dir = {x:0, y:-1}。单次操作是对的,但在快速连按的场景下会出人命。
想象蛇正在向右走。玩家在同一个渲染帧内(大约 16 毫秒)快速按了"上"和"左"。第一次赋值把方向改成上,蛇还没动;第二次赋值把方向改成左。等到下一次逻辑更新时,蛇相对它实际的身体朝向(右)直接转成了左——掉头,撞自己,死。而玩家主观感受是"我明明按的是上再按左,转个弯而已,怎么死了"。
根因是:判断"能不能转"必须相对于上一个已经被执行的方向,而不是相对于"当前刚被写进去但还没生效的方向"。解决方案是把方向做成队列,按键只是往队列里塞意图,真正的方向切换发生在每次逻辑更新之前,从队列头部取一个,并且校验它跟当前执行方向是否相反。同时入队时也要做两件事:跟队尾比较去重(按住一个键不放会疯狂入队),以及限制队列长度最多两个(缓冲太多会让操作感觉延迟)。
这套机制写完,你会明显感觉到转弯更跟手了——因为你按下的每一个意图都会被记住并依次执行,而不是被后来者覆盖。
2.4 食物生成与碰撞顺序:先算后判,别边动边查
食物生成有个经典错误写法:随机一个坐标,然后判断是不是在蛇身上,是的话就重来,用while循环包住。绝大多数时候它能正常工作,但蛇很长的时候,随机落在蛇身上的概率大幅上升,循环次数暴涨,极端情况下会有那么一瞬间卡顿。而且如果蛇占满了整个棋盘,这就成了死循环——虽然正常玩法几乎不可能,但代码里留一个理论死循环,是给自己埋雷。
我的写法是反过来:把所有空格子收集成一个数组,然后随机取一个索引。400 个格的遍历开销可以忽略,而且逻辑上永远不会失败。顺带还能处理"填满了"的情况——数组为空,那就是通关了。
碰撞判定的顺序也很讲究。我的流程是:先从队列取方向,再算出预期的新头位置,然后依次判墙、判身体,全部通过之后才真正执行unshift。这个"先算后判"的顺序保证了任何一次非法移动都不会污染游戏状态——如果先unshift再判断,死了之后还得回滚,代码会脏得没法看。
3. 手把手落地:从空文件到能玩的一局
前面都是铺垫,这一节直接上真东西。我按文件从上到下的顺序讲,每一段代码都说明它为什么长这样,你照抄完之后应该能得到一个完整可运行的单文件。
3.1 HTML 骨架:三层结构足够
整个页面就三块:顶部的分数条、中间的画布舞台、覆盖在画布上的遮罩层。遮罩层用来显示"准备开始"和"游戏结束",比用alert优雅一百倍,也比在 Canvas 里画半透明层再画文字省事得多——让 DOM 去做 DOM 擅长的事。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"> <title>贪吃蛇</title> <link rel="stylesheet" href="game.css"> </head> <body> <div class="hud"> <span>分数 <b id="score">0</b></span> <span>最高 <b id="best">0</b></span> </div> <div class="stage"> <canvas id="cv"></canvas> <div class="mask" id="mask"> <h2 id="maskTitle">准备开始</h2> <p id="maskTip">方向键 / WASD 控制,空格暂停</p> <button id="btn">开始</button> </div> </div> <script src="game.js"></script> </body> </html>viewport里的user-scalable=no是为了防止手机上双击缩放把游戏搞乱。canvas不写width和height属性是故意的——宽高全部交给 JS 按设备像素比动态设置,HTML 里写死反而会和高清屏适配打架。
3.2 样式与高清屏适配:让画面不糊
先看 CSS。核心思路是让.stage成为一个正方形容器,宽度由 CSS 变量控制,画布 100% 撑满,这样在手机上只要改变量就能整体缩放,不用改任何 JS。
:root { --board: 480px; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: #11151c; color: #dfe6f1; font-family: ui-monospace, Menlo, Consolas, monospace; -webkit-user-select: none; user-select: none; touch-action: none; } .hud { width: var(--board); display: flex; justify-content: space-between; font-size: 15px; letter-spacing: .5px; } .stage { position: relative; width: var(--board); height: var(--board); } canvas { display: block; width: 100%; height: 100%; background: #0d1117; border-radius: 10px; box-shadow: 0 0 0 1px #1f2a37, 0 18px 40px rgba(0,0,0,.5); } .mask { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(9,12,17,.84); border-radius: 10px; } .mask.hide { display: none; } .mask h2 { margin: 0; font-size: 20px; font-weight: 600; } .mask p { margin: 0; font-size: 13px; color: #8b98a9; } button { padding: 9px 22px; border-radius: 999px; border: 1px solid #2b3a4d; background: #18212d; color: #dfe6f1; font-size: 14px; font-family: inherit; cursor: pointer; } button:active { transform: translateY(1px); } @media (max-width: 520px) { :root { --board: min(92vw, 420px); } }那里面的touch-action: none是移动端的关键,不加的话在画布上滑动会触发浏览器自己的滚动,方向手势会丢。user-select: none则是防止长按选中文字弹出菜单。
接下来是高清屏适配,这段代码不长但特别重要,不写的话在 Retina 屏或者手机上,画面会像蒙了一层雾。
const COLS = 20, ROWS = 20, CELL = 24; const LW = COLS * CELL; // 逻辑宽 480 const LH = ROWS * CELL; // 逻辑高 480 const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); function fitCanvas() { const dpr = window.devicePixelRatio || 1; const rect = cv.getBoundingClientRect(); const cssW = rect.width || LW; const cssH = rect.height || LH; // 真实像素缓冲区 = 显示尺寸 × dpr cv.width = Math.round(cssW * dpr); cv.height = Math.round(cssH * dpr); // 把绘制坐标系映射回 480×480 的逻辑空间 ctx.setTransform( (cssW * dpr) / LW, 0, 0, (cssH * dpr) / LH, 0, 0 ); }这段的意图值得说透。canvas.width和canvas.height是像素缓冲区的尺寸,CSS width/height是它在页面上被拉伸到的显示尺寸,两者不一致时浏览器会做插值,画面就糊了。所以我们要把缓冲区设成"显示尺寸 × 设备像素比",然后用setTransform把绘图坐标系放大同样的倍数。这样做的效果是:后续所有ctx.fillRect(x, y, w, h)里的数值,永远按 0 到 480 的逻辑坐标写,缩放的事情一次性解决,业务代码里再也不用管 dpr。
3.3 游戏状态与初始化:一个对象装下所有
我不喜欢散落一地的全局变量,所以所有状态塞进一个state对象。好处是重置的时候只需要重新赋值,调试的时候console.log(state)一眼能看全。
const BASE_STEP = 150; // 初始每格 150ms const MIN_STEP = 70; // 最快每格 70ms const STEP_DEC = 3; // 每吃一个加速 3ms const state = { snake: [], dir: { x: 1, y: 0 }, queue: [], food: { x: 0, y: 0 }, score: 0, step: BASE_STEP, acc: 0, last: 0, running: false, over: false }; function reset() { const cx = Math.floor(COLS / 2); const cy = Math.floor(ROWS / 2); // 头在索引 0,初始长度 3 节,朝右 state.snake = [ { x: cx, y: cy }, { x: cx - 1, y: cy }, { x: cx - 2, y: cy } ]; state.dir = { x: 1, y: 0 }; state.queue = []; state.score = 0; state.step = BASE_STEP; state.acc = 0; state.over = false; spawnFood(); renderScore(); render(); }三个速度参数是我反复调出来的。BASE_STEP = 150意味着每秒走 6.67 格,横穿整个棋盘需要 20 × 150 = 3000 毫秒,也就是 3 秒。这个节奏对新手刚刚好——不至于慢到无聊,也留足了反应时间。
加速曲线我用的是线性递减:每吃一个食物减 3 毫秒。吃到第 27 个食物时正好触底到 70 毫秒,此时每秒走 14.3 格,横穿棋盘只要 1.4 秒。为什么不继续往下减?因为低于 70ms 之后,人的视觉暂留和手指反应已经跟不上了,变成纯拼运气而不是拼操作,体验反而变差。这条下限是我踩过坑才加的,早期版本没限制,玩到后面蛇快得像在闪现,完全没有乐趣。
食物生成用的是前面说的"收集空格法":
function spawnFood() { const occupied = new Set(state.snake.map(s => s.y * COLS + s.x)); const free = []; for (let i = 0; i < COLS * ROWS; i++) { if (!occupied.has(i)) free.push(i); } if (free.length === 0) return; // 棋盘满了,视为通关 const idx = free[(Math.random() * free.length) | 0]; state.food = { x: idx % COLS, y: (idx / COLS) | 0 }; }把二维坐标压成一维索引y * COLS + x再塞进Set,这是个很小但很实用的技巧——比用some()遍历去查重快,代码也更短。(Math.random() * free.length) | 0是取整的位运算写法,比Math.floor少敲几个字符,在这里两者的行为完全一致。
3.4 主循环与单步逻辑:核心就在这四十行
先看单步逻辑tick(),这是整个游戏规则的全部:
function tick() { // 1. 从队列里取一个方向意图 if (state.queue.length) { const next = state.queue.shift(); if (next.x !== -state.dir.x || next.y !== -state.dir.y) { state.dir = next; } } const head = state.snake[0]; const nx = head.x + state.dir.x; const ny = head.y + state.dir.y; // 2. 撞墙判定 if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) { return gameOver(); } // 3. 是否吃到食物,决定尾巴是否保留 const willGrow = (nx === state.food.x && ny === state.food.y); // 4. 撞自己判定:没吃到食物时,尾巴这一格即将空出,不参与判定 const body = willGrow ? state.snake : state.snake.slice(0, -1); if (body.some(s => s.x === nx && s.y === ny)) { return gameOver(); } // 5. 真正移动 state.snake.unshift({ x: nx, y: ny }); if (willGrow) { state.score += 10; state.step = Math.max(MIN_STEP, state.step - STEP_DEC); spawnFood(); renderScore(); } else { state.snake.pop(); } }我特意把注释里的 1 到 5 编号保留下来,因为这五步的顺序是有依赖关系的,调换任何两步都可能出 bug。比如把第 5 步的unshift提前到第 4 步之前,那some()检查就会把新头自己也算进去,一移动就判死。
然后是渲染,整帧重绘,不搞增量更新:
function render() { ctx.clearRect(0, 0, LW, LH); // 淡淡的网格线,坐标 +0.5 是为了让 1px 线落在像素中心,不虚 ctx.strokeStyle = 'rgba(255,255,255,.035)'; ctx.lineWidth = 1; for (let i = 1; i < COLS; i++) { ctx.beginPath(); ctx.moveTo(i * CELL + .5, 0); ctx.lineTo(i * CELL + .5, LH); ctx.stroke(); } for (let j = 1; j < ROWS; j++) { ctx.beginPath(); ctx.moveTo(0, j * CELL + .5); ctx.lineTo(LW, j * CELL + .5); ctx.stroke(); } // 食物 drawCell(state.food.x, state.food.y, '#ff5f6d', .26); // 蛇身,越靠尾巴越淡 const n = Math.max(1, state.snake.length - 1); state.snake.forEach((s, i) => { const t = i / n; const color = i === 0 ? '#7ee787' : `rgba(126,231,135,${0.85 - t * 0.45})`; drawCell(s.x, s.y, color, .1); }); } // 在格子内画一个带内缩的圆角方块 function drawCell(gx, gy, color, inset) { const pad = CELL * inset; const size = CELL - pad * 2; const r = Math.min(6, size / 4); ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(gx * CELL + pad + r, gy * CELL + pad); ctx.arcTo(gx * CELL + pad + size, gy * CELL + pad, gx * CELL + pad + size, gy * CELL + pad + size, r); ctx.arcTo(gx * CELL + pad + size, gy * CELL + pad + size, gx * CELL + pad, gy * CELL + pad + size, r); ctx.arcTo(gx * CELL + pad, gy * CELL + pad + size, gx * CELL + pad, gy * CELL + pad, r); ctx.arcTo(gx * CELL + pad, gy * CELL + pad, gx * CELL + pad + size, gy * CELL + pad, r); ctx.closePath(); ctx.fill(); }这里有两个视觉上的小讲究。第一个是网格线的+0.5:因为 1 像素的线是以坐标为中心向两边各画 0.5,落在整数坐标上会横跨两个物理像素,渲染出来是两像素宽的浅灰色,看着就是"糊"。加 0.5 之后线正好居中在一个像素里,边缘锐利。这个技巧在 Canvas 里画任何细线都适用。
第二个是drawCell的内缩参数。蛇身用 0.1,也就是每边缩进 2.4 像素,节与节之间留出细缝,视觉上能看出这是一节一节的,而不是一根连续的条。食物用 0.26,缩得更狠,看起来像个小圆点,跟蛇身区分明显。这个数值改起来很随意,但一定要调,全是实心方块的话画面会显得特别笨重。
主循环本身:
function loop(ts) { requestAnimationFrame(loop); if (!state.last) state.last = ts; const dt = Math.min(ts - state.last, 200); // 关键:夹紧单帧时间 state.last = ts; if (!state.running) { render(); return; } state.acc += dt; while (state.acc >= state.step) { state.acc -= state.step; tick(); if (state.over) break; } render(); }Math.min(ts - state.last, 200)这一行是我后来补上的补丁,非常关键。不加的话会遇到一个诡异现象:切换标签页几分钟再回来,游戏会瞬间"闪现"很长一段,蛇直接撞墙死了,玩家完全不知道发生了什么。原因是浏览器在标签页不可见时会暂停rAF,回来之后ts跳了几十万毫秒,累加器一口气执行了几千次tick。夹紧到 200 毫秒,意味着最坏情况只补跑一次逻辑,游戏相当于"暂停"了,体验才正常。
while而不是if是因为低刷新率设备上可能一帧要跑多次逻辑;state.over之后break则是为了在死亡那一帧立刻停住,避免同帧内继续执行导致越界报错。
3.5 输入、暂停与重开:把交互兜住
输入这块我统一用一个映射表,方向键和 WASD 都收进去,省得写一堆if。注意e.key对字母键返回的是真实字符,大小写敏感,所以大写也要列一遍。
const KEYMAP = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], W: [0, -1], S: [0, 1], A: [-1, 0], D: [1, 0] }; function pushDir(dx, dy) { const ref = state.queue.length ? state.queue[state.queue.length - 1] : state.dir; // 反向,丢弃 if (dx === -ref.x && dy === -ref.y) return; // 同向,去重 if (dx === ref.x && dy === ref.y) return; // 最多缓冲两个意图 if (state.queue.length >= 2) return; state.queue.push({ x: dx, y: dy }); } window.addEventListener('keydown', e => { if (e.key === ' ') { e.preventDefault(); togglePause(); return; } const dir = KEYMAP[e.key]; if (!dir) return; e.preventDefault(); // 阻止方向键滚动页面 if (!state.running && !state.over) start(); pushDir(dir[0], dir[1]); }, { passive: false });pushDir里的ref计算是这段的灵魂:如果队列空,参照物是当前执行方向;如果不空,参照物是队尾那个还没执行的意图。这样连续按"上、左"时,第二次入队会跟"上"比较,合法,入队成功;如果连续按"上、下",第二次会跟"上"比较,判定为反向,直接丢弃。这就从根本上杜绝了"一帧内掉头自杀"的可能。
if (!state.running && !state.over) start()是个贴心的小设计:玩家不用先点按钮再按方向键,直接按方向键就开局了。但要注意判断!state.over,否则死亡后按方向键会立刻重开,玩家还没看清分数就没影了。
暂停与重开:
function togglePause() { if (state.over) return; state.running = !state.running; if (state.running) { mask.classList.add('hide'); state.last = 0; // 重置时间基准,避免补帧 state.acc = 0; } else { showMask('已暂停', '按空格继续'); } } function start() { reset(); state.running = true; mask.classList.add('hide'); state.last = 0; state.acc = 0; } function gameOver() { state.running = false; state.over = true; saveBest(); showMask('游戏结束', `本局得分 ${state.score}`); }state.last = 0这两处重置很重要。如果不重置,恢复运行的第一帧dt会是"暂停时长",累加器直接爆表,蛇会瞬间狂奔好几格。这个坑我在早期版本里遇到过,暂停十秒再恢复,蛇直接飞出去撞墙,非常影响体验。
3.6 完整代码:拼起来就能跑
把上面所有片段按顺序合并,就是完整的一个 JS 文件。补上分数渲染、最高分存取和触摸滑动支持,最终形态如下:
(function () { 'use strict'; const COLS = 20, ROWS = 20, CELL = 24; const LW = COLS * CELL, LH = ROWS * CELL; const BASE_STEP = 150; const MIN_STEP = 70; const STEP_DEC = 3; const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); const scoreEl = document.getElementById('score'); const bestEl = document.getElementById('best'); const mask = document.getElementById('mask'); const maskTitle = document.getElementById('maskTitle'); const maskTip = document.getElementById('maskTip'); const btn = document.getElementById('btn'); const state = { snake: [], dir: { x: 1, y: 0 }, queue: [], food: { x: 0, y: 0 }, score: 0, step: BASE_STEP, acc: 0, last: 0, running: false, over: false }; let best = 0; try { best = Number(localStorage.getItem('snake_best') || 0); } catch (e) { best = 0; } function fitCanvas() { const dpr = window.devicePixelRatio || 1; const rect = cv.getBoundingClientRect(); const cssW = rect.width || LW; const cssH = rect.height || LH; cv.width = Math.round(cssW * dpr); cv.height = Math.round(cssH * dpr); ctx.setTransform((cssW * dpr) / LW, 0, 0, (cssH * dpr) / LH, 0, 0); } function renderScore() { scoreEl.textContent = state.score; bestEl.textContent = best; } function saveBest() { if (state.score > best) { best = state.score; try { localStorage.setItem('snake_best', String(best)); } catch (e) {} } renderScore(); } function spawnFood() { const occupied = new Set(state.snake.map(s => s.y * COLS + s.x)); const free = []; for (let i = 0; i < COLS * ROWS; i++) { if (!occupied.has(i)) free.push(i); } if (!free.length) return; const idx = free[(Math.random() * free.length) | 0]; state.food = { x: idx % COLS, y: (idx / COLS) | 0 }; } function reset() { const cx = Math.floor(COLS / 2), cy = Math.floor(ROWS / 2); state.snake = [ { x: cx, y: cy }, { x: cx - 1, y: cy }, { x: cx - 2, y: cy } ]; state.dir = { x: 1, y: 0 }; state.queue = []; state.score = 0; state.step = BASE_STEP; state.acc = 0; state.last = 0; state.over = false; spawnFood(); renderScore(); render(); } function tick() { if (state.queue.length) { const next = state.queue.shift(); if (next.x !== -state.dir.x || next.y !== -state.dir.y) { state.dir = next; } } const head = state.snake[0]; const nx = head.x + state.dir.x; const ny = head.y + state.dir.y; if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) return gameOver(); const willGrow = (nx === state.food.x && ny === state.food.y); const body = willGrow ? state.snake : state.snake.slice(0, -1); if (body.some(s => s.x === nx && s.y === ny)) return gameOver(); state.snake.unshift({ x: nx, y: ny }); if (willGrow) { state.score += 10; state.step = Math.max(MIN_STEP, state.step - STEP_DEC); spawnFood(); renderScore(); } else { state.snake.pop(); } } function drawCell(gx, gy, color, inset) { const pad = CELL * inset; const size = CELL - pad * 2; const r = Math.min(6, size / 4); const x = gx * CELL + pad, y = gy * CELL + pad; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + size, y, x + size, y + size, r); ctx.arcTo(x + size, y + size, x, y + size, r); ctx.arcTo(x, y + size, x, y, r); ctx.arcTo(x, y, x + size, y, r); ctx.closePath(); ctx.fill(); } function render() { ctx.clearRect(0, 0, LW, LH); ctx.strokeStyle = 'rgba(255,255,255,.035)'; ctx.lineWidth = 1; for (let i = 1; i < COLS; i++) { ctx.beginPath(); ctx.moveTo(i * CELL + .5, 0); ctx.lineTo(i * CELL + .5, LH); ctx.stroke(); } for (let j = 1; j < ROWS; j++) { ctx.beginPath(); ctx.moveTo(0, j * CELL + .5); ctx.lineTo(LW, j * CELL + .5); ctx.stroke(); } drawCell(state.food.x, state.food.y, '#ff5f6d', .26); const n = Math.max(1, state.snake.length - 1); state.snake.forEach((s, i) => { const t = i / n; const color = i === 0 ? '#7ee787' : `rgba(126,231,135,${0.85 - t * 0.45})`; drawCell(s.x, s.y, color, .1); }); } function showMask(title, tip) { maskTitle.textContent = title; maskTip.textContent = tip; mask.classList.remove('hide'); } function start() { reset(); state.running = true; state.last = 0; state.acc = 0; mask.classList.add('hide'); } function gameOver() { state.running = false; state.over = true; saveBest(); showMask('游戏结束', `本局得分 ${state.score}`); } function togglePause() { if (state.over) return; if (!state.running && state.acc === 0 && !state.snake.length) return; state.running = !state.running; if (state.running) { mask.classList.add('hide'); state.last = 0; state.acc = 0; } else { showMask('已暂停', '按空格继续'); } } function pushDir(dx, dy) { const ref = state.queue.length ? state.queue[state.queue.length - 1] : state.dir; if (dx === -ref.x && dy === -ref.y) return; if (dx === ref.x && dy === ref.y) return; if (state.queue.length >= 2) return; state.queue.push({ x: dx, y: dy }); } const KEYMAP = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], W: [0, -1], S: [0, 1], A: [-1, 0], D: [1, 0] }; window.addEventListener('keydown', e => { if (e.key === ' ') { e.preventDefault(); togglePause(); return; } const dir = KEYMAP[e.key]; if (!dir) return; e.preventDefault(); if (!state.running && !state.over) start(); pushDir(dir[0], dir[1]); }, { passive: false }); // 触摸滑动 let touchStart = null; cv.addEventListener('touchstart', e => { const t = e.changedTouches[0]; touchStart = { x: t.clientX, y: t.clientY }; }, { passive: true }); cv.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.abs(dx) < 24 && Math.abs(dy) < 24) return; if (Math.abs(dx) > Math.abs(dy)) pushDir(dx > 0 ? 1 : -1, 0); else pushDir(0, dy > 0 ? 1 : -1); }, { passive: true }); btn.addEventListener('click', () => { if (state.over || !state.running) start(); }); window.addEventListener('resize', () => { fitCanvas(); render(); }); function loop(ts) { requestAnimationFrame(loop); if (!state.last) state.last = ts; const dt = Math.min(ts - state.last, 200); state.last = ts; if (!state.running) { render(); return; } state.acc += dt; while (state.acc >= state.step) { state.acc -= state.step; tick(); if (state.over) break; } render(); } fitCanvas(); reset(); requestAnimationFrame(loop); })();触摸手势那里我设的阈值是 24 像素。这个值不能太小,手指滑动前几像素的抖动会被误判成有效手势,蛇会乱拐;也不能太大,否则轻扫一下没反应。24 像素大约是一根手指宽度的四分之一,实测在手机上手感比较自然。另外注意判断主轴的方式是直接比较|dx|和|dy|,不要算角度,算角度属于给自己找麻烦。
4. 常见问题与排查实录
代码能跑起来只是开始,真正折磨人的是那些"看起来没问题但就是不对劲"的状态。这一节是我自己调试过程中记下来的东西,基本覆盖了这个项目 90% 的疑难杂症。
4.1 症状速查表:先定位再动手
遇到问题别急着改代码,先对照症状缩小范围。这张表是我边调边加的,现在基本能覆盖大部分情况:
| 症状表现 | 大概率原因 | 排查与修复 |
|---|---|---|
| 蛇一转向就死 | 方向直接赋值,未做反向校验 | 改用队列 + 入队时与队尾比较 |
| 快速连按两下方向键后穿过身体 | 同一帧内多次修改方向 | 队列长度限制为 2,逐次消费 |
| 画面发虚、边缘模糊 | 未处理 devicePixelRatio | 缓冲区设为显示尺寸 × dpr,用 setTransform 缩放 |
| 网格线颜色偏淡且两像素宽 | 1px 线画在整数坐标上 | 坐标统一+0.5 |
| 切回标签页后蛇瞬间撞墙 | 单帧 dt 过大,累加器爆表 | dt = Math.min(dt, 200) |
| 暂停恢复后蛇狂奔好几格 | 恢复时未重置时间基准 | 恢复时把last和acc清零 |
| 按住方向键蛇疯狂加速 | keydown 重复事件反复入队 | 入队时判同向去重 |
| 手机滑动没反应,页面反而滚了 | 缺少 touch-action | body 加touch-action: none |
| 食物偶尔出现在蛇身下 | 随机后未排除已占格 | 收集空格法 |
| 分数加了但蛇没变长 | willGrow判断用了移动后的头 | 用nx/ny与食物坐标比较 |
这张表里最好用的是"蛇一转向就死"和"切标签页后撞墙"这两条,因为它们出现的频率最高,而且症状都很吓人,容易让人怀疑是自己逻辑写错了,其实是两个非常固定的小疏漏。
4.2 几个容易忽略的坑
除了表里那些,还有几个坑属于"不看不知道,一看恍然大悟"的类型。
第一个是e.key和e.code的区别。用e.key有个隐患:当用户切到中文输入法时,方向键的行为在部分环境下会被输入法拦截,keydown里拿到的e.key可能变成"Process"。如果你的游戏在某些用户机器上方向键失灵,先检查这个。稳妥的做法是同时监听e.code(ArrowUp、KeyW这种物理键位),或者在输入框获得焦点时完全不管键盘事件。我这个简易版没做输入框,所以e.key够用,但你得知道这个边界在哪。
第二个是preventDefault的时机。方向键在浏览器里的默认行为是滚动页面,如果不阻止,玩的时候整个页面会跟着上下跳。但preventDefault只能在非 passive 的监听器里调用,所以addEventListener的第三个参数必须显式写{ passive: false }。现代浏览器对window上的touchstart、wheel默认就是 passive 的,你不写这个参数,调用preventDefault会被无视,控制台甩一句警告就过去了,特别容易漏。
第三个是最高分的存储。localStorage在隐私模式、某些沙箱环境、file://协议下的行为并不统一,可能直接抛异常。我第一次写的时候没包try/catch,用户打开页面直接白屏。现在的做法是读写都包起来,读失败就用 0 兜底,写失败就只存在内存里,功能降级但不崩。
第四个是Math.random()的可预测性。这个纯粹是玩的时候发现的——某些随机数实现下,食物的位置分布看起来不够"均匀",会连续几次出现在同一片区域。想要更均匀的分布,可以把空格子数组做一次洗牌(Fisher-Yates),然后按顺序取,这样保证一轮之内每个格子都被取到一次。不过对于休闲游戏来说,真随机反而更有趣,洗牌法会让玩家摸出规律,所以这个我最后没加。
第五个是画布尺寸变化。手机横竖屏切换、浏览器窗口拉伸都会改变getBoundingClientRect()的结果,如果不重新fitCanvas(),画布会被拉伸变形。我加了resize监听,但要注意resize触发非常频繁,好在fitCanvas本身很轻(只重设尺寸和变换矩阵),不需要做防抖。真正需要防抖的是那些会触发大量计算的操作,这里没有。
4.3 手感调优:让游戏"能玩"和"好玩"的分界线
功能全对不等于好玩。这个项目里最影响体感的其实是三个参数和两个细节,我把自己调过的过程记一下,你可以直接拿去当起点。
速度曲线方面,我试过三种:固定速度(永远 150ms,玩两分钟就腻)、指数加速(每吃一个乘 0.95,二十个之后快得离谱,直接玩不了)、线性加速(每吃一个减 3ms,26 个触底)。线性加速的体验最平滑,玩家的技术提升和难度提升基本同步,不会出现某个坎突然卡死。
初始长度也有讲究。长度 3 是最舒服的,长度 1 的话第一下就得注意别撞自己