2026寒假练 - 用RP2040游戏机实现重力迷宫滚球小游戏
该项目使用了树莓派RP2040、MicroPython语言,实现了【RP2040游戏机】重力迷宫滚球小游戏的设计,它的主要功能为:使用姿态传感器(IMU)俯仰和横滚控制小球在迷宫中移动。
标签
嵌入式系统
小游戏
MicroPython
IMU
RP2040
刘文博+1120212208
更新2026-03-26
北京理工大学
12

、所选任务介绍

【RP2040游戏机】重力迷宫滚球小游戏

使用姿态传感器(IMU)俯仰和横滚控制小球在迷宫中移动,LCD 实时显示。  

  1. 设计至少 3 关固定迷宫;按键实现开始、暂停、重开与关卡切换。  
  2. 计时与得分显示在屏幕;蜂鸣器提示碰壁与通关。  
  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系统架构框图

屏幕截图 2026-03-08 211546.png

4.2数据流向

image.png

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)

六、功能展示

bac3e2a2e79cda7c15c102d1e38c71e5.jpg

七、遇到的难题及解决方法

1. 小球响应迟钝,必须大角度倾斜才会移动

这是项目中最影响体验的核心问题之一。在初版程序中,玩家需要将设备倾斜到比较大的角度,小球才会开始缓慢移动,导致操作明显“发闷”,失去了重力控制应有的自然感。深入分析后发现,该问题并非单一参数设置错误,而是由多个环节叠加造成的:

  1. 动态死区偏大校准时根据噪声峰值自动计算死区,虽然提高了抗抖能力,但在部分设备上死区被放大,吞掉了大量小角度有效输入;
  2. 速度映射增益不足原有映射公式对小角度倾斜不够敏感,导致轻微姿态变化无法转化为明显速度;
  3. 整体速度上限偏保守即使检测到倾斜,映射到小球上的运动速度仍然较低,视觉反馈不明显。

针对这一问题,最终做了三方面优化:

  1. 将动态死区改为固定小死区,只保留抑制静止抖动所需的最小范围;
  2. 重构姿态到速度的映射关系,提升小角度区域的输出增益,让轻微倾斜也能产生可感知的运动;
  3. 重新匹配速度上限与插值参数,让小球启动更及时、反馈更直接。

在优化后,小球在轻微倾斜时即可自然启动,整体操控感明显提升。

2. 左右方向过快、上下方向过慢,控制手感不统一

在解决灵敏度问题后,又出现了新的体验差异:左右倾斜时小球移动过快,容易撞墙;而上下方向依旧偏慢,需要更明显的前后倾动作才能获得理想效果。这一现象说明,单纯提高整体灵敏度并不能从根本上解决问题。继续分析后发现,问题来自于X 轴和 Y 轴在真实使用场景中的天然差异

  1. 左右操作更多依赖手腕旋转,动作幅度通常更大、变化更快;
  2. 上下操作更依赖手臂前后抬放,受握持姿势影响更明显,输入往往更弱;
  3. 同时,MMA7660 在两个方向上的噪声表现和实际响应也并不完全一致。

因此,项目最终放弃了“两个方向共用一套参数”的简单做法,转而采用分轴独立调参方案

  1. X/Y 轴分别设置独立的死区;
  2. 分别设置满速映射尺度;
  3. 分别设置最高速度;
  4. 分别设置低通滤波系数;
  5. 分别设置加速与制动插值参数。

这样的设计使得左右方向更加平稳可控,上下方向更加灵敏自然,最终让两个方向的操控手感趋于一致,整体体验更协调。

3. LCD 全屏刷新过慢,导致画面卡顿明显

在嵌入式图形显示中,性能瓶颈往往不在计算,而在数据传输。项目早期采用较直接的全屏刷新方式,每次更新小球位置都重新绘制大面积区域。虽然实现简单,但在 ST7789 SPI 屏幕上表现非常不理想,画面刷新明显卡顿,影响操作流畅度。问题根源在于:240×240 的彩色屏幕数据量较大,而 SPI 接口带宽有限,MicroPython 环境下全屏重绘的开销更加明显。每一帧都进行大量无意义的重复绘制,直接拖慢了整体刷新速度。为此,项目对显示策略进行了系统优化:

  1. 采用增量刷新机制只更新发生变化的区域,而不是每次重绘整屏;
  2. 小球区域局部擦除 + 重绘每帧只处理旧位置与新位置;
  3. 状态栏按需刷新只有时间或得分发生变化时才更新文本区域;
  4. 按格恢复背景颜色擦除小球时,不再简单用单一背景色覆盖,而是根据小球经过的迷宫格类型恢复对应颜色,避免破坏墙体、起点、终点等视觉元素。

经过优化后,屏幕刷新压力大幅下降,动画流畅性明显提升,小球移动过程更加连贯自然。

4. 小球“停手不停”,惯性过强,难以精确控制

在最初的物理实现中,小球运动采用的是类似“加速度积分”的方式:倾斜越久,速度越大;放平之后,再通过摩擦逐渐减速。这个模型虽然看起来更接近真实物理,但在迷宫游戏中却带来了明显问题——操控不够跟手具体表现为:

  1. 玩家已经将设备放平,小球仍然会继续滑动;
  2. 狭窄通道中很难精准停在想要的位置;
  3. 反向修正时存在明显拖尾,容易造成连续撞墙。

从游戏体验角度看,这种“真实惯性”反而削弱了控制感。因此,项目重新设计了小球的运动模型:不再把倾斜量视为“持续加速度”,而是视为“目标速度”。

新的控制思路是:

  1. 当前姿态先映射为一个目标速度;
  2. 小球当前速度再逐步向目标速度靠拢;
  3. 当设备放平时,目标速度立即变为 0,小球会快速减速停止。

同时,为了优化手感,系统还采用了非对称插值策略

  1. 启动时加速稍缓,避免突然冲出;
  2. 停止和反向时制动更快,增强“停手即停”的可控性。

这一改动大幅提升了操作精度,使小球既保留一定平滑感,又能在关键位置迅速停住,更符合迷宫游戏对控制精确性的要求。

5. 传感器分辨率有限,滤波强了没反应,滤波弱了又抖动

MMA7660 只有 6bit 分辨率,本身输出数据的量化台阶感比较明显。这意味着系统既不能“不过滤”,也不能“过度滤波”:

  1. 如果不做滤波,小球在设备静止时也会轻微抖动;
  2. 如果滤波过强,小角度有效姿态变化又会被直接抹平,导致控制迟钝。

这实际上是一个典型的平衡问题:既要保留细小动作带来的真实控制输入,又要尽量抑制硬件噪声与量化跳变。最终,项目采用了以下方案:

  1. 归一化之前的 LSB 域进行低通滤波;
  2. 使用单阶低通,避免多级滤波导致响应过慢;
  3. 对 X/Y 两轴分别设置滤波参数,使不同方向获得更匹配的动态表现。

这一方案成功在“稳定性”和“灵敏度”之间取得了平衡,让系统既不会抖动明显,又能保留足够的操控细节。

八、心得体会

关于嵌入式传感器数据处理:

项目最大的收获是对 IMU 数据处理有了完整的认识。一开始认为把加速度计的 XY 轴读数直接当作控制量是最简单直接的方案,但实际调试中发现 6bit 分辨率的传感器噪声、个体增益差异、非线性特性叠加在一起,直接使用原始数据根本无法得到稳定可控的输出。经历了从"直接用原始值"到"加低通滤波"到"归一化坡度"再到"分轴独立参数"的完整迭代过程,才真正理解了为什么工程中的传感器数据处理需要这么多环节——每一个环节都是在解决一个具体的实际问题,而不是为了复杂而复杂。

关于物理手感调优:

"停手即停"这个看似简单的需求,背后是物理模型的根本性转变。从加速度积分模型改为目标速度插值模型之后,停手响应从 10+ 帧缩短到 5 帧,这是模型层面的改进,而不是参数微调能够解决的。这让我意识到:当参数怎么调都无法达到预期效果时,应该先反思模型本身是否合适,而不是继续在错误的模型上堆调参。

关于嵌入式资源限制:

LCD 刷新帧率问题让我对"算法复杂度"有了更直观的感受。在 PC 上 fill_rect 是可以忽略不计的操作,但在 RP2040 + SPI LCD 上,一次全屏刷新就是 200ms 的延迟。增量渲染方案将有效数据量压缩了约 99%,帧率从 5fps 提升到流畅的 30fps。嵌入式开发中,了解硬件瓶颈在哪里,并在设计阶段就针对瓶颈做架构决策,比事后优化要高效得多。

关于调试方法:

串口 print 调试在嵌入式中依然是最高效的手段。每个模块初始化时都打印状态信息(IMU 模式、校准零偏、各轴参数),游戏运行时通过观察这些信息可以快速定位问题出在哪一层。把调试信息设计好,本身就是工程能力的体现。

附件下载
完整代码.zip
完整代码
团队介绍
Lancer一狗
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号