用行空板 M10 玩一场3D体感游戏
用行空板 M10 玩一场3D体感游戏
硬件:行空板 M10(UNIHIKER M10,DFRobot)
技术栈:Python 3 标准库 / TypeScript / three.js / Rapier
成品:板子一倾斜,浏览器里的 3D 球就滚;插上 USB 就能玩
一、创作背景
大模型把"写一个能跑的 demo"这件事的成本压得很低。给一段需求,半小时就能拿到一个动得起来的 HTML5 小游戏。
但这个项目卡住的从来不是"能不能跑"。真正花时间的是三件事:板子的数据怎么稳定地送进浏览器、倾角怎么变成球的重力、以及手柄一样的手感怎么调。这三件事 AI 不会主动替你想,得你在提示词里把约束说清楚。
所以下面记的不是聊天过程,是我最后留在代码里的那几条提示词,和它们对应的实现。
二、作品简介
分成两部分。
板端是一个 1348 行的 Python 文件,只用标准库,连 WebSocket 协议都是手写的。它读 /dev/icm20689 拿六轴数据,按 100 Hz 打成 JSON 推给电脑;板载 A / B 两个按键当跳跃键;2.8 寸彩屏顺手用来显示关卡和评级。
网页端是 three.js + Rapier 的 3D 滚球游戏,8 个关卡。重力方向跟着板子倾角转,球是"真的滚"而不是被推着走。
整条链路只有三段:

板子插 USB 后是 10.1.2.3,电脑上跑一个零依赖的 serve.mjs 做中转。浏览器从头到尾只连本地端口。这一步很关键,后面会说为什么。
实机效果:

演示视频

三、创作步骤
我的顺序是:先写监视器看数据,再写板端服务,再做最小闭环,最后堆内容。反过来做,所有"手感不对"都会变成"到底是数据错还是映射错"的猜谜。
1. Web 端
立项先说约束
第一段提示词不写代码,只对齐链路:
你是嵌入式 + 前端全栈工程师。我要做个体感滚球网页游戏,硬件是行空板 M10。
硬约束(不可违反):
1. 板端只能用 Python 3 标准库,不许装 pinpong 之外任何包;
实际上连 pinpong 也不要用。
2. 板端要自己实现 WebSocket 服务端,不许用 websockets 库。
3. PC 端浏览器不许直连板子的内网 IP,必须走同源代理。
4. 整个项目要能"双击一个 bat 就能玩"。
5. 面向做过单片机和前端的创客,文案直接说操作,不要教学腔和装饰性废话。
先不要写代码。先用 200 字说明数据链路(谁连谁、什么协议、多少 Hz),
再列出你打算建的文件清单和每个文件的职责。
第 3 条是我后来补的。Chrome 156 起有 Local Network Access 限制:页面从 127.0.0.1 打开、JS 去连 10.x 内网地址,会触发权限询问;没人值守的机器上没人会去点"允许",表现就是静默超时,控制台连个 error 都没有。
所以改成浏览器只连同源 ws://127.0.0.1:5180/imu,由 serve.mjs 做原始 TCP 透传。同理,Rapier 的 WASM 也必须在 http 下跑,双击 html 打不开。
手感靠这几条
物理与手感,按这个来(数值是实测的,别自己改):
- 物理固定步长 1/60 + 累加器。慢镜只改每帧推进的步数,不改步长。
直接喂 dt\*0.25 会让 dt 变小,球更容易穿透薄地形。
- 重力方向随倾斜旋转,而不是给球加推力。
保持模长守恒:gx=G·sin(θx),gy=-G·sqrt(1-sin²-sin²),gz=G·sin(θz)。
- 倾角映射放大到 32°(输入上限 18°)。18° 只有 0.3G 水平分量,球滚得"太懒"。
- 摄像机朝向恒定,只做平移跟随。代价是放弃追尾的速度感,换来零学习成本。
- 必须有土狼时间 0.12s 和跳跃缓冲 120ms,否则玩家说得出"不跟手"却说不出哪里不对。
- 跳跃只覆盖竖直速度,保留横向动量。
- 特效全部事件驱动,正常滚动时画面必须干净。
"改重力"而不是"加推力"是这里最省事的一步。引擎里只是每帧改一次重力向量,摩擦、加速度上限、松手回正这些全交给物理引擎,手感天然是对的。自己写推力的话,这几个边界条件要挨个调。
倾角映射放大到 32° 也是被迫的:手腕舒服的倾角实测 15—20°,按 18° 映射到重力只有 0.3G 的水平分量,球滚起来像在冰面上推箱子。
手感和标定得现场调
参数只有握着板子的人能判断,所以做了个反引号呼出的调参面板:

8 个滑块:死区、平滑、满速角、重力、限速、起跳、土狼、速度 FOV。每个下面写着"调大 / 调小会怎样"。底部是只读的实时读数:原始加速度、倾斜角、输出向量、基向量。
调满意了再写回 config.ts。没有这个东西,"改代码 → 编译 → 重启 → 试"这个循环,一个下午也试不了几轮。
方向标定则是另一个坑。板子拿反、转 90°、换一块板子,用"轴 + 符号"写死的映射全废。最后改成用两个基向量投影:传感器平面内定义 right / near 两个单位向量,horizR = a·right、horizN = a·near。轴向交换、取反、整体旋转全被基向量吸收,代码里再没有一处 sign 可写错。
配套的提示词是持久化优先级:
方向标定按这个优先级:服务端 > localStorage > 出厂值。
1. 只有内存一份不够:dev(5173) 与 dist(5180) 是两个实例,会出现"两边左右相反"。
2. 只有 localStorage 也不够:它按 origin 隔离,5173 和 5180 还是两份。
3. 留 localStorage 是为了同一端口内立刻生效,以及服务端不可达时兜底。
写入时做正交化:right 保留原方向,near 减去在 right 上的投影。
两次方向夹角 < 25° 直接拒绝。
打包版曾经"一启动就往右上角偏",查了两天,最后发现是 basis.json 里躺着一份整体旋转 2.26° 的歪标定。它把"向右"耦合出了"向远"分量,投到屏幕上正好是 45° 斜线,看着像摄像机歪了。摄像机是数学上完全对称的。
注意事项
- 碰撞体别用 trimesh。顶点密度不均会让球在坡上抖、卡接缝、穿模,而且每次生成都不一样,手感无法复现。全部用 cuboid / cylinder / ball。
- 慢镜不要改 dt,只减少每帧推进的步数,步长恒为 1/60。卡顿之后要丢掉累加器余量,别疯狂追帧,否则会瞬移。
- 触发器只在 playing 状态判定,否则掉下去还会判通关。
- 调参面板不写 localStorage。持久化的唯一出口是
config.ts,不然会出现"代码里改了值却被缓存盖掉"。
2. 行空板端
绕开 pinpong
这是整个项目最值钱的一条提示词。
板端传感器读取不要用 pinpong,直接读设备文件和 sysfs。理由写进注释:
1. accelerometer.get\_x()/get\_y()/get\_z() 每次调用都重新读设备文件,三轴来自
三个不同时刻,轴间不同步;内部 round(x,2) 把精度砍到 0.01g(约 0.58°)。
2. 直接 os.read(fd, 15) 一次拿到三轴加速度 + 三轴角速度,全精度。
实测:分轴读 7.73ms/帧(129Hz);一次读 15 字节 2.34ms/帧(427Hz)。
3. button\_a.is\_pressed() 内部有 time.sleep(0.05),每次调用阻塞 50ms,
在 100Hz 循环里调它等于自杀。按键也直接读 sysfs。
设备 /dev/icm20689,15 字节包(buffer[0] 恒为 0x02):
[0]=类型 [1..2]=accX [3..4]=accY [5..6]=accZ [7..8]=温度 [9..14]=gyroXYZ
全部 int16 大端,手动拼并处理符号位。
输出 100Hz 定频 JSON 文本帧:n t ax ay az gx gy gz a b
加速度单位 g(静止约 0.98),角速度 rad/s,a/b 按键 1=按下。
架构上必须做到采样与发送解耦:一个线程按固定节拍写全局快照,
每个客户端一个独立线程读快照发出去。慢客户端只能拖慢它自己。
最后一句是踩过坑才补的。原来的写法是"accept 一个客户端 → 在主循环里同步服务它",只要某个客户端连上不发也不断开,sendall 就堵在 TCP 缓冲区,主循环再也回不到 accept,新连接全堆在 backlog 里。现象是"板子 ping 得通、SSH 进得去,但 8765 连不上"。
顺带两个硬件细节,不写 AI 一定漏:sysfs 同一个 fd 重复 read 会返回空,每次读之前要 lseek(fd, 0);GPIO 号是 A=219、B=200,低电平为按下。
屏和编码
板载屏最后没用官方 unihiker.GUI(pygame 套壳,字距和居中都不可控),改成 tkinter 自绘。这里遇到一个很安静的坑:
装成 systemd 服务后,systemctl status 是 running,终端日志一行不少,
唯独屏幕不亮。误以为是 GUI 没做或屏坏了。
根因:systemd 环境里没有 \$DISPLAY,tkinter 抛异常,而这段被 try/except 包着,
程序"优雅降级成纯终端日志",退出码依然是 0。
修法有三层:.service 里补 Environment=DISPLAY=:0 和 XAUTHORITY;程序里探测 /tmp/.X11-unix/X0 存在才补 DISPLAY;main() 外层套重试——systemd 几乎肯定比桌面先起来,开机那一刻 X 还没就绪。
还原到用户视角,屏上是这样的:

左边是待机(第几关、怎么玩、累计分),右边是通关(大号评级字母、用时、金币、总分)。右边那屏不是设计稿,是网页发了一帧 clear 过来后真实刷出来的。
编码那件事更朴素:行空板 Debian 的 LANG 常为空,Python 3 把 stderr 判成 ascii,直接 print 中文会 UnicodeEncodeError,服务崩在启动阶段——最容易被误判成"板子坏了"。做法是先 reconfigure(encoding="utf-8"),真正的输出统一走 sys.stderr.buffer 写字节,绕开上层的编码判断。
注意事项
- 采样率别贪高。板子能到 427 Hz,但 100 Hz 已经远超手感需求,余量留给 Wi-Fi 抖动。
- 板端零第三方依赖。现场网络经常不通,
pip install失败一次就够毁掉整场演示。 - 批处理脚本存 GBK + CRLF。cmd.exe 在中文 Windows 按 GBK 逐字节解码,UTF-8 的汉字会被切错,
if (...)块直接不闭合,chcp 65001也救不回来。 - 部署逻辑别写在 ssh 后面。引号会被 cmd 剥一层再交给 bash,很容易出现"未预期的文件结束符"。整串动作放
board\_setup.sh,ssh只执行一句sh /root/board\_setup.sh。
四、作品改进
美术层还没接完。art/ 里已经有程序化贴图、天空盒 + IBL、8 套主题配色、道具美术库,但 Game.ts 还在往 Level 传旧的材质对象,编译不过。接入顺序是:先造主题传下去,boot 时建一次环境贴图,关卡 JSON 加 "theme" 字段,最后挂道具。这层一定放最后,而且要可插拔,否则你会花两天在配色上,而球还滚不动。
关卡只用了建造器的一小半。16 种零件里,现在 8 个关卡只用了 6 种。金币、加速带、传送带、风扇、锯片、尖刺、移动平台都是现成的,改 JSON 就能加,不用动 TypeScript。
手感参数还可以下放到关卡。现在重力、死区、满速角是全局的。放进关卡 JSON 之后,冰原关可以调低重力(更滑),熔岩关缩小死区(更灵敏),同一批几何体换关就是换手感。
再加一个可视化关卡编辑器。现在加关靠手写 JSON。用 three.js 的 TransformControls 拖放零件,给 16 种零件各配一个参数面板,导出 JSON 就行。
代码注释里留着 12 条踩坑记录。这个项目改过四轮方向——sign 反了、零点错位、端口隔离、歪标定。没有这些注释,第五轮我自己也会重新踩一遍。
提示词不是魔法。它做的是把"我踩过什么"提前告诉 AI,让它别在同一个地方再踩一次。




