尧图精选

HTML Canvas 贪吃蛇实战:游戏循环、状态管理与碰撞检测

🕒 发布时间:2026/10/2 3:01:52 📁 来源:尧图网络
用 HTML 写一个贪吃蛇是我这些年带新人时最常拿出来的第一个练手题没有之一。它不需要装任何环境不依赖任何框架一个.html文件双击就能在浏览器里跑起来它又足够完整——有实时循环、有状态管理、有输入响应、有碰撞判定、有数据持久化麻雀虽小五脏俱全一个下午就能从零写到能玩。很多人第一次看完觉得就这但真自己动手写一遍八成会在方向反冲、速度失控、长按堆键这几个地方卡住卡住之后又不知道从哪查起。这篇文章我不打算只扔一份贪吃蛇完整源码了事。代码网上一搜一大把缺的是为什么这么写。我会把整个实现拆成几个能独立讲清楚的问题游戏循环为什么不能直接用定时器、蛇身为什么用数组、按键为什么要排队、提速参数怎么算才不别扭、画布在高分屏上为什么会糊。整份 HTML 源码我会放在第五部分可以整段复制走改改参数就能变成你自己的版本。适合刚学完 HTML、CSS、JS 基础语法、想找一个能跑通的完整小项目练手的朋友也适合想回头复习一下 canvas 绘制和requestAnimationFrame的老手。1. 为什么我至今还推荐用HTML写一个贪吃蛇1.1 它把前端三件套用在了刀刃上选练手项目最怕两种情况一是简单到只剩语法练习比如写个计算器写完除了熟悉一下事件绑定什么也没学到二是复杂到要引入一堆概念还没看到画面就被劝退。贪吃蛇刚好卡在中间。它的 HTML 部分只有一句canvas加几个显示分数的span但你会顺手搞明白元素怎么挂载、脚本为什么放在/body前面。CSS 部分不用多一个深色背景、一个圆角、一个居中布局就能让画面从能用变成像样你会体会到display: flex居中比margin: auto好在哪。JS 部分才是重头戏定时循环、数组增删、坐标运算、事件监听、本地存储全都能用上而且都是真实项目里天天见的东西不是为练而练的花架子。我更看重的一点是反馈速度。改一个数字刷新一下页面就能看到变化这种即时反馈对新手太重要了。相比之下写后端接口要起服务、发请求、看日志一个来回就是几分钟学习热情很容易被磨掉。1.2 和 C 语言版本、单片机版本比浏览器版练的是什么贪吃蛇这个题目被移植到了几乎所有平台上C 语言控制台版、点阵屏版、各种语言的小游戏版本随便搜都能找到。它们的核心逻辑其实一模一样一个格子地图、一条由若干节点组成的蛇、一个随机出现的食物、每帧根据方向移动一格、撞墙撞自己就结束。差别只在怎么把状态画出来和怎么拿到输入。浏览器版的独特价值在于它把渲染和逻辑彻底解耦了。在控制台版本里你可能要用一堆printf配合光标定位来刷新画面在点阵屏上你要自己算每一颗灯珠的亮灭。而有了 canvas你只需要在每一帧把整个画面重画一遍clearRect一擦该画的都画上去剩下的交给浏览器合成。这种声明式重绘的心智模型和现在主流的界面开发思路是一致的。反过来说如果你之后想把这套逻辑搬到嵌入式点阵屏上需要补的是如何在不支持整屏重绘的硬件上做局部刷新这份状态机本身可以照搬。所以先把它在浏览器里写通是最省事的路径。1.3 我给自己定的验收标准写这个东西不要满足于能跑。我给自己定三条线第一转向时不能出现原地掉头自杀这在快速连按方向键时特别容易触发第二在任何刷新率的屏幕上蛇的移动速度要一致144Hz 显示器上不能变成开挂第三切到别的标签页再切回来蛇不能瞬移出去撞墙。这三条都做到了才算把这个小项目真正吃透。2. 动手之前先把游戏循环这件事想透2.1 setInterval 为什么是新手最容易掉的坑几乎所有人第一次写的时候都会这么干setInterval(move, 200)每 200 毫秒让蛇走一格。跑起来看着没问题但隐患有两处。一是setInterval的执行时机不保证。浏览器主线程如果正在忙别的事情回调会被推迟多个周期的回调可能挤在一起连续执行蛇就会出现一顿一顿然后突然窜出去两格的现象。二是它和渲染完全脱节你没法保证我算完了再画画面偶尔会停在中间状态。更关键的是你没地方做细粒度的时间控制。想做平滑动画、想做加速减速、想暂停恢复setInterval那套clearInterval再setInterval的写法会越来越乱。2.2 拆成逻辑帧和渲染帧才是正解我这边的写法是渲染交给requestAnimationFrame它跟着显示器的刷新节奏走一般是每秒 60 次高刷屏上是 120 或 144 次逻辑更新则用一个时间累加器攒够了预设的步长才走一步。function loop(t) { if (!lastTime) lastTime t; let dt t - lastTime; lastTime t; if (dt 200) dt 200; // 切标签页回来时防止一次性补太多步 if (state running) { acc dt; while (acc interval) { acc - interval; step(); if (state ! running) break; } } draw(); requestAnimationFrame(loop); }acc就是累加器interval是当前每步的耗时。这样做的直接收益是逻辑速度只由 interval 决定和屏幕刷新率无关。144Hz 的屏幕每秒会多跑几十次loop但累计到 140 毫秒才会真正执行一次step蛇的移动速度和你想象的完全一致。if (dt 200) dt 200这行也千万别省。用户切走标签页的时候浏览器会暂停requestAnimationFrame回来时t可能一下子跳了几秒。不加限制的话累加器会瞬间攒出几十步蛇在你还没看清之前就冲出去撞墙了。把单次dt钳到 200 毫秒最多补一步体验就正常了。2.3 提速参数怎么定才不别扭速度相关的三个数字我一般这么定参数取值说明BASE_INTERVAL140 ms开局每步耗时24 格宽的地图横穿约 3.4 秒MIN_INTERVAL70 ms速度上限再快人的反应就跟不上了SPEED_STEP4 ms每吃一个食物减掉的毫秒数从 140 减到 70 需要吃 17.5 个也就是 18 个食物左右到顶。按每个 10 分算差不多 180 分之后速度就不再变化。这个曲线是我试过比较舒服的前 5 个食物提速明显能感觉到难度在涨到了后期趋于平滑不会出现突然快得没法玩的断层。如果你把SPEED_STEP调到 10那么只要吃 7 个就顶到最快速度多数人撑不过 20 个食物挫败感太强。反过来调到 2玩到后面几乎感觉不到变化也会无聊。4 这个数不是什么科学结论纯粹是试出来的手感值你可以自己改着玩。3. 蛇的本质是一个坐标数组3.1 为什么用数组而不是链表很多算法书上讲蛇身用链表头插尾删都是 O(1)理论上更优。但在这个规模下蛇最长不过五百多格数组的性能完全够用而且代码可读性高出一大截。我的表示法是snake是一个对象数组每个元素是{ x, y }单位是格子坐标不是像素坐标。snake[0]永远是蛇头数组最后一位是蛇尾。snake [ { x: 8, y: 12 }, { x: 7, y: 12 }, { x: 6, y: 12 } ];数组顺序天然携带了哪节在前、哪节在后的信息画的时候从后往前遍历就能让蛇头压在最上层看起来更有立体感。如果用链表你每次渲染还得先遍历一遍收集节点反而更麻烦。3.2 移动就是头插尾删这是整个游戏里最优雅的一步。每走一格先根据当前方向算出新蛇头的位置unshift进数组然后判断有没有吃到食物没吃到就pop掉尾巴蛇的长度不变吃到了就不 pop长度加一。const head { x: snake[0].x dir.x, y: snake[0].y dir.y }; snake.unshift(head); if (food head.x food.x head.y food.y) { score 10; interval Math.max(MIN_INTERVAL, interval - SPEED_STEP); placeFood(); } else { snake.pop(); }整个过程没有一处分格移动的逻辑没有把第 N 节的位置赋值给第 N1 节这种循环。很多人第一次写的时候会想着遍历数组往后挪写出来一堆边界判断其实完全没必要。用数组的话蛇的形变是自动发生的——你只管往头部长尾巴自然会丢。这里有个反直觉的点值得说一下撞到自己尾巴的那一格不算死。因为尾巴在下一步就会让开。所以自撞检测要写成for (let i 0; i snake.length - 1; i)把最后一节排除掉。不少人在这里写成了全遍历结果贴着尾巴转向时莫名其妙就死了。3.3 格子坐标和像素坐标的换算逻辑层一律用格子坐标只有画的时候才乘上每格边长const SIZE 480; const COLS 24; const CELL SIZE / COLS; // 20 像素一格好处是head.x food.x head.y food.y这种判断可以直接比整数不会出现浮点误差导致明明看着吃到了却没吃到的玄学问题。如果一开始就用像素坐标食物又是随机生成的你就得处理各种四舍五入调试起来非常难受。每格 20 像素在 480 的画布上是 24×24 的棋盘我试下来这个尺寸刚好格子不至于小到看不清蛇身也不至于大到几步就撞墙。如果你要做成响应式的记得让COLS和ROWS保持不变只改SIZE这样游戏逻辑一行都不用动。4. 按键响应里最容易踩的两个坑4.1 方向反冲为什么只判断当前方向不够新手最常见的写法是在keydown里判断新方向是不是当前方向的相反方向如果是就忽略。看着能防住向右时按左键自杀但挡不住快速连按。举个例子蛇正在向右走。玩家手快先按下键再按左键。这两次按键发生在同一个逻辑帧之内140 毫秒内完成对大多数人来说毫无压力。第一次按下键时dir还是右下不是反向代码认为合法更新了方向状态。紧接着按左键下的反向是上不是左所以左也被判定合法。结果就是蛇在这一帧内先转下、再转左而下转左是完全合法的转向蛇确实没自杀但它转了个玩家完全没预期的弯手感极其糟糕。真正的问题在于基准方向不能是当前方向而应该是已经排在队列里、即将执行的那个方向。我采用的做法是把方向请求压进一个队列入队前拿队尾元素做校验function setDir(name) { const next DIRS[name]; if (!next || state over || state win) return; const base queue.length ? queue[queue.length - 1] : dir; if (next.x base.x 0 next.y base.y 0) return; // 反向丢弃 if (next.x base.x next.y base.y) return; // 重复丢弃 if (queue.length 2) return; // 队列限长 queue.push(next); if (state ready) state running; }反向判断用的是向量相加等于零向量比写四个if判断清晰得多。up是{x:0, y:-1}down是{x:0, y:1}两者相加是{x:0, y:0}一眼就能看懂意图。4.2 队列限长长按键盘的隐藏问题按住方向键不放的时候操作系统会持续触发keydown频率大概是每秒 30 次左右。如果不限制队列长度按住右键两秒钟队列里就堆了六十个右等你松手再按上蛇要过两秒才会响应玩家会觉得键盘失灵了。我的处理是三件事一起上重复方向直接丢弃上面代码里的第二个if、队列长度硬限制为 2、每执行一步就shift出一个。限长 2 是有讲究的。为什么不是 1因为节奏快的玩家会做两步连转比如从右转上再转左中间只隔十几毫秒。如果队列长度为 1第二个左会被丢弃蛇就转不过去。限长 2 刚好容纳这种连招同时又不会堆积。限到 3 或者更多就会出现按键延迟感。4.3 按钮抢焦点一个特别隐蔽的坑这个坑我调了半小时才找到原因。页面上有个重新开始按钮点完之后按空格想暂停游戏结果游戏直接重启了。原因是浏览器里按钮被点击后会获得焦点之后按空格键会触发按钮的click事件。你的keydown监听里处理了空格暂停但按钮的默认行为也在同时发生两个动作撞在一起。更糟的是某些浏览器里空格还会带着页面往下滚。两处都得堵。一是在按钮的点击回调里加上this.blur()主动把焦点交出去二是在keydown里对方向键和空格调用e.preventDefault()顺手解决页面滚动。document.getElementById(restart).addEventListener(click, function () { init(); state running; this.blur(); });preventDefault只对方向键和空格加不要对全部按键加否则将来你想加个别的快捷键就容易被自己坑到。5. 完整源码一份能直接双击打开的HTML文件5.1 完整代码下面这份是完整可运行的版本包含键盘、触摸、暂停、重开、本地最高分直接保存成.html文件双击打开就行。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title贪吃蛇/title style * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; background: #12161f; color: #e6e9ef; font-family: -apple-system, Segoe UI, Microsoft YaHei, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; } .hud { width: min(480px, 92vw); display: flex; justify-content: space-between; font-size: 15px; letter-spacing: 1px; color: #9aa4b8; } .hud b { color: #7ce38b; font-size: 18px; } canvas { width: min(480px, 92vw); height: min(480px, 92vw); background: #1b2130; border-radius: 10px; display: block; touch-action: none; } .tip { font-size: 13px; color: #6d7688; } button { padding: 9px 26px; border: 0; border-radius: 6px; background: #2f7cf6; color: #fff; font-size: 15px; cursor: pointer; } button:hover { background: #4a8ef8; } /style /head body div classhud span得分 b idscore0/b/span span最高 b idbest0/b/span /div canvas idgame/canvas div classtip方向键 / WASD 转向空格暂停R 重开手机可滑动/div button idrestart重新开始/button script (function () { use strict; /* ---------- 基础参数 ---------- */ const SIZE 480; const COLS 24; const ROWS 24; const CELL SIZE / COLS; const BASE_INTERVAL 140; const MIN_INTERVAL 70; const SPEED_STEP 4; const DIRS { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }; /* ---------- 元素与画布 ---------- */ const canvas document.getElementById(game); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const bestEl document.getElementById(best); function fitCanvas() { const dpr window.devicePixelRatio || 1; canvas.width Math.round(SIZE * dpr); canvas.height Math.round(SIZE * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } fitCanvas(); /* ---------- 状态 ---------- */ let snake, dir, queue, food, score, best; let interval, acc, lastTime, state; best Number(localStorage.getItem(snake_best) || 0); bestEl.textContent best; function init() { snake [ { x: 8, y: 12 }, { x: 7, y: 12 }, { x: 6, y: 12 } ]; dir DIRS.right; queue []; score 0; interval BASE_INTERVAL; acc 0; lastTime 0; state ready; scoreEl.textContent 0; placeFood(); } function placeFood() { const empty []; for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { let occupied false; for (let i 0; i snake.length; i) { if (snake[i].x x snake[i].y y) { occupied true; break; } } if (!occupied) empty.push({ x: x, y: y }); } } food empty.length ? empty[(Math.random() * empty.length) | 0] : null; } /* ---------- 逻辑更新 ---------- */ function step() { while (queue.length) { const next queue.shift(); if (next.x dir.x 0 next.y dir.y 0) continue; dir next; break; } const head { x: snake[0].x dir.x, y: snake[0].y dir.y }; if (head.x 0 || head.x COLS || head.y 0 || head.y ROWS) { return gameOver(); } for (let i 0; i snake.length - 1; i) { if (snake[i].x head.x snake[i].y head.y) return gameOver(); } snake.unshift(head); if (food head.x food.x head.y food.y) { score 10; scoreEl.textContent score; interval Math.max(MIN_INTERVAL, interval - SPEED_STEP); placeFood(); if (!food) state win; } else { snake.pop(); } } function gameOver() { state over; if (score best) { best score; localStorage.setItem(snake_best, best); bestEl.textContent best; } } /* ---------- 渲染 ---------- */ function roundRect(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); } function draw() { ctx.clearRect(0, 0, SIZE, SIZE); ctx.strokeStyle rgba(255,255,255,0.045); ctx.lineWidth 1; for (let i 1; i COLS; i) { const p i * CELL; ctx.beginPath(); ctx.moveTo(p, 0); ctx.lineTo(p, SIZE); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, p); ctx.lineTo(SIZE, p); ctx.stroke(); } if (food) { ctx.fillStyle #f2545b; roundRect(food.x * CELL 2.5, food.y * CELL 2.5, CELL - 5, CELL - 5, 6); ctx.fill(); } for (let i snake.length - 1; i 0; i--) { const seg snake[i]; if (i 0) { ctx.fillStyle #a6f5b0; } else { const t i / Math.max(snake.length - 1, 1); ctx.fillStyle rgba(76, 200, 120, (0.95 - t * 0.45).toFixed(3) ); } roundRect(seg.x * CELL 1.5, seg.y * CELL 1.5, CELL - 3, CELL - 3, 5); ctx.fill(); } if (state ! running) { ctx.fillStyle rgba(10, 13, 20, 0.72); ctx.fillRect(0, 0, SIZE, SIZE); ctx.textAlign center; ctx.font bold 32px sans-serif; ctx.fillStyle #ffffff; const texts { ready: [准备开始, 按方向键或滑动屏幕], paused: [已暂停, 按空格继续], over: [游戏结束, 本局得分 score 按 R 重开], win: [通关了, 棋盘已经被你填满] }; const t texts[state]; if (t) { ctx.fillText(t[0], SIZE / 2, SIZE / 2 - 6); ctx.font 15px sans-serif; ctx.fillStyle #9aa4b8; ctx.fillText(t[1], SIZE / 2, SIZE / 2 30); } } } /* ---------- 主循环 ---------- */ function loop(t) { if (!lastTime) lastTime t; let dt t - lastTime; lastTime t; if (dt 200) dt 200; if (state running) { acc dt; while (acc interval) { acc - interval; step(); if (state ! running) break; } } draw(); requestAnimationFrame(loop); } /* ---------- 输入 ---------- */ function setDir(name) { const next DIRS[name]; if (!next || state over || state win) return; const base queue.length ? queue[queue.length - 1] : dir; if (next.x base.x 0 next.y base.y 0) return; if (next.x base.x next.y base.y) return; if (queue.length 2) return; queue.push(next); if (state ready) state running; } const KEYMAP { arrowup: up, w: up, arrowdown: down, s: down, arrowleft: left, a: left, arrowright: right, d: right }; window.addEventListener(keydown, function (e) { const k e.key.toLowerCase(); if (KEYMAP[k]) { e.preventDefault(); setDir(KEYMAP[k]); return; } if (k ) { e.preventDefault(); if (state running) state paused; else if (state paused) state running; return; } if (k r) { init(); state running; } }); let touchStart null; canvas.addEventListener(touchstart, function (e) { const t e.changedTouches[0]; touchStart { x: t.clientX, y: t.clientY }; if (state ready) state running; e.preventDefault(); }, { passive: false }); canvas.addEventListener(touchend, function (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)) { setDir(dx 0 ? right : left); } else { setDir(dy 0 ? down : up); } e.preventDefault(); }, { passive: false }); document.getElementById(restart).addEventListener(click, function () { init(); state running; this.blur(); }); /* ---------- 启动 ---------- */ init(); requestAnimationFrame(loop); })(); /script /body /html5.2 几个关键写法为什么这么写ctx.setTransform(dpr, 0, 0, dpr, 0, 0)这行是解决画布模糊的核心。canvas 有两套尺寸canvas.width是位图的实际像素数CSS 里的width是显示尺寸。在 2 倍屏上如果不做处理480 像素的位图被拉伸到 960 个物理像素显示边缘就糊了。把位图开成 960×960再用setTransform把坐标系缩放回 480 的逻辑单位绘制代码里的坐标一个都不用改画面却清晰了一倍。注意setTransform之后所有尺寸相关的计算都按逻辑坐标来clearRect(0, 0, SIZE, SIZE)用的还是 480。roundRect是我自己写的小工具用arcTo画圆角矩形。为什么不用现成的ctx.roundRect因为它是比较新的 API旧一点的浏览器不支持为了兼容性还是自己写几行稳当。这个方法接收的宽高减去了一点边距让相邻的蛇身之间有细微缝隙视觉上能数得清有几节比糊成一条实心色块好看得多。蛇身颜色用了渐变透明度越靠尾巴越淡。这一手是从霓虹风格的街机游戏里学来的成本几乎为零但画面层次感立刻不一样。t是当前节点在整条蛇里的位置比例透明度从 0.95 线性降到 0.5。6. 吃、撞墙、撞自己三段判定的边界处理6.1 食物生成不能落在蛇身上最粗暴的做法是Math.random()直接生成坐标然后判断在不在蛇身上在的话重新生成。这在蛇很短的时候没问题蛇长了之后就有隐患如果棋盘快被填满随机撞上蛇身的概率极高while循环可能要转几百次才落到一个空位上极端情况下甚至会卡死。我用的做法是先收集所有空格再随机取一个。写起来是双重循环遍历整个棋盘24×24 也就 576 次判断一次遍历换一次Math.random()性能完全不是问题而且永远不会退化成死循环。更重要的是这个数组的长度天然就是剩余空格数蛇填满棋盘时empty.length 0直接判定通关不用另外算。这里有个细节placeFood必须在蛇头移动、unshift完成之后调用。如果顺序反了新食物有可能生成在蛇头马上要去的那一格上玩家会觉得明明是我先到的地方怎么冒出来个食物又被我吃了。虽然不影响正确性但手感会怪。6.2 撞墙判定用越界而不是等于边界撞墙判断我写成head.x 0 || head.x COLS而不是head.x -1 || head.x COLS。看着差不多但前者更健壮。万一因为某种原因坐标跳了比如累加器算错了导致一次移动两步后者会漏判蛇可能直接穿墙飞出去前者则一定拦得住。自撞判定必须排除尾巴那一节这个前面提过了。还有一点判定要在unshift之前做。先算出 head 的位置检查它有没有撞到现有的蛇身通过了再把它插进数组。如果先插入再检查你会拿新蛇头跟自己比永远判定为撞。6.3 结束状态要做的事gameOver里我只做两件事改状态、更新最高分。为什么不直接clearInterval或者停止requestAnimationFrame因为我的主循环是常驻的state变成over之后逻辑更新自然不会执行draw还在跑用来画结束遮罩。这样重开只需要把state改回running不用重新启动任何东西代码路径简单很多。最高分存在localStorage里键名是snake_best。读的时候要处理首次访问的情况localStorage.getItem返回nullNumber(null)是 0Number(undefined)是NaN所以必须写成localStorage.getItem(snake_best) || 0用短路把空值挡掉。这个坑很小但不处理的话页面上会显示 NaN很难看。7. 实测中翻车的几个细节与修法7.1 移动端滑动手势的判定阈值触摸操作我用的是touchstart记录起点、touchend算位移向量的方式。阈值设成 24 像素是有原因的设得太小比如 10手指轻微抖动就会被判成滑动手势玩家点一下屏幕想做点什么蛇却突然转向自杀了设得太大比如 60在手机上要划很长一段距离才有反应玩起来累。24 像素大概是一个指甲盖的宽度在手机上刚好是有意识地去划和不小心蹭到的分界线。另外插值判断用的是Math.abs(dx) Math.abs(dy)也就是取绝对值大的那个轴作为主方向这样斜着划也能正确识别。touch-action: none这个 CSS 属性别忘加否则在手机上滑动时页面会跟着滚游戏区跑出屏幕。7.2 高刷屏和低配设备的速度差前面讲的累加器方案已经解决了刷新率问题但还有一种情况会出岔子设备性能太差帧率掉到 30 以下。这时候dt会变大累加器攒得也快while循环一次可能连跑两三步画面看起来就是蛇一顿一顿地窜。这是物理限制没法完全避免但把dt钳制在 200 毫秒以内能保证它不会一次性跑出十步导致瞬间撞墙。我在低端安卓机上实测过140 毫秒的步长下即使帧率掉到 25操作也还能接受。如果把BASE_INTERVAL调到 80 以下低端机上就会明显卡顿。所以速度上限设 70 毫秒是有道理的不只是考虑人类反应速度。7.3 切标签页回来时的瞬移这个前面提过一嘴这里说完整的排查过程。现象是游戏玩到一半切去回消息切回来发现蛇已经撞墙死了而且死相很奇怪——不在角落而是在地图中间某处。第一反应是逻辑有 bug加了日志打印每步的坐标发现从切走的那一刻开始坐标就一路往前冲几十步连着执行完直到撞墙。原因就清楚了requestAnimationFrame在标签页不可见时会暂停回来时的第一个t相比lastTime差了十几秒累加器一下子多了十几秒的量while循环当然要跑到它减完为止。修法就是那行if (dt 200) dt 200。更优雅的做法是监听visibilitychange切回来时主动把lastTime重置。但加个钳制只用一行够用了。7.4 暂停时的隐藏问题暂停功能有个容易忽略的点暂停期间lastTime还在被更新吗答案是还在因为loop一直在跑。所以从暂停恢复的时候dt是正常的一帧间隔不会因为暂停了十分钟就攒出十分钟的进度。这也是主循环常驻这个设计的附带好处。但如果你把暂停实现成cancelAnimationFrame再重新requestAnimationFrame恢复时就必须手动重置lastTime否则同样会瞬移。我用常驻循环就是为了让这类边界情况自动消失。8. 跑通之后这套思路还能往哪些地方搬8.1 从单机玩法往互动方向走基础版本跑通之后加东西的方向其实挺多但我不建议一上来就加障碍物、加传送门、加多蛇对战容易把自己绕进去。比较稳妥的升级路径是先做视觉和手感的打磨给吃到食物加一个短暂的放大动画、给死亡加一个蛇身消散的效果、给转向加一点缓动。这些改动都在draw里做逻辑层完全不用动风险极低但观感提升明显。再往后可以考虑加墙壁穿越模式也就是撞到边界从对面出来实现只是在越界时把坐标取模head.x (head.x COLS) % COLS。这么一改游戏的紧张感来源就从别撞墙变成了纯粹的别撞自己玩法感觉完全不同值得单独做一个开关让玩家选。再进一步是双人对战同一块画布上两条蛇抢同一个食物。这时候要注意 ROWS 和 COLS 得调小一点不然两条蛇在 24×24 的地图里很难遇上。数据结构上把snake换成snakes数组step里循环处理判定时互相检查头碰头、头碰身。工作量比想象中小因为核心的头插尾删逻辑可以直接复用。8.2 换载体同一套状态机不同的输出方式这套东西最有价值的部分其实是那个状态机——snake数组、dir方向、step每步更新、draw负责呈现。把它搬到别的地方改的只是输入和输出两头。比如你手头有块点阵屏想做个小玩意逻辑层可以原封不动照搬只需要把draw换成根据snake和food的坐标去点亮对应的灯珠把setDir换成读取按键或者摇杆。难点会从游戏逻辑转移到驱动和刷新率上那又是另一个话题了但游戏这一层你已经彻底想清楚不会再被它绊住。同样的道理把draw换成往终端输出字符就是一个命令行版本换成 SVG 元素操作就是一个不依赖 canvas 的 DOM 版本。想清楚逻辑和呈现分离这一条一个题目能衍生出七八种实现每一版都只用重写一小部分。这也是我为什么总说贪吃蛇写一遍不够值得隔一段时间换个方式再写一遍。8.3 把它当成调试能力的训练场最后说个我自己的习惯。每写一个能跑的小游戏我都会故意做三件事来测试它的健壮性疯狂快速连按方向键二十下看蛇会不会做出诡异动作把浏览器窗口缩到最小再拉回来看画面有没有错位开着游戏切到别的窗口等一分钟再回来看进度有没有乱。这三个测试基本能覆盖输入、布局和时序三大类问题。贪吃蛇因为逻辑简单出问题时原因很好定位正好拿来练从现象倒推原因的能力。等你习惯了这套排查思路将来面对复杂得多的项目看到类似现象的第一反应就会是是不是时间基准错了而不是漫无目的地翻代码。localStorage那条最高分记录我建议再加一个本局得分历史存最近十次的分数重开的时候在角落画个简易折线图。这东西代码不到二十行但能让你顺便把数组的slice、map和 canvas 画路径都过一遍一举两得。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →