文章封面

触控板已经能很方便地完成选择、确认和返回,那么,眼镜上的游戏还能不能多利用一步“戴在头上”这件事?我想试着让头部动作直接参与玩法,于是把“低头—抬头”做成了一根弹珠机拉杆。

GitHub 开源地址:https://github.com/andaoking/aiui-pinball

最初接触 Rokid Glasses 时,我对它的第一印象其实是“省事”。触控板就在镜腿旁,滑动可以切换内容,单击可以确认,双击可以返回;许多短操作不必再拿出手机。对普通应用来说,这套输入已经足够清楚。也正因为如此,我在想:如果要做一款小游戏,除了把触控板当成按钮,还有没有一种更贴合眼镜形态的交互?

我先从一个很小的动作开始试:低头。它不需要抬手,也不需要寻找屏幕上的虚拟按钮。如果画面里恰好有一根弹簧,那么低头可以对应压簧,回正可以对应释放,动作和物体的变化天然就能接上。这个想法后来变成了“弹珠机”——一款用 AIUI 完成的轻量体感游戏。

玩家进入游戏后先自然平视完成校准;低头时,右侧弹簧和弹珠一起向下移动,力量逐渐积累;抬头,弹珠沿右侧发射道冲上圆弧,再滑入钉阵。之后每一次碰撞都会改变轨迹,直到球落进底部奖励槽。触控板仍然保留:单击可以跳过校准、直接发射或触发救球,是随时可用的后备操作。

基于 3.9.0 源码确定性模拟生成的玩法演示

这篇文章不只展示结果。我更想复盘一个问题:在算力和画布尺寸都受约束、但同时拥有触控板和传感器的穿戴设备上,怎样把一个简单念头打磨成完整、稳定,而且愿意让人再玩一局的交互?

一、先读文档,再决定哪些能力值得用

动手之前,我先把 AIUI 的项目结构、Canvas、传感器和页面事件文档过了一遍。它的开发方式对前端开发者很友好:项目以 app.jsapp.jsonAGENTS.mdpages/ 组织;页面既可以拆成传统的结构、样式和逻辑文件,也可以像这个项目一样写成一个 .ink 单文件组件。Canvas API 延续了 Web 2D 绘图思路,陀螺仪则可以直接获取三个轴向的旋转速度。

这几份官方资料是本项目最直接的起点:

读完这些资料后,方案反而更明确了:触控板适合离散操作,例如开始、确认和退出;陀螺仪适合表达连续变化,例如蓄力从 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';
}

为什么每球都重新校准,而不是启动时只校准一次?因为真实佩戴中,人会换站姿、抬手、低头看东西,眼镜也可能发生轻微位移。一次长局里,固定零点会逐渐变得“不像零点”。把校准放进每次发射的节奏,短暂的停顿反而像是瞄准,也顺手抵消了漂移。

当然,体感不能成为唯一入口。预览器没有陀螺仪,部分用户也更习惯按键,所以点击、EnterGlobalHook 都保留了后备操作:校准阶段点击可跳过,蓄力阶段点击会以满力量发射,球在台面时点击可触发一次救球。体感是主交互,按键是安全网。

三、一个 Canvas,承担整个游戏世界

游戏区只有 420 × 340,外层 Scene 是 480 × 352。在这个面积里同时放传统组件、动画节点和调试信息,很快就会显得拥挤。我最后保留一个常驻 Canvas:球、钉子、发射道、弹簧、奖励槽、提示层和 HUD 全部由它绘制。

弹珠机单 Canvas 游戏画面

单 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 个物理步。它们会牺牲极端卡顿后的少量时间精度,但能避免恢复画面时突然计算几十步,把弹珠直接“传送”到桌外。球速也有上限,整个模拟因此始终保持有限和可预测。

发射道最初是一段直角折线,满蓄力时弹珠常在顶角损失过多速度。后来我改成右上四分之一圆:球先在竖直管道上行,撞上圆弧后将一部分向上的速度转换为向左的冲量,再沿顶边滑入钉阵。这个变化看似只是几条绘图线,实际同时影响碰撞法线、入场角度和玩家对力量的感知。

五、钉子不是越多越好,稳定比“真实”更重要

圆与圆的碰撞公式并不复杂。麻烦在于,弹珠可能在一个物理步里同时压进两颗钉子。如果逐个完整反弹,它会在狭缝里左右交换速度,肉眼看起来像被钉阵吸住。

最后采用了四层处理:

  1. 每个物理步只解决穿透最深的一颗钉,避免多个修正互相打架。
  2. 无论是否处在冷却期,都先把重叠的球推出钉子。
  3. 同一颗钉短时间内不重复施加强反弹,只做衰减和轻微向下引导。
  4. 球速持续低于阈值时增加下落速度,打破双钉之间的周期振荡。
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、陀螺仪、声音和本地存储这些基础能力。剩下的工作,是在限制中不断删掉多余东西,留下最直接的一条链路:看见弹簧,低头蓄力,抬头发射,然后等待一颗球穿过钉阵时那一点不可预测的惊喜。

这也是我认为体感小游戏最迷人的地方。

更多推荐