语音回合制战斗智能体:在 Rokid Glasses 上做一局三格血的战斗
项目简介
这个项目是一个跑在 Rokid Glasses 上的回合制小游戏。玩家说一句“开始战斗”,眼镜拍照识别眼前的人当对手,然后双方轮流从攻击、防御、蓄力里选一个出招,谁的血先掉到零谁输。玩家也可以中途说“我投降”结束。
一开始想做的其实不是“游戏”,而是想验证一下:在 480×352 的单色微显示屏上,只靠语音加镜腿按键,能不能把回合制战斗这种要稳定状态、要清楚结算的交互完整跑下来。目标用户是 Rokid Glasses 的早期用户和 AIUI 开发者,他们愿意用语音做主要交互,但也需要语音不好使的时候能接着玩。
场景与用户意图
典型的唤醒场景是玩家想跟眼前某个人玩一局。准备页是栈底页,屏幕上就等一个输入:“开始战斗”。
用户说出这四个字之后,智能体不是把它当成一个命令执行一下就完了,后面还要接着走:启动摄像头拍照,把照片里的人当对手,进战斗主页,开始第一回合。这中间任何一步都可能出问题,拍照有延迟、识别可能失败、对手血量是随机的 1 到 10 格。所以准备页要准备的不是“用户一定会顺利进战斗”,而是“进了战斗之后的所有状态”。
方案设计
这个智能体的身份被刻意收得很窄:它就是一个本地规则驱动的回合制裁判。战斗判断不走大模型,不生成叙事文本,也不维护对话历史。出招就三种,结算就四类,血量固定,对手 AI 是一组带条件的随机概率。
这么做不是因为模型做不到,是回合制战斗最要紧的是“可预期”。玩家每次出招完得马上知道扣了几格、为什么扣、下回合该干嘛。规则判断放在本地,语音识别和语音播报交给平台能力,边界清楚,状态才不会被打散。
页面和状态流转
三个页面,一条主线:准备页 navigateTo 进战斗主页,战斗主页 redirectTo 换成结算页。
准备页在栈底,双击交给宿主退出。战斗主页用 navigateTo 进来,理论上保留了回准备页的路径,但实际玩的时候不会往回走,进战斗之后退出只有投降和打完两种。结算页是 redirectTo 替换掉战斗页的,不在栈上,双击由运行时默认退出。这个栈结构直接决定了游戏结束后没有“重新开始”的入口,要么退出,要么下次重新唤醒。
战斗主页的状态是最麻烦的部分。它要同时管玩家血量、对手血量、对手蓄没蓄力、玩家蓄没蓄力、当前回合到哪一步了。这些东西不能散在各处的事件处理里,不然语音的异步回调和按键事件会互相覆盖。实际做法是把“回合”做成一个显式的阶段机:
awaiting_player → resolving → awaiting_enemy → resolving → …
每次结算完才推进到下一阶段,不在当前阶段允许的输入一律忽略。
能力调用
语音识别只管两件事:准备页的“开始战斗”,战斗里的“我投降”。出招全走镜腿按键——单击 Enter 防御,前滑攻击,后滑蓄力。语音播报在每次结算后报结果,但它被明确设计成可失败、不阻断流程的旁路,播报挂了视觉反馈照样完整。
摄像头是唯一一次外部调用。玩家说“开始战斗”之后触发 wx.media.createCameraContext().takePhoto,拍到的照片拿去做识别。识别失败就降级:对手变成随机日常物品,水杯、桌子、椅子、显示器、沙发里随机挑一个,没有头像,界面上写明为什么降级。降级不是为了好看,是为了让“开始战斗”这个意图在任何情况下都有个能玩的落点。
AIUI 实现
工程结构是 AIUI 的标准入口,根目录 app.json 声明三个页面和语音触发表达,app.js 只管全局生命周期。重心在 pages/battle/battle.ink,一个单文件,
战斗规则纯函数:resolve(playerMove, enemyMove, playerCharged, enemyCharged) 返回 { playerHpDelta, enemyHpDelta, log }。页面只负责把结果映射到状态和模板,不自己算伤害。
battle.ink 的 data 里放 playerHp、enemyHp、enemyCharged、playerCharged、phase 和 logs。phase 是唯一的门控,玩家出招的事件处理先检查 phase === ‘awaiting_player’,不是就直接 return。结算时一次 setData 把血量和日志更新掉,再用 setTimeout 推到 awaiting_enemy,避免同一个事件循环里连着触发播报和下一次输入。
onShow() {
this._armedAt = Date.now()
this._speakFirst = true
const oppHp = randomOpponentHp()
this.setData({
phase: 'choose',
opponentHp: oppHp, opponentMaxHp: oppHp, opponentCharged: false,
opponentActionGlyph: '', opponentActionLabel: '',
opponentPips: this._hpToPips(oppHp, oppHp),
opponentFlash: false,
playerHp: PLAYER_MAX_HP, playerMaxHp: PLAYER_MAX_HP, playerCharged: false,
playerActionGlyph: '', playerActionLabel: '',
playerPips: this._hpToPips(PLAYER_MAX_HP, PLAYER_MAX_HP),
playerFlash: false,
playerName: '你',
roundNum: 1,
feedbackText: '选择你的行动',
feedbackBig: '',
damageText: '',
damageVisible: false,
voiceError: '',
surrender: false,
winner: '',
lastTaunt: '',
})
this.loadPlayerName()
this.startVoiceListen()
},
之所以坚持“一次结算一次 setData”,是因为单色屏实在没地方用动画掩盖中间态。480×352 的屏幕,状态必须离散、可见、一次成型,不然画面闪一下玩家就懵了。
血量用 pip 表达,实心方块满血,空心框失血。出招用三个大字形:攻⚔、防盾、蓄⚡。这些不是为了好看,是因为语音播报可能延迟或者失败,屏幕上得有一样东西让人不靠声音也能半秒内看懂结算结果。伤害浮字叠在 pip 上,命中闪光用一次全屏透明度脉冲,不引入新颜色。
按键和点击是双绑的,onKeyDown 和 bindtap 同时挂在可交互区域上,因为眼镜没有指针,而语音也有不可用的时候。准备页的降级写得很直白:语音识别失败就提示“语音不可用”,按键照样能玩。战斗中“我投降”没做按键入口,这是有意的——投降得说出声来确认一下,免得误触把一局还在打的战斗给结束了。
交互与体验
最重要的界面是战斗主页的结算帧。玩家按下攻击之后屏幕不会立刻跳走,而是停在一个合成画面里:双方出招 glyph 并排出现,pip 一格一格从实心变空心,伤害数字往上浮,同时语音播报“你攻击,对手防御,造成 1 点伤害”。四层反馈叠在一起,是因为单一通道在眼镜上都可能被漏掉,语音被环境盖住,闪光在户外看不清,pip 变化太细。回合制游戏没什么操作手感可以依赖,它唯一的沉浸感来源就是每一次出招都被清楚地接住。
操作路径压得很短,准备页一个入口,战斗页每回合最多一次按键,结算页没有操作。投降是唯一能主动提前结束的路径,触发后先来一句嘲讽,再 redirectTo 到结算页,标注投降态。嘲讽的台词没做成通用辱骂,就是短、具体、带点意外感的一句话,想让投降变成一个有点后果的选择,而不是个安静的退出按钮。
调试与验证
在 AIUI Studio 的网页模拟器里跑,用效果预览模拟镜腿操作和语音输入,重点测非法输入——在 resolving 阶段按攻击、在对手回合按蓄力、在结算页单击,确认全被门控挡下来,没有一次把状态改乱。

成果与复盘
现在能跑的是一个三页、纯本地规则、语音加按键双通道的完整回合制战斗智能体。战斗页出招、结算、对手 AI、投降都齐了,结算页双击退出。拍照失败会进日常物品降级,语音识别失败保留按键路径,语音播报失败只走视觉反馈不阻断。
踩得最狠的坑不是某个 API 怎么用,是“回合”这个东西在异步环境里的边界。语音识别回调、按键事件、setTimeout 推进、语音播报完成回调,这几个的到达顺序是随机的,没有显式的 phase 门控,同一回合会被结算两次,或者对手出招在玩家结算完之前就写进去了。最后的解法是把所有状态迁移收到 setData 之后的单一推进函数里,事件处理只做三件事:检查 phase、提交 move、触发一次 resolve,绝不在事件处理里直接改血量。
后面想先做的是把对手 AI 的性格从纯概率里拉出来一点。现在血量 ≤2 偏防御、已蓄力时 80% 攻击,规则是成立的,但一局打完对手没什么面孔。降级路径的物品对手可以加一句随物品变的开场台词,拍照成功的真人对手可以加一句基于拍照结果的短评,不改战斗规则,只是让“眼前人变成对手”这件事在结算之后还能留点痕迹。第二件是投降嘲讽的台词库,按对手血量和回合数分档,让不同局势下的投降听起来不一样。这两件事都不扩大状态机,只是给已经闭合的循环加点可感知的质地。
更多推荐


所有评论(0)