弹珠迷宫python版
行空板 K10 弹珠迷宫
一、项目背景
1.1 需求来源
原项目是 DFRobot 社区上一篇弹珠迷宫教程:用一块带屏幕和三轴加速度计的小型开发板,做一个"靠倾斜板子控制弹珠滚出迷宫"的掌上小游戏。原方案硬件是 Stick S3,开发方式是 Arduino C++。
1.2 硬件平台
行空板 K10 是一块高度集成的 ESP32-S3 教学板,本项目用到的资源全部是板载的,没有外接任何一个元件:
| 板载资源 | 规格 | 在本项目中的用途 |
|---|---|---|
| 主控 | ESP32-S3 | 运行 MicroPython |
| 屏幕 | 2.8" ILI9341,240×320 | 显示迷宫与弹珠 |
| 三轴加速度计 | SC7A20H | 感知倾斜,作为"重力"输入 |
| 按键 | A / B 两个 | A=开始/换图,B=校正方向 |
| RGB 灯 | 3 × WS2812 | 方向模式指示、胜利提示 |
| 扬声器 | I2S + ES8311 | 撞墙/胜利音效 |
| USB-C | 原生 USB Serial/JTAG | 供电、烧录、REPL |
固件版本:micropython_unihiker_k10&Box_20260430_V0.9.8.bin
MicroPython 版本标识:05bed2375-dirty on 2026-04-30; Generic ESP32S3 module
二、功能介绍
2.1 玩法
倾斜板子,靠"重力"让金色弹珠在迷宫里滚动,滚进右上角的红色终点即通关。计时从开局开始,用时越短越好。
2.2 功能清单
| 类别 | 功能 | 说明 |
|---|---|---|
| 关卡 | 随机迷宫 | 15×17 格,每局重新生成,用随机 DFS 保证有解且全连通 |
| 操作 | A 键 | 菜单页=开始游戏(同时重新校准水平);游戏中=换一张新迷宫;胜利页=回菜单 |
| 操作 | B 键 | 循环切换 4 种重力方向映射,解决"弹珠往反方向滚"的问题 |
| 输入 | 加速度计 | 实时读取倾斜量,转成弹珠加速度 |
| 输入 | 开机校准 | 开局采样 16 次当前姿态作为"水平基准",所以可以用任意舒服的姿势握持 |
| 显示 | 顶栏 | 实时计时 + 步数 |
| 显示 | 底栏 | 操作提示 + 当前方向模式 |
| 显示 | 菜单页 | 标题、玩法说明、演示小球、历史最佳 |
| 显示 | 胜利页 | 用时、步数、是否破纪录 |
| 音效 | 撞墙音 | 180 Hz 短音,带 180 ms 冷却防连响 |
| 音效 | 开始音 / 胜利音 | 开始=两声上行;胜利=C-E-G-C 上行琶音 |
| 存档 | 最佳成绩 | 写入板载文件系统 maze_best.txt,断电不丢 |
2.3 状态机
程序有三个状态,由 A 键驱动流转:
A键(开始) 到达终点
[菜单页] ──────────> [游戏中] ──────────> [胜利页]
^ | |
| | A键(换图) | A键
└────────────────────┘ └──> [菜单页]
B键:在菜单页/游戏页随时循环切换方向模式(1→2→3→4→1)
三、硬件清单
3.1 必需件
| 序号 | 名称 | 数量 | 备注 |
|---|---|---|---|
| 1 | 行空板 K10 | 1 | 主控 + 屏幕 + 加速度计 + 按键 + 喇叭全在板上 |
| 2 | USB-C 数据线 | 1 | 需支持数据传输,不能是纯充电线 |
| 3 | 电脑(Windows) | 1 | 用于烧录和上传程序;板子识别为 COM3 |
就这些。 本项目没有外接传感器、没有杜邦线、没有焊接——弹珠是画在屏幕上的虚拟小球,重力是板载加速度计的读数。
四、程序编写
4.1 整体结构
源码 15_marble_maze.py 共 592 行,从上到下分为 8 个模块:
| 序号 | 模块 | 主要函数 | 作用 |
|---|---|---|---|
| 1 | 配置区 | 常量定义 | 分辨率、迷宫尺寸、颜色、物理参数 |
| 2 | 方向映射 | apply_orient() |
4 种重力方向组合,B 键切换 |
| 3 | 迷宫生成 | shuffle4(), gen_maze(), wall_at(), cell_cx/cy() |
随机 DFS 生成完美迷宫 |
| 4 | 音效 | tone(), sfx_bump(), sfx_win(), sfx_start() |
扬声器兼容封装 |
| 5 | 校准 | calibrate() |
采样当前姿态作为水平基准 |
| 6 | 物理 | update_ball() |
加速度→速度→位移→碰撞 |
| 7 | 绘制 | draw_maze_full(), erase_region(), draw_ball(), draw_bar(), render_play_full(), render_menu(), render_win() |
全量绘制 + 增量绘制 |
| 8 | 存档与主循环 | load_best(), save_best(), main() |
最佳成绩持久化;状态机与帧循环 |
5.2 配置区:把"魔法数字"集中管理
SCREEN_DIR = 2 # 2 = 竖屏 240x320
MAZE_COLS = 15 # 列数(必须奇数)
MAZE_ROWS = 17 # 行数(必须奇数)
CELL = 16 # 每格像素
TOP_BAR = 20 # 顶部信息栏高度(容纳16号字)
MAZE_Y0 = TOP_BAR
FOOT_Y = TOP_BAR + MAZE_H # 底部提示区起始 y (290)
BALL_R = 5 # 弹珠半径(像素)
### 5.3 迷宫生成:随机 DFS
```python
def gen_maze(cols, rows):
"""随机深度优先(DFS)生成完美迷宫。返回 bytearray, 1=墙 0=路。"""
g = bytearray([1] * (cols * rows))
stack = [(1, 1)]
g[1 * cols + 1] = 0
dirs = [(0, 2), (0, -2), (2, 0), (-2, 0)]
while stack:
cr, cc = stack[-1]
d = shuffle4(list(dirs))
moved = False
for dr, dc in d:
nr, nc = cr + dr, cc + dc
if 0 < nr < rows - 1 and 0 < nc < cols - 1 and g[nr * cols + nc] == 1:
g[((cr + nr) // 2) * cols + (cc + nc) // 2] = 0 # 打通中间墙
g[nr * cols + nc] = 0
stack.append((nr, nc))
moved = True
break
if not moved:
stack.pop()
return g
说明:
- 先把整张图填成墙(全 1),然后从 (1,1) 开始"挖洞"。
- 每次只朝偶数步方向跳两格(步长 2),并把起点和目标格之间的那格墙也打通——这就是
g[((cr+nr)//2)*cols + (cc+nc)//2] = 0这一行的意义。 - 用显式栈而不是递归:MicroPython 的递归深度有限,17×15 的迷宫递归会爆栈。
- 用
bytearray而不是 list:255 个格子只占 255 字节,ESP32 内存吃紧,这很重要。
5.4 物理模块:从倾斜到移动
def update_ball():
global bx, by, vx, vy, moves
ax = (acce.read_x() - bias_x) * X_DIR
ay = (acce.read_y() - bias_y) * Y_DIR
# 死区
if abs(ax) < DEADZONE:
ax = 0.0
else:
ax = ax - DEADZONE if ax > 0 else ax + DEADZONE
# 加速度 -> 速度, 再摩擦衰减
vx = (vx + ax * ACCEL_SCALE) * FRICTION
vy = (vy + ay * ACCEL_SCALE) * FRICTION
# ... 限速 ...
四步流水线:读加速度 → 减基准 → 去死区 → 积分成速度(带摩擦)。
bias_x / bias_y是calibrate()采到的水平基准,减去它意味着"相对于你握持时的姿态"计算倾斜,而不是相对于绝对水平面。这条设计让游戏非常好上手。* X_DIR/* Y_DIR是方向符号,见 5.5。
碰撞采用分轴处理,这是关键:
# ---- X 轴移动 + 碰撞 ----
if abs(vx) > 0.03:
nx = bx + vx
ex = nx + BALL_R if vx > 0 else nx - BALL_R # 检测边缘点
ec = int(ex) // CELL
er = int(by - MAZE_Y0) // CELL
if wall_at(ec, er):
nx = (ec * CELL - BALL_R - 1) if vx > 0 else ((ec + 1) * CELL + BALL_R + 1)
vx = 0.0
sfx_bump()
bx = nx
意义:X、Y 分开算,每轴撞墙只清掉该轴速度、并把弹珠贴到墙面前 1 px(- BALL_R - 1),另一轴速度保留。效果是弹珠会沿墙滑行,而不是撞墙就死死停住——这是手感好坏的分水岭。
检测点用 bx ± BALL_R(球边缘)而不是球心,保证球体不会视觉上嵌进墙里。
最后判断终点:
bc = int(bx) // CELL
br = int(by - MAZE_Y0) // CELL
return bc == goal_c and br == goal_r
六、总结:遇到的问题与解决办法
这是本项目真正有价值的部分。按时间顺序整理,每项都写明现象 → 原因 → 解决办法。
| # | 问题 | 现象 | 根本原因 | 解决办法 |
|---|---|---|---|---|
| 1 | 烧录/运行崩溃 | MemoryError: memory allocation failed, allocating 1013454792 bytes |
显示驱动被重复初始化(上一次程序还在跑,又跑一次) | 每次 mpremote run 前先复位板子;封装成 run_k10.sh |
| 2 | 字号报错 | AttributeError: module has no attribute 'font_montserrat_12' |
K10 的 MicroPython 不支持 12 号字 | 实测可用字号为 8/10/14/16/18/20/22/24/28/32;全部改用 14 或 16,顶栏高度相应从 18 加到 20 |
| 3 | 洗牌报错 | AttributeError: module has no attribute 'shuffle' |
MicroPython 的 random 是裁剪版,没有 shuffle |
手写 shuffle4()(Fisher-Yates,只需洗 4 个元素) |
| 4 | 帧率只有 4.7 FPS | 弹珠一顿一顿地瞬移 | 每帧全量重绘 144 个矩形 ≈ 210 ms | 发现 show_draw() 会清空绘制队列 → 改为脏矩形增量渲染,迷宫只画一次,每帧只擦 1~4 格 + 画 1 个球,降到 ≈50 ms / 19 FPS |
| 5 | 弹珠往反方向滚 | 板子往左倾,球往右走;静止时速度不为 0 | 加速度计 X/Y 轴符号取决于传感器安装方向,离线无法确定(实测水平静置 X≈18、Y≈-21、Z≈-946,1g≈946) | 不猜:加 X_DIR/Y_DIR 常量,B 键循环切换 4 种组合,使用者一键试出正确的那一种;同时用 RGB 灯号指示当前模式 |
| 6 | 加速度单位困惑 | 水平放桌面时 Z 读数是 -946 而不是 9.8 | 驱动返回的是原始计数值,1g ≈ 946;负号说明 Z 轴方向朝下 | 文档化"1g ≈ 946 原始单位",物理参数全部按此标定(死区 45 ≈ 0.048g) |
| 7 | 扬声器 API 不明 | 想确认 playTone 的签名,一调用板子就 Guru Meditation |
ESP32-S3 上对 C 层函数做参数试探会直接崩溃 | 放弃探测,改为 try/except 双签名兜底(playTone → play_tone → 静默),保证音效失败也不影响游戏 |
| 8 | USB 串口被占死 | 程序跑起来后串口读 0 字节、write 报 Write timeout、mpremote 连接挂死 |
主循环持续渲染时 ESP32-S3 的 USB CDC 接收不再被排空 | ① 硬件复位(翻转 RTS/DTR 触发 USB_UART_CHIP_RESET);② 代码里加上电 2 秒按住 A 键退出到 REPL 的逃生窗口 |
| 9 | 崩溃后串口句柄失效 | 板子重启后,宿主机已打开的串口写超时、读 0 字节 | USB 重新枚举,旧句柄作废 | 每次操作前重新 serial.Serial() 打开端口 |
| 10 | 软复位不管用 | 发 Ctrl+C + Ctrl+D 后程序照样占死 | 软重启会重新执行 main.py,屏幕照样被占 | 改用 RTS/DTR 硬件复位,并在 main.py 启动窗口内连发 Ctrl+C 打断 |
| 11 | 区分不了"死机"和"正常运行" | 串口 20 秒 0 字节输出 | 游戏源码本身没有任何 print |
判断依据是关键字扫描(Traceback / Guru Meditation / MemoryError),而不是"有没有输出";同时给程序加了 boot 打印 |
| 12 | 递归深度风险 | 迷宫 DFS 若用递归可能爆栈 | MicroPython 栈很浅 | 一开始就写成显式栈迭代版本 |




