基于触摸事件订阅的 T1 Pro 限时交互游戏设计与实现


拿到启元 T1 Pro 之后,我决定先把交互链路走通:拍背、拍胸、滑动,探索六种触摸动作能读出什么、游戏规则从哪来、三个真实的 ROS2 编程错误的犯法与修法。本文记录 touch_game(反应力挑战)的完整开发:从 TouchState 消息字段到一局游戏的判定逻辑,从
create_timer被当成 setTimeout 用了三次到 sim 不进 spin 的空转,最后打包 .rpk 部署上板、上架 PrimeHub。代码仓库地址: touch_game:基于 ROS2 与 PrimeBot SDK 的人机触摸互动游戏项目 - AtomGit
目录
-
一、选题:为什么第一个技能是游戏
-
二、触摸事件流:六种动作从哪来
-
三、如何设置游戏的判定逻辑
-
四、三个开发 bug:timer、spin、mock
-
五、代码结构与 --sim 验证
-
六、部署上板:一次 skillid unknown 的排障
-
七、上架 PrimeHub
-
八、已知限制
-
结语 / 参考资料
一、选题:为什么第一个技能是游戏
新硬件上第一个项目,是把从传感器到执行器的整条链路验证通。我觉得T1 Pro 身上最适合入门的传感器是触摸:背部和胸部两组触摸点,SDK 的 /aima/hal/touch/state 直接给出单击、双击、滑动事件,很低的运动风险。
我把它做成了一个对战游戏——反应力挑战:机器人随机出题,玩家限时 3 秒内拍对部位,三轮结算,我的设计思路:
-
规则天然驱动完整链路——要出题得有随机数,要判定得有时间戳,要反馈得调 TTS、表情、灯带,一样都跑不掉
-
竞品空缺——PrimeSkillHub 上 13 个已发布技能里互动类只有“萝卜蹲”浏览量过百,触摸反应类没人做
-
数据天然好采——每局的反应耗时、正确率自动落盘,文章里的表格都是真的
背景:WSL2 + Ubuntu 22.04 + ROS2 Humble 的搭建、t1-sdk 四个包的编译,在环境搭建阶段能就绪,touch_game 开工时直接 source install/setup.bash:

前置工作:t1-sdk 在 WSL 里 colcon build,4 packages finished,aimdk_msgs 消息包就绪。
触摸事件流也不是 touch_game 前面我已经接触过了——它的前身 touch_reply(训练用技能,不交付)已经把 TouchState 的订阅、注入和六种动作的响应路径验证过一遍,touch_game 就可以直接继承开发链路:

前作 touch_reply 的
--sim注入实录:单击、双击、滑动、未定义事件,每种触摸动作的响应分支都跑过——touch_game 的事件模型就是从它继承来的。
二、触摸事件流:六种动作从哪来
一切从消息定义开始。官方 aimdk_msgs 里 TouchState.msg 的注释把硬件布局写明:T 系列的触摸传感器在背部和胸部(头部触摸是 Q1 人形独有的),每条消息带 event_type 和 sensor_id 两个字段。我们把两个字段的组合全部试出来,得到六种可用动作:
| 动作 | event_type | sensor_id |
|---|---|---|
| 背部单击 | 1 | 1(背) |
| 背部双击 | 2 | 1 |
| 胸部单击 | 1 | 2(胸) |
| 胸部双击 | 2 | 2 |
| 背部上滑 | 9 | 1 |
| 背部下滑 | 8 | 1 |
这里有个容易想当然的点:双击是传感器层给的 event_type=2——固件已经做了连击判定,技能侧直接用就行
消息路通过三路反馈,下面是整个映射关系:

题目池与三路应答的映射关系:六个动作经同一套路由,按当前相位(出题中 / 结算后)决定是发题、判定还是忽略,最终落到 TTS、表情、灯带三个执行通道。
订阅端有个 QoS 细节:/aima/hal/touch/state 官方规格是 Reliable / Depth=1 / 触发式上报,和电池 BMS 那套 Best Effort / 1Hz 正好相反。两类接口 QoS 混用不会报错,只会静默收不到数据——第一个接 BMS 的技能在这上面栽过,我们直接把两套规格列成对照表写进代码注释位。触摸订阅本身的代码反而没什么花样:
def subscribe_touch(self, cb):
self.node.create_subscription(TouchState, TOPIC_TOUCH, cb, 10)
触发式上报意味着话题平时是静默的,只有手拍到传感器上才有消息——订阅端不需要高频轮询,也不需要 sensor_data QoS,一个普通 Reliable 订阅就够。重点是拿到消息之后的判定。
三、如何设置游戏的判定逻辑
如何设计单题的时序:

touch_game 单题时序与判定分支:出题时刻起 3 秒倒计时,答对报耗时、答错给答案、超时挨催,三种结局各有反馈。
判定需要看时间戳,核心逻辑在 on_touch 里面:
def on_touch(self, msg):
if self.phase != PHASE_LISTENING:
self.node.get_logger().info(
f"[touch] not listening, event={msg.event_type} ignored")
return
event, sensor, prompt, _ = self._question
rt = time.monotonic() - (self._deadline - self.time_limit_s)
if msg.event_type == event and msg.sensor_id == sensor:
self.score += 1
self.reaction_times.append(rt)
self.bot.say(random.choice(CORRECT_LINES).format(rt=f"{rt:.1f}"))
self.bot.play_emotion(EMOTION["great_joy"])
self.bot.play_led(LED["rainbow"])
else:
self.bot.say(random.choice(WRONG_LINES).format(answer=prompt))
self.bot.play_emotion(EMOTION["tired"])
self._end_round()
需要注意的是:不在 LISTENING 相位的触摸一概拒绝。这不是防御性编程的摆设——第四节 Bug 1 的日志里,同一个事件重复触发四次,靠这行把错误时机的注入挡在游戏逻辑外面,驱动层的节拍打错了,那么游戏过程就会出现问题。
反应耗时 rt 的算法:_deadline 是出题时刻加 3 秒,倒推回出题时刻再和事件到达时刻相减——判定和计时共用一个时间戳基准,不会出现“判定说有效、耗时算出来是负数”的错位。
如何防连触。第一版我们给它上了三态状态机(IDLE / RESPONDING / COOLDOWN)加两个定时器,结果定时器时序不可靠,冷却判定时灵时不灵。后来把状态机整个删掉——冷却本来就是一个时间戳比较:
if now - self._last_event < self.cooldown_s:
log("click within cooldown, dropped")
return
这个“做减法”的过程对于实践很有说服力:当需求足够简单时,时间戳就是全部的状态。最终代码里保留的三态相位(IDLE / LISTENING / COOLDOWN)只做“当前该不该响应”的粗判,所有精确定时全部交回时间戳。
战绩持久化走本地 JSON,累计局数、最佳得分、最快平均反应、最近 5 局:
class GameStats:
def record(self, score, total, avg_rt, best_rt):
entry = {"date": datetime.now().strftime("%m-%d %H:%M"),
"score": score, "total": total,
"avg_rt": round(avg_rt, 2) if avg_rt else None,
"best_rt": round(best_rt, 2) if best_rt else None}
self.data["games"] += 1
self.data["history"].append(entry)
self.data["history"] = self.data["history"][-5:]
if score > self.data["best_score"]:
self.data["best_score"] = score
self.path.write_text(
json.dumps(self.data, ensure_ascii=False, indent=2),
encoding="utf-8")
return entry
结算时自动和上一局对比——第二次玩它会报“你上次的纪录”:打破了你上次2题的纪录!或者离上次3题的纪录还差一点。就这一句对比,重复可玩性完全不一样。
四、三个开发 bug:timer、spin、mock
三个 bug 正好是一条 ROS2 编程模型的理解曲线。
Bug 1 · 把周期定时器当一次性用。 第一版冷却逻辑写的是 self.create_timer(2.0, cb),想着两秒后回调一次回到空闲态。实际 rclpy 的 timer 是周期性的,回调每两秒来一次,而且每次触摸都会新建一个——--sim 日志里同一个事件重复出现了四次。修法是回调里自我注销,再封装成一个“一次性定时器”帮手,全项目统一用它:
def _schedule_once(self, delay, cb):
holder = {}
def fire():
holder["t"].cancel()
cb()
holder["t"] = self.node.create_timer(delay, fire)
fire() 第一行先 cancel() 自己——回调第一次进入时就把定时器注销掉,后续周期全部不会再触发。sim 里四条注入事件全走这个帮手排程,谁也别想再犯。
Bug 2 · sim 注册了定时器,没进 spin。 第二个 bug 更隐蔽:--sim 里注册了心跳和全部注入定时器,然后 run() 直接返回,main 里紧接着 shutdown()——rclpy 的 timer 只在 spin 循环里执行,一个都没跑,程序打完开场白就退出了。修法是 sim 末尾补 rclpy.spin(),main 里去掉重复 shutdown。
Bug 3 · mock 不干净,验证结果不算数。 这个 bug 是验证方法论层面的。--sim 里服务调用没有真机应答,走了超时重试,一次阻塞十秒,把冷却期的时间线整个冲乱——明明该丢弃的第二次点击被响应了。日志看起来像冷却逻辑错了,其实是sim 的时间线和真机不一致。修法是接口层加 sim 开关,模拟模式下服务调用只打日志直接返回

Bug 2 的现场:
--sim打完开场白直接回到提示符,出题、注入、结算全部没执行——定时器注册了,但没进 spin。

Bug 1 的现场:
inject CORRECT出现四次(t=3、6、9、12),周期定时器每三秒重复触发。意外收获是游戏状态机把全部错误时机的注入正确拒绝——“not listening, ignored” 出现了三次,驱动层节拍打错,游戏逻辑纹丝不动。
修复完三个BUG之后--sim 就可以一次性通过了:

最终验证:六条事件行为完全符合预期——4 次响应、1 次冷却丢弃、1 次未定义忽略,
sim sequence done干净退出。
五、代码结构与 --sim 验证
touch_game/
├── main.py # 入口:--dry-run / --sim / 真机,部署入口 main:run
├── manifest.json # .rpk 技能声明(4 项原子能力)
├── pack_rpk.py # 打包脚本
├── src/
│ ├── game.py # 游戏逻辑:出题、判定、计分、持久化
│ └── iface.py # SDK 封装:触摸订阅、TTS/表情/灯带、sim 开关
└── cfg/pkg.yaml # PrimeConsole 读取的技能身份
游戏状态流转(IDLE → LISTENING → COOLDOWN → FINISHED)全部由事件与时间戳驱动,心跳每 50ms 醒一次读表:
def _tick(self):
now = time.monotonic()
if self.phase == PHASE_COOLDOWN and now >= self._next_ask_at:
if self._asked >= self.rounds:
self._finish()
else:
self._ask()
elif self.phase == PHASE_LISTENING and now > self._deadline:
self.bot.say(random.choice(TIMEOUT_LINES).format(limit="3"))
self.bot.play_emotion(EMOTION["tired"])
self._end_round()
超时催促、下一题出题、整局结算,三件事都塞在这十几行里——相位加时间戳,没有任何定时器参与状态转换。
--sim 注入一条固定触摸序列:背部单击(正确)→ 冷却期单击(应丢弃)→ 胸部双击 → 上滑 → 下滑 → 未定义事件,一次跑完一局。真机模式下全程不动机器人——不切形态、不发速度,纯原地互动。
六、部署上板:一次 skillid unknown 的排障
部署过程本身是非常顺利的:pack_rpk.py 打包 → PrimeConsole 导入 → 七维校验 PASS → 部署成功。真正的坑在第一次:部署按钮点了没反应,skillid 显示 unknown。
排查方法是对照实验——拿已经部署成功的 touch_game 和失败的 color_seek、crosstalk 三个包做结构 diff,唯一差异是 cfg/pkg.yaml:PrimeConsole 从它的 package_name 字段读技能身份,缺了就直接拒装,而校验报告照样显示“通过”。补上之后三个包全部一次通过。

PrimeConsole 解析出的原子能力列表:表情播放、语音合成、LED 灯效三项,入口分别为 prime.motion.emotion / prime.tts.speak / prime.led.control。

验证报告:两条记录双双“通过”——静态校验(文件完整性、依赖、安全扫描)与运行时校验(环境可用性)都过。

部署成功:touch-game-1.0.0.rpk 已安装到机器人,PrimeConsole 显示“部署成功”与运行状态。
七、上架 PrimeHub
部署验证完成后,我把技能发布到了 PrimeSkillHub:

PrimeHub 技能页:反应力挑战已上架,标签为游戏/互动/触摸/反应力,机器人类型 T1。发布信息和技能包走同一套 manifest 声明,不用维护两份元数据。
到这一步,从“传感器上一个事件”到“别人可以下载的技能”,链路完整走通:开发(WSL)→ 验证(--sim)→ 打包(.rpk)→ 部署(PrimeConsole)→ 分发(PrimeHub)。
八、已知限制
-
双击判定依赖固件的事件窗口,拍得太快或太慢都可能拿到两个单击
-
反应耗时从出题指令发出算起,包含 TTS 播报时间;严格计时可以等灯带亮起再触发
-
战绩 JSON 写在机器人板上,取回需要 SSH 或日志下载
结语
这个技能的技术密度不高,但它把一条完整的链路走通了:触摸事件 → 游戏规则 → 多模态反馈 → 部署 → 分发。过程中真正学到的开发点在第四节的三个 bug 中——周期定时器、spin 义务、mock 保真,这三件事现在写进了我们对所有 sim 验证的检查清单。对同样刚拿到 T1 的开发者,建议第一个技能也从纯交互做起:不碰运动,风险最低,链路最全。
本文使用的 SDK 与消息包均来自启元机器人开发者社区,消息定义与接口规格以官方 t1-sdk 仓库为准。
参考资料
启元机器人开发者社区(SDK/文档下载):启元机器人开发者社区 · 一站式具身智能研发平台
官方 t1-sdk 仓库(含 aimdk_msgs 消息包):t1-sdk:基于 ROS2 与 FastDDS 的机器人二次开发 SDK 项目 - AtomGit
本文配套仓库 touch_game:touch_game:基于 ROS2 与 PrimeBot SDK 的人机触摸互动游戏项目 - AtomGit
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)