1. 背景与目标
贪吃蛇是最适合入门的 2D 网页小游戏之一:规则简单、反馈清晰、可扩展空间大(穿墙模式、道具、多食物、排行榜......)。
demo地址:game.haiyong.site/snake-game....

本项目的目标是:
- 纯前端、零依赖:一个 HTML 文件搞定(你提供的版本已内联 CSS/JS;也可轻松拆分为三文件)。
- 屏幕高清适配(DPR):在 1× 与 2× 屏幕上都不糊。
- 多端输入:键盘 + 触控滑动 + 移动端虚拟方向键。
- 基础玩法完善:吃食物加分加速、不可 180° 反向、穿墙可切换。
- 体验细节:音效开关、本地最高分存档、状态灯、结束面板。
- 架构清晰:有状态机与时间驱动的主循环,易于扩展。
2. 需求拆解与技术选型
2.1 功能需求清单
- 画布区域:固定逻辑分辨率 600×600,基于 20×20 网格。
- 信息面板:分数 / 最高分 / 运行状态 / 难度选择。
- 控制面板:开始/暂停、重置、穿墙模式、音效开关。
- 交互:键盘(方向 / 空格 / R)+ 触控滑动 + 移动端虚拟方向键。
- 玩法:吃食物 +10 分;每 50 分提速(下限 80ms/格);撞墙或撞自己 Game Over。
- 存档:最高分写入
localStorage
。 - 细节:状态指示灯、结束模态、DPR 适配、触控阈值、按钮响应式布局。
2.2 技术栈
- HTML5 Canvas:绘制网格、食物与蛇体。
- CSS:控制面板与信息栏;移动端响应式。
- JavaScript(原生):游戏循环、状态机、事件绑定、碰撞检测。
- Web Audio API(可降级):吃食物与失败音效。
- localStorage:最高分记忆。

3. 界面与样式:把玩法信息「可视化」
你提供的 HTML 结构已经非常贴近上线形态:
.game-info
顶栏用来展示分数、最高分、状态文本与状态灯(.status-indicator
)。.game-controls
包含四个按钮(开始/暂停、重置、穿墙模式、音效)。.mobile-controls
中的.touch-pad
是 3×3 网格,布局出 ↑ ← ↓ → 的虚拟键,仅在 <768px 时显示。.game-message
是结束/提示用的模态层 ,避免alert
破坏体验。
这里有几个小亮点:
-
状态灯 仅用一个
running
类控制颜色(红/绿)。与statusText
文本联动,能快速传达状态。 -
按钮语义与可达性 按钮文本会随状态切换,例如「穿墙模式: 开/关」,让用户始终知道当前配置。
-
响应式体验 使用
@media (max-width: 768px)
切换移动端 UI,桌面端则隐藏虚拟方向。
4. 画布与 DPR 适配:清晰不糊的关键
在高清屏幕上,Canvas 如果只设置 CSS 尺寸会模糊。正确做法是逻辑尺寸 + 像素尺寸分离:
js
const dpr = window.devicePixelRatio || 1;
canvas.width = LOGICAL_WIDTH * dpr; // 实际像素
canvas.height = LOGICAL_HEIGHT * dpr;
canvas.style.width = `${LOGICAL_WIDTH}px`; // 逻辑尺寸(CSS)
canvas.style.height = `${LOGICAL_HEIGHT}px`;
ctx.scale(dpr, dpr); // 坐标系仍按逻辑尺寸绘制
LOGICAL_WIDTH=600
/GRID_SIZE=20
→CELL_SIZE=30
。- 在 2× 屏上,实际像素会是 1200×1200,但我们仍然用 600×600 的坐标系绘制,既清晰 又好算。
5. 核心建模:网格、蛇、食物、状态机
5.1 网格与单位
- 网格 20×20,单位为格(cell);渲染时乘
CELL_SIZE
得到像素位置。 - 你使用了浅色网格线(
rgba(255,255,255,0.1)
)作为背景辅助,这是一个很实用的视觉调试手段。
5.2 蛇(Snake)
- 用数组
snake
表示蛇,从snake[0]
到snake[snake.length-1]
依次为头到尾。 - 每个元素是
{x, y}
的格子坐标。 - 移动 :复制一份
head
,根据方向x±1
或y±1
,再unshift
到数组前端;如果没吃到食物,pop()
尾巴(这就是"向前移动一格"的直觉实现)。
5.3 食物(Food)
generateFood()
随机选格,循环重试直到不与蛇体重叠。- 对于极端情况(蛇很长快满屏),这套策略也能靠多次抽样找到空位;若要更保险,可加入最大重试次数 + 回退(例如扫描第一个空格)。
5.4 状态机(Game State)
paused
→running
→gameOver
三态。- 开始/暂停按钮切换
paused ↔ running
,Game Over 仅在碰撞时进入。 statusText + 状态灯 + 按钮文案
同步反馈当前状态。
6. 主循环:requestAnimationFrame + Tick 节流
游戏循环由 requestAnimationFrame(drawGame)
驱动,但蛇的逻辑步进用固定 Tick 控制(tickInterval
)。核心片段:
js
if (gameState === 'running') {
if (!lastTickTime) lastTickTime = timestamp;
const elapsed = timestamp - lastTickTime;
if (elapsed >= tickInterval) {
lastTickTime = timestamp;
updateGame();
canChangeDirection = true;
}
}
两个点特别关键:
-
时间驱动而不是帧驱动 不同电脑的帧率差异很大,但我们希望"每 N 毫秒前进一格",这就是"基于时间"的 Tick。
-
canChangeDirection
防抖 在一次逻辑步完成之前,禁止再次改向,避免一帧内多次按键导致"瞬间 180° 反向"的非法移动。
7. 碰撞检测:边界与自身
7.1 撞墙
- 非穿墙模式 :只要
head.x/y
越界(<0 或 ≥GRID_SIZE)直接gameOver()
。 - 穿墙模式 :越界则从另一侧出现(如
x<0 → x=GRID_SIZE-1
),让玩家体验更自由。
7.2 撞自己
- 在把新头
unshift
之前,先用一个循环与当前蛇身比较坐标,相等即 Game Over。 - 这里的复杂度是 O(n),在 20×20 网格里瓶颈不明显;如果扩展到大地图,可以考虑用
Set
(key =x#y
)实现 O(1) 查询。
8. 得分、提速与难度
- 每吃一个食物 +10 分 ;每达到 50 分 的整数倍触发
increaseSpeed()
。 increaseSpeed()
逐步把tickInterval
每次减少20ms
,但不低于 80ms 的安全下限。- 下拉框设置初始难度(200/150/100ms),只影响起步速度,后续仍按分数加速。
这种"有限加速"的节奏设计能让玩家感觉逐步紧张但不至于失控。
9. 输入系统:键盘 + 触控滑动 + 虚拟方向键
9.1 键盘
- 方向键与
WASD
等价;空格暂停/开始;R
重置。 - 方向设置统一走
setDirection(newDirection)
,在此处封装禁止 180° 与canChangeDirection
的逻辑,避免重复校验。
9.2 触控滑动
touchstart
记录起点;touchend
计算dx/dy
,绝对值较大者代表滑动方向,并设置一个阈值(50px)过滤误触。- 移动端滑动比点击按钮更自然,尤其在全屏 Canvas 上。
9.3 虚拟方向键
- 在
<768px
显示,由 3×3 网格排布四个方向按钮构成。 - 绑定
touchstart
即可,不争抢touchend
,手感更灵敏。
小建议:如果想进一步提升移动端操控,可给虚拟方向键加入按下/抬起的视觉反馈 (例如
scale(0.96)
+ 投影增强)。
10. 视听与可达性:音效、状态可见、模态反馈
10.1 Web Audio 小音效(可降级)
你用原生 Web Audio 生成了"吃到食物(sine,高音短促)"与"失败(sawtooth,低音略长)"。优点是体积 0 、无资源加载。 浏览器未授权或不支持时静默降级,不会阻塞游戏。
10.2 状态可视化
- 文本 + 状态灯(颜色切换)双重反馈。
开始/暂停
文案与状态保持一致,减少认知负担。- Game Over 用自定义模态层(非
alert
),用户体验更柔和,还能在面板上放"重新开始"。
11. 存档:localStorage 的最高分
- 启动时
loadHighScore()
读取,Game Over 时saveHighScore()
更新。 - 只在分数超过历史时写入,避免无谓的存取。
进阶:你可以把难度、穿墙、是否静音也一并持久化,做到"偏好记忆"。
12. 性能与边界:稳定运行的小技巧
-
标签页切换自动暂停 当前版本在
visibilitychange
未处理。如果实现:- 不要在隐藏时继续 RAF + Tick;主动暂停并在信息栏提示"已自动暂停"。
参考代码:
jsdocument.addEventListener('visibilitychange', () => { if (document.hidden && gameState === 'running') { startPauseGame(); // 触发暂停 statusTextElement.textContent = '已自动暂停'; } });
-
Resize 的幂等性 你在
resize
时调用initGame()
,这会重置蛇与分数。文案已有"适配新窗口"的注释,但对玩家不友好。 更好的做法是:仅重配画布与缩放,不改动游戏状态与数据:jsfunction resizeCanvasOnly() { const dpr = window.devicePixelRatio || 1; ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置 transform canvas.width = LOGICAL_WIDTH * dpr; canvas.height = LOGICAL_HEIGHT * dpr; canvas.style.width = `${LOGICAL_WIDTH}px`; canvas.style.height = `${LOGICAL_HEIGHT}px`; ctx.scale(dpr, dpr); } window.addEventListener('resize', resizeCanvasOnly);
-
自碰撞的优化 当前 O(n) 遍历在 20×20 内完全足够。若你把地图放大,可用
Set
存x#y
哈希来 O(1) 查询。 -
渲染顺序与清屏 你已正确使用
clearRect
+ "网格→食物→蛇"的顺序。若加粒子特效,注意在蛇之后绘制,保证覆盖关系。
13. 常见 Bug 与排查清单
-
按住按键快速抖动,蛇突然反向? 确认
canChangeDirection
是否只在updateGame()
后释放;不要在keydown
处反复释放。 -
移动端滑动不生效或误触严重? 检查
touchstart/touchend.preventDefault()
是否设置;增大滑动阈值(如 70px);避免与页面滚动冲突(Canvas 容器设置touch-action: none
)。 -
DPR 下线条断裂或模糊? 使用偶数像素或对半像素线进行偏移(本项目用浅色网格,影响不大)。
-
最高分没有保存? 确认浏览器隐私模式下
localStorage
是否可用;或被跨域页面嵌套导致安全限制。
14. 可扩展清单(附思路与实现要点)
14.1 反弹墙模式(Bumper)
- 玩法:撞墙不死,方向向内反弹(左右墙翻转
dx
,上下墙翻转dy
),但扣 1 分或扣生命。 - 实现:把越界处的判断从
gameOver()
改为反向修正,同时加一个lives
或score--
。
14.2 多食物与特殊物品
- 普通食物:+10 分;
- 金色食物:限时出现,+30 分,吃到播放不同音效;
- 毒苹果:吃到减速或扣分;
- 实现:维护食物数组与
type
字段,渲染时区分颜色与大小。
14.3 关卡与任务
- 目标:在 60 秒内达到 200 分;
- 限制:禁止穿墙、限定初始难度;
- 奖励:关卡完成后解锁皮肤或粒子特效。
14.4 皮肤与主题
-
预置主题对象:
jsconst themes = { classic: { bg:'#000', snake:'#4CAF50', head:'#FFC107', food:'#F44336' }, neon: { bg:'#0a0a0a', snake:'#00e5ff', head:'#b388ff', food:'#ff6e40' }, };
-
在渲染函数里用主题色,配合下拉或按钮切换。
14.5 录像与回放(Ghost)
- 记录每个 Tick 的方向与食物坐标,生成"幽灵蛇"数据。
- 回放时在同一张地图重放路径,玩家可挑战自己最佳路线。
15. 关键代码走读与讲解
下面选取几个代表性的代码片段做解构说明(与原代码保持一致/等价),帮助你在文章或讲座里逐步带读。
15.1 初始化与重置
js
function initGame() {
const dpr = window.devicePixelRatio || 1;
canvas.width = LOGICAL_WIDTH * dpr;
canvas.height = LOGICAL_HEIGHT * dpr;
canvas.style.width = `${LOGICAL_WIDTH}px`;
canvas.style.height = `${LOGICAL_HEIGHT}px`;
ctx.scale(dpr, dpr);
loadHighScore();
resetGame();
}
function resetGame() {
snake = [];
for (let i = INITIAL_SNAKE_LENGTH - 1; i >= 0; i--) {
snake.push({ x: i, y: Math.floor(GRID_SIZE / 2) });
}
direction = nextDirection = 'right';
generateFood();
score = 0;
updateScore();
gameState = 'paused';
statusTextElement.textContent = '已暂停';
statusIndicatorElement.classList.remove('running');
startPauseBtn.textContent = '开始';
setDifficulty(difficultySelectElement.value);
gameMessage.style.display = 'none';
drawGame(); // 先绘一帧静态画面
}
解读:
snake
从中线开始,向右排 3 格;- 先绘制一帧静态画面,再等待开始指令;
- 所有 UI 状态(文本、指示灯、按钮)与
gameState
一致,是"紧耦合"的必要同步。
15.2 更新一步(游戏逻辑)
js
function updateGame() {
direction = nextDirection; // 只在 Tick 边界切换方向
const head = { ...snake[0] }; // 拷贝头部
switch (direction) {
case 'up': head.y -= 1; break;
case 'down': head.y += 1; break;
case 'left': head.x -= 1; break;
case 'right': head.x += 1; break;
}
if (!wallThroughMode) {
if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {
gameOver(); return;
}
} else {
if (head.x < 0) head.x = GRID_SIZE - 1;
else if (head.x >= GRID_SIZE) head.x = 0;
if (head.y < 0) head.y = GRID_SIZE - 1;
else if (head.y >= GRID_SIZE) head.y = 0;
}
for (let segment of snake) {
if (segment.x === head.x && segment.y === head.y) {
gameOver(); return;
}
}
snake.unshift(head);
if (head.x === food.x && head.y === food.y) {
score += 10;
updateScore();
playSound('eat');
generateFood();
if (score % SCORE_THRESHOLD === 0) increaseSpeed();
} else {
snake.pop();
}
}
解读:
- 方向的延迟生效 (Tick 边界切换)配合
canChangeDirection
,保证没有"同帧多次拐弯"的竞态。 - 穿墙与越界死亡两个分支互斥,逻辑清晰;
- "吃到食物"才增长,否则移除尾巴维持长度不变。
15.3 触控滑动的方向判定
js
canvas.addEventListener('touchend', (e) => {
if (gameState === 'gameOver') return;
e.preventDefault();
if (!touchStartX || !touchStartY) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartX;
const dy = touch.clientY - touchStartY;
if (Math.abs(dx) > Math.abs(dy)) {
if (dx > 50) setDirection('right');
else if (dx < -50) setDirection('left');
} else {
if (dy > 50) setDirection('down');
else if (dy < -50) setDirection('up');
}
touchStartX = 0;
touchStartY = 0;
});
解读:
- 方向由主轴位移决定(横向或纵向);
- 50px 阈值过滤轻微滑动;
preventDefault()
避免浏览器把滑动当滚动处理。
16. 代码打磨:两处值得改进的小细节
-
音频上下文复用 每次
playSound
都创建AudioContext
成本较高,且部分浏览器限制实例数量。可以外部维护一个惰性单例:jslet audioCtx; function getAudioCtx() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); return audioCtx; } function playSound(type) { if (!soundEnabled) return; try { const audioContext = getAudioCtx(); const osc = audioContext.createOscillator(); const gain = audioContext.createGain(); osc.connect(gain); gain.connect(audioContext.destination); // ...同原逻辑 } catch (e) { /* 静默 */ } }
-
重绘请求的幂等性
drawGame()
内部会requestAnimationFrame(drawGame)
,启动时你又在start()
调了drawGame()
一次是对的,但要避免重复绑定导致多重 RAF (当前代码没问题,因为调用链单一)。如果以后抽取模块,注意只在唯一入口里开始 RAF。
总结
把这套工程化骨架掌握住,你基本就拥有了前端小游戏的"模板思维":
- 数据结构先行(网格→蛇→食物);
- 状态机护航(paused/running/gameOver);
- 时间驱动循环(Tick 节流);
- 交互合流(键盘/触控/虚拟键统一到
setDirection
); - 体验闭环(状态灯/模态/音效/存档);
- 渐进增强(DPR/移动端)。