🔍 核心机关与修改指南
🎨 第一块:积木配置区 改这里就行!
包含 ENABLE_NET_QUOTE 联网开关、LUCKY_POOL 随机签文池、OPENING_LINE 开场白以及 ACTION_TABLE 动作字典。不用懂底层硬件也能 3 分钟改出新技能!
🛡️ 第二块:0.7秒防抖与断网护盾 别乱动它!
speak() 负责盯着机器人开口出声且连续安静满 0.7 秒才放行;fetch_quote() 负责 2.5 秒限时联网,一旦断网立刻优雅返回 FALLBACK_QUOTE!
🎬 第三块:三幕剧主循环 丝滑演出
严格按照「先说完话 ➔ 再触发 play_animation(id, interrupt=False) ➔ 再 time.sleep(hold) 等动作做完」的黄金节奏推进,最后打印 UNIVERSAL_SKILL_DONE steps=3。
💻 完整源码与配置内容(共 184 行 · 支持行号定位与一键复制)
| 1 | """启元百变互动官(Q1 / T1 通用全能技能模板,纯 Python 3.10 标准库)。 |
| 2 | |
| 3 | 设计目标: |
| 4 | 1. 开箱即用:可直接打包部署到 Q1 或 T1 真机运行,完成「迎宾 → 每日金句(联网+断网兜底) → 随机抽签互动 → 谢幕」完整闭环。 |
| 5 | 2. 极易改编:只需修改顶部的配置区(台词、动作别名、抽签池、是否开启联网/灯光),即可快速改成迎宾、算命、播报、小剧场等任意技能。 |
| 6 | 3. 严守契约:不导入 robot / ROS2,内蔵 TTS 0.7 秒防抖轮询与超时保护,自动适配 Q1 upper_body 与 T1 双足/四足动作白名单。 |
| 7 | """ |
| 8 | |
| 9 | import json |
| 10 | import random |
| 11 | import time |
| 12 | import urllib.request |
| 13 | |
| 14 | # ============================================================================== |
| 15 | # 【第一部分:积木配置区 —— 改这里就能变成你的新技能!】 |
| 16 | # ============================================================================== |
| 17 | |
| 18 | # 构建脚本 build.py 会在打包时自动将此值替换为 "q1" 或 "t1" |
| 19 | ROBOT_MODEL = "q1" |
| 20 | |
| 21 | # 功能开关 |
| 22 | ENABLE_NET_QUOTE = False # True=尝试联网拉取每日一言(失败自动用离线兜底);False=纯离线最稳模式 |
| 23 | ENABLE_RANDOM_DRAW = True # True=从 LUCKY_POOL 随机抽签;False=固定使用第一支签 |
| 24 | ENABLE_MOOD_EFFECTS = True # True=尝试联动呼吸灯带与面部表情(自带安全容错) |
| 25 | |
| 26 | # 联网 API 配置与断网兜底金句 |
| 27 | QUOTE_API_URL = "https://v1.hitokoto.cn/?c=i&encode=json" |
| 28 | FALLBACK_QUOTE = "代码写得稳,真机跑得准,每一次动手都是超酷的创造!" |
| 29 | |
| 30 | # 随机互动签文池:(签文台词, 动作语义名) |
| 31 | LUCKY_POOL = ( |
| 32 | ("叮!为你抽到「真机丝滑上上签」!今天你的所有动作指令全部一次通过!", "cheer"), |
| 33 | ("叮!为你抽到「脑洞大开幸运签」!你的创意全场最亮眼,评委看了都点赞!", "heart"), |
| 34 | ("叮!为你抽到「零Bug欧皇签」!编译不报错,网络不超时,好运直接拉满!", "greet"), |
| 35 | ) |
| 36 | |
| 37 | # 主流程固定剧本:(动作语义名, 台词文本, 备注说明) |
| 38 | # 支持占位符:{quote} 会自动填入联网/兜底金句,{lucky_text} 会填入抽签结果 |
| 39 | OPENING_LINE = "哈喽大家好!我是启元百变互动官,很高兴在活动现场见到你!" |
| 40 | CLOSING_LINE = "今日互动圆满完成!祝你玩得开心,作品大放异彩,我们下次见!" |
| 41 | |
| 42 | # 语义化动作映射表:把好记的名字映射到 Q1 / T1 的安全动作 ID 和保持秒数 (动作ID, 等待秒数) |
| 43 | # - Q1 严格只用室内模式允许的 upper_body 动作(3000 系) |
| 44 | # - T1 四足/双足均默认使用四足白名单兼容动作(26 喜悦 / 43 律动),也可在 biped 下扩展 |
| 45 | ACTION_TABLE = { |
| 46 | "q1": { |
| 47 | "greet": (3001, 6.5), # 招手 |
| 48 | "cheer": (3008, 6.5), # 加油 |
| 49 | "heart": (3007, 6.5), # 双手比心 |
| 50 | }, |
| 51 | "t1_quadruped": { |
| 52 | "greet": (26, 16.0), # 喜悦(四足安全白名单) |
| 53 | "cheer": (43, 16.0), # 律动(四足安全白名单) |
| 54 | "heart": (26, 16.0), # 喜悦(四足安全白名单) |
| 55 | }, |
| 56 | "t1_biped": { |
| 57 | "greet": (26, 16.0), # 喜悦(双足同样安全可用) |
| 58 | "cheer": (43, 16.0), # 律动 |
| 59 | "heart": (26, 16.0), # 喜悦 |
| 60 | }, |
| 61 | } |
| 62 | |
| 63 | POLL_SECONDS = 0.1 |
| 64 | IDLE_SECONDS = 0.7 |
| 65 | |
| 66 | |
| 67 | # ============================================================================== |
| 68 | # 【第二部分:安全硬件底座 —— 防吞字轮询、形态校验、网络兜底(一般无需修改)】 |
| 69 | # ============================================================================== |
| 70 | |
| 71 | def audio_state() -> int: |
| 72 | """读取并校验音频状态:1=播放中,2=空闲。""" |
| 73 | snapshot = robot.get_audio_play_state() |
| 74 | if isinstance(snapshot, dict): |
| 75 | state = snapshot.get("state") |
| 76 | if isinstance(state, dict) and state.get("value") in (1, 2): |
| 77 | return state["value"] |
| 78 | raise RuntimeError("AUDIO_STATE_INVALID") |
| 79 | |
| 80 | |
| 81 | def speak(text: str) -> None: |
| 82 | """说话并等待真实播完(含 4 秒起声监控 + 0.7 秒防抖滤波 + 超时保护)。""" |
| 83 | print("SPEAK " + text, flush=True) |
| 84 | robot.play_tts(text) |
| 85 | start_deadline = time.monotonic() + 4.0 |
| 86 | while audio_state() != 1: |
| 87 | if time.monotonic() >= start_deadline: |
| 88 | raise RuntimeError("TTS_NOT_STARTED") |
| 89 | time.sleep(POLL_SECONDS) |
| 90 | |
| 91 | finish_deadline = time.monotonic() + max(len(text) * 0.45, 4.0) + 8.0 |
| 92 | idle_since = None |
| 93 | while time.monotonic() < finish_deadline: |
| 94 | if audio_state() == 1: |
| 95 | idle_since = None |
| 96 | elif idle_since is None: |
| 97 | idle_since = time.monotonic() |
| 98 | elif time.monotonic() - idle_since >= IDLE_SECONDS: |
| 99 | return |
| 100 | time.sleep(POLL_SECONDS) |
| 101 | raise RuntimeError("TTS_NOT_FINISHED") |
| 102 | |
| 103 | |
| 104 | def trigger_mood(color: str = "#FF6B2B", mode: int = 3) -> None: |
| 105 | """可选的灯带氛围触发(带安全容错,不影响主流程与离线替身测试)。""" |
| 106 | if not ENABLE_MOOD_EFFECTS: |
| 107 | return |
| 108 | play_light = getattr(robot, "play_light", None) |
| 109 | if callable(play_light): |
| 110 | try: |
| 111 | play_light(color, mode) |
| 112 | except Exception as exc: |
| 113 | print("LIGHT_SKIP " + str(exc), flush=True) |
| 114 | |
| 115 | |
| 116 | def fetch_quote() -> str: |
| 117 | """获取今日金句:若开启联网则请求 API,超时或断网时 100% 自动回退到离线金句。""" |
| 118 | if not ENABLE_NET_QUOTE: |
| 119 | return FALLBACK_QUOTE |
| 120 | try: |
| 121 | req = urllib.request.Request(QUOTE_API_URL, headers={"User-Agent": "PrimeBot-Skill/1.0"}) |
| 122 | with urllib.request.urlopen(req, timeout=2.5) as resp: |
| 123 | data = json.loads(resp.read().decode("utf-8")) |
| 124 | hitokoto = data.get("hitokoto") |
| 125 | if isinstance(hitokoto, str) and hitokoto.strip(): |
| 126 | return hitokoto.strip() |
| 127 | except Exception as exc: |
| 128 | print("NET_FALLBACK " + str(exc), flush=True) |
| 129 | return FALLBACK_QUOTE |
| 130 | |
| 131 | |
| 132 | def resolve_action_profile() -> dict: |
| 133 | """根据机型(Q1 / T1)与实时形态(biped / quadruped)选择安全动作表。""" |
| 134 | if ROBOT_MODEL == "q1": |
| 135 | return ACTION_TABLE["q1"] |
| 136 | if ROBOT_MODEL == "t1": |
| 137 | form = robot.get_t1_robot_form() |
| 138 | if form not in ("biped", "quadruped"): |
| 139 | raise RuntimeError("T1_FORM_UNKNOWN") |
| 140 | print("T1_FORM " + form, flush=True) |
| 141 | return ACTION_TABLE["t1_" + form] |
| 142 | raise RuntimeError("ROBOT_MODEL_UNKNOWN") |
| 143 | |
| 144 | |
| 145 | # ============================================================================== |
| 146 | # 【第三部分:主演出流程】 |
| 147 | # ============================================================================== |
| 148 | |
| 149 | def main() -> None: |
| 150 | profile = resolve_action_profile() |
| 151 | print("UNIVERSAL_SKILL_START model=" + ROBOT_MODEL, flush=True) |
| 152 | trigger_mood("#FF6B2B", 3) |
| 153 | |
| 154 | # 1. 开场问候 |
| 155 | speak(OPENING_LINE) |
| 156 | |
| 157 | # 2. 组装三幕互动内容(迎宾问候 → 今日灵感金句 → 幸运抽签) |
| 158 | quote_text = fetch_quote() |
| 159 | lucky_line, lucky_action = ( |
| 160 | random.choice(LUCKY_POOL) if ENABLE_RANDOM_DRAW else LUCKY_POOL[0] |
| 161 | ) |
| 162 | steps = ( |
| 163 | ("greet", "第一幕,元气问候。先挥个手,给你打满今日份的开发者Buff!"), |
| 164 | ("cheer", "第二幕,灵感广播。送你一句话:" + quote_text), |
| 165 | (lucky_action, "第三幕,好运盲盒。" + lucky_line), |
| 166 | ) |
| 167 | |
| 168 | # 3. 逐幕执行:先说话播完 → 再触发安全动作 → 等待动作完成 |
| 169 | for index, (action_key, line) in enumerate(steps, 1): |
| 170 | speak(line) |
| 171 | animation_id, hold = profile[action_key] |
| 172 | accepted = robot.play_animation(animation_id, interrupt=False) |
| 173 | print("ANIMATION step=%d key=%s id=%d accepted=%s" % (index, action_key, animation_id, accepted), flush=True) |
| 174 | if not accepted: |
| 175 | raise RuntimeError("ANIMATION_REJECTED id=%d" % animation_id) |
| 176 | time.sleep(hold) |
| 177 | |
| 178 | # 4. 收场谢幕与完成标记 |
| 179 | speak(CLOSING_LINE) |
| 180 | print("UNIVERSAL_SKILL_DONE steps=%d" % len(steps), flush=True) |
| 181 | |
| 182 | |
| 183 | # 机器人运行时在注入全局 robot 对象后直接执行 |
| 184 | main() |