【转战智能眼镜开发】ROKID AIUI 小游戏 亚马逊标枪项目实战讲解
标枪像素:在 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 |
| Canvas | wx.createCanvasContext('stage', this),480×236 | 236px |
| 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) 这点",直接解出需要的初速度。有了它,两件事变得非常便宜:
- 辅助线是精确的。瞄准时的预测弧线用
simulate({ dt: 1/60 })算一遍并记忆化(键是力度|角度|关卡的量化值),所以画面上的点和最终飞行轨迹是同一条曲线,不是"示意一下"。 - 力度可以和距离解耦。
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 步右转约 30°,进度条与箭头实时反馈)→ 休息 1.2 秒 → 第 2 步抬头约 20° → “校准完成,请把头回到正前方”(1.5 秒宽限);
- 进入第 1 关:HUD 显示"第1关 · 12m · 射手在右",底部提示"向右转头蓄力,抬头低头调角度,快速向左甩头投掷";
- 向右转头 → 标枪上的十格刻度逐格点亮,每升一格一声轻响;抬头/低头 → 角度数字与辅助线弧线一起变;
- 快速反向甩头 → 出手,画面进入飞行,底部文字换成"出手!力度 58% · 角度 40°";
- 落地 → 命中(浮字 -40 + 横幅"命中胸部")或未命中(“偏短 1.0m:向右多转一点再甩头” / “飞过头顶 3.7m:少转一点再甩头”),停留 1.4 秒;
- 目标血量清零 → 通关面板"第1关 通过!用了 5 支标枪 / 下一关 15m · 射手换到左边";标枪用完 → 失败面板"标枪用完了 / 目标剩余生命 100 / 参考辅助线弧线,调整转头幅度和角度再试";
- 随时返回键 → 暂停菜单(继续 / 辅助线 / 音效 / 重新校准 / 帮助 / 退出),滑动镜腿移焦点、轻触镜腿确认;菜单里再按返回键 → 交给系统退出。


2. 反馈机制一览
| 事件 | 画面(Canvas) | 文字(DOM) | 音效 | 状态持久化 |
|---|---|---|---|---|
| 蓄力 +1 格 | 标枪刻度点亮 | HUD ticks | tick(0.22 音量) | — |
| 出手 | 投掷姿态 420ms、飞行拖尾 14 点 | “出手!力度…角度…” | throw(0.5 音量) | throws++ |
| 命中 | 骑士受击闪白、标枪插入、浮字 -40 | 横幅 + 底部结果行 | hit / crit | hits++、headshots++ |
| 爆头 | 大号浮字"一击毙命"、骑士倒地 | “爆头!一击毙命” | crit 三音上行 | kills++(任何致死一击都会加,不只爆头) |
| 偏短/过头 | 落点标枪 + 浮字"差1.0m"/“远3.7m” | 带修正建议的结果行 | miss | — |
| 通关 | 倒地骑士保留 | 面板列出下一关距离与站位 | clear 四音 | level、bestLevel |
| 传感器异常 | — | 状态行带原因与下一步 | — | — |
| 输入冲突 | — | 菜单底部"(点击已合并)/(滑动已合并)" | — | — |
3. 关卡曲线:难度封顶在哪里
关卡不是配置表,是公式(levelConfig)。下面这行不是设计文档里的数字,是我用工程里的 physics.js + rules.js 实际扫出来的(力度步长 0.25%,角度固定 40°):
| 关卡 | 距离 | 射手 | 标枪 | 命中窗口 | 甜点力度 | 能命中的力度区间 | 等效转头幅度 | 辅助线比例 |
|---|---|---|---|---|---|---|---|---|
| 1 | 12m | 右 | 5 | 36% | 50 | 32.25–68.0 | 14.7° | 100% |
| 2 | 15m | 左 | 5 | 34% | 59 | 42.25–76.0 | 13.8° | 100% |
| 3 | 18m | 右 | 5 | 32% | 38 | 22.25–54.0 | 13.0° | 100% |
| 4 | 21m | 左 | 5 | 30% | 50 | 35.25–65.0 | 12.2° | 55% |
| 6 | 27m | 左 | 4 | 26% | 41 | 28.25–54.0 | 10.6° | 55% |
| 8 | 33m | 左 | 4 | 22% | 65 | 54.25–76.0 | 8.9° | 55% |
| 12 | 45m | 左 | 4 | 15% | 47 | 39.75–54.5 | 6.0° | 55% |
| 15 | 54m | 右 | 4 | 15% | 50 | 42.75–57.5 | 6.0° | 55% |
| 30 | 54m | 左 | 4 | 15% | 65 | 57.75–72.5 | 6.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 做四件事:
- 调
aix-cli preview把整个工程编译成一份真实运行时 HTML(QuickJS + Skia 的 wasm 引擎); - 重写这份 HTML:把 jspm CDN 换成本地
/runtime/index.js;注入view.setHostCapabilities({ gyroscope: { start, stop } })伪造一个陀螺仪宿主;在页面onLoad里把setData包一层,让每次状态更新都console.log('NATIVE_STATE:' + JSON.stringify(this.data));在Sound.play里打日志; - Playwright 打开它,用
nativeView.dispatchInput('keydown'/'keyup', code)发按键,用CustomEvent('gyroscope.reading')按 16ms 的传感器时钟逐条喂读数; - 断言只看两样东西:观测到的
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. 五条真正改变了我做法的结论
- 模拟量意图要找"身体的连续量"来承载,而不是把按钮换个位置。 转头幅度→力度是天然的线性映射(41° 覆盖 0–100%),所以我能把这条映射写成可逆函数,并让测试脚本用它反推输入。如果当初做成"连按镜腿加力度",物理手感、测试、文档三处都会更差。
- 凡是"设备可能同时给你两种事件"的输入,都要设计成排队 + 合并,而不是拒绝。 镜腿滑动会带点击、一次滑动会连发方向键、keyup 会触发宿主点击。解法是让点击延迟 300ms 排队、滑动前后 350ms 忽略点击、200/400ms 内合并方向键——而不是"判断这是滑动就丢弃所有点击"。
- 确定性物理是测试的前提,不只是玩法需要。 弹道先解后播(
speedToReach反解 +simulate采样)让"同样的输入必然同样的落点"成立,我才能在 Node 里扫出"第 8 关胸部力度 54.25–76.0"这种数字并直接拿去喂测试。 - 难度设计最好的杠杆是"收回信息",不是"收紧数值"。 第 4 关起辅助线只画 55%,比把窗口从 15% 压到 8% 有效得多——玩家学到的是手感,而不是挫败。
- 构建产物不要写回被构建的目录。 这条听起来像废话,但它的失败模式(体积自增 → 报一个完全无关的字符串长度错误)足以让人怀疑是引擎坏了。我这次是靠"连续三次构建实测体积 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编程能力非常强,不懂代码不懂编程也可以来设计实现你想要的东西。
更多推荐

所有评论(0)