三键之内的意图:用 AIUI 在 Rokid 眼镜上做一个能玩的 21 点

这次投稿的作品不是对话机器人,也不是问答助手,而是一个眼镜上的单人 21 点牌桌 ------黑杰克。

它看起来是一道偏题:既然是"AI 理解意图",为什么去做游戏?

因为我在真正开始写的时候撞上了一个很具体的问题:Rokid 眼镜上没有触摸屏,方向键只有上下两个,没有左右。任何"让界面响应意图"的方案,落到这台设备上都要先回答一个问题------当你能用的输入只有一个纵向的两向轴加一个确认键时,界面怎么表达"我现在可以做哪几件事"?

21 点刚好是一个把意图压缩得足够干净的载体:一局牌里,人想做的事永远只有那么两三件(下注、要牌、停牌、再来一局),而且每件事都有一个明确的"此刻该做"的时机。它不是靠内容量取胜,而是靠把意图收敛成一个焦点。

这篇文章记录的就是这条思路怎么一步步落成一个 8.5 KB 的 AIX 包,以及在开发、验证过程中撞到的几个真实问题。


一、项目简介

作品名称 :黑杰克(21 点) 载体 :Rokid Glasses 上的 AIUI 智能体,单页面(pages/blackjack/index),AIX 包体 8.5 KB 依赖:不联网、不申请任何权限(定位 / 相机 / 麦克风一律不声明),只用到本机存储保存战绩

解决什么问题 :眼镜用户在两三分钟的碎片时间里(等电梯、排队、饭后)想玩点什么,但现状是------掏出手机,屏幕小、单手难操作、还得抬头低头切换注意力。而眼镜本身就在眼前,透明显示不遮挡视野,本来是更合适的载体。缺的不是硬件,是缺一个能被四个按键驱动起来的交互形态。

目标用户:Rokid 眼镜的日常佩戴者;单手空着、不方便掏手机的场景。

它长什么样:整屏 480×352,单绿,纯黑处透明,纵向三段------顶部状态、中间牌桌、底部操作区。


二、场景与用户意图

2.1 唤醒与触发

用户戴上眼镜,说一句:

「乐奇,打开黑杰克」

宿主按 agentName 做语义命中,直接把智能体拉起来,第一帧就是牌桌的下注页------没有首页、没有菜单、没有二次确认。这是我做得很坚决的一个取舍:眼镜上的界面不应该有"门厅",唤醒之后看到的第一屏就应该是能立刻操作的东西。

2.2 三层意图与界面承接

一局牌下来,用户的意图是层层收窄的:

时刻 用户意图 界面给出的可执行动作 按键
刚打开 想玩一局 调注额 / 发牌 ↑↓、Enter
牌局中 这一手怎么下 要牌 / 停牌 ↑↓(切焦点)、Enter
结算 结果如何、还玩吗 下一局 / 退出 Enter、Back
筹码输光 重开还是走 重新开始 / 退出 Enter、Back

关键点是:任何时刻,屏幕上"能执行的动作"都不超过 2 个。这不是偷懒,而是对输入能力的正面回应------只有上下两键,就不要设计需要 3 个以上选项的界面。

2.3 把意图翻译成"焦点"

没有指针、没有触摸,怎么表达"我现在选中了这个"?

我的答案是焦点:屏幕底部永远只有两块并排的操作区,其中一块带 2px 亮边 + 12% 填充 + 文字提到满亮度,另一块是 1px 暗边。按 ↑↓ 只做一件事------把"亮的那块"换一个。

用户的心智因此变得极简单:上下选,确认按 。这句话同时也是下注页的提示文案(↑↓ 调注额 · ENTER 发牌),让首次接触的人不用读说明书。


三、方案设计

3.1 智能体定义

app.json 里只声明了一个页面和三个描述字段,没有任何 Widget、Agent Worker 或权限:

json 复制代码
{
  "agentName": "黑杰克",
  "agentDesc": "在 Rokid 眼镜上用上下键和确认键玩的单人 21 点(黑杰克)牌桌:下注、要牌/停牌、筹码与连胜记录。",
  "agentPrompt": "黑杰克开局。上下键调注额,确认键发牌;牌局中上下键切「要牌 / 停牌」,确认键定。返回键退出,牌局中按返回是弃局。",
  "pages": ["pages/blackjack/index"],
  "window": { "navigationBarTitleText": "黑杰克" }
}

agentPrompt 就是商店里"开场白"那一栏:一句话把操作模型讲完,因为它会被念出来,所以我刻意没写任何 Markdown 符号和箭头。

3.2 状态流转

整个游戏是一个五状态机,状态之间只靠 setData 推进:

scss 复制代码
        ┌──────────────────────────────┐
        │                              │
        ▼                              │
   ┌─────────┐  Enter   ┌──────────┐   │
   │   BET   │────────▶│ PLAYER   │   │
   │ 下注页  │          │ 要牌/停牌 │   │
   └─────────┘          └────┬─────┘   │
        ▲                    │ 停牌/爆牌│
        │                    ▼         │
        │              ┌──────────┐    │
        │              │  DEALER  │    │
        │              │ 庄家补牌  │    │
        │              └────┬─────┘    │
        │                   ▼          │
        │              ┌──────────┐    │
        └──────────────│  SETTLE  │    │
           Enter(下一局)│  结算页  │    │
                       └────┬─────┘    │
                            │ 筹码<10  │
                            ▼          │
                       ┌─────────┐     │
                       │  BROKE  │─────┘ Enter(重开)
                       │ 破产页  │
                       └─────────┘

每个状态在 data 里对应一组布尔量(showBet / showActions / showSettle / showBroke / showField / showBrief),模板用 ink:if 分支渲染。没有用一个 canvas,也没有一行动画------这是下面视觉部分要解释的决定。

3.3 页面布局

480×352 的可用画幅,纵向切成三块,中间那块用 flex: 1 把剩余空间吃掉:

erlang 复制代码
┌──────────────────────────────────────┐ 480×352
│ BLACKJACK        筹码 80   连胜 2     │ 11px label / 13px mono data
├──────────────────────────────────────┤
│ 庄家                             10  │ 48% 亮度(暗牌未翻时只显示明牌点数)
│ ┌────┐ ┌────┐                        │
│ │K S │ │ ?? │   ← 暗牌               │
│ └────┘ └────┘                        │
│ ─────────────────────────────────    │ 1px / 24% 结构线
│ 你                               19  │
│ ┌────┐ ┌────┐ ┌────┐                 │
│ │9 H │ │7 D │ │3 C │                 │
│ └────┘ └────┘ └────┘                 │
│                                      │
│ ┏━━━━━━━━━━┓  ┌──────────┐           │ 焦点:2px + 12% 填充
│ ┃  要牌    ┃  │  停牌    │            │
│ ┗━━━━━━━━━━┛  └──────────┘           │
│   ↑↓ 选动作 · ENTER 确认 · BACK 弃局  │ 10px caption
└──────────────────────────────────────┘

3.4 视觉:为什么不做动画

项目的视觉规范是单绿透明显示:只有一个绿色通道,纯黑等于透明,颜色不能承载语义。规范里还有一句很关键的话------animation 系列属性不支持 ,但 transition 支持。

所以这个牌桌的所有"动态"都只有两种:

  1. 状态跳变 (ink:if 切换面板)------零成本、不占帧
  2. 定时的离散更新(庄家每 450ms 补一张牌、翻一张暗牌)------不是动画,是"事件按节奏到达"

这带来一个意外的好处:整个页面没有帧循环,自然也就不用操心帧率。牌局推进的速度由牌的数量决定,和渲染性能完全解耦。

配色只用六档绿:#40ff5e(焦点与关键数值)、72%(正文)、48%(次要)、24%(结构线)、12%(选中填充)、6%(面板底)。2px 描边只出现在焦点上,其余一律 1px------这是规范里"仪表感优先于卡片感"的直接落地。


四、AIUI 实现

4.1 项目结构

bash 复制代码
my-agent/
├── AGENTS.md                     # 智能体身份、玩法、行为边界
├── app.json                      # 页面声明 + 智能体元信息
├── app.js                        # 应用级生命周期
└── pages/blackjack/index.ink     # 单文件 Page:配置 + 逻辑 + 模板 + 样式(907 行)

只有一个 .ink 文件------配置在 <script def>,逻辑在 <script setup>,结构在 <page>,样式在 <style>。对这类"一个页面就是一个完整应用"的智能体,单文件比四文件结构更好维护:改动一处不用在 4 个文件之间跳。

4.2 关键实现一:A 的计点

规则要求 "A 可计 11 或 1,自动取不爆的那个"。实现上不去做组合枚举,而是先全部当 11,爆了再逐个降 1:

js 复制代码
function handTotal(cards) {
  let total = 0;
  let aces = 0;
  for (let i = 0; i < cards.length; i += 1) {
    const value = rankValue(cards[i].rank);
    total += value;
    if (value === 11) aces += 1;
  }
  while (total > 21 && aces > 0) {   // 只在爆点时降级
    total -= 10;
    aces -= 1;
  }
  return total;
}

这个写法的好处是"最有利"是自动的:只要不爆,A 就是 11;一旦会爆,才逐张退回 1。A+A+9 = 21、A+A+A = 13 这些边界都被这段循环自然覆盖。

4.3 关键实现二:固定槽位渲染

AIUI 的模板里没有列表渲染(没有 wx:for 这类指令),所以"手牌数量不定"这件事不能用循环解决。我的做法是固定 5 个槽位 ,空槽给空串,靠 opacity: 0 隐藏:

js 复制代码
function slotText(cards) {
  const slots = [];
  for (let i = 0; i < MAX_SLOTS; i += 1) slots.push('');
  if (cards.length > MAX_SLOTS) {           // 超过 5 张折叠成 +N
    for (let i = 0; i < MAX_SLOTS - 1; i += 1) slots[i] = cardText(cards[i]);
    slots[MAX_SLOTS - 1] = '+' + (cards.length - (MAX_SLOTS - 1));
    return slots;
  }
  for (let i = 0; i < cards.length; i += 1) slots[i] = cardText(cards[i]);
  return slots;
}

模板里就是 5 个平铺的卡片:

xml 复制代码
<view class="card {{d0 ? '' : 'empty'}}"><text class="card-text">{{d0}}</text></view>

用 opacity 而不是"不渲染",是为了布局稳定:牌从 2 张变 3 张时,后面几张卡不会整体位移,视觉上只是"多亮了一张"。

4.4 关键实现三:按键归属

眼镜上 Backspace 有宿主默认行为(返回上一级,无上级时请求关闭智能体)。但牌局中途按返回,语义应该是"弃局"而不是"退出"。所以要精确决定这一下按键归谁:

js 复制代码
ownsKey(code) {
  if (code === 'Enter' || code === 'ArrowUp' || code === 'ArrowDown') return true;
  // Backspace 只在牌局中归本页处理;其余阶段交还宿主做返回/退出
  if (code === 'Backspace') return this.data.phase === 'PLAYER';
  return false;
}

onKeyDown 里做动作,onKeyUp 里补一次 preventDefault()------因为宿主的默认动作在 onKeyUp 之后执行。这样下注页和结算页按返回能正常退出,而牌局中途按返回会被本页接住,走"弃局"分支。

4.5 关键实现四:庄家回合的定时器链

庄家补牌要一张一张地出,所以用回调串成一条链,每一步都存句柄:

js 复制代码
after(ms, fn) {
  const id = setTimeout(() => {
    this.timers = this.timers.filter((handle) => handle !== id);
    fn();
  }, ms);
  this.timers.push(id);
  return id;
},

clearTimers() {
  for (let i = 0; i < this.timers.length; i += 1) clearTimeout(this.timers[i]);
  this.timers = [];
}

配套的还有一个容易被忽略的兜底:如果用户在庄家补牌过程中切到后台,链路会断在半路,页面就卡在 DEALER 状态了 。所以 onHide 里除了清计时器,还要把庄家回合一次算完:

js 复制代码
onHide() {
  this.clearTimers();
  if (this.data.phase === 'DEALER') this.resolveDealerInstant();
}

resolveDealerInstant() 用一个 while 循环把庄家补到 17 点以上再结算,guard < 20 防死循环。

4.6 关键实现五:存档失败要能降级

wx.setStorageSync 在存储不可用时是抛异常 的,不是返回 false。所以读写都包了 try/catch,失败就把 showStorageWarn 置真,在页面底部多一行 10px 的提示:

js 复制代码
loadStats() {
  try {
    const saved = wx.getStorageSync(STORE_KEY);
    if (saved && typeof saved === 'object') {
      const best = Number(saved.best);
      this.stats = { best: isNaN(best) ? 0 : best };
    }
  } catch (error) {
    this.stats = { best: 0 };
    this.setData({ showStorageWarn: true });
  }
}

要点是存档只是附加价值,不能成为游戏的前提:存储挂了,牌局照常能打。


五、交互与体验

5.1 三个界面

下注页------第一屏就把规则讲清楚(四行),底部是注额与发牌入口:

牌局页------见上文图 1:庄家一张明牌一张暗牌,玩家手牌与点数,底部两块操作区,焦点在"要牌"。

结算页------结果用文字(赢/输/平/BLACKJACK/弃局)而不是颜色,右侧是本局盈亏:

5.2 反馈机制

反馈 做法
焦点在哪 2px 亮边 + 12% 填充 + 文字满亮度(三重线索,不只靠亮度)
该按什么 底部一行 10px 提示,随阶段变化
庄家在做事 每 450ms 翻一张牌,用户能看见"它在补"而不是瞬间出结果
输了 / 赢了 结果文字 + 盈亏数字 + 一句原因("庄家爆牌"/"你爆牌了")
筹码没了 单独的破产页,显示历史最高筹码,Enter 直接重开

5.3 为什么没有触摸

这条容易被忽略:整页也绑了 bindtap,但没有为触摸做任何布局优化。因为在眼镜上,用户的手通常是空的或者在做别的事,触摸不是主路径。所有交互都按"四个键"设计,触摸只是顺带能用。


六、调试与验证

6.1 工具链

@yodaos-pkg/aix-cli 提供了一条完整的本地链路:

bash 复制代码
aix check ./my-agent        # 静态检查:JSON、结构、JS 语法、权限声明
aix pack  ./my-agent -o my-agent.aix
aix show  ./my-agent.aix    # 查看生效的智能体定义
aix preview ./my-agent.aix  # 本地起预览
aix install ./my-agent.aix  # ADB 直装眼镜

aix check 的输出是分项的,跑完是 0 诊断:

scss 复制代码
[passed] JSON syntax and text encoding (4 files)
[passed] App, Page, Widget, and component structure
[passed] JS/TS syntax (2 scripts)
[passed] Permission declarations and direct API use
0 diagnostic(s)

6.2 三层验证

我把验证拆成三层,每层的"能证明什么"是分开的:

第一层:静态检查(工具能证明) aix check 0 诊断;打包后 aix show 能看到 agentName、agentPrompt、permissions: [] 都按预期进了 manifest。

第二层:逻辑验证(我自己写的 harness 能证明) 把 <script setup> 从 .ink 里抽出来,去掉 import、把 export default 换成赋值,丢进 Node 的 vm 里跑,桩掉 wx 的存储接口和 setTimeout:

  • 40 局固定种子的整局对打(其中 20 局故意打到玩家爆牌):扣注额、暗牌只暴露明牌点数、停牌后庄家自动补到 17、结算后牌面全开、结果文案与赔付金额一致、筹码不为负
  • 结算规则矩阵 6 组:Blackjack 3:2 / 双方 Blackjack 退注 / 玩家爆牌 / 庄家爆牌 / 点数领先 / 和局
  • 边界定点用例:庄家 16 必补、17 必停、软 16 必补、软 17 必停、21 必停
  • 异常路径:存储抛异常时页面仍可用、切后台打断庄家回合能收敛到结算

这一层累计跑了 200 多条断言,全部通过,且因为用了固定种子,结果可复现。

第三层:渲染验证(官方运行时能证明) aix preview 生成的预览页会用真实的 Ink 运行时 (本例是 0.19.1)渲染页面。我用它确认了 ink:if 条件渲染、class 三元绑定、46×60 的卡片网格、空槽 opacity:0 占位不塌陷、中文排版、100vh 撑满------这些在静态检查里是看不出来的。

6.3 三个踩到的坑

坑一:预览运行时拉不下来

aix preview 的预览页会从 ga.jspm.io 动态加载 Ink 运行时,我这边的网络环境访问不到,页面直接报:

sql 复制代码
TypeError: Failed to fetch dynamically imported module:
https://ga.jspm.io/npm:@yodaos-pkg/ink@0.19.1/index.js

不是 aix 的问题,是 CDN 可达性。我把预览 HTML 里的 CDN 前缀换成另一个可达的镜像(https://ga.jspm.io/npm: → https://unpkg.com/)就跑通了。这只影响我本地的验证方式,不影响 Studio 和真机。

坑二:截图抓不到多段定时器的终态

我想截"玩家爆牌后庄家补牌"这个过程,但用 Edge 的 --screenshot --virtual-time-budget=25000 抓到的永远是庄家翻牌前那一帧。原因很实在:虚拟时间在等待 CDN 模块加载时被消耗掉了,剩下的时间不够跑完 450ms × 3 段的定时器链。

最后换成 CDP 驱动:Page.navigate → 真实等待 9 秒 → Page.captureScreenshot,才拿到终态。顺带用 clip + scale: 3 把 480×352 的屏幕区域按 3 倍抓成 1440×1056,直接用作文素材。

坑三(最值得记):我自己加的"优化"写出了 bug

最初为了让流程更干脆,我在庄家补牌的循环里加了一条短路:

js 复制代码
// 玩家爆牌时庄家不再补牌,直接比点
if (handTotal(this.player) > 21) { this.settle(); return; }

上线前实测才暴露问题:只要玩家先爆牌,庄家哪怕只有 10 点也会立刻停手,结算页上出现"庄家 10 点"这种不合规则的画面。

这条短路是我加的,不是规则要求的。正确的规则是庄家只看自己的牌:16 点及以下必须要牌、17 点及以上必须停牌------与玩家是否爆牌无关。改法是把短路删掉:

js 复制代码
dealerStep() {
  if (this.data.phase !== 'DEALER') return;
  // 庄家规则:16 点及以下必须要牌,17 点及以上必须停牌;
  // 不受玩家是否爆牌影响 ------ 玩家爆牌后庄家同样要补到 17 点
  if (handTotal(this.dealer) < 17) {
    this.dealer.push(this.deck.pop());
    this.render();
    this.after(450, () => this.dealerStep());
    return;
  }
  this.settle();
}

改完后我加了两类回归用例:20 局玩家爆牌的样本里,庄家最低点数必须是 17;以及一个定点用例(玩家 25 点爆牌、庄家 9 点,deck 顺序保证补到 17)。再用真实运行时跑一遍------下面这张就是修复后的证据:玩家 25 点已爆牌,庄家仍从 9 点连补到 17 点才结算。

这次教训很直接:"看起来更合理的优化"要拿规则原文核对一遍,否则它会安静地改掉游戏规则,而且只在特定路径(玩家爆牌)下出现,随机测试不一定会覆盖。

6.4 明确没验证的部分

写文章时把没验证的东西说清楚,比含糊过去有用:

  • 真机上四个 event.code 是否按文档到达、preventDefault() 是否真的能拦住返回键的默认行为
  • wx.setStorageSync 在该运行上下文里是否真的可用(代码里已做降级,但没在设备上验证过降级路径)
  • 真机上单绿透明显示在强光/复杂背景下的实际可读性

这三项只能上设备确认。


七、成果与复盘

7.1 可运行的成果

  • AIX 包 :8.5 KB,aix check 0 诊断
  • 单页实现 :907 行 .ink,一个页面承载完整牌局
  • 零依赖:不联网、零权限声明、零第三方包
  • 可视化素材:官方 Ink 运行时真实渲染的状态截图,按 3 倍抓取(1440×1056)
  • 智能体图标 :单绿卡牌 + 21,128px 下仍可辨

7.2 设计上真正起作用的两件事

  1. 限制可执行动作的数量:每屏最多两个动作,直接消灭了"选项太多在眼镜上没法选"的问题
  2. 用焦点代替指针:没有触摸的设备上,焦点是唯一能表达"选择"的视觉装置

7.3 后续迭代

  • 规则层 :双倍下注、保险、分牌(分牌要把单一 player 重构为多手,是唯一有结构改动的)
  • 交互层 :语音报牌(speechSynthesis.speak,属未验证能力,需先实测)、「再来一局」的连击语音
  • 内容层:连胜榜、历史牌局回放

7.4 一点感想

在手机上做界面,输入能力是"过剩"的,所以设计可以很随意;在眼镜上做界面,输入是稀缺资源,设计就变成了分配稀缺资源的工作。

这个项目里我最满意的决定,不是用了什么 API,而是坚持"每屏只给两个动作"。它让一个只有上下键的设备,真的能完整玩完一局 21 点。

相关推荐
火山引擎开发者社区8 分钟前
给企业配一双 AI 的手:火山引擎 OS Agent 替你操作手机和电脑
前端·人工智能
神鹰手哥_起飞版10 分钟前
我是怎么管理 Coding Agent 的:把任务交出去,也把结果验回来
人工智能·架构·产品经理
泯泷27 分钟前
你的 AI 知识库为什么答非所问?问题出在“切块”这一刀
人工智能
国科安芯41 分钟前
算力上天之后的电与静:星上边缘计算载荷带外管理网络的低功耗调度与辐射容错
网络·人工智能·嵌入式硬件·安全·边缘计算·低轨卫星·商业航天
kisdiem1 小时前
让 AI 边玩游戏边编写世界规则:从 XLand 到 ARC-AGI-3 的实验记录
人工智能·agi·玩游戏
kaixin_啊啊2 小时前
【零基础学AI】第 27 章课后练习与答案
人工智能
慢慢来不要急2 小时前
Anthropic 自建源码控制,AI Agent 把 Git 跑到了极限 · 11-09
人工智能·开发工具
CyrusYiVPP2 小时前
实测 Ollama 本地部署:我用 Python 搓了个离线问答机器人,Qwen4 27B 一到换个模型名就能用
人工智能·python
美团技术团队2 小时前
美团 LongCat-2.5 开源:从看见,到抵达
人工智能