让头部动作成为游戏机关:用 AIUI 做一台真正能“拉动”的弹珠机

触控板已经能很方便地完成选择、确认和返回,那么,眼镜上的游戏还能不能多利用一步“戴在头上”这件事?我想试着让头部动作直接参与玩法,于是把“低头—抬头”做成了一根弹珠机拉杆。
GitHub 开源地址:https://github.com/andaoking/aiui-pinball
最初接触 Rokid Glasses 时,我对它的第一印象其实是“省事”。触控板就在镜腿旁,滑动可以切换内容,单击可以确认,双击可以返回;许多短操作不必再拿出手机。对普通应用来说,这套输入已经足够清楚。也正因为如此,我在想:如果要做一款小游戏,除了把触控板当成按钮,还有没有一种更贴合眼镜形态的交互?
我先从一个很小的动作开始试:低头。它不需要抬手,也不需要寻找屏幕上的虚拟按钮。如果画面里恰好有一根弹簧,那么低头可以对应压簧,回正可以对应释放,动作和物体的变化天然就能接上。这个想法后来变成了“弹珠机”——一款用 AIUI 完成的轻量体感游戏。
玩家进入游戏后先自然平视完成校准;低头时,右侧弹簧和弹珠一起向下移动,力量逐渐积累;抬头,弹珠沿右侧发射道冲上圆弧,再滑入钉阵。之后每一次碰撞都会改变轨迹,直到球落进底部奖励槽。触控板仍然保留:单击可以跳过校准、直接发射或触发救球,是随时可用的后备操作。

这篇文章不只展示结果。我更想复盘一个问题:在算力和画布尺寸都受约束、但同时拥有触控板和传感器的穿戴设备上,怎样把一个简单念头打磨成完整、稳定,而且愿意让人再玩一局的交互?
一、先读文档,再决定哪些能力值得用
动手之前,我先把 AIUI 的项目结构、Canvas、传感器和页面事件文档过了一遍。它的开发方式对前端开发者很友好:项目以 app.js、app.json、AGENTS.md 和 pages/ 组织;页面既可以拆成传统的结构、样式和逻辑文件,也可以像这个项目一样写成一个 .ink 单文件组件。Canvas API 延续了 Web 2D 绘图思路,陀螺仪则可以直接获取三个轴向的旋转速度。
这几份官方资料是本项目最直接的起点:
- AIUI 官方文档首页:框架、组件、API、设计和工具的总入口。
- AIUI 快速开始:了解智能体工程、运行方式和开发流程。
- 代码组成与目录结构:传统多文件页面与
.ink单文件组件的组织方式。 - AIUI 已验证 API 清单:Canvas、Gyroscope、Sound、
wx存储等当前已核验能力。 - 陀螺仪 API 文档:采样频率、读数和事件监听方式。
- Canvas 基础文档:在页面中创建上下文并执行 2D 绘制。
- 页面按键事件:
Enter、Backspace和设备侧GlobalHook的处理方式。 - Rokid Glasses 操作说明:触控板滑动、单击、双击和长按等设备操作。
读完这些资料后,方案反而更明确了:触控板适合离散操作,例如开始、确认和退出;陀螺仪适合表达连续变化,例如蓄力从 0 到 1 的过程。它们不是互相替代,而是各做自己擅长的部分。
二、先别急着画界面,先找到动作的意义
刚开始做眼镜游戏时,很容易沿用手机思路:在画面中放一个“发射”按钮,让用户通过触控板单击。这样完全可以运行,也是项目必须保留的可靠入口;但如果只有一次单击,弹簧蓄力的过程就只剩下一段自动动画。头部动作在这里提供的不是“另一个点击”,而是一段连续的力度输入。
我把弹珠机最标志性的动作拆成了三个阶段:
| 游戏阶段 | 玩家动作 | 系统反馈 |
|---|---|---|
| 校准 | 保持自然正视 | 采集当前佩戴姿态作为零点 |
| 蓄力 | 缓慢低头 | 弹簧压缩、弹珠下移、力量累积 |
| 发射 | 抬头回正 | 弹簧释放,弹珠按蓄力大小获得初速度 |
这里最重要的不是“识别低头”,而是让动作和结果之间有直觉联系。低头像压缩拉杆,抬头像松手;即使玩家没有读说明,也能从画面中的弹簧变化猜到下一步。
代码里使用陀螺仪 x 轴的相对变化判断动作。每一球开始前采集 48 个样本,把平均值记为本轮零点:
function finishCalibration(game, samples) {
let sum = 0;
for (let i = 0; i < samples.length; i += 1) sum += Number(samples[i]) || 0;
game.gyroBias = samples.length ? sum / samples.length : 0;
game.phase = 'pull';
}
为什么每球都重新校准,而不是启动时只校准一次?因为真实佩戴中,人会换站姿、抬手、低头看东西,眼镜也可能发生轻微位移。一次长局里,固定零点会逐渐变得“不像零点”。把校准放进每次发射的节奏,短暂的停顿反而像是瞄准,也顺手抵消了漂移。
当然,体感不能成为唯一入口。预览器没有陀螺仪,部分用户也更习惯按键,所以点击、Enter 和 GlobalHook 都保留了后备操作:校准阶段点击可跳过,蓄力阶段点击会以满力量发射,球在台面时点击可触发一次救球。体感是主交互,按键是安全网。
三、一个 Canvas,承担整个游戏世界
游戏区只有 420 × 340,外层 Scene 是 480 × 352。在这个面积里同时放传统组件、动画节点和调试信息,很快就会显得拥挤。我最后保留一个常驻 Canvas:球、钉子、发射道、弹簧、奖励槽、提示层和 HUD 全部由它绘制。

单 Canvas 的好处不只是“节点少”。物理世界里的坐标可以直接变成绘图坐标,不需要在组件布局和碰撞系统之间来回换算;奖励槽的位置、墙体边界和球的半径都来自同一组常量,调试时也更容易发现穿模究竟发生在哪里。
页面本身只管理两个状态:主页和游戏。进入游戏后延迟绑定画布,成功后才启动陀螺仪与循环;退出时统一停止计时器、传感器和声音实例。这个收口很关键,否则反复进出页面后,很容易留下多个循环同时推进同一颗球。
四、不要把显示帧率当成物理时间
这套物理没有引入第三方引擎,而是围绕“一颗球、一组静态障碍和几条边界”写了一个够用的小型二维模拟器。这样做不是为了重复造轮子,而是因为问题规模很明确:场上始终只有一颗活动弹珠,钉子不会移动,也不需要刚体旋转、关节、摩擦堆叠等完整能力。比起加载一套通用引擎,直接维护几十个数值更轻,也更容易针对真机问题调整。
整个物理层可以按下面这条管线理解:

1. 先定义世界,而不是先写碰撞
我先把游戏世界固定在 420 × 340 的坐标中。左墙、顶边、发射道隔板、底部结算线都是数值常量;33 颗钉子则是一组 {x, y, r} 圆形数据。活动弹珠也只需要位置、速度、半径和是否仍在发射道中这几个字段:
game.ball = {
x: 361,
y: 276,
vx: -150,
vy: -1015,
r: 8,
inLane: true,
lastPeg: -1,
stallT: 0
};
与此同时,游戏流程用 calibrating → pull → play → celebrate → over 五个阶段控制。只有 play 阶段推进球的物理;校准和蓄力时,球的位置直接由弹簧姿态计算。把“流程状态”和“空间状态”分开以后,庆祝动画不会意外继续计算碰撞,重新校准也不会继承上一球的速度。
2. 用最小积分器让球先动起来
每个物理步先给竖直速度增加重力,再用新速度更新位置,最后施加与时间相关的阻尼:
ball.vy += 320 * dt;
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
const damping = Math.pow(0.94, dt * 45);
ball.vx *= damping;
ball.vy *= damping;
它是一种简单的“先速度、后位置”积分方式。这里不追求精确复刻现实单位,只要重力、发射初速度和阻尼在固定坐标系里形成稳定比例,玩家看到的上升、滑行和下落就会连贯。阻尼写成指数形式,是为了让它和经过的时间相关,而不是和调用次数绑定。
3. 把边界拆成几种简单约束
直线墙体最好处理:球越过左墙时,把圆心推回“墙坐标 + 半径”,再翻转法向速度。发射道隔板则根据 inLane 判断球应该待在隔板哪一侧,避免进入台面后又穿回管道。
右上圆弧本质上也是圆约束。先计算弹珠圆心到圆弧中心的向量和距离;当距离超过允许半径时,把球投影回弧内,再取速度在法线方向上的分量进行反射。为了让满蓄力确实能沿顶边向左滑行,圆弧还会把一部分向上的动量转换成向左推动。这是游戏手感需要的调校,不是假装所有系数都有现实世界单位。
4. 钉子碰撞只解决最紧急的一次穿透
对每颗钉子,先计算两圆圆心距离;距离小于半径和就说明发生重叠。代码不会在同一步里依次对所有钉子反弹,而是找出穿透最深的一颗,只处理它。确定碰撞法线后,先把球推出重叠区域,再移除朝向钉子内部的速度分量,并加一点向下偏置,让球继续穿过钉阵。
5. 奖励槽不是地板碰撞,而是终点事件
早期版本曾让底部像普通地板一样反弹,结果是球已经碰到奖励区,仍可能向上弹回游戏区。现在底部改成一条终端结算线:球心越过后,根据横坐标计算 7 个槽中的索引,立即销毁当前球并进入 celebrate。物理世界到这里结束,奖励、积分、RUSH 和下一球校准由状态机接手。
6. 随机也要能够复现
奖励槽会洗牌,但项目没有直接依赖不可控的全局随机数,而是给每局保存一个种子,用线性同余生成器推进。这样相同种子会产生相同的槽位与轨迹条件,自动化测试和本文动图都能稳定复现。调试某一次异常时,只要保留种子,就不必期待“运气好再撞一次同样的 Bug”。
物理状态只负责计算,不直接操作页面;Canvas 渲染器在每轮更新后读取球、弹簧、槽位和分数,再画出最终画面。这种分离也让测试可以完全不启动 UI,只提取 index.ink 中的核心函数,在 Node.js 里连续模拟数百步。
7. 最后再解决显示帧率
弹珠游戏最怕两件事:卡顿时穿墙,帧率变化时手感忽快忽慢。如果简单地“每渲染一帧,物理前进一步”,设备偶发掉帧就会直接改变游戏结果。
项目采用固定 45 Hz 的物理步长,把真实经过的时间先存进累加器,再按固定间隔补算:
function stepPinballClock(game, realDt) {
realDt = clamp(Number(realDt) || DT, 0, 0.1);
game.physAcc += realDt;
let steps = 0;
while (game.physAcc >= DT && steps < 6) {
stepPinball(game, DT);
game.physAcc -= DT;
steps += 1;
}
if (steps === 6) game.physAcc = 0;
}
这里又加了两道保险:真实帧间隔最多按 0.1 秒处理,单帧最多追 6 个物理步。它们会牺牲极端卡顿后的少量时间精度,但能避免恢复画面时突然计算几十步,把弹珠直接“传送”到桌外。球速也有上限,整个模拟因此始终保持有限和可预测。
发射道最初是一段直角折线,满蓄力时弹珠常在顶角损失过多速度。后来我改成右上四分之一圆:球先在竖直管道上行,撞上圆弧后将一部分向上的速度转换为向左的冲量,再沿顶边滑入钉阵。这个变化看似只是几条绘图线,实际同时影响碰撞法线、入场角度和玩家对力量的感知。
五、钉子不是越多越好,稳定比“真实”更重要
圆与圆的碰撞公式并不复杂。麻烦在于,弹珠可能在一个物理步里同时压进两颗钉子。如果逐个完整反弹,它会在狭缝里左右交换速度,肉眼看起来像被钉阵吸住。
最后采用了四层处理:
- 每个物理步只解决穿透最深的一颗钉,避免多个修正互相打架。
- 无论是否处在冷却期,都先把重叠的球推出钉子。
- 同一颗钉短时间内不重复施加强反弹,只做衰减和轻微向下引导。
- 球速持续低于阈值时增加下落速度,打破双钉之间的周期振荡。
if (cooling) {
ball.vx *= 0.88;
ball.vy *= 0.88;
if (ball.vy < 30) ball.vy += 18;
return;
}
这不是追求实验室级物理,而是在小屏幕和有限算力下追求“看起来对、玩起来稳”。同样的取舍也体现在钉阵密度上:钉子太密虽然更像真实机器,却更容易形成夹球;略微稀疏后,轨迹变化依然丰富,节奏反而更顺。
六、奖励要有惊喜,也要能长期运行
底部有 7 个槽,奖励值来自一组保守模板:空槽、小额槽、中额槽和高额槽的组成固定,但位置会在每一球开始时洗牌。这样既让落点保持悬念,又不会因为完全随机而连续生成一排高奖励。
系统还会根据当前弹珠库存切换模板。库存少时奖励稍宽松,库存多时自动收紧。它不是复杂的经济系统,却解决了两个体验问题:新玩家不容易立刻结束,运气很好时库存也不会无限膨胀。
3.9.0 加入了更清晰的成长目标:有效撞钉获得 10 分,落槽按奖励弹珠数乘以 100 计分;累计分数达到 3000 和 10000 后,新局初始弹珠会从 3 枚增加到 4 枚和 5 枚。落入 +10 或大奖槽会开启 8 球“连珠 RUSH”,期间积分翻倍。即时奖励、短期爆发和长期解锁因此连成了一条完整循环。
七、声音和性能,都应该服务于反馈
游戏实际只播放四类声音:发射、撞钉、普通奖励和大奖。音效没有使用外部采样,而是由 Python 标准库生成单声道 WAV;这样文件来源清晰,也方便调整音高和时长。
撞钉声没有每次碰撞都播放。高密度连续触发既嘈杂,也可能同时创建过多声音实例,所以代码给不同声音设置了最小间隔,撞钉声的间隔更长。视觉上已经能看到球改变方向,声音只需要强调关键触点,不必把每一次数值变化都广播出来。
渲染循环约每 16 毫秒调度一次,但动画帧不调用高频 setData;分数、弹珠和提示直接画进 Canvas。物理与绘制使用同一个轻量状态对象,也避免了重复序列化。
八、真正花时间的,是那些“不应该发生”的瞬间
回看版本记录,功能本身很快就能搭起来,之后的大部分时间都花在边界问题上:
- 校准样本曾被循环反复清空,页面一直停在“校准中”。
- 发射道隔板只有单向碰撞,球会从台面穿回管道。
- 右下角直角区域会把球卡死,后来改成斜向导流。
- 直接跟随显示帧率计算物理,掉帧后会产生穿透。
- QuickJS 严格模式不接受重复函数声明,真机出现“渲染初始化失败”。
- 底部如果保留普通地板反弹,球已经进入奖励区还可能弹回台面。
这些问题很难靠“看代码感觉没问题”发现,所以项目保留了 28 项自动化测试。测试不只检查函数返回值,还会跑数百步长模拟,验证数值始终有限、相同种子得到相同轨迹、满蓄力能越过圆弧、双钉夹球最终能够脱困,以及不同显示帧间隔不会改变固定步长结果。
node --test test-coin-pusher.js
对小项目来说,测试不是为了追求覆盖率数字,而是把每次真机踩过的坑固定成一条以后不能再退回去的底线。
九、项目结构与打包
开源包保持了尽量短的结构:
aiui-pinball-3.9.0/
├─ coin-pusher-zh-agent/
│ ├─ app.js
│ ├─ app.json
│ ├─ pages/index/index.ink
│ ├─ assets/icon/
│ ├─ assets/sfx/
│ ├─ README.md
│ └─ LICENSE
├─ test-coin-pusher.js
└─ tools/make_sfx.py
项目本身没有第三方运行依赖。将 coin-pusher-zh-agent 交给 AIUI AIX 打包工具即可生成安装包:
.\aiui-aix\aiui-aix-x86_64.exe pack --optimize `
-o .\dist\coin-pusher-zh-agent-3.9.0.aix `
.\coinpusher\coin-pusher-zh-agent
GitHub 开源地址:https://github.com/andaoking/aiui-pinball
代码以 MIT License 开源。AIUI SDK、运行时与平台商标仍遵循各自条款。为了让演示可复核,本文动图不是手工绘制的预设轨迹:生成脚本直接提取 index.ink 中的物理核心,以固定随机种子跑出状态帧,再按游戏的 Canvas 视觉规则离线渲染。
写在最后
做完这台弹珠机后,我对“穿戴设备交互”最大的感受是:并不是用了传感器,交互就一定会更自然。关键仍然是输入方式和任务是否合适。
触控板单击很适合开始和确认,所以我一直把它保留在游戏里;低头和抬头则负责连续改变蓄力大小,让玩家能够从弹簧的位置看到自己的动作正在产生什么结果。两种输入各有分工,操作才不会为了“体感”而体感。
AIUI 给了我 Canvas、陀螺仪、声音和本地存储这些基础能力。剩下的工作,是在限制中不断删掉多余东西,留下最直接的一条链路:看见弹簧,低头蓄力,抬头发射,然后等待一颗球穿过钉阵时那一点不可预测的惊喜。
这也是我认为体感小游戏最迷人的地方。
更多推荐



所有评论(0)