一、所选任务介绍
【RP2040游戏机】重力迷宫滚球小游戏
使用姿态传感器(IMU)俯仰和横滚控制小球在迷宫中移动,LCD 实时显示。
- 设计至少 3 关固定迷宫;按键实现开始、暂停、重开与关卡切换。
- 计时与得分显示在屏幕;蜂鸣器提示碰壁与通关。
- 可选加分:迷宫地图从板载 Flash 读取或由上位机串口下发。
二、项目介绍
Gravity Maze 是一款运行在嵌入式开发板上的重力感应迷宫游戏。玩家无需使用任何按键控制方向,而是通过物理倾斜开发板来控制金色小球在迷宫中滚动,引导小球从绿色起点出发,穿越迷宫通道,最终到达红色终点完成过关。
游戏共设计三个难度递增的关卡,配备实时计分系统——用时越短得分越高,三关累计总分作为最终成绩。同时支持蜂鸣器音效反馈(碰墙音效、过关音乐),提升游戏沉浸感。
核心亮点:
- 纯物理倾斜控制,无需方向键,操作直觉自然
- 基于 MMA7660 加速度计的完整姿态解算链路(零偏校准 → 低通滤波 → 归一化 → 死区映射)
- X/Y 轴独立参数调优,解决不同方向灵敏度不一致问题
- 增量渲染策略,规避 SPI LCD 带宽瓶颈,保证流畅帧率
三、硬件介绍
- 采用树莓派Pico核心芯片RP2040:
- 双核Arm Cortex M0+内核,可以运行到133MHz
- 264KB内存
- 性能强大、高度灵活的可编程IO可用于高速数字接口
- 片内温度传感器、并支持外部4路模拟信号输入,内部ADC采样率高达500Ksps、12位精度
- 支持MicroPython、C、C++编程
- 板上功能:
- 240*240分辨率的彩色IPS LCD,SPI接口,控制器为ST7789
- 四向摇杆 + 2个轻触按键 + 一个三轴姿态传感器MMA7660用做输入控制
- 板上外扩2MB Flash,预刷MicroPython的UF2固件
- 一个红外接收管 + 一个红外发射管
- 一个三轴姿态传感器MMA7660
- 一个蜂鸣器
- 双排16Pin连接器,有SPI、I2C以及2路模拟信号输入
- 可以使用MicroPython、C、C++编程
- USB Type C连接器用于供电、程序下载
四、方案框图与设计思路
4.1系统架构框图

4.2数据流向

4.3核心设计思路
1.姿态解算:为何不直接用 XY 的 LSB 值
MMA7660 测量的是重力在三轴的投影,倾斜角与 X 轴读数是 sin 关系,非线性。且不同个体 1g 对应的 LSB 数不同(18~23 之间),直接用 LSB 值会导致不同设备灵敏度不一致。采用 sin(atan2(ax, az)) 归一化:用 Z 轴作为参考重力量,输出的坡度值与设备个体差异无关,只与真实倾斜角对应。
2.物理模型:目标速度插值而非加速度积分
旧方案(加速度积分)的问题:放平设备后速度靠摩擦衰减,约需 10+ 帧才能停止,手感像在冰面滑行。
新方案(目标速度插值):倾斜角决定目标速度,当前速度向目标速度插值追踪。放平时目标速度=0,约 5 帧(150ms)内完全停止,手感"跟手"。
3.X/Y 轴独立参数
由于手持设备时左右倾(手腕旋转)和前后倾(手肘抬放)的动作幅度天然不同,MMA7660 在两轴的噪声特性也不同,共用同一套参数必然导致两个方向手感不平衡。X/Y 轴分别设置死区、满速尺度、速度上限、低通系数和插值系数。
五、软件说明与关键代码
5.1开发环境
编程语言 | MicroPython |
运行环境 | RP2040 MicroPython 固件 |
调试工具 | Thonny IDE(代码烧录、串口监视器、REPL 交互调试) |
5.2 软件流程图
上电启动
│
▼
硬件初始化(LCD / IMU / 蜂鸣器 / 按键)
│
▼
IMU 校准(预热16帧 → 采集40帧 → 计算零偏)
│
▼
显示主菜单(MENU状态)
│
├── Start/Select键:切换关卡选择
├── 长按Select(1.8s):重新校准IMU
└── A键:进入游戏
│
▼
PLAYING 状态(主循环 30ms/帧)
│
├── read_attitude() 读取IMU → 目标速度
│ │
│ ├── I2C读取XYZ
│ ├── 减零偏 → 低通滤波
│ ├── sin归一化 → gx/gy
│ └── 分轴映射 → target_vx/vy
│
├── physics_update() 物理更新
│ │
│ ├── 速度插值(非对称lerp)
│ ├── 极小速度归零
│ └── 子步碰撞(4步)
│ │
│ ├── 碰墙 → 回退 + 速度清零 + 蜂鸣
│ └── 过关 → 记录得分
│
├── update_render() 增量渲染
│ │
│ ├── 位置变化 → 擦旧位置(按格恢复底色)
│ ├── 画新位置(黄色小球)
│ └── 每秒刷新状态栏(时间/得分)
│
├── B键 → PAUSED 状态
│ ├── A键 → 恢复 PLAYING
│ └── B键 → 返回 MENU
│
└── 过关 → 下一关 或 WIN 状态
│
└── A键 → 返回 MENU
5.3 关键代码介绍
IMU 多频率重试初始化
def _init_imu():
for freq in (10_000, 5_000): # 先高频后低频尝试
try:
bus = machine.I2C(...)
found = bus.scan()
if MMA7660_ADDR not in found:
continue
# 尝试写配置寄存器(失败则降级为只读模式)
for attempt in range(5):
try:
bus.writeto_mem(MMA7660_ADDR, _REG_MODE, bytes([0x00]))
...
write_ok = True
break
except OSError:
time.sleep_ms(50 * (attempt + 1))
# 验证读取有效性(至少2帧alert bit未置位)
if valid >= 2:
imu_mode = "rw" if write_ok else "ro"
return
sin(atan2) 归一化坡度计算
# Z轴防零保护(避免除零)
az = _lpf_rz if abs(_lpf_rz) > 3.0 else math.copysign(3.0, _lpf_rz)
# sin(atan2) 归一化:消除个体增益差异
# gx = sin(atan2(ax, az)) = ax / sqrt(ax² + az²)
denom_x = max(math.sqrt(_lpf_rx * _lpf_rx + az * az), 1e-6)
denom_y = max(math.sqrt(_lpf_ry * _lpf_ry + az * az), 1e-6)
gx = -_lpf_rx / denom_x # 取反使方向与屏幕坐标系一致
gy = -_lpf_ry / denom_y
分轴独立参数映射
def _map_axis(gv, dead, scale, max_spd):
"""
死区 + 线性映射:
|gv| < dead → 0(静止)
dead ≤ |gv| < dead+scale → 线性插值到 max_spd
|gv| ≥ dead+scale → max_spd(夹位满速)
"""
av = abs(gv)
if av < dead:
return 0.0
sign = 1.0 if gv > 0 else -1.0
ratio = min((av - dead) / scale, 1.0)
return sign * ratio * max_spd
# X/Y 轴分别传入独立参数
tvx = _map_axis(gx, _DEAD_GX, _SCALE_X, _MAX_SPD_X)
tvy = _map_axis(gy, _DEAD_GY, _SCALE_Y, _MAX_SPD_Y)
非对称速度插值(跟手感的关键)
def _lerp_vx(self, cur, tgt):
# 判断是加速还是制动
accel = (abs(tgt) > abs(cur)) and (tgt * cur >= 0)
c = self.LERP_ACCEL_X if accel else self.LERP_BRAKE_X
return cur + (tgt - cur) * c
# 停手时 tgt=0,每帧速度衰减:
# v(n) = v(n-1) × (1 - LERP_BRAKE) = v × 0.45
# 约5帧(150ms)内速度归零
按格恢复底色(增量渲染核心)
def _restore_bg(self, x, y):
# 计算小球 bounding box 覆盖的格子范围
col_min = max(0, (int(x)-r) // GRID_SIZE)
col_max = min(GRID_COLS-1, (int(x)+r) // GRID_SIZE)
# 对每个被覆盖的格子,用该格真实颜色填充交集区域
for row in range(row_min, row_max+1):
for col in range(col_min, col_max+1):
color = self._cell_color(lvl[row][col]) # 墙/起点/终点/通道
# 计算交集矩形
px0 = max(int(x)-r, col*GRID_SIZE)
px1 = min(int(x)+r, col*GRID_SIZE+GRID_SIZE-1)
self._fill_rect(px0, py0, px1-px0+1, py1-py0+1, color)
六、功能展示

七、遇到的难题及解决方法
1. 小球响应迟钝,必须大角度倾斜才会移动
这是项目中最影响体验的核心问题之一。在初版程序中,玩家需要将设备倾斜到比较大的角度,小球才会开始缓慢移动,导致操作明显“发闷”,失去了重力控制应有的自然感。深入分析后发现,该问题并非单一参数设置错误,而是由多个环节叠加造成的:
- 动态死区偏大:校准时根据噪声峰值自动计算死区,虽然提高了抗抖能力,但在部分设备上死区被放大,吞掉了大量小角度有效输入;
- 速度映射增益不足:原有映射公式对小角度倾斜不够敏感,导致轻微姿态变化无法转化为明显速度;
- 整体速度上限偏保守:即使检测到倾斜,映射到小球上的运动速度仍然较低,视觉反馈不明显。
针对这一问题,最终做了三方面优化:
- 将动态死区改为固定小死区,只保留抑制静止抖动所需的最小范围;
- 重构姿态到速度的映射关系,提升小角度区域的输出增益,让轻微倾斜也能产生可感知的运动;
- 重新匹配速度上限与插值参数,让小球启动更及时、反馈更直接。
在优化后,小球在轻微倾斜时即可自然启动,整体操控感明显提升。
2. 左右方向过快、上下方向过慢,控制手感不统一
在解决灵敏度问题后,又出现了新的体验差异:左右倾斜时小球移动过快,容易撞墙;而上下方向依旧偏慢,需要更明显的前后倾动作才能获得理想效果。这一现象说明,单纯提高整体灵敏度并不能从根本上解决问题。继续分析后发现,问题来自于X 轴和 Y 轴在真实使用场景中的天然差异:
- 左右操作更多依赖手腕旋转,动作幅度通常更大、变化更快;
- 上下操作更依赖手臂前后抬放,受握持姿势影响更明显,输入往往更弱;
- 同时,MMA7660 在两个方向上的噪声表现和实际响应也并不完全一致。
因此,项目最终放弃了“两个方向共用一套参数”的简单做法,转而采用分轴独立调参方案:
- X/Y 轴分别设置独立的死区;
- 分别设置满速映射尺度;
- 分别设置最高速度;
- 分别设置低通滤波系数;
- 分别设置加速与制动插值参数。
这样的设计使得左右方向更加平稳可控,上下方向更加灵敏自然,最终让两个方向的操控手感趋于一致,整体体验更协调。
3. LCD 全屏刷新过慢,导致画面卡顿明显
在嵌入式图形显示中,性能瓶颈往往不在计算,而在数据传输。项目早期采用较直接的全屏刷新方式,每次更新小球位置都重新绘制大面积区域。虽然实现简单,但在 ST7789 SPI 屏幕上表现非常不理想,画面刷新明显卡顿,影响操作流畅度。问题根源在于:240×240 的彩色屏幕数据量较大,而 SPI 接口带宽有限,MicroPython 环境下全屏重绘的开销更加明显。每一帧都进行大量无意义的重复绘制,直接拖慢了整体刷新速度。为此,项目对显示策略进行了系统优化:
- 采用增量刷新机制:只更新发生变化的区域,而不是每次重绘整屏;
- 小球区域局部擦除 + 重绘:每帧只处理旧位置与新位置;
- 状态栏按需刷新:只有时间或得分发生变化时才更新文本区域;
- 按格恢复背景颜色:擦除小球时,不再简单用单一背景色覆盖,而是根据小球经过的迷宫格类型恢复对应颜色,避免破坏墙体、起点、终点等视觉元素。
经过优化后,屏幕刷新压力大幅下降,动画流畅性明显提升,小球移动过程更加连贯自然。
4. 小球“停手不停”,惯性过强,难以精确控制
在最初的物理实现中,小球运动采用的是类似“加速度积分”的方式:倾斜越久,速度越大;放平之后,再通过摩擦逐渐减速。这个模型虽然看起来更接近真实物理,但在迷宫游戏中却带来了明显问题——操控不够跟手。具体表现为:
- 玩家已经将设备放平,小球仍然会继续滑动;
- 狭窄通道中很难精准停在想要的位置;
- 反向修正时存在明显拖尾,容易造成连续撞墙。
从游戏体验角度看,这种“真实惯性”反而削弱了控制感。因此,项目重新设计了小球的运动模型:不再把倾斜量视为“持续加速度”,而是视为“目标速度”。
新的控制思路是:
- 当前姿态先映射为一个目标速度;
- 小球当前速度再逐步向目标速度靠拢;
- 当设备放平时,目标速度立即变为 0,小球会快速减速停止。
同时,为了优化手感,系统还采用了非对称插值策略:
- 启动时加速稍缓,避免突然冲出;
- 停止和反向时制动更快,增强“停手即停”的可控性。
这一改动大幅提升了操作精度,使小球既保留一定平滑感,又能在关键位置迅速停住,更符合迷宫游戏对控制精确性的要求。
5. 传感器分辨率有限,滤波强了没反应,滤波弱了又抖动
MMA7660 只有 6bit 分辨率,本身输出数据的量化台阶感比较明显。这意味着系统既不能“不过滤”,也不能“过度滤波”:
- 如果不做滤波,小球在设备静止时也会轻微抖动;
- 如果滤波过强,小角度有效姿态变化又会被直接抹平,导致控制迟钝。
这实际上是一个典型的平衡问题:既要保留细小动作带来的真实控制输入,又要尽量抑制硬件噪声与量化跳变。最终,项目采用了以下方案:
- 在归一化之前的 LSB 域进行低通滤波;
- 使用单阶低通,避免多级滤波导致响应过慢;
- 对 X/Y 两轴分别设置滤波参数,使不同方向获得更匹配的动态表现。
这一方案成功在“稳定性”和“灵敏度”之间取得了平衡,让系统既不会抖动明显,又能保留足够的操控细节。
八、心得体会
关于嵌入式传感器数据处理:
项目最大的收获是对 IMU 数据处理有了完整的认识。一开始认为把加速度计的 XY 轴读数直接当作控制量是最简单直接的方案,但实际调试中发现 6bit 分辨率的传感器噪声、个体增益差异、非线性特性叠加在一起,直接使用原始数据根本无法得到稳定可控的输出。经历了从"直接用原始值"到"加低通滤波"到"归一化坡度"再到"分轴独立参数"的完整迭代过程,才真正理解了为什么工程中的传感器数据处理需要这么多环节——每一个环节都是在解决一个具体的实际问题,而不是为了复杂而复杂。
关于物理手感调优:
"停手即停"这个看似简单的需求,背后是物理模型的根本性转变。从加速度积分模型改为目标速度插值模型之后,停手响应从 10+ 帧缩短到 5 帧,这是模型层面的改进,而不是参数微调能够解决的。这让我意识到:当参数怎么调都无法达到预期效果时,应该先反思模型本身是否合适,而不是继续在错误的模型上堆调参。
关于嵌入式资源限制:
LCD 刷新帧率问题让我对"算法复杂度"有了更直观的感受。在 PC 上 fill_rect 是可以忽略不计的操作,但在 RP2040 + SPI LCD 上,一次全屏刷新就是 200ms 的延迟。增量渲染方案将有效数据量压缩了约 99%,帧率从 5fps 提升到流畅的 30fps。嵌入式开发中,了解硬件瓶颈在哪里,并在设计阶段就针对瓶颈做架构决策,比事后优化要高效得多。
关于调试方法:
串口 print 调试在嵌入式中依然是最高效的手段。每个模块初始化时都打印状态信息(IMU 模式、校准零偏、各轴参数),游戏运行时通过观察这些信息可以快速定位问题出在哪一层。把调试信息设计好,本身就是工程能力的体现。