背景
历史知识学习有一个奇怪的现象:人人都有兴趣,但很少有人真正去"学"。我想用 Rokid Glasses 做一件事:把历史变成一场可以随时穿越的闯关游戏。不需要打开手机、不需要搜索词条,在 AIUI 对话流里说一句"穿越到古代",就能切出交互卡片,用眼镜按键完成答题、排除干扰项、跳过已掌握事件。整个过程不超过 3 秒的启动成本。
这就是历史穿越者。

玩法设计
我给整个游戏套了一层叙事外壳------用户扮演一名时间穿越者,每一次答题都是在修复一段时间线。答对 = 时间线修复,答错 = 时间悖论产生。被跳过的题目进入"已穿越"名单,永不再现;答错的题目则沉入"时间悖论本",等待用户在"时光倒流"模式中重返修正。
游戏的三个核心机制:
- 连击系统:连续答对会触发连击计数,模拟"时间线持续稳定"的沉浸感。连击中断意味着时间线出现波动,视觉反馈从金色变为红色。
- 时间透镜:每道题限用一次的"排除一个错误选项"能力,对应"穿越者对时间线的模糊预知"------你知道哪个不是正确答案,但具体原因仍需推理。
- 时光倒流:回合结束后,所有答错的题目构成"时间线裂痕"。用户可以选择回到这些裂痕点,逐条修复。修复成功后,悖论消除,事件从悖论本中移除。
ini
// store.js --- 悖论逻辑核心
export function recordAnswer(event, correct, mode) {
const stats = getStats();
stats.answered += 1;
if (correct) {
stats.correct += 1;
if (mode === 'revenge') {
// 时光倒流模式答对:悖论消除
stats.paradoxList = stats.paradoxList.filter(
(item) => item.answer !== event.answer
);
}
} else {
// 答错:悖论记录累计
const entry = stats.paradoxList.find((item) => item.answer === event.answer);
if (entry) {
entry.wrongCount = (entry.wrongCount || 1) + 1;
} else {
stats.paradoxList.push({ ...event, wrongCount: 1 });
}
}
saveStats(stats);
return stats;
}

题库设计
历史穿越者目前内置了 50 个中外历史事件,覆盖从秦朝到冷战时期的关键节点。每个事件是一个六元组:
json
// events.js --- 历史事件数据结构
{
"question": "公元208年,孙刘联军与曹军对峙于长江,一场以少胜多的经典战役改变了三国格局。这场战役是什么?",
"type": "event",
"answer": "赤壁之战",
"detail": "赤壁之战是三国时期决定性战役之一。孙刘联军利用火攻战术击溃曹操水军...",
"funFact": "《三国演义》中诸葛亮草船借箭的故事在真实历史中并无记载...",
"era": "三国"
}
但固定的题库终会耗尽。当用户跳过和答对的事件越来越多,可用的新鲜题目就会减少。为此我设计了双轨回退机制:
- 优先本地:先从事件库中筛掉已穿越事件,再随机抽取
- 设备 LLM 兜底:当本地库存不足时,[/history-timewalker/utils/generate.js) 调用设备本地 LanguageModel API,现场生成符合格式的新事件
javascript
// timeline.js --- 抽题引擎
export function pickTimelineEvents(count, excluded) {
const excludeSet = new Set(
(excluded || []).map((item) =>
typeof item === 'string' ? item : item.answer
)
);
const fresh = EVENTS.filter((item) => !excludeSet.has(item.answer));
const pool = fresh.length >= count ? fresh : EVENTS;
return shuffle(pool).slice(0, Math.min(count, pool.length));
}
// 为当前事件生成 4 个选项(1 正确 + 3 干扰)
export function buildOptions(event) {
const distractors = shuffle(
EVENTS.filter((item) => item.answer !== event.answer)
)
.slice(0, 3)
.map((item) => item.answer);
const labels = shuffle(distractors.concat(event.answer));
return {
options: labels.map((label, i) => ({ label, cls: '', key: 'opt_' + i })),
correctIndex: labels.indexOf(event.answer)
};
}
干扰项生成策略很简单但有效:从其他事件的正确答案中随机抽。这样做的副产品是------干扰项本身也是真实历史知识,用户即使在排除错误选项的过程中也在被动学习。
架构
整个应用只有两张卡片,但覆盖了完整的学习闭环:
| 卡片 | 职责 | 触发方式 |
|---|---|---|
| 闯关卡片 | 出题、答题、提示、跳过、总结 | "穿越到古代" / "时光倒流" |
| 时代档案馆 | 战绩总览、悖论本浏览、悖论清除 | "战绩" / "正确率" |
这不是功能上的"做减法",而是架构上的主动选择。在 AIUI 对话流中,卡片生命周期与对话绑定,用户用完即走。多页面栈反而增加认知负担------用户需要在"答题 → 查看战绩 → 返回答题"之间手动导航。两张卡片让"学习 → 复盘"的切换靠语音一键完成。
json
// app.json --- 极简应用配置
{
"lifetime": "cut",
"pages": [
"pages/index/index",
"pages/stats/index"
]
}
闯关卡片本身内部又以 phase 字段驱动五个视图状态,本质上是"一卡五用"。时代档案馆则是一个纯展示 + 交互面板,暴露出悖论本中每个事件的完整信息(问题、答案、详解、冷知识),支持展开/折叠和"修复全部时间线"批量操作。

闯关流程
闯关卡片的核心是一个有限状态机。phase 是唯一的舞台导演,它决定了当前渲染哪个视图、哪些元素可以聚焦、按键应该触发什么动作:
scss
loading → quiz ⇄ answered → quiz → ... → summary → (revenge → quiz) / (restart → loading)
↑ skip ────────────┘ ↓
empty
整个状态流转由一个 _session 对象驱动,它保存了当前轮次的全部上下文:
yaml
this._session = {
mode: 'quiz', // quiz | revenge
queue: [], // 本轮事件队列
index: 0, // 当前题目索引
combo: 0, // 当前连击数
bestCombo: 0, // 本轮最佳连击
correct: 0,
wrong: 0,
skip: 0,
wrongList: [], // 答错的事件(用于复仇模式)
correctIndex: -1, // 当前题正确答案在选项中的位置
hintUsed: false,
lastCount: 10 // 用于 restart 时复用本轮数量
};
每个阶段的职责清晰到可以用一句话概括:
- loading:2 秒旋转沙漏动画,冷启动事件库、筛选已穿越事件
- quiz:展示问题、4 选项、时间透镜、跳过,接收上下键焦点移动和确定键答题
- answered:显示正误反馈、完整详解、冷知识开关、TTS 发音,接收下一题或查看更多
- summary:展示本轮正确率、最佳连击、跳过数、时间悖论列表,提供"时光倒流"和"继续穿越"
- empty:事件库枯竭或悖论本清空时的正向反馈,提供"开始穿越"重新进入
- revenge:复用 quiz/answered 阶段,但 mode 被设为 'revenge',答题逻辑有所不同(答对消除悖论)
这种设计的一个实际好处是:调试时只需要 inspect _session。你不需要在五个页面之间跳转,不需要追踪复杂的事件总线------this._session 完整描述了用户当前卡在哪一步、还有什么题没做、错了哪些。
视觉设计
AR 眼镜的显示环境与手机截然不同。AIUI 官方规范要求透明镜片上使用单绿色显示------这既是物理限制,也是设计约束。
我围绕这条约束建立了一套"古卷"色彩体系。不是简单的绿色渐变,而是通过亮度层级模拟纸张、墨水与金箔的质感:
| 角色 | 色值 | 设计意图 |
|---|---|---|
| 主背景 | #1a1206 | 深棕黑底,模拟羊皮纸历经千年的底色 |
| 卡片底色 | #2c1e0e → #3d2b14 | 渐变模拟古卷翻开的光影 |
| 金色主调 | #b8860b | 边框、进度条底色,对应古籍中的金箔装饰 |
| 金色高亮 | #daa520 | 按钮激活态、焦点边框、得分数字 |
| 正文 | #f5e6c8 | 暖白纸色,长时间阅读不刺眼 |
| 连击强调 | #ffd76a | 琥珀金,用于连击计数和时光倒流标识 |
| 错误/悖论 | #cd5c5c、#e8a0a0 | 柔化红色,悖论提示和错词标签 |
css
/* 核心视觉元素 */
.card {
border: 1.5px solid #b8860b;
background: linear-gradient(135deg, #2c1e0e 0%, #3d2b14 100%);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.40);
opacity: 0.72;
transform: scale(0.98);
transition: opacity 0.2s, transform 0.2s;
}
.card:host-focus {
opacity: 1;
transform: scale(1);
}
按键导航
眼镜上没有触摸屏,但用户依然需要"选择"这个概念。我的方案是模拟键盘焦点:上下键移动高亮框,确定键触发当前焦点的操作。
焦点系统的核心是两个方法 + 一个映射:
kotlin
moveFocus(delta) {
const count = this.getFocusCount();
const next = (this.data.focusedIndex + delta + count) % count;
this.setData({ focusedIndex: next });
},
getFocusCount() {
switch (this.data.phase) {
case 'quiz':
return this.data.options.length + 3; // 选项 + 发音 + 提示 + 跳过
case 'answered':
return 3; // 发音 + 冷知识 + 下一站
case 'summary':
return this.data.summary?.wrongCount > 0 ? 2 : 1;
default:
return 1;
}
}
关键洞察是:不同阶段的可聚焦元素数量不应该相同。
在 quiz 阶段,用户需要访问 4 个选项 + 时间透镜 + 发音 + 跳过 = 7 个焦点。到了 answered 阶段,答题已经结束,选项不再有意义,焦点缩减为 3 个:发音、冷知识、下一站。summary 阶段又不同------如果有错题,产生 2 个焦点(时光倒流 + 继续穿越);没有错题则只有 1 个(继续穿越)。
动态焦点数量避免了用户在无意义的元素之间浪费时间。实操中,大多数用户 3-5 秒内就能完成一次"看题 → 按键选择 → 确认"的循环。

离线设计
历史穿越者没有做任何网络请求。所有数据------事件库、学习战绩、悖论本------都在设备本地。
这不是技术限制,而是刻意为之。历史问答这件事不需要实时联网:用户可能在通勤地铁上、在会议间隙、在飞机上。网络不可靠,但学习冲动稍纵即逝。离线设计让"开始闯关"的成本降到最低------不需要等待加载,不需要担心流量,打开即穿越。
存储层使用 wx.getStorageSync / wx.setStorageSync,数据结构直白得不需要 ORM:
csharp
// store.js --- 本地战绩数据结构
function createEmptyStats() {
return {
answered: 0,
correct: 0,
bestCombo: 0,
skippedEvents: [], // 已穿越(跳过)的事件答案列表
paradoxList: [] // 时间悖论(答错)的事件完整记录
};
}
skippedEvents 和 paradoxList 的设计互为补充:跳过的词不再出现,答错的词可以"时光倒流"重考。两者共同构成了用户的"历史知识图谱"------系统知道你知道什么、不知道什么、以及哪些你以前不知道但现在已经修复了。
边界处理
- "跳过"按钮的语义是"我已经掌握了"------点击后,该事件被永久标记为已穿越,后续所有轮次不再出现。
ini
export function recordSkip(event) {
const stats = getStats();
if (stats.skippedEvents.indexOf(event.answer) === -1) {
stats.skippedEvents.push(event.answer);
}
stats.paradoxList = stats.paradoxList.filter(
(item) => item.answer !== event.answer
);
saveStats(stats);
return stats;
}
跳过代表的是"自信已掌握",不可撤回。
- 当用户跳过了 50 个本地事件中的大部分,pickTimelineEvents 会回退到全事件库(包括已穿越的)。这意味着老用户可以重复遇到曾经答对的题------但这并不无聊,因为选项顺序每次随机,干扰项也每次不同。
fetchEventsFromLLM------设备本地的 LanguageModel API。但目前 LLM 生成逻辑尚未在主流程中调用(index.ink 中只 import 了但未 invoke),这是下一步的集成点。
- 时代档案馆中,每个悖论条目默认折叠显示答案,点击展开可以看到完整的问题、详解和冷知识。这避免了信息过载------用户通常只需要快速浏览"我错了哪些",而不是重新阅读每一个 3 句长的详解。
kotlin
toggleParadox(index) {
this.setData({
expandedIndex: this.data.expandedIndex === index ? -1 : index
});
}
实战总结
- 存储:一个 key 就够。50 条事件撑不起 getStorageSync 的性能瓶颈,别上来就设计 DAO 层。
- 眼镜按键:别做防抖,眼镜上没人会连按。但 Backspace 必须 preventDefault,真机上它会直接退出卡片。
- TTS:playTTS 返回空字符串不代表失败,别当异常处理,打个 warn 就行。
- 单色屏:一开始觉得受限,后来发现是好事------不用纠结配色,明度分三层就完了。