这次投稿的作品不是对话机器人,也不是问答助手,而是一个眼镜上的单人 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 支持。
所以这个牌桌的所有"动态"都只有两种:
- 状态跳变 (
ink:if切换面板)------零成本、不占帧 - 定时的离散更新(庄家每 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 check0 诊断 - 单页实现 :907 行
.ink,一个页面承载完整牌局 - 零依赖:不联网、零权限声明、零第三方包
- 可视化素材:官方 Ink 运行时真实渲染的状态截图,按 3 倍抓取(1440×1056)
- 智能体图标 :单绿卡牌 +
21,128px 下仍可辨
7.2 设计上真正起作用的两件事
- 限制可执行动作的数量:每屏最多两个动作,直接消灭了"选项太多在眼镜上没法选"的问题
- 用焦点代替指针:没有触摸的设备上,焦点是唯一能表达"选择"的视觉装置
7.3 后续迭代
- 规则层 :双倍下注、保险、分牌(分牌要把单一
player重构为多手,是唯一有结构改动的) - 交互层 :语音报牌(
speechSynthesis.speak,属未验证能力,需先实测)、「再来一局」的连击语音 - 内容层:连胜榜、历史牌局回放
7.4 一点感想
在手机上做界面,输入能力是"过剩"的,所以设计可以很随意;在眼镜上做界面,输入是稀缺资源,设计就变成了分配稀缺资源的工作。
这个项目里我最满意的决定,不是用了什么 API,而是坚持"每屏只给两个动作"。它让一个只有上下键的设备,真的能完整玩完一局 21 点。