AI 编程实践:用 Trae 快速开发 HTML 贪吃蛇游戏
1. 背景与目标
贪吃蛇是最适合入门的 2D 网页小游戏之一:规则简单、反馈清晰、可扩展空间大(穿墙模式、道具、多食物、排行榜……)。
demo地址:https://game.haiyong.site/snake-game.html
本项目的目标是:
- 纯前端、零依赖:一个 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 尺寸会模糊。正确做法是逻辑尺寸 + 像素尺寸分离:
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
)。核心片段:
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;主动暂停并在信息栏提示“已自动暂停”。
参考代码:
document.addEventListener('visibilitychange', () => {if (document.hidden && gameState === 'running') {startPauseGame(); // 触发暂停statusTextElement.textContent = '已自动暂停';} });
-
Resize 的幂等性
你在resize
时调用initGame()
,这会重置蛇与分数。文案已有“适配新窗口”的注释,但对玩家不友好。
更好的做法是:仅重配画布与缩放,不改动游戏状态与数据:function resizeCanvasOnly() {const dpr = window.devicePixelRatio || 1;ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置 transformcanvas.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 皮肤与主题
-
预置主题对象:
const 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 初始化与重置
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 更新一步(游戏逻辑)
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 触控滑动的方向判定
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
成本较高,且部分浏览器限制实例数量。可以外部维护一个惰性单例:let 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/移动端)。