汽车迷宫 · Pygame 多关卡小游戏
Pygame 搭的 2D 汽车迷宫游戏。吃完所有星星才能到终点,多关卡地图 + 矩形/圆形混合碰撞检测 + BGM 音效。打包成独立 exe。
🚗 👉 在首页玩一把(Hero 下方那块) Canvas 2D 手搓的轻量版,零依赖、首屏 < 100ms。 下面记录原版 Pygame 桌面版与首页迷你版两套实现的取舍。
两个版本
从一个 Pygame 本地练手小游戏,到"首页零依赖轻量入口",两种实现各有取舍:
| 版本 | 入口 | 体积 | 技术栈 |
|---|---|---|---|
| 🖥️ 原版 EXE | 本地双击 | ~18 MB | Python 3.12 + Pygame + PyInstaller |
| 🚗 首页迷你版 | 博客首页 Hero 下方 | < 20 KB 单文件 | Vue + Canvas 2D + TypeScript,DFS 生成 + 自适应难度 |
下面按这两个顺序说各自做了什么、踩了什么坑。
A · 原版:Python + Pygame
最初的练手项目,目的是复习 Pygame 的 sprite / group 机制。
玩法
开一辆小车在迷宫里 吃完所有星星,然后开到终点 —— 过关。撞墙直接 crash。每关地图越来越复杂,全部过完显示 Win 画面。
墙壁 (矩形) + 星星 (圆形) + 终点 (圆形) + 玩家 (旋转的矩形)
代码结构
maze/
├── main.py · Pygame 主循环
├── config.py · SCREEN_WIDTH / HEIGHT / FPS
├── game_manager.py · 场景管理器(加载 / 碰撞 / 渲染)
├── player.py · 玩家汽车(移动 + 旋转 + 碰撞处理)
├── wall.py / star.py / target.py · 三种 sprite
├── utils/
│ ├── collide.py · 矩形 AABB + 圆形距离两种检测
│ └── draw_text.py
└── static/
├── images/ sounds/
└── maps/level1.txt level2.txt ...
地图格式(纯文本)
每关一个 .txt,格式是结构化几段数字:
3 # 3 堵墙
10 10 200 20 # 每行 "x y 宽 高"
...
5 # 5 颗星星
100 100 # 每行 "x y"
...
1 # 1 个终点
400 300
400 300 0 # 玩家初始 "x y 朝向角度"
GameManager.load() 逐行解析。加关卡写一份 level3.txt 就行,不用动代码。
碰撞检测 · 两种 shape 混用
- 玩家 vs 墙:
spritecollide(..., collided_rect)矩形 AABB - 玩家 vs 星星 / 终点:
spritecollide(..., collided_circle)圆形距离判定
星星视觉是圆的,圆形检测旋转时不会有"边角冲突"感。
通关条件
if self.stars_cnt == 0: # 所有星星吃完
if spritecollide(self.player, self.targets, True, collided_circle):
self.success_sound.play()
return True # 过关
不是简单"碰终点就赢",而是逼玩家把星星全吃了再收尾,强制绕路。
打包 & 音效
- 打包:PyInstaller 单文件
main.exe(~18 MB),static/用--add-data塞进去 - BGM:
pygame.mixer.music+play(-1)循环,音量 0.1(不抢戏) - SFX:
pygame.mixer.Sound吃星星 / 通关两个短音,音量 0.3
B · 首页迷你版:Canvas 2D + 自适应难度
👉 就在首页 Hero 下方那块,无需加载直接玩
桌面 EXE 不可能让随便逛博客的访客每个都下载来玩。所以首页放了个 < 20 KB 单文件 Vue 组件 当轻量入口 —— 看完博客顺手玩两关,零等待、零下载。
组件:components/games/MiniMaze.vue
设计取舍
- 纯 Canvas 2D:不引 Phaser / Pixi,< 10 KB gzip
- 零音频文件:Web Audio API 现场合成(三角波拾星 + C-E-G-C 大三和弦胜利音)
- 关键视觉不依赖 emoji 字体:星星 / 终点用 emoji(字体缺了也不致命),车是 Canvas 手绘矢量
关卡生成 · DFS backtracker + BFS 最远点
前两关手设当暖身,第 3 关起按玩家水平程序化生成:
function generateLevel(skill: number): string[] {
const s = clamp(skill, 0, 1)
// 网格尺寸随 skill 线性插值;强制奇数(backtracker 每步跨 2 格)
const cols = (Math.round(10 + s * 8) | 1) // 11 → 19
const rows = (Math.round(6 + s * 4) | 1) // 7 → 11
// 1) DFS backtracker 从 (1,1) 开始雕通道
// 2) Braid:随机打通死胡同形成环路,skill 低时多开(更宽松)
// 3) BFS 从起点算距离场,最远可达点放终点 —— 强制走最长对角线
// 4) 星星 2~6 颗随 skill 增加,只放在深度 > 2 的地板
}
关键在第 3 步:BFS 测距找最远点当终点,不是随便扔。保证玩家至少走一遍"最长对角线",不会出现起点旁边就是终点的水关。
自适应难度
每关记录移动次数 + 用时,通关时算"每步平均秒",映射回 skill:
function onLevelWin() {
const spm = secs / moves // seconds per move
const raw = (3 - spm) / (3 - 0.5) // 0.5s/步 → skill 1.0,3s/步 → 0.0
const thisSkill = clamp(raw, 0, 1)
// 滑动平均(旧 40% + 新 60%)—— 避免单关波动跌宕
skillLevel.value = skillLevel.value * 0.4 + thisSkill * 0.6
}
每步 0.5s 内 → 算你老手,下关迷宫放大、星星加多;每步 3s 以上 → 新手,下关小而空旷。难度标签写在通关面板(简单 / 适中 / 有挑战 / 高手级),让玩家意识到系统在调。
手绘车 · 角度旋转
最早用 emoji 🚗 —— 单 emoji 没法转方向,朝左时看起来像倒着走。换成 Canvas 手绘俯视角小车,默认朝 +x,移动方向直接设 facingAngle,ctx.rotate(...) 搞定:
if (dx > 0) player.facingAngle = 0
else if (dx < 0) player.facingAngle = Math.PI
else if (dy < 0) player.facingAngle = -Math.PI / 2
else if (dy > 0) player.facingAngle = Math.PI / 2
车身是 lilac 主调的圆角矩形,加了车顶座舱 / 挡风玻璃反光 / 4 个轮子 / 前黄灯后红灯。dark mode 切更深的紫,跟主站色板一致。
移动插值 · 消除卡顿
最初每按一次键走一格 + setTimeout 触发下一步 —— 连按方向键时看到明显顿挫。现在:
- linear 插值:
player.progress从 0 到 1,STEP_MS = 90(90ms 跨一格,刚好看到动画又不拖沓) - 零间隙续接:当帧
progress >= 1立即检查keysDown,有按键直接启动下一步 —— 不等任何 timer
结果:长按方向键时,车在格子之间 一帧间隙都没有,手感接近原版 Pygame。
回头看
同一个游戏两次重实现,触到的东西远不止游戏本身:
- A · 原版:Pygame 的 sprite / group + PyInstaller 打包
- B · 迷你版:Canvas 2D 渲染 + DFS backtracker 生成 + BFS 距离场 + 自适应难度 + Web Audio 现场合成 + 手绘矢量小车
从"Pygame 桌面打包"到"浏览器零依赖手搓",同一玩法换两套技术栈,是有趣的对照。