简介这份面向前端入门者的贪吃蛇小项目用HTML组织游戏区域、分数显示与控制入口配合CSS完成布局和视觉样式再由JavaScript承担蛇身移动、食物随机生成、碰撞判定、分数更新与键盘方向控制等逻辑适合希望从静态页面迈向动态交互的网页开发初学者。压缩包共3个文件含1个HTML主页面、1张JPG背景图与1张PNG图片素材整体约86KB结构轻量便于直接运行、阅读和二次修改。目前已有1456人学习作为入门实践案例具备一定参考价值。读者可得到一个可运行的网页小游戏原型并围绕事件监听、定时器刷新、DOM操作、边界与自身碰撞判断等环节理解HTML、CSS和JavaScript如何协同完成一个完整小应用也可据此尝试调速、改难度或加入音效等扩展进一步巩固前端基础与逻辑组织能力。1. 双击就能跑的贪吃蛇一个 HTML 文件里到底塞了多少东西一个.html文件双击打开就能玩不用装 Node、不用跑 npm install也没有一行构建配置——这是我愿意把贪吃蛇这个老题目重新拆一遍的理由。用 html 语言写游戏最容易翻车的地方不是「写不出来」而是「写出来了但改不动」速度想调快一点动了三处格子想改成 30×30渲染和判定对不上想加个最高分发现状态散落在六个函数里。这里不聊贪吃蛇怎么玩只聊这套单文件实现是怎么把 20×20 网格、蛇身、食物刷新、碰撞判定和键盘输入串成一条闭环的以及每一块要改的时候该动哪一行。适合两类人刚学完 HTML 语法、想找一个有完整闭环的小项目练手的手上有现成页面、想塞一个不占带宽的小游戏模块进去的。看完你应该能本地跑通、看懂每一帧发生了什么、按自己的需求改速度改尺寸改外观。2. 骨架与选型单文件 HTML 拆成三段后怎么放才不乱先解决一个最现实的问题就这么一个小游戏到底该写成一个文件还是拆成三个。我见过两种极端一种是把 CSS、JS 全塞进style和script里文件两百行改到后面自己都找不到函数在哪另一种是上来就 webpack TypeScript 资源打包为了一个吃豆子的小方块引入了三十兆依赖。两种都不合适。比较务实的做法是三层index.html只负责结构style.css只管布局和外观game.js装全部逻辑中间不引入任何构建工具用script src直接挂。2.1 三层文件划分与各自的职责边界目录就四个文件扁平放不要建什么src/utils之类的层级这个体量撑不起目录树snake/ ├── index.html # 结构容器 canvas 计分板 ├── style.css # 布局把画布摆到页面中间控制尺寸 ├── game.js # 逻辑状态、循环、渲染、输入全在这 └── README.md # 操作说明写清楚键位和改造点职责边界要划清楚不然改的时候会来回跳。index.html里只允许出现 DOM 节点和脚本引用不许写内联样式style.css里只管「画布多大、摆在哪、圆角多少、背景什么色」不许出现任何与游戏逻辑有关的数值game.js里管一切跟格子、蛇、食物相关的东西。这条线一旦守住后面加关卡、换皮肤都是局部改动。我一般会在game.js顶部集中放一组常量把所有可调参数提到最前面// game.js 顶部所有可调参数集中在这里 const COLS 20; // 横向格子数改这里就能改难度 const ROWS 20; // 纵向格子数 const STEP_MS 140; // 每走一格耗时越小蛇越快 const START_LEN 3; // 初始蛇长 const COLORS { bg: #12161c, grid: rgba(255,255,255,0.05), head: #3ad07a, body: #2a8f5c, food: #e5533d, };把参数提出来这件事看着不起眼但它是「能改」和「不敢改」的分水岭。COLS、ROWS、STEP_MS这三个值散落在十几个函数里的时候你想调速度就得靠全局搜索搜到140还得判断这个 140 是速度还是画布宽度。集中之后改难度就是改一个数字。2.2 渲染方案选型Canvas 2D 网格与 DOM 方块阵列的差别贪吃蛇的渲染有两条路一条是给每个格子生成一个div靠改className或style.background来切换颜色另一条是拿一个canvas每帧整块重绘。两种都能跑但适用场景完全不同。DOM 方案的好处是天然可访问、可调试你在开发者工具里能直接点到每一个格子加个 CSS transition 就有平滑过渡坏处是节点数量上来了会卡。20×20 是 400 个节点这个量级其实还撑得住但如果把网格放大到 40×40 就是 1600 个节点每帧改样式会触发大量样式重算和重排蛇一快就开始掉帧。Canvas 方案没有节点开销绘制就是往位图上画矩形1600 个格子照样稳代价是你要自己算坐标、自己做高分屏适配而且画出来的东西对屏幕阅读器不友好。这个项目选 Canvas理由很实际它的核心是「每帧重绘整个棋盘」这正好是 Canvas 的强项用 DOM 反而是拿短处硬扛。如果哪天有人想做成纯文本、无障碍优先的版本DOM 方案会更合适但那是另一个取舍了。2.3 页面结构与基础样式index.html的骨架要极简能少一个div就少一个!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title贪吃蛇/title link relstylesheet hrefstyle.css /head body main classstage header classhud span得分 b idscore0/b/span span最高 b idbest0/b/span /header canvas idgame aria-label贪吃蛇游戏区域/canvas p classtip方向键 / WASD 控制空格暂停/p /main script srcgame.js/script /body /html样式上有一个坑必须提前避开不要把canvas的宽高写死在 CSS 里同时又写死在属性上。CSS 的width/height决定盒子在页面上占多大canvas.width/height属性决定位图分辨率两者不一致画面就会糊。正确姿势是 CSS 只负责布局尺寸位图尺寸交给 JS 在resize()里按比例算/* style.css */ * { box-sizing: border-box; } html, body { margin: 0; height: 100%; background: #0b0e12; color: #cfd8e3; font: 14px/1.6 system-ui, sans-serif; } body { display: grid; place-items: center; /* 一行搞定水平垂直居中 */ } .stage { width: min(90vw, 480px); /* 窄屏按 90vw宽屏封顶 480px */ } .hud { display: flex; justify-content: space-between; padding: 8px 2px; font-variant-numeric: tabular-nums; /* 数字等宽跳分时不抖动 */ } #game { width: 100%; aspect-ratio: 1 / 1; /* 强制正方形跟 COLS/ROWS 保持等比 */ display: block; border-radius: 10px; background: #12161c; touch-action: none; /* 移动端后续加滑动时不触发页面滚动 */ } .tip { margin: 8px 0 0; font-size: 12px; opacity: .6; text-align: center; }aspect-ratio: 1 / 1这一行值得单独说。早几年没有这个属性的时候要么用padding-top: 100%的伪元素撑高要么在 JS 里resize时手动算高度都是一堆额外代码。现在一行就能让画布自动保持正方形配合min(90vw, 480px)天然适配手机和桌面两种屏幕。宽度定死了格子的边长就只需要用「画布宽度 ÷ COLS」算不用考虑自适应。3. 循环与渲染从 requestAnimationFrame 到 20×20 网格坐标换算游戏跑得顺不顺八成取决于循环写得对不对。新手最容易写成setInterval(step, 140)然后每隔一段时间调一次渲染短时间看着没问题切个后台标签页回来、或者机器load高一点节奏立刻乱掉。这套实现用的是「逻辑固定步长 渲染跟帧」的双轨结构是 2D 小游戏里最稳妥的一种。3.1 Canvas 初始化与高分屏适配先解决画面糊的问题。devicePixelRatio在普通显示器上是 1在大多数笔记本和手机上是 2 甚至 3。如果不处理你设canvas.width 400浏览器会把 400 像素的位图拉伸到 800 个物理像素上显示边缘就是虚的。处理办法是把位图尺寸乘以 dpr然后用setTransform一次性把绘制坐标系缩回去后面所有代码仍然按 CSS 像素写不用到处乘系数。const canvas document.getElementById(game); const ctx canvas.getContext(2d); let cell 0; // 每个格子的边长单位是 CSS 像素 function resize() { const rect canvas.getBoundingClientRect(); // 布局后的真实尺寸 const dpr window.devicePixelRatio || 1; cell rect.width / COLS; // 位图分辨率 CSS 尺寸 × dpr取整避免出现半像素 canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); // 坐标系整体放大 dpr 倍之后按 CSS 像素画就行 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.imageSmoothingEnabled false; } window.addEventListener(resize, resize); resize();参数说明getBoundingClientRect()拿到的是 CSS 像素下的宽高这个值必须用不能用canvas.width因为后者是位图宽度已经被 dpr 污染过。cell用宽度除以COLS算由于aspect-ratio保证了正方形高度方向自然也是同一个格子边长。这里有个细节resize()之后画布内容会被清空所以它不能只在启动时调一次还得在尺寸变化时调。因为渲染循环一直在跑下一次render()就会把画面补回来不需要额外手动重绘。ctx.imageSmoothingEnabled false只在用到图片素材时才有效现在纯色块画法其实用不上但留着不影响后面换成雪碧图素材时就是它保证像素风不发虚。3.2 固定步长的游戏主循环核心思路是把「时间推进」和「画面刷新」拆开。requestAnimationFrame保证每秒回调 60 次左右跟显示器刷新率走但蛇不应该每帧走一格——144Hz 显示器上蛇会快得没法玩。所以用累加器每次回调把流逝的毫秒加进acc只要acc超过STEP_MS就执行一步逻辑同时扣掉相应的毫秒数。let acc 0; // 累积的毫秒 let last performance.now(); function loop(now) { acc now - last; last now; // 一帧最多补 5 步防止切后台回来一次性追帧把自己撞死 let guard 0; while (acc STEP_MS guard 5) { step(); // 只改状态不画 acc - STEP_MS; guard; } if (guard 5) acc 0; // 追不上就丢掉欠账别越积越多 render(); // 每帧都画 requestAnimationFrame(loop); } requestAnimationFrame(loop);guard这一段是血泪经验。浏览器切到后台时 rAF 会被暂停切回来那一刻now - last可能是一万毫秒按朴素写法while会瞬间执行七十多次step()蛇在玩家看到画面之前就已经撞墙了。限次 丢账两步必须一起做只限次不丢账acc会一直大于STEP_MS下一帧又是连跑 5 步蛇会持续超速只丢账不限次则会触发一次几十步的死亡冲锋。performance.now()和Date.now()的区别也提一句。Date.now()受系统时间调整影响用户改一下时区或系统对时游戏可能突然跳帧或冻结performance.now()是单调递增的不受影响动画计时一律用它。3.3 蛇身、食物与网格的绘制顺序渲染函数一条原则先铺底再画内容最后画覆盖层。顺序错了会出现食物被蛇盖住、网格线压在蛇身上的情况。function render() { const W COLS * cell; const H ROWS * cell; // 1. 底色整块刷掉 ctx.fillStyle COLORS.bg; ctx.fillRect(0, 0, W, H); // 2. 网格线浅到不抢主体 ctx.strokeStyle COLORS.grid; ctx.lineWidth 1; ctx.beginPath(); for (let x 1; x COLS; x) { ctx.moveTo(x * cell, 0); ctx.lineTo(x * cell, H); } for (let y 1; y ROWS; y) { ctx.moveTo(0, y * cell); ctx.lineTo(W, y * cell); } ctx.stroke(); // 所有线攒一个 path 一次 stroke比逐条画快 // 3. 食物食物为 null 时跳过满屏通关会走到这 if (food) { ctx.fillStyle COLORS.food; ctx.beginPath(); ctx.arc((food.x 0.5) * cell, (food.y 0.5) * cell, cell * 0.34, 0, Math.PI * 2); ctx.fill(); } // 4. 蛇身头亮尾暗 snake.forEach((seg, i) { ctx.fillStyle i 0 ? COLORS.head : COLORS.body; ctx.fillRect(seg.x * cell 1, seg.y * cell 1, cell - 2, cell - 2); }); }(x 0.5) * cell是画圆形食物的关键x * cell得到的是格子左上角加半个格子才是中心点。少这个 0.5食物会整体偏左上看着别扭但很多人说不出哪不对。fillRect里的1和-2是在每节身子之间留一条缝视觉上能分辨出是几节。缝的宽度别超过 2 像素格子小的时候超过 2 像素蛇就变成一串珠子了。网格线那一段把多条moveTo/lineTo攒进同一个beginPath()再一次性stroke()这个优化在 20×20 下感知不明显但改成 60×60 就差了将近一倍时间——每条线单独stroke()意味着四十次绘制调用攒起来只有一次。4. 状态与规则蛇身数组、移动算法与碰撞判定的完整链路渲染解决的是「看得见」状态和规则解决的是「玩得对」。这一章是整套代码里最值得逐行读的部分因为所有「手感」问题最后都落在这里。4.1 蛇身数组与坐标系的统一约定蛇身用数组存index 0永远是头。用数组而不是链表是因为贪吃蛇的每一步操作只有两个头部插入、尾部删除吃到食物时不删数组的unshift和pop正好对应而且渲染的时候要遍历整条蛇数组遍历比链表快也直观得多。有人担心unshift是 O(n) 会有性能问题蛇长撑死四百节这点开销可以忽略。// 全部状态集中在一个对象里方便后面做暂停、重开 const state { snake: [], // [{x, y}, ...]index 0 是头 dir: { x: 1, y: 0 }, // 当前生效的方向 queue: [], // 待生效方向最多存 2 个 food: null, score: 0, best: 0, alive: true, paused: false, }; function reset() { state.snake []; for (let i 0; i START_LEN; i) { // 从中间往左排开头在最右 state.snake.push({ x: Math.floor(COLS / 2) - i, y: Math.floor(ROWS / 2) }); } state.dir { x: 1, y: 0 }; state.queue.length 0; state.score 0; state.alive true; state.paused false; state.food spawnFood(); }坐标系约定必须一次定死x向右增y向下增原点在左上角。这是 Canvas 的天然坐标系跟数学课的笛卡尔坐标系上下颠倒新手在这个点上栽过太多次——写「向上」的时候顺手写成了y 1结果蛇往下跑。建议在代码里留一行注释把这句钉死比记在脑子里可靠。4.2 一步移动的完整逻辑step()是整个游戏的规则核心它一次性处理方向切换、前进、吃食物、碰撞判定四件事。这段逻辑的顺序不能乱尤其是碰撞判定必须在真正移动之前做完。function step() { if (!state.alive || state.paused) return; // 1. 从队列取一个合法方向合法性在入队时已经校验过 if (state.queue.length) { state.dir state.queue.shift(); } // 2. 算出新头位置 const head { x: state.snake[0].x state.dir.x, y: state.snake[0].y state.dir.y, }; // 3. 撞墙 if (head.x 0 || head.x COLS || head.y 0 || head.y ROWS) { state.alive false; return; } // 4. 是否吃到食物决定尾巴要不要留 const willEat state.food head.x state.food.x head.y state.food.y; // 5. 撞自己这一步尾巴会移走所以尾巴那格要放行 const body willEat ? state.snake : state.snake.slice(0, -1); if (body.some(s s.x head.x s.y head.y)) { state.alive false; return; } // 6. 前进 state.snake.unshift(head); if (willEat) { state.score 1; state.food spawnFood(); // 吃到不 pop蛇长 1 } else { state.snake.pop(); } }第 5 步里的slice(0, -1)是整段代码最容易写错的地方。如果直接拿完整蛇身去比对蛇在长度 4 的时候紧贴着自己拐弯一定会被判定为撞到自己——因为新头正好落在尾巴当前所在的格子上而这一步尾巴本来就要移走了。这个 bug 特别隐蔽短蛇的时候不容易触发蛇一长、玩家开始贴边操作就频繁暴毙。第 4 步把willEat提前算出来是因为它同时影响第 5 步和第 6 步。如果分两处各算一次很容易出现「判定时吃到了、前进时没吃」的不一致。4.3 食物生成与空格池抽样食物生成看着简单Math.random乘一下坐标就完事但真写起来有个坑随机出来的位置可能正好压在蛇身上。朴素写法是「随机一次撞了就 while 重试」蛇短的时候没问题蛇长到占满八成格子的时候每次生成都可能要循环几十上百次极端情况下会卡住。稳的做法是先枚举所有空格再从池子里抽function spawnFood() { // 把所有空格收集成数组用 Set 做占用查询比嵌套 some 快 const occupied new Set(state.snake.map(s s.y * COLS s.x)); const free []; for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { if (!occupied.has(y * COLS x)) free.push({ x, y }); } } if (free.length 0) return null; // 满屏判定通关 return free[Math.floor(Math.random() * free.length)]; }参数说明y * COLS x是把二维坐标压成一维整数用作Set的 key。不用字符串拼x,y是因为字符串创建和比较的开销比整数大而这里最多要算四百次。返回null这个分支必须处理render()里那句if (food)就是给它留的。满屏的时候游戏其实已经赢了可以在step()里判断score COLS * ROWS - START_LEN然后给个通关提示比让食物消失、蛇继续瞎跑合理。4.4 键盘输入与方向锁输入处理决定手感。两个经典问题一是按得太快导致反向自杀比如蛇正在向右走玩家手快先按了上再按左两帧之内方向变了两次中间那次「上」还没走到就被「左」覆盖但「左」相对「右」是 180 度掉头蛇直接咬到自己二是默认行为没拦方向键会滚动整个页面。const KEYMAP { ArrowUp: { x: 0, y: -1 }, KeyW: { x: 0, y: -1 }, w: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, KeyS: { x: 0, y: 1 }, s: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, KeyA: { x: -1, y: 0 }, a: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 }, KeyD: { x: 1, y: 0 }, d: { x: 1, y: 0 }, }; function pushDir(next) { // 拿基准方向队列非空就比队列末尾否则比当前生效方向 const base state.queue.length ? state.queue[state.queue.length - 1] : state.dir; if (next.x base.x next.y base.y) return; // 重复按键 if (next.x -base.x next.y -base.y) return; // 180 度掉头 if (state.queue.length 2) return; // 队列封顶 state.queue.push(next); } window.addEventListener(keydown, (e) { if (e.code Space) { e.preventDefault(); state.paused !state.paused; return; } const d KEYMAP[e.code] || KEYMAP[e.key]; if (!d) return; e.preventDefault(); // 方向键默认会滚动页面必须拦 pushDir(d); });这里的设计要点是pushDir里的「基准方向」取队列末尾而不是state.dir。如果拿state.dir做比较蛇向右走时玩家快速连按「上、左」第二个「左」会相对state.dir还是右被判成 180 度而丢弃玩家会觉得按键没反应。改成对比队列末尾就能正确处理连续转向。队列长度封顶为 2 是另一个细节。不封顶的话玩家疯狂连按会攒下一长串方向蛇会在接下来好几步里沿着历史按键慢慢转向操作反馈严重延迟感觉像在开船。封顶 2 个足够覆盖「快速两次转向」的场景再多就是在排队指令了。用e.code而不是只有e.key是为了兼容大小写和输入法状态。e.key在中英文输入法下可能给出不同结果e.code是物理键位永远稳定。5. 避坑与排查手感、渲染、存档这三处最容易翻车的点这一章全是踩过的坑按「现象 → 原因 → 解决」写可以直接当排查表用。5.1 输入与手感反向自杀、按键丢失、暂停后还在动现象一蛇向右跑玩家快速按「上」再按「左」蛇没拐弯而是直接撞死或者往反方向咬到自己。原因方向校验用的是「当前生效方向」而不是「队列末尾方向」。两帧之间方向只走了一步第二个按键相对的是上一步的旧方向判定逻辑把合法的转向误判成非法或者反过来把非法掉头放行了。解决方向入队时统一走pushDir()基准取queue末尾step()里只shift()不校验。校验点和消费点分离逻辑只有一个入口就不会出现两处判断不一致。现象二长按方向键再松开蛇会多走两格才停。原因keydown在长按时会以系统重复速率持续触发一次长按可能塞进去十几个事件。解决if (e.repeat) return;放在keydown最前面。浏览器会给自动重复的事件带上repeat: true一行就拦住。现象三按空格暂停画面不动了但蛇还在后台走恢复时已经死了。原因只把render()停了step()没停。解决在step()开头判断state.paused直接 return。暂停标志要放在状态逻辑的最前面而不是只控制绘制。同时loop()里可以顺手把acc清零否则恢复的那一帧会因为欠账太多连跑好几步。5.2 渲染与循环速度漂移、画面模糊、切后台回来暴毙现象一同一份代码同事的电脑上蛇跑得飞快自己这边正常。原因用「每帧走一格」而不是固定步长。60Hz 屏每秒 60 步144Hz 屏每秒 144 步速度直接差两倍多。解决改成 3.2 节的累加器结构。STEP_MS是唯一决定速度的参数跟帧率彻底解耦。现象二画布边缘发虚网格线看着像蒙了一层雾。原因canvas.width用的是 CSS 像素值没乘devicePixelRatio位图被浏览器放大显示。解决位图尺寸乘 dprctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系缩回去。注意resize()里每次都要重设 transform因为改canvas.width会重置上下文的所有状态。现象三切到别的标签页待一会儿回来蛇瞬间死在地图边缘。原因rAF 在后台被暂停now - last累积成一个大数while循环一口气补了上百步。解决guard限次 超限丢账。这两步必须同时做只做一半会得到「持续超速」或「一次性死亡冲锋」。5.3 数据与存档localStorage 抛异常与脏数据现象一本地双击打开file://页面最高分永远不保存控制台报一条 SecurityError。原因某些浏览器对file://协议下的localStorage有限制或者用户开了隐私模式、禁用了站点数据。解决读写全部包try/catch失败就退化到内存变量功能不受影响只是不持久const BEST_KEY snake.best; function loadBest() { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (err) { console.warn(存档不可用本次成绩不会保留, err); return 0; } } function saveBest(v) { try { localStorage.setItem(BEST_KEY, String(v)); } catch (err) { /* 静默失败不打断游戏 */ } }现象二改过一次分数逻辑之后最高分显示成NaN或者一直卡在某个奇怪的数字。原因localStorage存的是字符串读到undefined、、12abc这类脏值Number()转出来是NaN一旦写进比较逻辑就永远不更新。解决读出后先Number.isFinite()校验不合法就回落到 0并且顺手把脏值覆盖掉。别指望|| 0能兜住一切NaN || 0确实得到 0但如果拿NaN去做过比较结果永远是false最高分就再也不刷新了。6. 二次开发把单文件拆成 ES 模块并加上关卡和最高分跑通之后想往上加东西第一个决定就是「要不要拆文件」。我的建议是单文件在三百行以内就别动它超过三百行、或者你打算加关卡系统、加皮肤、加音效那就拆。拆的原则是按「变化原因」分不是按「代码长度」分。推荐的四层结构用原生 ES Module不需要打包工具index.html里写script typemodule src./src/main.js就能跑// src/state.js —— 只存数据和常量不碰 DOM、不碰 Canvas export const CONFIG { cols: 20, rows: 20, stepMs: 140, startLen: 3 }; export function createState(cfg) { return { cfg, snake: [], dir: { x: 1, y: 0 }, queue: [], food: null, score: 0, best: 0, alive: true, paused: false, level: 1, }; } // src/rules.js —— 纯函数输入状态输出新状态方便单测 export function tick(state) { /* 返回 { next, event } */ } export function spawnFood(state) { /* ... */ } export function isDead(state, head) { /* ... */ } // src/render.js —— 只认 state不认键盘和计时器 export function createRenderer(canvas, cfg) { const ctx canvas.getContext(2d); function resize() { /* ... */ } function draw(state) { /* ... */ } return { resize, draw }; } // src/main.js —— 接线把 input、loop、render 串起来 import { CONFIG, createState } from ./state.js; import { tick } from ./rules.js; import { createRenderer } from ./render.js;拆完之后最直接的收益是rules.js变成了纯函数集合给它喂一个状态、断言输出的蛇头坐标三行就能写完一个测试不用起浏览器。加关卡和加速也很自然tick里根据score调整stepMs或者在外面维护一张难度表改造点改动位置大致工作量每吃 5 个加速一档rules.tick里改stepMs十几行加个档位表穿墙模式isDead里把越界改成取模三五行的分支障碍物 / 关卡地图state加walls数组isDead和spawnFood各加一次判断半天主要是画图移动端滑动控制新增input-touch.js把滑动向量喂给pushDir一小时边界是滑动阈值皮肤切换render.js抽一个theme参数CSS 变量同步一两个小时加障碍物这个改造要特别注意spawnFood的空格池里必须把墙也排除掉否则食物会刷在墙里玩家永远吃不到游戏卡死。最后一件事也是我踩得最狠的一次。早期图省事把CONFIG.cols和 CSS 里的aspect-ratio分开维护改地图大小时只改了 JS画布还是正方形但格子被拉成了长方形蛇看着像一列列竖条查了四十分钟才发现是两处数值不同步。从那以后我每次动任何跟尺寸、速度、格子数相关的参数都强制走一遍「改常量 → 刷新页面 → 走两步 → 撞一次墙 → 按空格暂停再恢复」这套自检五分钟走完比事后 debug 一小时划算得多。这套单文件实现和拆模块后的版本我都整理在一起了拿过去改的时候对着上面这张表动基本不会迷路。希望帮到你。本文还有配套的精品资源点击获取