【转战智能眼镜开发】ROKID AIUI 小游戏 亚马逊标枪项目实战讲解

0 阅读35分钟

标枪像素:在 AR 眼镜上,把"用多大劲投"变成一次真实的转头

一、项目简介:眼镜上没有力度条,那"用多大劲"该怎么说

标枪是个"模拟量"游戏。玩家要表达的从来不是"按下了投掷键",而是我要用多大的劲、往哪个高度投——这两个量在传统手柄上是摇杆行程和扳机深度,在手机上是一根会涨的蓄力条。

把它搬到 AR 眼镜上,我遇到的第一个别扭是:眼镜既没有可以盯着慢慢推的摇杆,也不适合在视野里塞一根占地方向的进度条。用户戴着眼镜、身体朝向场地时,他最自然的"发力表达"其实是头。于是我没有做"把蓄力键挪到镜腿",而是重新定义了三件事:

  • 用多大劲 —— 朝射手背后方向转头,转得越多力度越大,标枪本身变成一根十格刻度条;
  • 投多高 —— 抬头/低头调整出手角度(35°–55°,平视是 40°),镜腿上下滑可以 ±2° 微调;
  • 要不要提示 —— 轻触镜腿开关辅助线:预测弧线、距离刻度、部位标尺一起消失或出现。

出手不是"按键",而是快速反向甩头——把"蓄力—释放"这个动作链完整地交给身体。

《标枪像素》要解决的问题可以一句话概括:在没有摇杆、没有扳机、视野又必须留给真实世界的眼镜设备上,怎样把"我要用多大劲、往哪投"这种连续的模拟意图,变成不占视野、不需要学习、并且能被物理规则如实反馈的动作。

目标用户是想在眼镜上玩一点轻量运动/竞技、并且愿意用身体而不是手指来操作的人。整个应用跑在本地:不需要账号、不依赖网络、没有任何联网权限,弹道、命中、音效、存档全部在设备内完成。

当前可运行版本的规模(都是仓库里能查到的数字,不是估算):

项目数量
Ink 页面1 个(pages/javelin/index.ink,846 行)
逻辑模块5 个(physics / rules / head-control / render / sfx,共 749 行)
关卡由公式生成,无上限;距离在 54m 封顶(第 15 关),命中窗口在 15% 封顶(第 12 关)
本地音效7 个 WAV,全部脚本生成,16kHz / 16bit 单声道,合计 96.9KB,最长的通关音 0.84 秒
打包产物1 个 AIX,102,898 字节,18 个文件
单元测试node --test 报告 23 项通过 / 0 失败
运行时测试scripts/native-test.mjs 报告 14 项通过,同时产出 17 张运行时截图

界面一览

下面四张覆盖了这个游戏最典型的四个瞬间:转头蓄力、飞行、命中、以及没有陀螺仪时的按键降级。 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

二、场景与用户意图:先分清"观察"和"发力"

1. 头上的动作有两种,含义完全不同

用户戴着眼镜转头,可能只是想看那边,也可能是正在发力。这两种意图在传感器层面长得一模一样——都是一段 yaw 角速度。如果不区分,游戏会变成"我一转头就误投",这是这个项目最不能接受的失败。

我的处理是把"看"和"发力"用幅度和速度两个维度分开:

用户想表达用户实际做的系统识别依据反馈
我在观察小幅、缓慢转头累计幅度 < 4° 死区,或角速度远低于 2.0 rad/s只有刻度不动,画面不投
我要用三分力向右转到约 16° 停住累计幅度映射成 power≈30%标枪刻度逐格点亮 + 每升一格一声轻响
我要出手快速向左甩头反向角速度 ≥ 2.0 rad/s 且峰值力度 ≥ 8%取甩头前 0.4 秒内的峰值力度出手
我想投高一点抬头pitch 角速度积分,增益 0.75角度数字与辅助线弧线一起变
我不想看辅助线轻触镜腿GlobalHook 点击弧线/刻度/部位标尺整体开关
这台设备没有陀螺仪按住确认键keydown 后持续计时力度按三角波涨落,松开即投

关键在最后一条:"转头=力度"这条主路径必须有降级路径,否则在无传感器或传感器被占用的设备上整个游戏就废了。

2. 为什么这个项目不需要语音和大模型

征文主题叫"从对话到行动",我特意想清楚了一件事:意图不等于语言。玩家说"帮我投 60% 力度"反而比直接转一次头更慢、更不准,还要占用听觉通道。AIUI 的"意图→执行→反馈"闭环在这里照样成立,只是意图的载体换成了头部姿态,执行换成了确定性的抛物线积分,反馈换成了画面、文字、音效和 HUD 四路同时到达。

所以 app.json 里只有一个页面、没有 agentWorkers、没有任何联网与语音权限;AGENTS.md 里写的也不是"对话人设",而是这个沉浸式智能体的行为说明书:

- 首次启动进行两步方向校准:先向右转头约 30°,再抬头约 20°,自动识别转头和抬头的陀螺仪轴向和正负号;菜单中可重新校准。
- 游戏由头部传感器控制:朝射手背后方向转头蓄力(标枪刻度随转头点亮),快速反向甩头投掷。
- 每关射手和目标位置互换:射手在右侧时向右转头蓄力,在左侧时向左转头蓄力。

这三行不是注释,它们直接决定了代码结构:第一步对应 createCalibrator(),第二步对应 createHeadControl().update(),第三步对应 levelConfig().shooterSide 和 setDirection()。

在这里插入图片描述

3. 一个必须写进设计的边界:颈部安全

这个游戏的输入是"转头",所以它天然会诱导玩家大幅度、快速地扭脖子。我在策划案里把边界写死了,也把它放进代码的取值里:

  • 蓄力满程只有 45°(FULL_CHARGE_DEG),而且力度从 4° 才开始响应,玩家不需要"转到极限"才能投满;
  • 出手靠角速度阈值而不是幅度极限,甩头是"快"而不是"大";
  • 抬头增益 0.75,意味着抬头 20° 就到 55° 上限,不需要用力仰头;
  • 文案定位是"以娱乐为入口,鼓励玩家在舒适范围内活动颈部",不用于颈椎病治疗、康复处方或医疗诊断。

三、方案设计:一个"物理先行"的沉浸式智能体

1. 形态选择:单页沉浸式,不是聊天里的页面工具

app.json 的 pages 只有一项,应用从首个页面直接启动为沉浸式;退出必须用 wx.exitMiniProgram()。这一点我在 AGENTS.md 里专门记了一句,因为它是个容易踩错的边界:

退出:菜单「退出」调用 `wx.exitMiniProgram()`(沉浸式应用不能用 `this.finish()`,它只对快切智能体生效);
暂停菜单中按返回键不拦截,交给系统关闭。1.5 秒内未关闭则在暂停菜单显示「退出失败」原因。

EXIT_TIMEOUT_MS = 1500 就是这条说明的实现:调了退出但 1.5 秒还没走,就在菜单里把原因显示出来,而不是让玩家对着一个"看起来没反应"的界面反复按。

2. 页面三层结构:352px 是硬预算

一个 .ink 文件里四段:<script def>(页面配置)、<script setup>(逻辑对象)、<page>(模板)、<style>。布局上我把高度算死:

层内容高度
DOM HUD左:第1关 · 12m · 射手在右;右:标枪 5/5 · 最高第1关22px
Canvaswx.createCanvasContext('stage', this),480×236236px
DOM 底部提示行 / 状态行 / 操作行,或面板标题 + 按钮 + 输入日志94px
合计352px

画布宽度固定 480、总高不超过 352 是眼镜端单色显示区域的约束。超出预算不会报错,只会被裁掉——所以我把这三个数字写成了表,而不是靠肉眼调。

DOM 层负责"文字",Canvas 负责"画面",这个分工是刻意的:文字用 DOM 渲染更清晰、也省掉一次画布绘制;而所有需要逐帧变化的东西(弹道、刻度、骑士、浮字)都在 Canvas 里。

3. 状态流转:一条主循环 + 一个面板叠加层

aim ──甩头/松键──▶ flight ──落地积分──▶ result(1.4s) ──┬──▶ aim(还有标枪、目标还活着)
 ▲                                                     ├──▶ ended → 面板 clear
 │                                                     └──▶ ended → 面板 fail(标枪用完)
 └──面板「下一关 / 重玩 / 重试」── startLevel() ────────┘
首次启动 / 菜单「重新校准」──▶ 面板 calibrate(两步)──▶ 关闭并进入 aim

面板是一个叠加层而不是独立状态:this.panel 有值时,step() 直接 return,游戏时间冻结,但 frame() 仍在跑(否则菜单动画和按钮焦点不会刷新)。这里踩过一个真实的坑,值得单独讲:

closePanel() {
  const now = Date.now();
  if (this.pausedAt) {
    const paused = now - this.pausedAt;
    if (this.flight) this.flight.start += paused;   // 飞行的起跳时间整体后移
    if (this.resultUntil) this.resultUntil += paused;
    this.pausedAt = 0;
  }
  …

弹道是按 (now - flight.start) / 1000 * FLIGHT_SPEEDUP 取样的。如果玩家在标枪飞行途中打开菜单(返回键随时可用),回来后 now 已经前进了几秒,标枪会直接瞬移落地。修法不是暂停时间,而是把"起跳时刻"整体平移暂停时长——飞行轨迹本身仍然是同一条确定性曲线。

4. 能力调用关系

Gyroscope(frequency:60) ──reading──▶ onGyro() ──▶ yawRate/pitchRate(校准后的轴与符号)
                                          │              ▲
                                          │              └── createCalibrator()  ← 面板 calibrate
                                          ▼
                              createHeadControl().update()
                                    │ power / throwAngle / throwPower
                                    ▼
   Enter 长按 ──▶ keyCharge(三角波) ─▶ throwJavelin(power, angle)
                                          │
                    launchSpeed(power, cfg) ─▶ simulate()  ← lib/physics.js
                                          │
                        flight{points,vx,vy,t} ─▶ positionAt/velocityAt 逐帧取样
                                          │
                                          ▼
                        drawScene(ctx, scene)  ← lib/render.js(单色绿光 6 档)
                                          │
                    sfx.play('tick'|'throw'|'hit'|'crit'|'miss'|'clear'|'fail')  ← audio/Sound
                                          │
                    loadProgress/saveProgress ← 运行时 localStorage

没有网络调用,没有大模型调用,没有云端存储。整条链路是闭环且确定的:同样的输入序列必然得到同样的落点——这也是第六节能用"喂传感器读数"的方式做自动化测试的前提。


四、AIUI 实现:六个关键实现点

1. 力度不是按钮,是转头角度

lib/head-control.js 里力度和角度是同一个量的两种写法,线性、可逆:

export const DEAD_ZONE_DEG = 4;
export const FULL_CHARGE_DEG = 45;
export function powerForAngle(chargeAngle) {
  const p = (chargeAngle - DEAD_ZONE_DEG) / (FULL_CHARGE_DEG - DEAD_ZONE_DEG) * 100;
  return Math.max(0, Math.min(100, p));
}
export function angleForPower(power) {
  return DEAD_ZONE_DEG + power / 100 * (FULL_CHARGE_DEG - DEAD_ZONE_DEG);
}

也就是说每 1% 力度 = 0.41° 转头,反过来玩家转 41° 就覆盖全部量程。这个映射必须是线性且可逆的,因为测试脚本要用它反推"要投 58% 力度,就得喂多少读数"(见第六节)。

力度之外还有高度:pitch 单独积分,增益 0.75,所以抬头 20° 就到 55° 上限、低头 6.7° 到 35° 下限。

在这里插入图片描述

出手判定有三个细节,都是被"误投"和"漏投"教育出来的:

if (!fired && -rate * direction >= SNAP_RATE) {
  // The snap itself wobbles the head, so use the aim held at peak charge.
  const peak = history.reduce((best, item) => (item.power > best.power ? item : best), { power: 0, pitch });
  if (peak.power >= MIN_THROW_POWER) { fired = true; throwPower = peak.power; throwAngle = throwAngleForPitch(peak.pitch); }
}
  • 甩头瞬间取峰值,不取瞬时值:反向甩头的那几十毫秒里,头必然带偏累计角度,如果按瞬时值出手,力度会被自己甩掉。history 保留最近 0.4 秒(PEAK_WINDOW)的 {t, power, pitch},出手时取其中的峰值力度和同一时刻的 pitch——所以"甩头"不会改变弹道高度。
  • MIN_THROW_POWER = 8:低于 8% 的甩头不算出手,避免"我随便动了一下头"就消耗一支标枪。
  • 中立点跟着人走:if (angle * direction < 0) angle = 0; —— 朝蓄力反方向回转时把累计角度清零。含义是"你回到正前方,力度就是 0",而不是"你必须精确转回校准时的绝对零点"。这一条直接消除了漂移。

另外两个防漂移的细节在同一个 update() 里:静止时角速度会有一点非零偏置,用 EMA 缓慢吸收(STILL_RATE = 0.05 以内才吸收,系数 0.02),以及单位自适应:

// Above this magnitude a reading cannot be rad/s from a head; the host reports deg/s.
const DEGREE_UNIT_THRESHOLD = 25;
function normalizeRate(rate, units) {
  const value = Number(rate) || 0;
  if (Math.abs(value) > DEGREE_UNIT_THRESHOLD) units.degrees = true;
  return units.degrees ? value / RAD_TO_DEG : value;
}

人头不可能一秒转 25 弧度,所以一旦某个读数量级超过 25,就断定宿主给的是 deg/s,之后全部按 deg/s 处理(units 是闭包里的单向开关,不会来回摇摆)。我不去赌运行时给的是哪种单位,而是让它自己说。

在这里插入图片描述

2. 轴向不能写死:两步校准,让人适配设备,而不是反过来

最直觉的写法是"y 轴就是 yaw"。但不同宿主、不同佩戴姿态下,转头可能落在 x、y 或 z,符号也可能相反。所以我把轴向和符号变成运行时学出来的:

第1步:请把头向右转约 30°   → createCalibrator(25)   三轴同时积分,取绝对值最大的轴 + 它的符号
(休息 1.2 秒,避免同一个动作被两步共用)
第2步:请抬头约 20°         → createCalibrator(15, 排除第1步选中的轴)

exclude 参数很关键:pitch 校准不允许再选中 yaw 已经占用的轴,否则抬头和转头会挤在同一个轴上互相污染。学完之后写进存档,yawRate/pitchRate 每次读数都按它取值:

export function yawRate(reading, calibration) {
  const cal = calibration || DEFAULT_CALIBRATION;
  return (Number(reading[cal.axis]) || 0) * cal.sign;
}

注意 || DEFAULT_CALIBRATION:没校准过也能玩(默认 {axis:'y', sign:-1, pitchAxis:'x', pitchSign:1})。校准面板因此可以「跳过」,跳过之后状态栏显示"陀螺仪正常(未校准)",玩家仍然能投,只是轴向可能不对——这比"不校准就锁死"友好得多。

校准完成也不是立刻进入战斗:finishCalibration() 会设一个 armedAt = now + 1500ms 的宽限期,期间所有读数只重置累积器不产生力度。因为玩家听到"校准完成"后正在把头回正,这段运动如果算进去,第一支标枪会被"回正动作"投出去。运行时测试里专门有一条断言覆盖它:校准后快速回正头部 10 组读数,标枪数仍是 5。

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

还有一个只在真机/宿主层面才看得出来的坑:同一次采样可能触发多次 reading 事件。如果每次都积分,转头幅度会被重复计算,力度虚高。onGyro() 用时间戳去重,没有时间戳时退化成"读数完全相同且间隔 < 3ms":

// The runtime can fire several 'reading' events for one coalesced sample; integrate each sample once.
if (Number.isFinite(stamp) ? stamp === this.lastStamp : key === this.lastKey && now - this.lastReadingAt < 3) return;

3. 抛物线先解出来,再逐帧播

这是整个项目我觉得最"值"的一个设计决定:飞行不是逐帧积分出来的模拟,而是先算好一条确定曲线,再按时间取样。

lib/physics.js 只有 86 行,核心是轨迹方程的反解:

// Launch speed whose arc passes through (x, y) at the given angle; null if unreachable.
export function speedToReach(x, y, angle, releaseHeight = RELEASE_HEIGHT) {
  const rad = angle * Math.PI / 180;
  const cos = Math.cos(rad);
  const rise = releaseHeight + x * Math.tan(rad) - y;
  if (rise <= 0) return null;
  return Math.sqrt(G * x * x / (2 * cos * cos * rise));
}

给定"要穿过 (x, y) 这点",直接解出需要的初速度。有了它,两件事变得非常便宜:

  1. 辅助线是精确的。瞄准时的预测弧线用 simulate({ dt: 1/60 }) 算一遍并记忆化(键是 力度|角度|关卡 的量化值),所以画面上的点和最终飞行轨迹是同一条曲线,不是"示意一下"。
  2. 力度可以和距离解耦。launchSpeed() 把 0–100 的力度映射到"能落在靶前"和"能打到靶顶"这两个速度之间:
export function launchSpeed(power, config) {
  const x = config.distance - TARGET_HALF_WIDTH;
  const low = speedToReach(x, 0, THROW_ANGLE, RELEASE_HEIGHT);              // 刚好落在靶前地面
  const high = speedToReach(x, TARGET_HEIGHT, THROW_ANGLE, RELEASE_HEIGHT); // 刚好够到靶顶
  const p = Math.max(0, Math.min(100, power));
  return Math.max(0.5, (low + high) / 2 + (p - config.center) * (high - low) / (config.window * 100));
}

于是命中用的量不再是"绝对速度多大",而是"离 center 差多少":center 永远是那条刚好穿过靶心的弹道,window 决定要多准。距离变了,low/high 这个速度带整体重算,但"力度比甜点大一点就往远一点"这条经验在第 1 关和第 30 关完全一样——玩家学一次就够了。

命中判定用逐帧步进 + 平面穿越插值,而不是解析求根:

export const ZONES = [
  { id: 'leg', label: '腿部', min: 0, max: 0.9 },
  { id: 'body', label: '腹部', min: 0.9, max: 1.2 },
  { id: 'chest', label: '胸部', min: 1.2, max: 1.5 },
  { id: 'head', label: '头部', min: 1.5, max: TARGET_HEIGHT }
];

弹道在 distance - TARGET_HALF_WIDTH(靶面前缘 0.2m)这个竖直平面上被插值取样,得到 crossing 高度 y,再落进分区。这样即使两帧之间跨过整个靶子,也不会漏判;positionAt() 用二分在已算好的点列里取任意时刻的位置,飞行渲染就是纯取样。

同一套判定也直接产出画面上的反馈文字:命中就报部位与剩余生命,没命中就报"差多少米"以及该怎么改。

在这里插入图片描述 在这里插入图片描述

伤害与暴击:

export function resolveHit(zone, hp, random = Math.random) {
  const crit = zone === 'head' && random() < HEAD_CRIT_CHANCE;   // 0.3
  const damage = crit ? hp : DAMAGE[zone];                        // 头60 胸40 腹30 腿15
  return { zone, crit, damage, hp: Math.max(0, hp - damage) };
}

爆头是"剩余血量清零"而不是固定 100 伤害,所以它在任何关卡、任何残血状态下都是干净的"一击毙命"。

在这里插入图片描述 在这里插入图片描述

4. 单色绿光像素:黑不是颜色,是透明

眼镜端是单色显示,能用的只有同一色相的亮度阶梯:

export const COLORS = { bg: '#000000', dim: '#1d3316', low: '#36592a', mid: '#5f9a45', hi: '#baff8a', top: '#eaffd8' };

由此推出三条硬规则,我都是先犯错再改的:

  • 黑色等于不发光等于透明。不能靠"画一块黑"来遮挡东西。骑士倒地、标枪插入、浮字消失,全部要靠"重画顺序 + 只画该画的像素",而不是覆盖。
  • 不能靠色相传达信息。没有红色可以表示错误、没有绿色/红色区分敌我,只能靠亮度、描边粗细、闪烁和文字区分。所以命中反馈是"浮字 + 亮度跳变 + 音效",而不是变红。
  • 美术必须和判定对齐。骑士不是一张手绘 bitmap,而是按判定高度反推出来的精灵网格:
// One row per 5 cm: head rows 0-5 (1.8-1.5 m), chest 6-11, belly 12-17, legs 18-35, so the art matches the hit zones.

这样 ZONES 的 0.9 / 1.2 / 1.5 / 1.8 米分界,天然落在画面的头、胸、腹、腿分界上;命中区画在骑士身上的位置,就是物理判定算出来的位置。倒地的骑士也不是重画,而是把同一张网格的身体部分切片转置得到(KNIGHT_DOWN 由 KNIGHT_BODY 逐列取出再翻转),保证风格绝对一致。

纵向映射做了分段,解决"近处要看清部位、高处要看清弧线"的矛盾:

// Linear below 2 m so body zones match sprites; compressed above so high arcs stay on screen.
export function screenY(y) {
  const h = y <= 2 ? y * K : 2 * K + CAP * (1 - Math.exp(-(y - 2) / CAP_SCALE));
  return GROUND_Y - h;
}

2 米以下是线性的(所以 1.8 米的骑士和 0.9/1.2/1.5 的判定带在画面上等比);2 米以上做指数压缩(所以 55° 高抛不会顶出画布)。

背景里的小点和草丛用固定伪随机数组而不是每帧 random(),这样画面不会"雪花闪烁"——这条看起来像小事,但在逐帧重画的 Canvas 上,闪烁的背景会直接把玩家注意力从靶子上抢走。

在这里插入图片描述

5. 镜腿一次滑动 ≠ 一次按键:五个时间常数

真机上最反直觉的一件事:滑动镜腿时,设备可能同时上报一次点击;而"一次滑动"可能被上报成一串方向键。如果不处理,玩家想"滑一下改焦点",结果是把菜单项确认了、或者焦点跳了四格。

我的解法不是拒绝某类事件,而是给它们排队和合并:

const TAP_DELAY_MS = 300;   // 点击延迟生效,等一眼看有没有滑动
const TAP_MERGE_MS = 650;   // 两次点击间隔过短视为重复
const SWIPE_GUARD_MS = 350; // 滑动前后这段时间内的点击丢弃
const SWIPE_BURST_MS = 200; // 相邻方向键间隔过短视为同一次滑动
const SWIPE_STEP_MS = 400;  // 距上次生效滑动不足此值也合并
queueTap(action) {
  const now = Date.now();
  if (now - this.lastSwipeAt < SWIPE_GUARD_MS) { this.logInput('(点击已忽略)'); return false; }
  const gap = now - this.lastTapAt;
  this.lastTapAt = now;
  if (gap < TAP_MERGE_MS) { this.logInput('(点击已合并)'); return false; }
  this.pendingTap = { at: now + TAP_DELAY_MS, action };
  return true;
}

pendingTap 由主循环 frame() 里的 runPendingTap(now) 到点执行——所以"延迟生效"是真的排队,不是 sleep。滑动侧同理:

// A single temple swipe may emit a burst of arrow events; only the first one moves.
if (event.repeat || gap < SWIPE_BURST_MS || now - this.lastSwipeStepAt < SWIPE_STEP_MS) {
  this.logInput('(滑动已合并)');
  return;
}

还有一个更容易忽略的来源:宿主在 keyup 上可能自己触发一次按钮点击,于是"按确认键"会变成"按键生效 + 宿主点击"两次。tapButton() 里那条判断就是为它写的:

// Host-generated button taps can arrive after the queued key action has already run.
if (Date.now() - this.lastKeyAt < TAP_MERGE_MS) { this.logInput('(点击已合并)'); return; }

为什么每个分支都调 logInput():因为这类问题在真机上无法用日志抓,玩家也说不出"我刚才到底按了什么"。我把最近 5 条输入直接显示在菜单底部,真机上一眼就能复盘。运行时测试里那条"一次滑动连发 4 个方向键只移动一格"的断言,就是靠这个 inputLog 字段验证的。

在这里插入图片描述

6. 降级、生命周期与"音效坏了也不改玩家的设置"

无陀螺仪路径是完整的第二操作方案,不是兜底提示:

if (this.keyCharge) {
  if (this.keyCharge.frozen) return this.keyCharge.power;
  const phase = ((now - this.keyCharge.start) % KEY_CYCLE_MS) / KEY_CYCLE_MS;
  this.keyCharge.power = (phase < 0.5 ? phase * 2 : 2 - phase * 2) * 100;
  return this.keyCharge.power;
}

按住确认键,力度按 2.4 秒一个周期的三角波涨落(0→100→0),松开即投当前值。用三角波而不是"一直涨到顶",是因为后者会让玩家只能"掐时间",而三角波允许在任意力度松手,手感更接近蓄力条。KEY_TAP_MS = 250 以下的短按不算投掷,改为弹提示"按住确认键蓄力"。

传感器状态是六种而不是两种,每种都有对应文案(sensorText()):正常 / 正常但未校准 / 启动中 / 2.5 秒内没收到数据 / 设备没有陀螺仪 / 出错并带上原因。玩家看到的原因文案里直接包含下一步动作("按住确认键蓄力"),而不是只说"失败"。

音效的失败处理我特意做了一条不对称的规则:

play(name) {
  if (!on || error) return false;      // 出过一次错就不再反复打扰,直到玩家重新开启
  …
  try { sound.play(); return true; } catch (e) { fail(e); return false; }  // fail() 只写 error,不碰 on
},
setEnabled(value) {
  on = Boolean(value);
  if (on && error && typeof SoundClass === 'function') { this.destroy(); error = ''; }  // 销毁缓存,下次播放重建
},

播放失败绝不改动玩家的音效开关(sfx.enabled 保持不变),只是状态栏显示"音效不可用"、菜单里显示原因;玩家手动关一次再开,就会重建播放器重试。单元测试里有一条专门锁这个行为:assert.equal(sfx.enabled, true, '播放失败不能改动用户的音效开关')。

生命周期上,onHide 暂停循环并停传感器,onShow 恢复;onUnload 里把传感器、定时器、动画帧、Sound 资源全部释放。stopSensor() 先把自己的引用置空再调 stop(),配合 onGyro() 开头的 gyro !== this.gyro 判断,避免"销毁后仍有回调进来改状态"这类最难查的问题。


五、交互与体验

1. 完整操作路径

  1. 首次启动 → 校准面板(第 1 步右转约 30°,进度条与箭头实时反馈)→ 休息 1.2 秒 → 第 2 步抬头约 20° → "校准完成,请把头回到正前方"(1.5 秒宽限);
  2. 进入第 1 关:HUD 显示"第1关 · 12m · 射手在右",底部提示"向右转头蓄力,抬头低头调角度,快速向左甩头投掷";
  3. 向右转头 → 标枪上的十格刻度逐格点亮,每升一格一声轻响;抬头/低头 → 角度数字与辅助线弧线一起变;
  4. 快速反向甩头 → 出手,画面进入飞行,底部文字换成"出手!力度 58% · 角度 40°";
  5. 落地 → 命中(浮字 -40 + 横幅"命中胸部")或未命中("偏短 1.0m:向右多转一点再甩头" / "飞过头顶 3.7m:少转一点再甩头"),停留 1.4 秒;
  6. 目标血量清零 → 通关面板"第1关 通过!用了 5 支标枪 / 下一关 15m · 射手换到左边";标枪用完 → 失败面板"标枪用完了 / 目标剩余生命 100 / 参考辅助线弧线,调整转头幅度和角度再试";
  7. 随时返回键 → 暂停菜单(继续 / 辅助线 / 音效 / 重新校准 / 帮助 / 退出),滑动镜腿移焦点、轻触镜腿确认;菜单里再按返回键 → 交给系统退出。

在这里插入图片描述 在这里插入图片描述

2. 反馈机制一览

事件画面(Canvas)文字(DOM)音效状态持久化
蓄力 +1 格标枪刻度点亮HUD tickstick(0.22 音量)—
出手投掷姿态 420ms、飞行拖尾 14 点"出手!力度…角度…"throw(0.5 音量)throws++
命中骑士受击闪白、标枪插入、浮字 -40横幅 + 底部结果行hit / crithits++、headshots++
爆头大号浮字"一击毙命"、骑士倒地"爆头!一击毙命"crit 三音上行kills++(任何致死一击都会加,不只爆头)
偏短/过头落点标枪 + 浮字"差1.0m"/"远3.7m"带修正建议的结果行miss—
通关倒地骑士保留面板列出下一关距离与站位clear 四音level、bestLevel
传感器异常—状态行带原因与下一步——
输入冲突—菜单底部"(点击已合并)/(滑动已合并)"——

3. 关卡曲线:难度封顶在哪里

关卡不是配置表,是公式(levelConfig)。下面这行不是设计文档里的数字,是我用工程里的 physics.js + rules.js 实际扫出来的(力度步长 0.25%,角度固定 40°):

关卡距离射手标枪命中窗口甜点力度能命中的力度区间等效转头幅度辅助线比例
112m右536%5032.25–68.014.7°100%
215m左534%5942.25–76.013.8°100%
318m右532%3822.25–54.013.0°100%
421m左530%5035.25–65.012.2°55%
627m左426%4128.25–54.010.6°55%
833m左422%6554.25–76.08.9°55%
1245m左415%4739.75–54.56.0°55%
1554m右415%5042.75–57.56.0°55%
3054m左415%6557.75–72.56.0°55%

从这张表能读出三件设计上的事:

  • 难度真正在变窄的是"转头幅度":第 1 关要落在 14.7° 的窗口里,第 12 关起只需要 6.0°。6° 大约是一次"缓慢转头四分之一"的量,仍然可操作,但需要玩家真的去看辅助线。
  • 难度在第 12 关封顶:window 有下限 0.15(第 12 关触底),distance 有上限 54m(第 15 关触底)。之后每关变化的只有"甜点位置" center——它按 50 + ((n*37)%11 - 5) * 3 在 35–65 之间跳(第 1–30 关实测最小 35、最大 65),让每一关都需要重新校准手感,而不是靠记忆上一个数字。
  • 辅助线在第 4 关起只画 55%:前 3 关给你完整弧线,从第 4 关开始只画前半段。这是我认为这个项目里最好的一条难度设计——它不是数值变狠,而是把"信息"逐步收回,逼玩家用转头幅度和角度自己建立感觉。

在这里插入图片描述

另外,"每个部位在每一关都能打到"不是嘴上说的。单元测试 每个部位在每关都有可操作的力度区间 会遍历第 1–30 关 × 四个部位,要求每个部位至少有 8 个 0.25% 的力度采样点(= 2% 力度 ≈ 0.8° 转头)。我把这张表整个扫了一遍:1–30 关全部满足,全局最窄的是第 11 关的胸部,10 个采样点(2.5% 力度 ≈ 1° 转头),同关的腿/腹/头分别是 31 / 11 / 12。也就是说难度封顶之后,"想打胸"和"想打腿"的精度要求是同一个量级的,不存在某个部位变成玄学。

4. 音效:小、轻、不炸

7 个音效全部由 scripts/make-sfx.mjs 程序化生成(16kHz、16bit、单声道,合计 96.9KB):一个带 8% 二次谐波的圆润正弦、sin² 的淡入淡出包络、(1-f)^decay 的衰减,外加一个一阶低通滤波的噪声做"气流"。

音量和波形被测试卡死:柔和音效采用低峰值和平滑起止 会读每个 WAV 的字节,断言 RIFF/WAVE 头、采样率 16000、16bit、首末采样为 0(淡入淡出真的回到零)、峰值不超过 0.3 满量程、相邻样本跳变小于 0.08(不许有爆音)。播放侧再压一层:默认 0.55,刻度音 0.22,出手 0.5。

眼镜是戴在头上的,音量和突变的容忍度比手机低得多——这条约束只能写成测试,否则下一次生成音效就会飘。


六、调试与验证

1. 23 项单元测试:把"手感"翻译成断言

npm test → node --test tests/*.test.js,23 项全通过、0 失败(本机实测耗时 0.5–0.8 秒)。挑几个能代表思路的:

用例它锁住的行为
每个部位在每关都有可操作的力度区间遍历 1–30 关 × 4 部位,每区 ≥8 个 0.25% 的力度采样点,保证没有"打不到的部位"
高关卡命中窗口更窄第 1 关 > 第 6 关 > 第 12 关单调收紧;第 30 关仍有 ≥28 个 0.5% 力度采样点命中(即 ≥14% 力度 ≈ 6° 转头),难度封顶但不会变成猜数字
speedToReach 求出的初速度恰好让弧线穿过目标点反解出的速度代回 simulate(),落点高度误差在容差内——辅助线和真实轨迹同源这件事被锁住
度/秒读数自动识别,静止偏置被抵消单位自适应只翻转一次;静止时的非零偏置被 EMA 吸收后力度不会自己漂上去
甩头出手时使用蓄力峰值时的角度甩头瞬间的 pitch 抖动不改变弹道高度
伤害结算:头部有概率一击毙命,其他部位按倍率注入固定随机数验证 30% 暴击与 60/40/30/15 倍率
重复镜腿确认、延迟按钮点击和按键连发不会把音效开关切换两次把真实的 .ink 里 <script setup> 抽出来,用 vm.runInNewContext 跑,四种重复输入路径逐一验证
音效播放暂时失败后,重新开启会恢复柔和音效失败不改开关、销毁缓存、清错误、重建后音量回到 0.55
柔和音效采用低峰值和平滑起止,播放增益保持克制直接解析 WAV 字节断言峰值/跳变/淡入淡出
进度读写与损坏数据回退非法存档回默认;只带 v1 校准的旧存档强制重新校准
音效开关随进度保存sound 字段真的落盘

第三条值得多说一句:它不是"我另写了一份逻辑",而是用正则从 index.ink 里取出真实代码、把 export default 变成 return、注入假的 Sound 类跑。这样页面层的输入仲裁逻辑被真正测到了,而它恰恰是最容易在重构时悄悄坏掉的部分。

2. 14 项运行时测试:用工程自己产出的 AIUI 运行时来测

这是我认为这个项目最值得分享的调试手段。node scripts/native-test.mjs 做四件事:

  1. 调 aix-cli preview 把整个工程编译成一份真实运行时 HTML(QuickJS + Skia 的 wasm 引擎);
  2. 重写这份 HTML:把 jspm CDN 换成本地 /runtime/index.js;注入 view.setHostCapabilities({ gyroscope: { start, stop } }) 伪造一个陀螺仪宿主;在页面 onLoad 里把 setData 包一层,让每次状态更新都 console.log('NATIVE_STATE:' + JSON.stringify(this.data));在 Sound.play 里打日志;
  3. Playwright 打开它,用 nativeView.dispatchInput('keydown'/'keyup', code) 发按键,用 CustomEvent('gyroscope.reading') 按 16ms 的传感器时钟逐条喂读数;
  4. 断言只看两样东西:观测到的 NATIVE_STATE,和 #preview-canvas 的截图;结束时 assert.deepEqual(errors, [])。

14 项全通过,覆盖:两步校准自动识别轴向(并断言校准后快速回正 10 组读数不误投、回正后角度回到默认 40°)、蓄力刻度随转头点亮(并断言缓慢回转只归零不出手)、偏短、胸部命中、腿部命中(50° 出手)、抬头低头与镜腿滑动微调、轻触镜腿开关辅助线、击倒进入通关面板、第 2 关射手互换、无陀螺仪的按住蓄力、返回键开菜单 / 菜单内退出、滑动与点击冲突(点击先到和滑动先到两种顺序都测)、一次滑动连发 4 个方向键只走一格、菜单导航与帮助返回。结束时 assert.deepEqual(errors, []),任何一条页面错误都会让整轮失败。

(爆头和"标枪用完"的失败面板不在这 14 项里,由第 3 小节的补充脚本负责。)

它比"我自己写个渲染台"强的地方在于:测的是交付物本身。我因此发现,如果只跑单元测试,第 5 节那些时间常数(300/650/350/200/400ms)根本不会被真正检验——它们只在"宿主以某个顺序发来一串事件"时才有意义,而只有真运行时会按那个顺序发事件。

在这里插入图片描述

3. 补充截图:只用玩家的手段

运行时测试覆盖了 17 张图,但缺"飞过头顶""标枪用完""远关卡的缩短辅助线""爆头瞬间"。我加了一个 scripts/native-shots.mjs(267 行),原则是只用玩家能用的输入:

  • 飞过头顶:满力度 100% 投一次;
  • 失败面板:连续 4 次 12% 短投,把 5 支标枪用完;
  • 第 4 关:真的用 powerFor(level,'chest') 算出的胸部力度,把第 1、2、3 关各打 3 次通过(胸部 40 伤害 × 3 = 120 > 100 血),再进第 4 关截图;
  • 爆头:算出头部力度区间中点反复投,靠 30% 的暴击概率自然出现,没出就"重玩本关"再来一轮。

这里有个我一开始做错、值得提醒的地方:截图必须在结果横幅还在屏上的 1.4 秒里抓。第一版脚本先等 mode==='result'、再等回到 aim、最后才截图,结果脚本里的 21-over-head.png(就是下面第 19 张图)拍到的是已经回到瞄准的普通画面——文字横幅早没了。改成"等到 mode==='result' 且 lastResult 匹配 → 立刻截图"才拿到真图。

在这里插入图片描述

4. 三个只有"真跑"才会暴露的问题

① 预览产物写回工程目录,导致每次重建体积自增,最后直接崩。

native-test.mjs 原本把生成的 HTML 写到工程内的 .preview/aiui.html。而 aix-cli preview 是把工程目录下的所有文件打进这份 HTML 的——包括它自己上一次写的 .preview/aiui.html。实测同一台机器连续三次构建:

第 1 次 65,317,793 字节   → 第 2 次 152,408,227 字节 → 第 3 次 424,222,689 字节
第 4 次:✖ Cannot create a string longer than 0x1fffffe8 characters

也就是说 npm run test:native 第二次连着跑就会污染产物,第四次直接失败,而且报错和真实原因(自包含循环)看起来毫无关系。修法是把构建产物写到工程外:

// The preview build embeds everything under the project directory, so writing its own output
// back into the project makes every re-run grow; keep the generated HTML in a temp dir.
const previewPath = resolve(tmpdir(), 'amzj-preview', 'aiui.html');

改完复跑,14 项仍然全通过,产物大小稳定。这是我在原工程里发现的真实缺陷,不是我的环境问题(我核对过 D:\D-DISK\15. AIUI\亚马逊标枪\scripts\native-test.mjs 第 14 行的原始路径)。

② 无头浏览器里音频根本放不出来,测试必须允许"音效不可用"是一种正常状态。

Sound.play() 在 Playwright 的 Chromium 里抛 is unavailable,于是状态栏显示"音效不可用"、暂停菜单显示原因。测试断言因此写成 assert.match(state.status, /音效(开|不可用)/),而音效是否真的被调用,改用注入的 NATIVE_SFX: 日志判断(assert.deepEqual(sfx.filter(n => n !== 'tick').slice(-2), ['throw', 'hit']))。这条经验很通用:在有头/无头差异的地方,断言要针对"我确实调用了它",而不是"环境允许它成功"。 也正因为这样,本文截图里状态栏写着"音效不可用"是真实运行时状态,不是缺图。

在这里插入图片描述

③ 运行时里的 localStorage 不是浏览器那个 window.localStorage。

我本来想直接改浏览器 localStorage 把存档跳到第 8 关,结果毫无效果。打印浏览器侧的 localStorage 只有两条宿主身份键(ink.host.identity.device_seed.v1 等),而 Ink 页面里 getStorage() 拿到的 localStorage 是运行时在 QuickJS 侧提供的存储,两者不同域——但它在同一个浏览器 profile 内是持久的(reload 后校准仍在,说明它落在 profile 关联的后端里)。两个推论:

  • 想驱动到某个游戏状态,只能走页面自己的输入和逻辑,不能从外面改数据(这也是第 3 小节选择"真打三关"的原因);
  • native-test.mjs 里 localStorage.clear() + reload 能稳定拿到"首次启动=校准面板",依赖的是 Playwright 每次开全新临时 profile。如果哪天有人把它改成持久化 profile 复用,第一项断言就会莫名失败。这个隐含前提值得在原工程里补一句注释。

顺带一个小的文档偏差:AGENTS.md 的能力表写"使用 localStorage 保存关卡进度",措辞容易被理解成浏览器那个 localStorage;实际是运行时提供的存储接口。代码是对的,文档表述可以更准。

七、成果与复盘

1. 五条真正改变了我做法的结论

  1. 模拟量意图要找"身体的连续量"来承载,而不是把按钮换个位置。 转头幅度→力度是天然的线性映射(41° 覆盖 0–100%),所以我能把这条映射写成可逆函数,并让测试脚本用它反推输入。如果当初做成"连按镜腿加力度",物理手感、测试、文档三处都会更差。
  2. 凡是"设备可能同时给你两种事件"的输入,都要设计成排队 + 合并,而不是拒绝。 镜腿滑动会带点击、一次滑动会连发方向键、keyup 会触发宿主点击。解法是让点击延迟 300ms 排队、滑动前后 350ms 忽略点击、200/400ms 内合并方向键——而不是"判断这是滑动就丢弃所有点击"。
  3. 确定性物理是测试的前提,不只是玩法需要。 弹道先解后播(speedToReach 反解 + simulate 采样)让"同样的输入必然同样的落点"成立,我才能在 Node 里扫出"第 8 关胸部力度 54.25–76.0"这种数字并直接拿去喂测试。
  4. 难度设计最好的杠杆是"收回信息",不是"收紧数值"。 第 4 关起辅助线只画 55%,比把窗口从 15% 压到 8% 有效得多——玩家学到的是手感,而不是挫败。
  5. 构建产物不要写回被构建的目录。 这条听起来像废话,但它的失败模式(体积自增 → 报一个完全无关的字符串长度错误)足以让人怀疑是引擎坏了。我这次是靠"连续三次构建实测体积 65MB→152MB→424MB"才确认的。

2. 后续迭代

  • 修:把 native-test.mjs 的产物路径固定到工程外(本文已改并复跑通过),并把 localStorage.clear() 依赖全新 profile 这一前提写进注释;
  • 修:AGENTS.md 与策划案的措辞/数字对齐(21 → 23 项,"localStorage" → "运行时存储");
  • 做:真机传感器噪声下的死区自适应(目前 STILL_RATE/EMA 系数是固定值);
  • 做:把"甩头出手"的阈值按关卡收紧(现在 SNAP_RATE 恒定 2.0 rad/s,高关卡仍靠力度窗口出难度);
  • 做:训练模式——只练角度控制,不消耗标枪;
  • 做:正式签名与商店上架流程(当前 key_id 为空)。
  • 做:接入互联网做联机对战

八、写在最后

这个项目让我改掉了"AIUI 智能体 = 会说话的界面"这个初始印象。

《标枪像素》里没有一句话交互,但它有完整的意图闭环:意图(转头幅度、pitch、甩头、镜腿)→ 执行(校准后的角速度积分、弹道反解、分区判定)→ 反馈(画面、DOM 文字、音效、HUD、进度落盘)。而且这个闭环可测试——14 项运行时断言直接跑在交付用的引擎上,23 项单元测试锁住物理与输入仲裁。

如果非要总结一条经验,那就是:在眼镜上,"少占视野"和"少需要解释"是同一件事。 辅助线可以关,但弹道必须诚实;文字提示可以只有一行,但它必须告诉玩家下一步该怎么改("偏短 1.0m:向右多转一点再甩头")。这两点做到了,玩家不需要说明书就能玩;做不到,再多的 UI 也补不回来。

另外就是开发过程中全程借助AIUI Studio + Qoder 完成开发和模拟测试,同事使用真机完成真实测试场景,现在AI编程能力非常强,不懂代码不懂编程也可以来设计实现你想要的东西。