guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿

- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
This commit is contained in:
SoundGame Dev
2026-08-30 22:46:05 +08:00
parent 7eb9e7f025
commit 79dffac01e
69 changed files with 2466 additions and 144 deletions
+16 -5
View File
@@ -1,7 +1,7 @@
# 00 · 全局交互规范(Global UX)
> **给开发者的实现要点摘要**
> 1. 设置项(voice / speed / bgm)只在大厅页用 `<select>` 可视化 UI 修改,存 `localStorage["soundgame.settings"]`,由 `engine/settings.js` 管理;游戏启动时只读生效,游戏内不提供修改入口(菜单里只播报当前值)。**R1 已落地,本文 §1/§2/§4 已按实现修订(R2 修订版)。**
> 1. 设置项(voice / speed / bgm / guide)只在大厅页用 `<select>` 可视化 UI 修改,存 `localStorage["soundgame.settings"]`,由 `engine/settings.js` 管理;游戏启动时只读生效,游戏内不提供修改入口(菜单里只播报当前值)。**R1 已落地,本文 §1/§2/§4 已按实现修订(R2 修订版);R7 增补 guide 设置与向导层(§6,见 14 号文档)。**
> 2. 所有提示音反馈改为震动:`engine/haptics.js`(**已实现**,震型名小写:`confirm/error/success/warning/swipe/heartbeat`);iOS Safari 不支持时**静默降级,绝不回退到提示音**。
> 3. 旁白全部整句预生成:禁止数字/碎片拼接(音色漂移根因),每款游戏旁白固定同一 `speaker` + 同一 `instruct` 常量。
> 4. 手势全局约定:上滑=帮助/状态、下滑=菜单、单击=确认/继续、双击=重听、左右滑=选择/移动;以滑动为核心玩法的游戏可偏离(见 §5 豁免表),但必须在开场交互教学中讲清。
@@ -11,25 +11,26 @@
## 1. 设置系统(已实现:engine/settings.js + 大厅设置面板)
### 1.1 三个设置项的精确语义
### 1.1 四个设置项的精确语义
| 设置项 | 取值(实现值) | 精确语义 |
|---|---|---|
| `voice` | `"female"`(默认)/ `"male"` | 切换 **voice 资产目录**。`play('voice/xxx')` 实际解析为 `audio/voice/<gender>/xxx.{ogg,m4a,mp3,wav}`,找不到时**回退平铺的 `audio/voice/xxx.*`**(已实现;storyline 角色台词靠此回退)。两性别的 id 集合由资产侧保证完全一致(见 docs/audio/round1-report.md),因此不做"回退另一性别"的逻辑。 |
| `speed` | `1.0`(默认)/ `1.35` / `1.7` | **只作用于 voice 层**的 `playbackRate`(创建 voice source 时实时读取,**从下一句开始生效**,正在播的句子不变速)。BGM、sfx 速率不变。TTS 一律按 1.0 语速生成;`instruct` 里禁止出现语速描述词。 |
| `speed` | `1.0`(默认)/ `1.35` / `1.7` / `2.2` | **只作用于 voice 层**的 `playbackRate`(创建 voice source 时实时读取,**从下一句开始生效**,正在播的句子不变速)。BGM、sfx 速率不变。TTS 一律按 1.0 语速生成;`instruct` 里禁止出现语速描述词。**2.2 档为视障档**(R7 增补:资深读屏用户听速 250+ WPM,见 research/01-literature/03)。 |
| `bgm` | `1.0`(默认)/ `0.3` / `0` | BGM 层独立增益总线系数。`0` = 不加载、不播放任何 `bgm/` 资产(`_playBgm` 短路),ducking 逻辑保持原样。改设置即时生效(已实现 subscribe 机制)。 |
| `guide` | `"full"`(默认)/ `"standard"` / `"off"` | **向导帮助等级**(R7 增补,engine/guide.js 实时读取,见 §6 与 14 号文档):`full`=签名 earcon+向导语音+震动(新手全托管);`standard`=只留 earcon+震动(无向导语音,尊重专家);`off`=全静且**不挂任何看门狗定时器**(老手/回归测试)。 |
存储:localStorage key = `soundgame.settings`,值形如:
```json
{ "voice": "female", "speed": 1.0, "bgm": 1.0 }
{ "voice": "female", "speed": 1.0, "bgm": 1.0, "guide": "full" }
```
注意(与设计初稿的差异,**以实现为准**):`speed`/`bgm` 存**数值**(1.0/1.35/1.7、1.0/0.3/0),不是字符串枚举;`engine/settings.js` 对非法键/值逐项丢弃,永不炸。
### 1.2 修改入口:只在大厅页(已实现)
- `web/index.html` 大厅已有"设置"面板:三个 `<select>`(旁白音色:女声/男声;语速:正常/快/特快;背景音乐:正常/小/关闭),变更即 `settings.set()` 写 localStorage。
- `web/index.html` 大厅已有"设置"面板:四个 `<select>`(旁白音色:女声/男声;语速:正常/快/特快/特快++;背景音乐:正常/小/关闭;向导帮助:详细/只听提示音/关闭),变更即 `settings.set()` 写 localStorage。
- 试听句(设计有、R1 未做):变更后播一句样例语音演示 voice+speed,列为 R3 打磨项,非阻塞。
- 游戏内:**只读**。`audio.js` 自动应用三项设置,游戏代码无需改动;下滑呼出的菜单里用语音播报当前设置(如"当前:女声,正常语速"),并提示"请回到大厅修改设置"。
@@ -172,3 +173,13 @@ PUT /api/progress { game, data } (Bearer) -> 200 { ok, game }
- 任何游戏里,**做了一个手势 3 秒内必须有反馈**(引擎内置震动 + 内容音/语音),被判定无效的手势给 `error` 震动;教学阶段除外(教学里错误手势给纠正语音)。
- 单击有 ~300ms 引擎级延迟:即时判定(格挡、节奏打击)一律绑滑动或双击,单击只用于非即时操作(确认、检查、菜单)。新设计继续遵守。
- 结算画面统一:单击=再来一局,下滑=菜单(回大厅)。教学可跳过:任何游戏的引导阶段,下滑=跳过引导(首次强制完整引导的标志位存进度里)。
## 6. 向导层(R7 增补,已实现:engine/guide.js)
> 设计裁定与台词表全录:[14-guide-layer.md](14-guide-layer.md)。本节只列与全局约定交叉的部分。
1. **四个全局机制**(方法论 research/02 §3 的 L3/L4 落地):状态签名 earcon(6 状态+2 功能音,运行时合成,全项目统一词表);回合标记 `turn`(等待输入开始处);静默看门狗(等待输入无操作→轻提醒→升级上下文提示,用尽即停不轰炸);纠错 `error`(error 震+一句"该做什么")。
2. **等级语义**:`full`=earcon+向导语音+震动;`standard`=earcon+震动(**无向导语音**);`off`=全部 no-op 且不注册定时器。向导语音一律 `queue:true`,**绝不顶掉在播对白**(A3 信息分层)。
3. **与既有约定的边界**:向导不改变任何手势映射与玩法判定;向导震动只用既有震型(confirm/error/nudge 复用),不新增语义震;向导 earcon 是**系统音**(P2 系统音/叙事音分离),不占用 sfx 内容音语义域。
4. **看门狗通用参数**(各游戏已按此接):第 1 级 8~10s(nudge+一句提示),第 2 级 20~25s(上下文提示/复述),之后停止;玩家任意手势复位。escape 三级卡关计时器(60/120/180s)保持独立,不受影响。
5. **测试约定**:既有 playtest 一律 `settings.set({guide:'off'})` 验证纯玩法逻辑;向导行为由 `web/harness/guide.test.mjs`(full 档)覆盖。
+59
View File
@@ -0,0 +1,59 @@
# 14 · 向导交互层设计裁定(v1 基线)
> 依据:research/02-methodology/methodology.md §3(L3 状态层 / L4 引导层)→
> research/03-schemes/schemes.md §1/§2(实施蓝图,唯一事实源)。
> 本文存档 v1 已落地的裁定与新增语音资产台词表,供后续轮次回溯。
> 实现载体:`web/engine/guide.js`(引擎向导模块)+ 各游戏 game.js 接线。
## 1. 裁定摘要
| # | 裁定 | 内容 |
|---|---|---|
| 1 | 分期 | v1 只做「全局基线」= 引擎向导层 + 逐游戏接线;场景参数包(A 通勤/B 摸鱼/C 睡前/D 下班)与人设旋钮在试玩验证后分期(schemes §3)。 |
| 2 | 等级 | `settings.guide`:`'full'`(默认,earcon+语音+震动)/ `'standard'`(earcon+震动,无语音)/ `'off'`(全静 no-op,**不挂任何定时器**)。大厅设置面板「向导帮助:详细/只听提示音/关闭」。 |
| 3 | earcon | 8 个签名(6 状态 menu/explore/battle/choice/settle/danger + 2 功能 turn/nudge)**全项目统一**,全部运行时 WebAudio 合成(零资产、可复现);总长 0.25~0.4s、单音 ≥0.08s、125Hz~5kHz、总增益 ≈0.12;家族=波形音色、子类=节奏轮廓(schemes §1.4)。 |
| 4 | 语音纪律 | 向导插播语音一律 `queue:true`(等当前对白播完),**绝不顶掉正在播的内容**;抉择提示(storyline p*)播完即可选的等待点不插任何语音。 |
| 5 | 看门狗 | 统一模板:静默 N 秒 → nudge 轻 earcon(+ full 档语音)→ 二次升级播上下文提示 → 级数用尽自动停止;任何手势/向导动作复位。escape 的三级卡关骨架保留,仅加「连续撞墙 ≥2 次 → full 档阈值提前一级」。 |
| 6 | 引擎配套 | `audio.isVoicePlaying()` 公开只读 API(收编 5 处私有 `_voice` 读);`audio.setRateBoostEnabled(bool)` 长按快进 per-game 开关(rhythm/space 对局期豁免);`settings.speed` 增加 2.2 档(视障档,250+ WPM)。 |
| 7 | 语义分工铁律 | 语音说「你要做什么」、earcon 说「哪类事/哪个状态」、BGM 说「宏观状态」、震动说「刚才那下对不对」——向导层不越界(L2 铁律:earcon 只做粗标记,语义靠语音)。 |
## 2. 逐游戏接线裁定(v1 落地范围)
| 游戏 | 接线 |
|---|---|
| storyline | W1/W3 静默看门狗 [8s]=nudge+`gd_continue`、[20s]=复用 `playHelpAndResume`;选择节点外左右滑的静默 return 处加 error 震+`gd_invalid`;抉择态(p* 播完)不插语音不挂看门狗;状态签名 explore/menu 与状态 BGM 同点。 |
| simon | input 看门狗 [10s]=nudge+`gd_relisten`、[25s]=免扣机会自动重听(每局 2 次);`your_turn` 泛化为 turn earcon(每轮,第 1 轮保留原语音);状态签名 settle/menu。 |
| escape | REOPEN 提示播完与引导步解锁给 turn cue;连续撞墙 ≥2 次 → full 档卡关三级阈值提前一级;其余(wrong/blunt/stuck/nav)保持既有完备体系。 |
| rhythm | 每曲首次领奏→应答切换推 `gd_answer`(谱面排程处入 `_timed`);连续 2 Miss 播 `gd_miss`(queue 插播,同曲一次);结算链换 `_speakGuarded`;main 演奏期 `setRateBoostEnabled(false)`;状态签名 menu/battle/settle。 |
| space | 对局第 1 次受击(保护期外)追加 `gd_hit_hint`;结算链换 `_speakGuarded`;对局期 `setRateBoostEnabled(false)`;状态签名 battle/menu/settle。 |
| rpg | 格挡窗口内做错手势 → error 震+`gd_parry_miss`(每局一次);PLAYER_SELECT 挂看门狗 [60s]=nudge+`gd_stall`;状态签名 explore/battle/menu/settle 与四状态 BGM 同点补齐。 |
## 3. 新增语音资产台词表(8 句 ×2 性别 = 16 文件)
定稿依据 schemes.md §1.5,台词全文一字不改;speaker/instruct 沿用各游戏旁白常量
(见 `tools/gen_voice_dual.py` SPEAKERS 与各 `_XXX` pair):
| 游戏 | id | 全文 | 触发 | female speaker / instruct | male speaker / instruct |
|---|---|---|---|---|---|
| storyline | `gd_continue` | 轻触屏幕,继续。 | W1/W3 叙事节点播完静默 8s(看门狗第 1 级) | Vivian / _SL_NARR[1] | Uncle_Fu / _SL_NARR[0] |
| storyline | `gd_invalid` | 这个操作在这里用不上。 | 无效手势(选择节点外左右滑静默 return,同局限次) | Vivian / _SL_NARR[1] | Uncle_Fu / _SL_NARR[0] |
| simon | `gd_relisten` | 需要的话,双击可以重新听一遍序列。 | input 阶段 10s 无输入(不改变重听扣机会规则) | Serena / _SIMON | Uncle_Fu / _SIMON |
| rhythm | `gd_answer` | 现在跟着我打。 | 每曲首次领奏→应答切换 | Vivian / _RHY | Uncle_Fu / _RHY |
| rhythm | `gd_miss` | 慢了半拍,下一小节跟上我就行。 | 连续 2 次 Miss(同曲限 1 次,queue 插播) | Vivian / _RHY | Uncle_Fu / _RHY |
| space | `gd_hit_hint` | 被撞了。听方位,同向滑动就能拨开。 | 对局第 1 次受击(保护期外,同局 1 次) | Serena / _SPC | Uncle_Fu / _SPC |
| rpg | `gd_parry_miss` | 前摇声起的时候双击,才能格挡。 | 格挡窗口内做错手势(每局限次) | Serena / _RPG | Uncle_Fu / _RPG |
| rpg | `gd_stall` | 想好了吗?左右滑动选择,单击出手。 | PLAYER_SELECT 60s 无输入 | Serena / _RPG | Uncle_Fu / _RPG |
资产落位:`web/<game>/audio/voice/{female,male}/gd_*.{ogg,m4a}`(管线
`tools/gen_voice_dual.py --game <g> --only gd_xxx` 生成 + `--convert` 落盘,
双格式配对由 `tools/check_all_assets.py` 终检保证)。
## 4. 已知边界与后续候选
- `onceKey` 语义为「同局(游戏会话)一次」;schemes §1.5 中 storyline `gd_invalid`
「同局限 2 次」、rpg `gd_parry_miss`「每场战斗 1 次」为更细粒度限次,v1 统一收敛到
onceKey(一次教学提示足矣),如试玩反馈不足再引入 per-run 限次 API。
- 教学义务(schemes §1.4「full 档开场教学带教签名 earcon」):v1 未在各游戏教学链中
逐个口播签名音,列为 v2 候选(需逐游戏改教学台词,涉及台词表返工)。
- 向导插播语音目前挂在引擎 voice 队列;若游戏层 `stopVoice()` 会顺带丢弃排队中的
向导句(作废语义,可接受)。
+43 -4
View File
@@ -14,8 +14,9 @@ web/
├── engine/ # 共享引擎 —— 所有游戏复用,勿修改
│ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈)
│ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置)
│ ├── settings.js # 全局用户设置(voice/speed/bgm,localStorage 持久化)
│ ├── settings.js # 全局用户设置(voice/speed/bgm/guide,localStorage 持久化)
│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级)
│ ├── guide.js # 全局向导层(状态签名 earcon / turn cue / 看门狗 / 纠错)
│ ├── game.js # Game / StateMachineGame 基类
│ ├── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势 + 闪光降级样式
│ └── vendor/soundtouch.js # SoundTouch 0.3.0(voice 变速不变调,LGPL v2.1)
@@ -137,6 +138,11 @@ new WebAudioEngine(basePath = 'audio/', opts?)
与 stopVoice 的差异:stopVoice 作废整条链,skipVoice 只跳当前句。
游戏层自行绑定手势(如长按/特定滑动的"跳过这句")。
- `stopAll()` — stopBgm(200) + stopVoice()(sfx 很短,不主动停)。
- `isVoicePlaying(): boolean` — 当前是否有对白在播(公开只读;游戏判定
"单击是否跳句"等语境用它,不要读私有字段 `_voice`)。
- `setRateBoostEnabled(on: boolean)` — 长按快进(旁白临时 1.7 倍速)的
per-game 开关,默认开启。rhythm/space 的对局/演奏期传 `false` 豁免
(长按是潜在演奏输入),回菜单/结算/退出时传 `true` 恢复。
三层行为(对齐 Android DefaultAudioEngine):
@@ -175,13 +181,46 @@ BGM。游戏层无需为此写任何判断。
`play('voice/intro')` 依次尝试 `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,
全部找不到再回退平铺的 `audio/voice/intro.*`(过渡期资产未分性别也能播)。
缓存键含性别,游戏中途切换音色下一句即用新性别资产。
- `settings.speed`(`1.0|1.35|1.7`,默认 `1.0`):voice 层 SoundTouch
- `settings.speed`(`1.0|1.35|1.7|2.2`,默认 `1.0`):voice 层 SoundTouch
tempo(**不变调**),在创建播放实例时读取,**从下一句生效**
(进行中的句子不变速——除非长按快进,见上)。
(进行中的句子不变速——除非长按快进,见上)。2.2 为视障档。
- `settings.bgm`(`1.0|0.3|0`,默认 `1.0`):BGM 层独立增益总线。
改动对**进行中的 BGM 立即生效**(150ms 渐变);调到 `0` 立即淡出停止
且此后 `play('bgm/...')` 只记意图不加载不播放;从 `0` 调回非零时,
若此前有想播的 BGM 会自动补播。
- `settings.guide`(`'full'|'standard'|'off'`,默认 `'full'`):向导帮助等级,
`engine/guide.js` 每次动作实时读取(改动即刻生效,无需重启游戏)。
## engine/guide.js — `Guide`(全局向导层)
语音承载语义、earcon 只做粗标记的全局向导:状态签名 earcon(L3)+ 回合标记
(A2)+ 静默看门狗(L4)+ 纠错语音(A4)。设计裁定与 8 句新增语音见
`docs/design/14-guide-layer.md`;逐游戏接线表见 `research/03-schemes/schemes.md` §2。
```js
import { Guide } from '../engine/guide.js';
this.guide = new Guide(audio); // 游戏构造时创建一次
guide.level(); // 'full' | 'standard' | 'off'(实时读 settings.guide)
guide.state('battle'); // 进入宏观状态播签名 earcon(menu/explore/battle/
// choice/settle/danger,全项目统一 8 音词表,
// 运行时 WebAudio 合成,0.25~0.4s,增益 ≈0.12)
guide.turn({ voice, haptic }); // 轮到你:turn earcon + 可选 confirm 轻震 +
// full 档可选语音(queue:true,绝不顶掉在播语音)
guide.watchdog({ arm: true, at: [8000, 20000], say: ['gd_x', null],
onEscalate: (n) => ..., cond: () => ... });
// 静默看门狗:每级到点播 nudge 轻 earcon(+ say[n-1]
// 语音,full 档)再调 onEscalate(n);级数用尽自动停;
// cond()=false 静默取消;任何手势 disarm() 复位
guide.error({ voice, onceKey, skipHaptic }); // 纠错:error 震(游戏层已震则 skipHaptic)
// + full 档语音(onceKey 同局只播一次)
guide.disarm(); // 复位看门狗(手势入口调用)
guide.setEventHook(fn); // 转写 harness 用:fn({type,name,voiceId})
```
- 等级语义:`full` = earcon+语音+震动;`standard` = earcon+震动(无语音);
`off` = 全部 API 静默 no-op,**不挂任何定时器**(回归测试/老手零干扰)。
- 向导语音一律 `queue:true` 插播,绝不打断游戏正在播的内容。
## engine/settings.js — `settings`
@@ -191,7 +230,7 @@ BGM。游戏层无需为此写任何判断。
```js
import { settings } from '../engine/settings.js';
settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7, bgm: 1|0.3|0 }
settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7|2.2, bgm: 1|0.3|0, guide: 'full'|'standard'|'off' }
settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn)
const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订
```
+109
View File
@@ -0,0 +1,109 @@
# 试玩转写:《escape》
> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,
> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.0s ♫ BGM 切换 → bgm/drone
t+ 0.0s ♪ 向导音:explore(探索/安全)
t+ 0.0s ▶ 语音:「黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。」
t+ 0.0s 【状态】黑屏,只有引子的声音,先听完 引导第 1/8 步,等我做手势(语音播放中)
t+ 0.0s 【状态】引导第 1/8 步 引导第 1/8 步,等我做手势(语音播放中)
#### 【玩家动作】照引导单击了一下
t+ 0.0s ▶ 语音:「先活动一下。朝任意方向滑一下,走一小步。」
t+ 20.0s ♪ 向导音:turn(轮到你)
t+ 20.0s 【状态】引导第 2/8 步 引导第 2/8 步,等我做手势(语音播放中)
#### 【玩家动作】随便滑了一步,有脚步声
t+ 20.0s ♪ 音效 sfx/step
t+ 20.0s ▶ 语音:「很好,你迈出了第一步。」
t+ 22.1s ▶ 语音:「这个房间不大,四面都是墙。再滑两下,试试碰碰墙。」
t+ 24.2s ♪ 向导音:turn(轮到你)
t+ 24.2s 【状态】引导第 3/8 步 引导第 3/8 步,等我做手势(BGM=bgm/drone)
t+ 24.2s ♪ 音效 sfx/wall
t+ 24.2s ▶ 语音:「那边是墙,换个方向。」
t+ 24.2s 〰 震动:error(做错了:两连短震)
#### 【玩家动作】又滑一步,又撞墙了——墙的方位听出来了
t+ 26.4s ♪ 音效 sfx/wall
t+ 26.4s ▶ 语音:「那边是墙,换个方向。」
t+ 26.4s 〰 震动:error(做错了:两连短震)
t+ 28.5s ▶ 语音:「跟着我指的路走。向左滑。」
t+ 30.6s ♪ 向导音:turn(轮到你)
t+ 30.6s 【状态】引导第 4/8 步 引导第 4/8 步,等我做手势(BGM=bgm/drone)
#### 【玩家动作】这回往左,走通了一步
t+ 30.6s ♪ 音效 sfx/step
t+ 30.6s ▶ 语音:「对。再向下滑。」
t+ 32.7s ♪ 向导音:turn(轮到你)
t+ 32.7s 【状态】引导第 5/8 步 引导第 5/8 步,等我做手势(BGM=bgm/drone)
#### 【玩家动作】往下滑,进了储物间
t+ 32.7s ♪ 音效 sfx/step
t+ 32.7s ♪ 音效 sfx/amb_storage
t+ 32.7s ▶ 语音:「听到水声了吗?单击一下,仔细检查这个房间。」
t+ 34.8s ♪ 向导音:turn(轮到你)
t+ 34.8s 【状态】引导第 6/8 步 引导第 6/8 步,等我做手势(BGM=bgm/drone)
#### 【玩家动作】单击摸一遍房间,摸到钥匙了
t+ 34.8s ♪ 音效 sfx/pickup
t+ 34.8s ▶ 语音:「你摸到了一把钥匙!」
t+ 34.8s 〰 震动:pickup(获得)
t+ 37.0s ▶ 语音:「钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。」
t+ 39.1s ♪ 向导音:turn(轮到你)
t+ 39.1s 【状态】引导第 7/8 步 引导第 7/8 步,等我做手势(BGM=bgm/drone)
#### 【玩家动作】双击看了看背包
t+ 39.1s ▶ 语音:「你身上带着一把钥匙。」
t+ 41.2s ▶ 语音:「对,就是这样。接下来,靠你自己了。」
t+ 43.3s ▶ 语音:「找到能用这把钥匙打开的东西。需要帮助时,在原地双击。」
t+ 45.4s ♪ 向导音:turn(轮到你)
t+ 45.4s ♪ 音效 sfx/amb_storage
t+ 45.4s 【状态】引导完毕,正式开始。我在储物间 探索中,我在 (0,2)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone)
t+ 45.4s ……… 静默 3s(辨了辨方向) ………
t+ 45.4s ♪ 音效 sfx/step
t+ 45.4s ♪ 音效 sfx/amb_hum
t+ 50.0s ♪ 音效 sfx/step
t+ 50.0s ♪ 音效 sfx/amb_power
t+ 50.0s ▶ 语音:「这个房间的墙上,有一只电箱。」
t+ 52.1s ♪ 音效 sfx/wall
t+ 52.1s ▶ 语音:「那边是墙,换个方向。」
t+ 52.1s 〰 震动:error(做错了:两连短震)
#### 【玩家动作】北边是墙,撞了一下
t+ 54.2s ♪ 音效 sfx/wall
t+ 54.2s ▶ 语音:「那边是墙,换个方向。」
t+ 54.2s 〰 震动:error(做错了:两连短震)
t+ 55.0s ▶ 语音:「需要帮助吗?」
t+ 57.1s ▶ 语音:「钥匙就在你身上。它能打开某一把锁。」
t+ 57.1s 〰 震动:warning(警告三连震)
#### 【玩家动作】又撞了一下——听到游戏主动来问我了
t+ 65.0s ♪ 音效 sfx/step
t+ 65.0s ♪ 音效 sfx/amb_wind
#### 【玩家动作】往东走,风声换了个方向
t+ 70.0s ♪ 音效 sfx/step
t+ 70.0s ♪ 音效 sfx/amb_hum
t+ 75.0s ♪ 音效 sfx/step
t+ 75.0s ♪ 音效 sfx/amb_cabinet
t+ 75.0s ▶ 语音:「这个房间里,立着一个铁柜。」
#### 【玩家动作】到了铁柜房,柜子锁着
t+ 77.1s ♪ 音效 sfx/unlock
t+ 77.1s ♪ 音效 sfx/door_creak
t+ 77.1s ▶ 语音:「钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。」
t+ 77.1s 〰 震动:success(成功长震)
t+ 85.0s 【状态】柜子开了,拿到保险丝——解到第二间,收工 探索中,我在 (2,1)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone)
+170
View File
@@ -0,0 +1,170 @@
# 试玩转写:《rhythm》
> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,
> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.0s ▶ 语音:「欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。」
t+ 8.0s ▶ 语音:「听,底鼓。在下一小节的同一个位置,单击。」
t+ 18.0s 【状态】教学开始了,先听领奏 教学:听领奏,下一小节照样打(tap)(语音播放中)
t+ 18.0s 【状态】教学第 1/3 句 教学:听领奏,下一小节照样打(tap)(语音播放中)
t+ 22.3s 〰 震动:beat(节拍点)
t+ 23.0s ▶ 语音:「再听一遍,跟着来。」
t+ 25.1s ▶ 语音:「再听一遍,跟着来。」
t+ 33.6s ▶ 语音:「没关系,我们先走下一句。」
t+ 35.8s ▶ 语音:「这次是左和右。跟着打,左滑,右滑。」
t+ 35.8s 〰 震动:error(做错了:两连短震)
t+ 35.8s 〰 震动:beat(节拍点)
t+ 35.8s 〰 震动:error(做错了:两连短震)
t+ 35.8s 【状态】教学第 2/3 句 教学:听领奏,下一小节照样打(tap)(语音播放中)
t+ 43.0s ▶ 语音:「再听一遍,跟着来。」
t+ 45.1s ▶ 语音:「再听一遍,跟着来。」
t+ 53.6s ▶ 语音:「没关系,我们先走下一句。」
t+ 55.8s ▶ 语音:「最后一句,三个音,中间是双击。」
t+ 55.8s 〰 震动:beat(节拍点)
t+ 55.8s 〰 震动:beat(节拍点)
t+ 55.8s 〰 震动:error(做错了:两连短震)
t+ 55.8s 〰 震动:beat(节拍点)
t+ 55.8s 〰 震动:beat(节拍点)
t+ 55.8s 〰 震动:error(做错了:两连短震)
t+ 55.8s 【状态】教学第 3/3 句 教学:听领奏,下一小节照样打(left→right)(语音播放中)
t+ 55.8s ♪ 音效 sfx/inst_hat_l
t+ 55.8s ♪ 音效 sfx/inst_hat_r
t+ 64.3s ▶ 语音:「再听一遍,跟着来。」
t+ 66.4s ▶ 语音:「再听一遍,跟着来。」
t+ 75.0s ▶ 语音:「没关系,我们先走下一句。」
t+ 77.1s ▶ 语音:「很好,演出开始。」
t+ 79.2s ♪ 向导音:battle(战斗/对抗)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:error(做错了:两连短震)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:beat(节拍点)
t+ 79.2s 〰 震动:error(做错了:两连短震)
t+ 79.2s 【状态】教学完毕,曲目一《启程》90 拍开始 演奏中(启程):还剩 31 个应答音。领奏停了、轮到空拍,就该我出手了(无 BGM)
t+ 81.7s ♪ 向导音:menu(系统界面)
t+ 81.7s ▶ 语音:「菜单。单击切换选项,双击确认。」
t+ 81.7s 【状态】曲中暂停,进了菜单 暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏(语音播放中)
t+ 81.7s ▶ 语音:「回大厅。」
#### 【玩家动作】还是想打完这首,下滑回去
t+ 87.1s ……… 静默 5s(还在数拍子,第一句没敢出手) ………
t+ 91.0s ♪ 向导音:turn(轮到你)
t+ 91.0s ▶ 向导语音:「现在跟着我打。」
t+ 93.0s ▶ 向导语音:「慢了半拍,下一小节跟上我就行。」
t+ 93.0s 〰 震动:beat(节拍点)
t+ 93.0s 〰 震动:beat(节拍点)
t+ 93.0s 〰 震动:error(做错了:两连短震)
t+ 93.0s 〰 震动:error(做错了:两连短震)
t+ 93.0s 【状态】第一句全漏了,听到「慢了半拍,下一小节跟上我就行」 演奏中(启程):还剩 29 个应答音。领奏停了、轮到空拍,就该我出手了(语音播放中)
t+ 99.0s 〰 震动:beat(节拍点)
t+ 99.0s 〰 震动:beat(节拍点)
t+ 99.0s ♪ 音效 sfx/inst_hat_l
t+ 99.0s ♪ 音效 sfx/inst_accent
t+ 100.3s ♪ 音效 sfx/inst_hat_r
t+ 100.3s ♪ 音效 sfx/inst_accent
t+ 100.3s 〰 震动:自定义震型 [20,40,20]
t+ 107.3s 〰 震动:beat(节拍点)
t+ 107.3s 〰 震动:beat(节拍点)
t+ 107.3s 〰 震动:beat(节拍点)
t+ 107.3s ♪ 音效 sfx/inst_kick
t+ 107.3s ♪ 音效 sfx/inst_accent
t+ 107.6s ♪ 音效 sfx/inst_snare
t+ 107.6s ♪ 音效 sfx/inst_accent
t+ 108.6s ♪ 音效 sfx/inst_kick
t+ 108.6s ♪ 音效 sfx/inst_accent
t+ 108.6s 〰 震动:自定义震型 [20,40,20]
t+ 115.0s 〰 震动:beat(节拍点)
t+ 115.0s 〰 震动:beat(节拍点)
t+ 115.0s 〰 震动:beat(节拍点)
t+ 115.0s ♪ 音效 sfx/inst_hat_l
t+ 115.0s ♪ 音效 sfx/inst_accent
t+ 115.6s ♪ 音效 sfx/inst_snare
t+ 115.6s ♪ 音效 sfx/inst_accent
t+ 116.3s ♪ 音效 sfx/inst_hat_r
t+ 116.3s ♪ 音效 sfx/inst_accent
t+ 116.3s 〰 震动:自定义震型 [20,40,20]
t+ 123.3s 〰 震动:beat(节拍点)
t+ 123.3s 〰 震动:beat(节拍点)
t+ 123.3s 〰 震动:beat(节拍点)
t+ 123.3s ♪ 音效 sfx/inst_kick
t+ 123.3s ♪ 音效 sfx/inst_accent
t+ 124.3s ♪ 音效 sfx/inst_hat_l
t+ 124.3s ♪ 音效 sfx/inst_accent
t+ 125.0s ♪ 音效 sfx/inst_snare
t+ 125.0s ♪ 音效 sfx/inst_accent
t+ 125.0s 〰 震动:自定义震型 [20,40,20]
t+ 125.7s ▶ 语音:「过半了,保持。」
t+ 128.3s 〰 震动:beat(节拍点)
t+ 128.3s 〰 震动:beat(节拍点)
t+ 128.3s 〰 震动:beat(节拍点)
t+ 128.3s ♪ 音效 sfx/inst_hat_r
t+ 128.3s ♪ 音效 sfx/inst_accent
t+ 130.0s ♪ 音效 sfx/inst_kick
t+ 130.0s ♪ 音效 sfx/inst_accent
t+ 130.3s ♪ 音效 sfx/inst_snare
t+ 130.3s ♪ 音效 sfx/inst_accent
t+ 130.3s 〰 震动:自定义震型 [20,40,20]
t+ 133.6s 〰 震动:beat(节拍点)
t+ 133.6s 〰 震动:beat(节拍点)
t+ 133.6s 〰 震动:beat(节拍点)
t+ 133.6s ♪ 音效 sfx/inst_tom_hi
t+ 133.6s ♪ 音效 sfx/inst_accent
t+ 135.0s ♪ 音效 sfx/inst_tom_lo
t+ 135.0s ♪ 音效 sfx/inst_accent
t+ 135.0s 〰 震动:success(成功长震)
t+ 135.6s ♪ 音效 sfx/inst_snare
t+ 135.6s ♪ 音效 sfx/inst_accent
t+ 135.6s 〰 震动:自定义震型 [20,40,20]
t+ 139.3s 〰 震动:beat(节拍点)
t+ 139.3s 〰 震动:beat(节拍点)
t+ 139.3s 〰 震动:beat(节拍点)
t+ 139.3s 〰 震动:beat(节拍点)
t+ 139.3s ♪ 音效 sfx/inst_kick
t+ 139.3s ♪ 音效 sfx/inst_accent
t+ 139.6s ♪ 音效 sfx/inst_tom_hi
t+ 139.6s ♪ 音效 sfx/inst_accent
t+ 140.3s ♪ 音效 sfx/inst_hat_r
t+ 140.3s ♪ 音效 sfx/inst_accent
t+ 141.0s ♪ 音效 sfx/inst_snare
t+ 141.0s ♪ 音效 sfx/inst_accent
t+ 141.0s 〰 震动:自定义震型 [20,40,20]
t+ 144.3s 〰 震动:beat(节拍点)
t+ 144.3s 〰 震动:beat(节拍点)
t+ 144.3s 〰 震动:beat(节拍点)
t+ 144.3s 〰 震动:beat(节拍点)
t+ 144.3s ♪ 音效 sfx/inst_hat_l
t+ 144.3s ♪ 音效 sfx/inst_accent
t+ 145.0s ♪ 音效 sfx/inst_hat_r
t+ 145.0s ♪ 音效 sfx/inst_accent
t+ 146.0s ♪ 音效 sfx/inst_kick
t+ 146.0s ♪ 音效 sfx/inst_accent
t+ 146.3s ♪ 音效 sfx/inst_snare
t+ 146.3s ♪ 音效 sfx/inst_accent
t+ 146.3s 〰 震动:自定义震型 [20,40,20]
t+ 150.0s 〰 震动:beat(节拍点)
t+ 150.0s 〰 震动:beat(节拍点)
t+ 150.0s 〰 震动:beat(节拍点)
t+ 150.0s 〰 震动:beat(节拍点)
t+ 150.0s ♪ 音效 sfx/inst_kick
t+ 150.0s ♪ 音效 sfx/inst_accent
t+ 150.3s ♪ 音效 sfx/inst_snare
t+ 150.3s ♪ 音效 sfx/inst_accent
t+ 151.3s ♪ 音效 sfx/inst_kick
t+ 151.3s ♪ 音效 sfx/inst_accent
t+ 151.6s ♪ 音效 sfx/inst_snare
t+ 151.6s ♪ 音效 sfx/inst_accent
t+ 151.6s 〰 震动:自定义震型 [20,40,20]
t+ 152.7s ♪ 向导音:settle(结算)
t+ 152.7s ▶ 语音:「A 级!准确率超过八成五,非常出色。」
t+ 152.7s 〰 震动:success(成功长震)
t+ 152.7s 〰 震动:success(成功长震)
t+ 152.7s 〰 震动:自定义震型 [20,40,20]
t+ 154.8s ▶ 语音:「新连击纪录!」
t+ 157.0s ▶ 语音:「新曲目解锁,追风!」
t+ 159.1s ▶ 语音:「单击再来一遍,右滑下一首,下滑回大厅。」
t+ 161.2s ♫ BGM 切换 → bgm/menu(淡入 800ms)
t+ 162.7s 【状态】曲子打完了,结算播报中 结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅(BGM=bgm/menu)
+107
View File
@@ -0,0 +1,107 @@
# 试玩转写:《rpg》
> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,
> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.0s ▶ 语音:「你的眼睛看不见了。但你的耳朵,比谁都灵。」
t+ 2.1s ▶ 语音:「黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。」
t+ 4.2s ▶ 语音:「先记住,怎么用耳朵走路。」
t+ 6.4s ▶ 语音:「单击可跳过旁白。」
t+ 8.5s ▶ 语音:「新的旅程,开始了。」
t+ 10.6s ♫ BGM 切换 → bgm/village(淡入 800ms)
t+ 10.6s ♪ 向导音:explore(探索/安全)
t+ 10.6s ♪ 音效 sfx/step
t+ 10.6s ▶ 语音:「外乡人,林子里不太平。这五十金,拿着防身。」
t+ 10.6s 【状态】进村了,先听场景描述和选项 【雾口村】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(语音播放中)
t+ 10.6s ▶ 语音:「去商店。」
t+ 12.7s ▶ 语音:「休息一晚。」
t+ 14.8s ▶ 语音:「查看状态。」
t+ 17.0s 【状态】选项听了一圈,往林间小路去 【雾口村】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(BGM=bgm/village)
t+ 30.6s ▶ 语音:「查看状态。」
t+ 32.7s ▶ 语音:「前进,去林间小路。」
t+ 34.8s ♫ BGM 切换 → bgm/explore(淡入 800ms)
t+ 34.8s ♪ 向导音:explore(探索/安全)
t+ 34.8s ♪ 音效 sfx/step
t+ 34.8s ▶ 语音:「林间小路。风穿过树叶,脚下的落叶沙沙作响。」
t+ 34.8s 【状态】林间小路 【林间小路】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(语音播放中)
t+ 34.8s ▶ 语音:「调查四周。」
#### 【玩家动作】翻找了一阵,捡到一点钱
t+ 37.0s ♪ 音效 sfx/coin
t+ 37.0s ▶ 语音:「你在落叶下摸到十五枚金币。」
t+ 54.8s ▶ 语音:「调查四周。」
t+ 54.8s ▶ 语音:「前进,去岔路口。」
t+ 57.0s ♪ 向导音:explore(探索/安全)
t+ 57.0s ♪ 音效 sfx/step
t+ 57.0s ▶ 语音:「岔路口。风声在左右两边,听起来不太一样。」
t+ 57.0s 【状态】岔路口,水声在东边 【岔路口】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(语音播放中)
t+ 57.0s ▶ 语音:「往右,去溪流。」
t+ 59.1s ♪ 向导音:explore(探索/安全)
t+ 59.1s ♪ 音效 sfx/step
t+ 59.1s ▶ 语音:「溪流。水声潺潺,是个歇脚的好地方。」
t+ 61.2s ♫ BGM 切换 → bgm/battle(淡入 400ms)
t+ 61.2s ♪ 向导音:battle(战斗/对抗)
t+ 61.2s ▶ 语音:「野狼拦路!」
t+ 63.3s ▶ 语音:「第一场战斗来了。别慌,战斗是回合制:你先出手,敌人再反击。」
t+ 65.4s ▶ 语音:「左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。」
t+ 67.6s ▶ 语音:「敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。」
t+ 69.7s ▶ 语音:「没把握就防御,伤害减半。金疮药可以回血。」
t+ 71.8s ▶ 语音:「记住:听清前摇,双击格挡。开始吧!」
t+ 73.9s ▶ 语音:「战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。」
t+ 76.0s ♪ 音效 sfx/turn_player
t+ 76.0s 【状态】遭遇教学狼,战斗开始 战斗中:你的血量八成以上,敌方血量八成以上,等你选招式(左右滑切换,单击出手,下滑逃跑)(BGM=bgm/battle)
t+ 76.0s ▶ 语音:「破空斩。」
t+ 78.2s ▶ 语音:「防御。」
t+ 78.2s ▶ 语音:「挡住了!轮到你了。左右滑选攻击,单击出剑。」
t+ 80.3s 【状态】招式名听熟了 战斗中:你的血量八成以上,敌方血量八成以上,等你选招式(左右滑切换,单击出手,下滑逃跑)(语音播放中)
t+ 80.3s ▶ 语音:「金疮药。」
t+ 82.4s ▶ 语音:「攻击。」
#### 【玩家动作】单击出手,攻击
t+ 84.0s ♪ 音效 sfx/atk_swing
t+ 84.0s ♪ 音效 sfx/atk_hit
t+ 84.0s ▶ 语音:「它要扑了,听前摇!」
t+ 86.2s ♪ 音效 sfx/turn_player
t+ 86.2s ▶ 语音:「就是现在,双击格挡!」
t+ 86.2s 〰 震动:beat(节拍点)
t+ 86.2s 【状态】前摇声响起了 战斗中:敌方前摇声响起了——双击,格挡!(语音播放中)
#### 【玩家动作】双击格挡
t+ 86.2s ♪ 音效 sfx/parry
t+ 86.2s ▶ 语音:「来不及格挡,就选防御。左右滑,选防御,单击。」
t+ 86.2s 〰 震动:confirm(轻点确认)
t+ 88.3s ♪ 音效 sfx/turn_player
t+ 88.3s ▶ 语音:「破空斩。」
t+ 90.4s ▶ 语音:「防御。」
#### 【玩家动作】这回合先防御,减半伤害
t+ 92.0s ▶ 语音:「好剑法!接下来,靠你自己了。」
t+ 94.2s 〰 震动:beat(节拍点)
#### 【玩家动作】双击格挡
t+ 94.2s ♪ 音效 sfx/parry
t+ 94.2s ♪ 音效 sfx/turn_player
t+ 94.2s 〰 震动:confirm(轻点确认)
t+ 94.2s ▶ 语音:「金疮药。」
t+ 96.0s ▶ 语音:「攻击。」
#### 【玩家动作】单击,攻击
t+ 98.1s ♪ 音效 sfx/atk_swing
t+ 98.1s ♪ 音效 sfx/atk_hit
t+ 98.1s ♪ 音效 sfx/enemy_die
t+ 98.1s ▶ 语音:「敌人倒下了。」
t+ 98.1s 〰 震动:success(成功长震)
t+ 100.2s ♪ 音效 sfx/victory
t+ 100.2s ▶ 语音:「战斗胜利。」
t+ 102.3s ▶ 语音:「小有收获。」
t+ 104.4s ♫ BGM 切换 → bgm/explore(淡入 2000ms)
t+ 104.4s ♪ 向导音:explore(探索/安全)
t+ 104.4s ▶ 语音:「饮水休息。」
t+ 104.4s 【状态】狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工 【溪流】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(语音播放中)
+178
View File
@@ -0,0 +1,178 @@
# 试玩转写:《simon》
> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,
> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.5s ▶ 语音:「欢迎来到音序回响。先认识六种声音。」
t+ 2.6s ▶ 语音:「单击可跳过旁白。」
t+ 4.7s ▶ 语音:「我会演奏一串声音,请你记住顺序,原样复现。」
t+ 6.9s ▶ 语音:「向上滑,是这个声音。」
t+ 9.0s ♪ 音效 sfx/tone_up
t+ 9.9s ▶ 语音:「你试试。」
t+ 11.4s 【状态】教学第 1/6 个音色 教学:该做「上滑」了(语音播放中)
t+ 11.4s ♪ 音效 sfx/tone_up
t+ 11.4s ▶ 语音:「对了。」
t+ 13.5s ▶ 语音:「向下滑,是这个声音。」
t+ 15.6s ♪ 音效 sfx/tone_down
t+ 16.5s ▶ 语音:「你试试。」
t+ 18.6s 【状态】教学第 2/6 个音色 教学:该做「下滑」了(无 BGM)
t+ 18.6s ♪ 音效 sfx/tone_down
t+ 18.6s ▶ 语音:「对了。」
t+ 20.8s ▶ 语音:「向左滑,是这个声音。」
t+ 22.9s ♪ 音效 sfx/tone_left
t+ 23.8s ▶ 语音:「你试试。」
t+ 25.9s 【状态】教学第 3/6 个音色 教学:该做「左滑」了(无 BGM)
#### 【玩家动作】把「左滑」记混了,先来了个右滑
t+ 25.9s ▶ 语音:「不是这个,向左滑试试。」
t+ 25.9s 〰 震动:error(做错了:两连短震)
t+ 25.9s ♪ 音效 sfx/tone_left
t+ 25.9s ▶ 语音:「对了。」
t+ 28.0s ▶ 语音:「向右滑,是这个声音。」
t+ 30.1s ♪ 音效 sfx/tone_right
t+ 31.0s ▶ 语音:「你试试。」
t+ 33.2s 【状态】教学第 4/6 个音色 教学:该做「右滑」了(无 BGM)
t+ 33.2s ♪ 音效 sfx/tone_right
t+ 33.2s ▶ 语音:「对了。」
t+ 35.3s ▶ 语音:「单击,是这个声音。」
t+ 37.4s ♪ 音效 sfx/tone_tap
t+ 38.3s ▶ 语音:「你试试。」
t+ 40.4s 【状态】教学第 5/6 个音色 教学:该做「单击」了(无 BGM)
t+ 40.4s ♪ 音效 sfx/tone_tap
t+ 40.4s ▶ 语音:「对了。」
t+ 42.5s ▶ 语音:「双击,是这个声音。」
t+ 44.7s ♪ 音效 sfx/tone_doubletap
t+ 45.6s ▶ 语音:「你试试。」
t+ 47.7s 【状态】教学第 6/6 个音色 教学:该做「双击」了(无 BGM)
t+ 47.7s ♪ 音效 sfx/tone_doubletap
t+ 47.7s ▶ 语音:「对了。」
t+ 49.8s ▶ 语音:「六种声音都记住了。后面的序列,会越来越长。」
t+ 51.9s ▶ 语音:「答错可以消耗机会重听。双击,可以随时重听。」
t+ 54.0s ▶ 语音:「第一轮,听好了。」
t+ 56.2s ♪ 音效 sfx/tone_up
t+ 56.9s ♪ 音效 sfx/tone_left
t+ 58.0s ♪ 向导音:turn(轮到你)
t+ 58.0s ▶ 语音:「轮到你了。」
t+ 70.1s ♪ 向导音:nudge(看门狗轻提醒)
t+ 70.1s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 85.1s ♪ 向导音:nudge(看门狗轻提醒)
t+ 85.1s ♪ 向导音:watchdog(看门狗升级)
t+ 85.1s ♪ 音效 sfx/tone_up
t+ 85.8s ♪ 音效 sfx/tone_left
t+ 86.9s ♪ 向导音:turn(轮到你)
t+ 86.9s ▶ 语音:「轮到你了。」
t+ 99.0s ♪ 向导音:nudge(看门狗轻提醒)
t+ 99.0s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 114.0s ♪ 向导音:nudge(看门狗轻提醒)
t+ 114.0s ♪ 向导音:watchdog(看门狗升级)
t+ 114.0s ♪ 音效 sfx/tone_up
t+ 114.7s ♪ 音效 sfx/tone_left
t+ 115.8s ♪ 向导音:turn(轮到你)
t+ 115.8s ▶ 语音:「轮到你了。」
t+ 127.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 127.9s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 142.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 142.9s ♪ 向导音:watchdog(看门狗升级)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:confirm(轻点确认)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:confirm(轻点确认)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:beat(节拍点)
t+ 142.9s 〰 震动:confirm(轻点确认)
t+ 142.9s 【状态】轮到我复现了 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(无 BGM)
t+ 142.9s ♪ 音效 sfx/tone_up
t+ 142.9s 【状态】轮到我复现了 第 1 轮轮到你了:已对上 1/2,照原顺序做手势(无 BGM)
t+ 142.9s ♪ 音效 sfx/tone_left
t+ 142.9s 〰 震动:success(成功长震)
t+ 143.2s ▶ 语音:「第二轮,听好了。」
t+ 145.3s ♪ 音效 sfx/tone_up
t+ 146.0s ♪ 音效 sfx/tone_left
t+ 146.7s ♪ 音效 sfx/tone_up
t+ 147.8s ♪ 向导音:turn(轮到你)
t+ 157.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 157.8s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 172.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 172.8s ♪ 向导音:watchdog(看门狗升级)
t+ 172.8s 〰 震动:beat(节拍点)
t+ 172.8s 〰 震动:beat(节拍点)
t+ 172.8s 〰 震动:beat(节拍点)
t+ 172.8s 〰 震动:confirm(轻点确认)
t+ 172.8s 【状态】序列有点长,先僵一会儿 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(无 BGM)
t+ 172.8s ……… 静默 11s(想不起来序列了) ………
t+ 172.8s 【状态】听到提示:需要的话,双击可以重新听一遍序列 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(无 BGM)
t+ 172.8s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(无 BGM)
t+ 172.8s ♪ 音效 sfx/tone_up
#### 【玩家动作】这一下故意点错,听听游戏怎么说
t+ 172.8s ♪ 音效 sfx/tone_right
t+ 172.8s ▶ 语音:「错了。用一次机会,再听一遍。」
t+ 172.8s 〰 震动:error(做错了:两连短震)
t+ 174.9s ▶ 语音:「重听机会用完了,小心。」
t+ 177.0s ♪ 音效 sfx/tone_up
t+ 177.7s ♪ 音效 sfx/tone_left
t+ 178.3s ♪ 音效 sfx/tone_up
t+ 179.4s ♪ 向导音:turn(轮到你)
t+ 189.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 189.4s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 204.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 204.4s ♪ 向导音:watchdog(看门狗升级)
t+ 204.4s 〰 震动:warning(警告三连震)
t+ 204.4s 〰 震动:beat(节拍点)
t+ 204.4s 〰 震动:beat(节拍点)
t+ 204.4s 〰 震动:beat(节拍点)
t+ 204.4s 〰 震动:confirm(轻点确认)
t+ 204.4s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(无 BGM)
t+ 204.4s ♪ 音效 sfx/tone_up
t+ 204.4s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 1/3,照原顺序做手势(无 BGM)
t+ 204.4s ♪ 音效 sfx/tone_left
t+ 204.4s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 2/3,照原顺序做手势(无 BGM)
t+ 204.4s ♪ 音效 sfx/tone_up
t+ 204.4s 〰 震动:success(成功长震)
t+ 204.7s ▶ 语音:「第三轮,听好了。」
t+ 206.8s ♪ 音效 sfx/tone_up
t+ 207.5s ♪ 音效 sfx/tone_left
t+ 208.1s ♪ 音效 sfx/tone_up
t+ 208.8s ♪ 音效 sfx/tone_left
t+ 209.8s ♪ 向导音:turn(轮到你)
t+ 219.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 219.8s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 234.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 234.8s ♪ 向导音:watchdog(看门狗升级)
t+ 234.8s 〰 震动:beat(节拍点)
t+ 234.8s 〰 震动:beat(节拍点)
t+ 234.8s 〰 震动:beat(节拍点)
t+ 234.8s 〰 震动:beat(节拍点)
t+ 234.8s 〰 震动:confirm(轻点确认)
t+ 234.8s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 0/4,照原顺序做手势(无 BGM)
t+ 234.8s ♪ 音效 sfx/tone_up
t+ 234.8s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 1/4,照原顺序做手势(无 BGM)
t+ 234.8s ♪ 音效 sfx/tone_left
t+ 234.8s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 2/4,照原顺序做手势(无 BGM)
t+ 234.8s ♪ 音效 sfx/tone_up
t+ 234.8s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 3/4,照原顺序做手势(无 BGM)
t+ 234.8s ♪ 音效 sfx/tone_left
t+ 234.8s 〰 震动:success(成功长震)
t+ 235.1s ▶ 语音:「第四轮,听好了。」
t+ 237.3s ♪ 音效 sfx/tone_up
t+ 237.9s ♪ 音效 sfx/tone_left
t+ 238.5s ♪ 音效 sfx/tone_up
t+ 239.1s ♪ 音效 sfx/tone_left
t+ 239.7s ♪ 音效 sfx/tone_down
t+ 240.8s ♪ 向导音:turn(轮到你)
t+ 250.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 250.8s ▶ 向导语音:「需要的话,双击可以重新听一遍序列。」
t+ 265.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 265.8s ♪ 向导音:watchdog(看门狗升级)
t+ 265.8s 〰 震动:beat(节拍点)
t+ 265.8s 〰 震动:beat(节拍点)
t+ 265.8s 〰 震动:beat(节拍点)
t+ 265.8s 〰 震动:beat(节拍点)
t+ 265.8s 〰 震动:beat(节拍点)
t+ 265.8s 〰 震动:confirm(轻点确认)
t+ 265.8s 【状态】收工:完成了 3 轮 第 4 轮轮到你了:已对上 0/5,照原顺序做手势(无 BGM)
+127
View File
@@ -0,0 +1,127 @@
# 试玩转写:《space》
> 本文件是「玩家感知日志」:一位新手玩家的整局体验转写。语音列出了玩家听到的每句话全文,
> 音效/BGM/震动按发生时间列出。「静默」段 = 玩家什么也听不到。手势是剧本注入的(模拟真人手部动作),
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.0s ▶ 语音:「欢迎登上盲航。你只有一双耳朵。」
t+ 2.1s ▶ 语音:「单击可跳过旁白。」
t+ 4.2s ▶ 语音:「陨石来了,会有声音告诉你方位。」
t+ 6.4s ▶ 语音:「听,左边有陨石。向左滑,把它拨开!」
t+ 8.5s ♪ 音效 sfx/whoosh_left
t+ 8.5s 【状态】演练 1/4:左侧有陨石 演练:左边有陨石在逼近,左边滑把它拨开(无 BGM)
#### 【玩家动作】逼近音一响,同向滑开
t+ 8.5s ♪ 音效 sfx/dodge
t+ 8.5s ▶ 语音:「漂亮。」
t+ 10.6s ▶ 语音:「这次在右边。向右滑!」
t+ 12.7s ♪ 音效 sfx/whoosh_right
t+ 12.7s 【状态】演练 2/4:右侧有陨石 演练:右边有陨石在逼近,右边滑把它拨开(无 BGM)
#### 【玩家动作】这颗犹豫了,没拨开——听当场重教
t+ 17.2s ▶ 语音:「那是右边来的,向右滑。」
t+ 17.2s 〰 震动:error(做错了:两连短震)
t+ 19.3s ♪ 音效 sfx/whoosh_right
#### 【玩家动作】重教听完,这回马上同向滑
t+ 19.3s ♪ 音效 sfx/dodge
t+ 19.3s ▶ 语音:「上面有东西,声音又尖又高。向上滑!」
t+ 21.5s ♪ 音效 sfx/whoosh_up
t+ 21.5s 【状态】演练 3/4:上侧有陨石 演练:上面有陨石在逼近,上面滑把它拨开(无 BGM)
#### 【玩家动作】逼近音一响,同向滑开
t+ 21.5s ♪ 音效 sfx/dodge
t+ 21.5s ▶ 语音:「漂亮。」
t+ 23.6s ▶ 语音:「下面是低沉的轰隆。向下滑!」
t+ 25.7s ♪ 音效 sfx/whoosh_down
t+ 25.7s 【状态】演练 4/4:下侧有陨石 演练:下面有陨石在逼近,下面滑把它拨开(无 BGM)
#### 【玩家动作】逼近音一响,同向滑开
t+ 25.7s ♪ 音效 sfx/dodge
t+ 25.7s ▶ 语音:「四个方向你都会了。引擎点火,出发!」
t+ 25.7s 【状态】演练做完了,要出发了 演练旁白在播,先听(语音播放中)
t+ 27.8s ♫ BGM 切换 → bgm/engine
t+ 27.8s ♪ 向导音:battle(战斗/对抗)
t+ 27.8s 【状态】出发。引擎声起来了,保护期里先练手 对局中:引擎声平稳,等下一颗(保护期)(BGM=bgm/engine)
t+ 29.3s ♪ 音效 sfx/whoosh_right
t+ 32.3s ♪ 音效 sfx/alarm
t+ 32.3s 〰 震动:danger(危险逼近)
t+ 32.8s ♪ 音效 sfx/alarm
t+ 33.3s ♪ 音效 sfx/dodge
t+ 33.3s ♪ 音效 sfx/whoosh_up
t+ 36.3s ♪ 音效 sfx/alarm
t+ 36.3s 〰 震动:danger(危险逼近)
t+ 36.3s 〰 震动:danger(危险逼近)
t+ 36.8s ♪ 音效 sfx/alarm
t+ 37.3s ♪ 音效 sfx/dodge
t+ 37.3s ♪ 音效 sfx/whoosh_left
t+ 40.3s ♪ 音效 sfx/alarm
t+ 40.3s 〰 震动:danger(危险逼近)
t+ 40.3s 〰 震动:danger(危险逼近)
t+ 40.8s ♪ 音效 sfx/alarm
t+ 41.3s ♪ 音效 sfx/dodge
t+ 41.3s ♪ 音效 sfx/whoosh_right
t+ 44.3s ♪ 音效 sfx/alarm
t+ 44.3s 〰 震动:danger(危险逼近)
t+ 44.3s 〰 震动:danger(危险逼近)
t+ 44.8s ♪ 音效 sfx/alarm
t+ 45.3s ♪ 音效 sfx/dodge
t+ 45.3s ♪ 音效 sfx/whoosh_down
t+ 47.8s ▶ 语音:「试试单击,引擎会短促加速,直接冲过空隙。」
t+ 58.3s ♪ 音效 sfx/whoosh_left
t+ 61.3s ♪ 音效 sfx/alarm
t+ 61.3s 〰 震动:danger(危险逼近)
t+ 61.3s 〰 震动:danger(危险逼近)
t+ 61.8s ♪ 音效 sfx/alarm
t+ 62.3s ♪ 音效 sfx/dodge
t+ 62.3s ♪ 音效 sfx/whoosh_down
t+ 65.3s ♪ 音效 sfx/alarm
t+ 65.3s 〰 震动:danger(危险逼近)
t+ 65.3s 〰 震动:danger(危险逼近)
t+ 65.8s ♪ 音效 sfx/alarm
t+ 66.3s ♪ 音效 sfx/dodge
t+ 66.3s ♪ 音效 sfx/whoosh_down
t+ 69.3s ♪ 音效 sfx/alarm
t+ 69.3s 〰 震动:danger(危险逼近)
t+ 69.3s 〰 震动:danger(危险逼近)
t+ 69.8s ♪ 音效 sfx/alarm
t+ 70.3s ♪ 音效 sfx/dodge
t+ 70.3s ♪ 音效 sfx/whoosh_right
t+ 72.8s ▶ 语音:「双击展开护盾,能挡下一次撞击。」
t+ 83.3s ♪ 音效 sfx/whoosh_left
t+ 86.3s ♪ 音效 sfx/alarm
t+ 86.3s 〰 震动:danger(危险逼近)
t+ 86.3s 〰 震动:danger(危险逼近)
t+ 86.8s ♪ 音效 sfx/alarm
t+ 87.3s ♪ 音效 sfx/dodge
t+ 87.3s ♪ 音效 sfx/whoosh_left
t+ 87.8s ▶ 语音:「训练护盾关闭。接下来,靠你自己了。」
t+ 90.3s ♪ 音效 sfx/alarm
t+ 90.3s 〰 震动:danger(危险逼近)
t+ 90.3s 〰 震动:warning(警告三连震)
t+ 90.3s 〰 震动:danger(危险逼近)
t+ 90.3s 【状态】保护期结束了,这颗我故意没动 对局中:1 颗陨石在逼近,听方位同向滑开(BGM=bgm/engine)
t+ 90.8s ♪ 音效 sfx/alarm
t+ 91.3s ♪ 音效 sfx/hit
t+ 91.3s ▶ 语音:「左边撞上的。听到左边的呼啸,向左滑。」
t+ 91.3s ▶ 向导语音:「被撞了。听方位,同向滑动就能拨开。」
t+ 91.3s 〰 震动:danger(危险逼近)
t+ 91.3s 〰 震动:hit(受击重震)
#### 【玩家动作】被撞了。先听到方位归因,又听到「同向滑动就能拨开」
t+ 91.3s ♪ 音效 sfx/whoosh_down
t+ 93.1s ♪ 音效 sfx/alarm
t+ 93.1s 〰 震动:danger(危险逼近)
t+ 93.6s ♪ 音效 sfx/alarm
t+ 94.1s ♪ 音效 sfx/dodge
t+ 94.5s ♪ 音效 sfx/whoosh_down
t+ 96.2s ♪ 音效 sfx/alarm
t+ 96.2s 〰 震动:danger(危险逼近)
t+ 96.2s 〰 震动:danger(危险逼近)
t+ 96.2s 【状态】躲过保护期、挨过一次撞,心跳有点快,先到这 对局中:1 颗陨石在逼近,听方位同向滑开(BGM=bgm/engine)
+194 -94
View File
@@ -5,99 +5,199 @@
> 非最优操作:包含犹豫、误操作与乱试。语音时长按语速估算(±20%),仅供节奏参考。
t+ 0.0s (玩家点开游戏,轻触屏幕开始)
t+ 0.0s ♫ BGM 切换 → bgm/dark_drone
t+ 0.0s ♪ 向导音:explore(探索/安全)
t+ 0.0s ▶ 语音:「大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。」
t+ 2.1s ▶ 语音:「单击可跳过旁白。」
t+ 4.2s 【状态】等待中(BGM=bgm/dark_drone)
t+ 4.2s ♪ 音效 sfx/heartbeat
t+ 4.2s ▶ 语音:「后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。」
t+ 6.4s 【状态】等待中(BGM=bgm/dark_drone)
t+ 6.4s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 6.4s ⚠ 资产缺失 voice/female/n02_linwan.ogg → 玩家此刻听到的是静默
t+ 6.4s ⚠ 资产缺失 voice/female/n02_linwan.m4a → 玩家此刻听到的是静默
t+ 6.4s ⚠ 资产缺失 voice/female/n02_linwan.mp3 → 玩家此刻听到的是静默
t+ 6.4s ⚠ 资产缺失 voice/female/n02_linwan.wav → 玩家此刻听到的是静默
t+ 8.5s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 10.6s 【状态】等待中(BGM=bgm/dark_drone)
t+ 10.6s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 12.7s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 14.8s 【状态】等待中(BGM=bgm/dark_drone)
t+ 14.8s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 17.0s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 19.1s 【状态】等待中(BGM=bgm/dark_drone)
t+ 19.1s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 21.2s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 23.3s 【状态】等待中(BGM=bgm/dark_drone)
t+ 23.3s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 25.4s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 27.6s 【状态】等待中(BGM=bgm/dark_drone)
t+ 27.6s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 29.7s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 31.8s 【状态】等待中(BGM=bgm/dark_drone)
t+ 31.8s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 33.9s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 36.0s 【状态】等待中(BGM=bgm/dark_drone)
t+ 36.0s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 38.2s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 40.3s 【状态】等待中(BGM=bgm/dark_drone)
t+ 40.3s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 42.4s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 44.5s 【状态】等待中(BGM=bgm/dark_drone)
t+ 44.5s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 46.6s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 48.8s 【状态】等待中(BGM=bgm/dark_drone)
t+ 48.8s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 50.9s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 53.0s 【状态】等待中(BGM=bgm/dark_drone)
t+ 53.0s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 55.1s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 57.2s 【状态】等待中(BGM=bgm/dark_drone)
t+ 57.2s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 59.4s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 61.5s 【状态】等待中(BGM=bgm/dark_drone)
t+ 61.5s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 63.6s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 65.7s 【状态】等待中(BGM=bgm/dark_drone)
t+ 65.7s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 67.8s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 70.0s 【状态】等待中(BGM=bgm/dark_drone)
t+ 70.0s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 72.1s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 74.2s 【状态】等待中(BGM=bgm/dark_drone)
t+ 74.2s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 76.3s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 78.4s 【状态】等待中(BGM=bgm/dark_drone)
t+ 78.4s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 80.6s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 82.7s 【状态】等待中(BGM=bgm/dark_drone)
t+ 82.7s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 84.8s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 86.9s 【状态】等待中(BGM=bgm/dark_drone)
t+ 86.9s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 89.0s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 91.2s 【状态】等待中(BGM=bgm/dark_drone)
t+ 91.2s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 93.3s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 95.4s 【状态】等待中(BGM=bgm/dark_drone)
t+ 95.4s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 97.5s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 99.6s 【状态】等待中(BGM=bgm/dark_drone)
t+ 99.6s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 101.8s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 103.9s 【状态】等待中(BGM=bgm/dark_drone)
t+ 103.9s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 106.0s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 108.1s 【状态】等待中(BGM=bgm/dark_drone)
t+ 108.1s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 110.2s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 112.4s 【状态】等待中(BGM=bgm/dark_drone)
t+ 112.4s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 114.5s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 116.6s 【状态】等待中(BGM=bgm/dark_drone)
t+ 116.6s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 118.7s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 120.8s 【状态】等待中(BGM=bgm/dark_drone)
t+ 120.8s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 123.0s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 125.1s 【状态】等待中(BGM=bgm/dark_drone)
t+ 125.1s ▶ 语音 voice/n02_linwan(无台词表文本)
t+ 127.2s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 12.2s ♪ 向导音:nudge(看门狗轻提醒)
t+ 12.2s ▶ 向导语音:「轻触屏幕,继续。」
t+ 24.2s ♪ 向导音:nudge(看门狗轻提醒)
t+ 24.2s ♪ 向导音:watchdog(看门狗升级)
t+ 24.2s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 26.4s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 28.5s ▶ 语音:「大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。」
t+ 30.6s 【状态】开场旁白播完了,屏幕黑着,有点不知所措 剧情等着你(节点 n00):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 30.6s ……… 静默 8s(新手没反应过来) ………
t+ 30.6s 【状态】听到一声轻响和「轻触屏幕,继续」,试着点一下 剧情等着你(节点 n00):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 30.6s ♪ 音效 sfx/heartbeat
t+ 30.6s ▶ 语音:「后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。」
t+ 40.7s ♪ 向导音:nudge(看门狗轻提醒)
t+ 40.7s ▶ 向导语音:「轻触屏幕,继续。」
t+ 52.7s ♪ 向导音:nudge(看门狗轻提醒)
t+ 52.7s ♪ 向导音:watchdog(看门狗升级)
t+ 52.7s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 54.8s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 57.0s ♪ 音效 sfx/heartbeat
t+ 57.0s ▶ 语音:「后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。」
t+ 59.1s 【状态】剧情停下来了,在等我 剧情等着你(节点 n01):单击继续,双击重听(BGM=bgm/dark_drone)
#### 【玩家动作】试着左滑了一下——这里用不上,向导纠正了我
t+ 59.1s ▶ 向导语音:「这个操作在这里用不上。」
t+ 59.1s 〰 震动:error(做错了:两连短震)
t+ 61.2s ▶ 语音:「咳……咳咳……我还活着。晨晨,你在哪儿……你说过,会在北区等我。姐一定,一定找到你。」
t+ 71.1s ▶ 语音:「请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。」
t+ 73.2s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n02):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 73.2s ♪ 音效 sfx/ui_confirm
t+ 73.2s ♪ 音效 sfx/radio_static
t+ 73.2s ▶ 语音:「收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……」
t+ 81.0s ▶ 语音:「姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。」
t+ 106.7s ♪ 向导音:nudge(看门狗轻提醒)
t+ 106.7s ▶ 向导语音:「轻触屏幕,继续。」
t+ 118.7s ♪ 向导音:nudge(看门狗轻提醒)
t+ 118.7s ♪ 向导音:watchdog(看门狗升级)
t+ 118.7s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 120.8s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 122.9s ♪ 音效 sfx/radio_static
t+ 122.9s ▶ 语音:「收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……」
t+ 130.8s ▶ 语音:「姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。」
t+ 148.4s 【状态】剧情停下来了,在等我 剧情等着你(节点 n03):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 148.4s ……… 静默 8s(走神想剧情去了) ………
t+ 148.4s 【状态】又听到那声轻响,想起该点屏幕了 剧情等着你(节点 n03):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 148.4s ♪ 音效 sfx/radio_static
t+ 148.4s ▶ 语音:「就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:」
t+ 150.5s ▶ 语音:「北区避难所,仍在接收幸存者。重复,北区避难所,仍在接收幸存者。坐标,三、七点、五。请沿广播塔红色信号灯方向前进。」
t+ 171.5s ♪ 向导音:nudge(看门狗轻提醒)
t+ 171.5s ▶ 向导语音:「轻触屏幕,继续。」
t+ 183.5s ♪ 向导音:nudge(看门狗轻提醒)
t+ 183.5s ♪ 向导音:watchdog(看门狗升级)
t+ 183.5s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 185.6s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 187.7s ♪ 音效 sfx/radio_static
t+ 187.7s ▶ 语音:「就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:」
t+ 189.8s ▶ 语音:「北区避难所,仍在接收幸存者。重复,北区避难所,仍在接收幸存者。坐标,三、七点、五。请沿广播塔红色信号灯方向前进。」
t+ 202.8s 【状态】剧情停下来了,在等我 剧情等着你(节点 n05):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 202.8s ♪ 音效 sfx/whistle
t+ 202.8s ▶ 语音:「你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。」
t+ 212.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 212.9s ▶ 向导语音:「轻触屏幕,继续。」
t+ 224.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 224.9s ♪ 向导音:watchdog(看门狗升级)
t+ 224.9s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 227.0s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 229.2s ♪ 音效 sfx/whistle
t+ 229.2s ▶ 语音:「你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。」
t+ 231.3s 【状态】剧情停下来了,在等我 剧情等着你(节点 n06):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 231.3s ▶ 语音:「哟,瞧瞧,有肥羊自己送上门了。别跑啊。这片儿的路,都是我猎狗修的。想过去?行啊,把包留下,把你值钱的玩意儿留下。人嘛……也留下。」
t+ 246.0s ▶ 语音:「请选择。向左滑:钻进旁边的通风管道。向右滑:躺进路边的尸体堆里装死。」
t+ 248.1s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n07):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 248.1s ♪ 音效 sfx/ui_confirm
t+ 248.1s ♪ 音效 sfx/rubble
t+ 248.1s ▶ 语音:「你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。」
t+ 258.3s ♪ 向导音:nudge(看门狗轻提醒)
t+ 258.3s ▶ 向导语音:「轻触屏幕,继续。」
t+ 270.3s ♪ 向导音:nudge(看门狗轻提醒)
t+ 270.3s ♪ 向导音:watchdog(看门狗升级)
t+ 270.3s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 272.4s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 274.5s ♪ 音效 sfx/rubble
t+ 274.5s ▶ 语音:「你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。」
t+ 276.6s 【状态】剧情停下来了,在等我 剧情等着你(节点 n08):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 276.6s ……… 静默 8s(走神想剧情去了) ………
t+ 276.6s 【状态】又听到那声轻响,想起该点屏幕了 剧情等着你(节点 n08):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 276.6s ♪ 音效 sfx/wind
t+ 276.6s ▶ 语音:「酸雨开始下了。街面上升起细密的嘶嘶声,像无数条小蛇在游。雨幕那头,隐约传来一阵哭声——细细的,是个孩子。」
t+ 278.7s ▶ 语音:「请选择。向左滑:循着哭声过去看看。向右滑:不去管它,直接去北区。」
t+ 280.9s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n10):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 280.9s ♪ 音效 sfx/ui_confirm
t+ 280.9s ▶ 语音:「哭声来自一间塌了一半的便利店。一个小男孩抱着膝盖坐在货架底下,身边躺着一个再也不会醒来的女人。他抬起头,脸上全是灰,小声说:“我叫小满。妈妈说,数到一千,她就会醒。我已经数到……数到好多个一千了。”」
t+ 283.0s ▶ 语音:「请选择。向左滑:带上小满一起走。向右滑:留下食物,独自上路。」
t+ 285.1s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n11):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 285.1s ♪ 音效 sfx/ui_confirm
t+ 285.1s ▶ 语音:「小满,别怕。数到一千太慢了。跟姐姐走,姐姐带你去找一座会发光的塔。抓紧我的手,别松开。」
t+ 303.2s ♪ 向导音:nudge(看门狗轻提醒)
t+ 303.2s ▶ 向导语音:「轻触屏幕,继续。」
t+ 315.2s ♪ 向导音:nudge(看门狗轻提醒)
t+ 315.2s ♪ 向导音:watchdog(看门狗升级)
t+ 315.2s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 317.3s ▶ 语音:「当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。」
t+ 319.4s ▶ 语音:「小满,别怕。数到一千太慢了。跟姐姐走,姐姐带你去找一座会发光的塔。抓紧我的手,别松开。」
t+ 329.5s 【状态】剧情停下来了,在等我 剧情等着你(节点 n12):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 329.5s ♪ 音效 sfx/wind
t+ 329.5s ▶ 语音:「两个小时后,江水拦住了去路。跨江大桥断成两截,桥身斜插进黑沉沉的江面。要去广播塔,只剩两条路:桥下的污水涵洞,黑得看不见底;或者,桥上悬着的钢索,在风里荡来荡去。」
t+ 331.6s ▶ 语音:「请选择。向左滑:走桥下的污水涵洞。向右滑:爬桥上悬着的钢索。」
t+ 333.7s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n15):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 333.7s ♪ 音效 sfx/ui_confirm
t+ 333.7s ♪ 音效 sfx/water
t+ 333.7s ▶ 语音:「你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。」
t+ 343.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 343.8s ▶ 向导语音:「轻触屏幕,继续。」
t+ 355.8s ♪ 向导音:nudge(看门狗轻提醒)
t+ 355.8s ♪ 向导音:watchdog(看门狗升级)
t+ 355.8s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 358.0s ▶ 语音:「当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。」
t+ 360.1s ♪ 音效 sfx/water
t+ 360.1s ▶ 语音:「你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。」
t+ 362.2s 【状态】剧情停下来了,在等我 剧情等着你(节点 n16b):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 362.2s ♪ 音效 sfx/footsteps
t+ 362.2s ▶ 语音:「啧啧啧。涵洞也拦不住你,钢索也拦不住你,命真大啊。正式认识一下——猎狗。北区广播塔底下这片,归我管。把你妹妹的坐标,还有包里剩下的东西,交出来。交出来,我让你死得体面点。」
t+ 381.6s ▶ 语音:「请选择。向左滑:交出坐标,保命要紧。向右滑:拒绝他,想办法周旋。」
t+ 383.7s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n18):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 383.7s ♪ 音效 sfx/ui_confirm
t+ 383.7s ▶ 语音:「好,好,我说。北区,避难所,坐标是……三、七点、五。拿去,都拿去。求你……放我走。」
t+ 401.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 401.4s ▶ 向导语音:「轻触屏幕,继续。」
t+ 413.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 413.4s ♪ 向导音:watchdog(看门狗升级)
t+ 413.4s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 415.5s ▶ 语音:「当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。」
t+ 417.6s ▶ 语音:「好,好,我说。北区,避难所,坐标是……三、七点、五。拿去,都拿去。求你……放我走。」
t+ 427.2s 【状态】剧情停下来了,在等我 剧情等着你(节点 n19):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 427.2s ▶ 语音:「猎狗的车队朝北区开走了,探照灯在雨幕里划出一道道口子。你蹲在原地,胃里一阵一阵地泛酸。晨晨在北区。而刚才,是你,亲手把狼,引到了她的门口。」
t+ 429.4s ▶ 语音:「请选择。向左滑:追上去,警告避难所。向右滑:自己逃命,越远越好。」
t+ 431.5s 【状态】抉择点,提示音念完了 「请选择」念完了(节点 n20):左滑或右滑,做出你的决定(BGM=bgm/dark_drone)
#### 【玩家动作】选了左边
t+ 431.5s ♪ 音效 sfx/ui_confirm
t+ 431.5s ♪ 音效 sfx/footsteps
t+ 431.5s ▶ 语音:「再快一点……再快一点!晨晨,等我。这一次,姐说什么,也不会再把你弄丢了!」
t+ 448.0s ♪ 向导音:nudge(看门狗轻提醒)
t+ 448.0s ▶ 向导语音:「轻触屏幕,继续。」
t+ 460.0s ♪ 向导音:nudge(看门狗轻提醒)
t+ 460.0s ♪ 向导音:watchdog(看门狗升级)
t+ 460.0s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 462.1s ▶ 语音:「当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。」
t+ 464.2s ♪ 音效 sfx/footsteps
t+ 464.2s ▶ 语音:「再快一点……再快一点!晨晨,等我。这一次,姐说什么,也不会再把你弄丢了!」
t+ 472.8s 【状态】剧情停下来了,在等我 剧情等着你(节点 n21):单击继续,双击重听(BGM=bgm/dark_drone)
t+ 472.8s ▶ 语音:「你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。」
t+ 482.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 482.9s ▶ 向导语音:「轻触屏幕,继续。」
t+ 494.9s ♪ 向导音:nudge(看门狗轻提醒)
t+ 494.9s ♪ 向导音:watchdog(看门狗升级)
t+ 494.9s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 497.0s ▶ 语音:「当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。」
t+ 499.1s ▶ 语音:「你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。」
t+ 501.2s 【状态】结局 结局播完了。单击屏幕,可以回到故事开始的地方(BGM=bgm/dark_drone)
#### 【玩家动作】听完结局,单击重开一次看看别的走向
t+ 501.2s ♪ 音效 sfx/ui_confirm
t+ 501.2s ♪ 向导音:explore(探索/安全)
t+ 501.2s ▶ 语音:「大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。」
t+ 511.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 511.4s ▶ 向导语音:「轻触屏幕,继续。」
t+ 523.4s ♪ 向导音:nudge(看门狗轻提醒)
t+ 523.4s ♪ 向导音:watchdog(看门狗升级)
t+ 523.4s ▶ 语音:「操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。」
t+ 525.5s ▶ 语音:「当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。」
t+ 527.6s ▶ 语音:「大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。」
t+ 529.7s 【状态】回到开头,会话结束 剧情等着你(节点 n00):单击继续,双击重听(BGM=bgm/dark_drone)
+5
View File
@@ -24,6 +24,11 @@ for game, lines in gvd.GAMES.items():
if fid in table:
raise SystemExit(f"重复 id:{game}/{fid}")
table[fid] = text
# NPC 单音色平铺(gen_voice_dual.NPCS:id, speaker, instruct, 台词)
for fid, _sp, _ins, text in gvd.NPCS.get(game, []):
if fid in table:
raise SystemExit(f"重复 id:{game}/{fid}")
table[fid] = text
out[game] = table
+18
View File
@@ -110,6 +110,9 @@ STORYLINE_LINES = [
("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"),
("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"),
("skip_hint", _SL_NARR, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_continue", _SL_NARR, "轻触屏幕,继续。"),
("gd_invalid", _SL_NARR, "这个操作在这里用不上。"),
]
@@ -266,6 +269,8 @@ SIMON_LINES = [
("menu_home", _SIMON, "回大厅。"),
("menu_back", _SIMON, "返回游戏。"),
("skip_hint", _SIMON, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_relisten", _SIMON, "需要的话,双击可以重新听一遍序列。"),
]
@@ -312,6 +317,9 @@ RHYTHM_LINES = [
("menu_resume", _RHY, "继续演奏。"),
("menu_lobby", _RHY, "回大厅。"),
("skip_hint", _RHY, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_answer", _RHY, "现在跟着我打。"),
("gd_miss", _RHY, "慢了半拍,下一小节跟上我就行。"),
]
@@ -380,6 +388,8 @@ SPACE_LINES = [
("settings_hint", _SPC, "音色、语速、背景音乐,请回大厅修改。"),
("paused", _SPC, "已暂停。"),
("skip_hint", _SPC, "单击可跳过旁白。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_hit_hint", _SPC, "被撞了。听方位,同向滑动就能拨开。"),
]
@@ -512,6 +522,14 @@ RPG_LINES = [
("tut_battle_3", _RPG, "敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。"),
("tut_battle_4", _RPG, "没把握就防御,伤害减半。金疮药可以回血。"),
("tut_battle_5", _RPG, "记住:听清前摇,双击格挡。开始吧!"),
# ---- R5 补录(11 文档新增台词,资产已先行落盘;staging 无 wav,convert 跳过)----
("tut_b0", _RPG, "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。"),
("tut_b6", _RPG, "来不及格挡,就选防御。左右滑,选防御,单击。"),
("btl_flee_fail", _RPG, "没甩掉!它追上来了。"),
("boss1_warn", _RPG, "里面就是黑风寨主。你现在回去练练再来,也来得及。"),
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
("gd_parry_miss", _RPG, "前摇声起的时候双击,才能格挡。"),
("gd_stall", _RPG, "想好了吗?左右滑动选择,单击出手。"),
]
# NPC 单音色平铺(id, speaker, instruct, 台词)→ web/rpg/audio/voice/<id>.*
+24 -1
View File
@@ -46,7 +46,8 @@
*
* **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件
* 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动。
* ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动;
* 演奏类阶段可 setRateBoostEnabled(false) 按 game 豁免(见该方法)。
*/
import { settings } from './settings.js';
@@ -90,6 +91,7 @@ export class WebAudioEngine {
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice)
this._voiceQueue = []; // queue:true 时的等待队列
this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制)
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
@@ -249,6 +251,26 @@ export class WebAudioEngine {
this.stopVoice();
}
/**
* 当前是否有对白在播(含 queue 排队中的不算,只看正在播的这句)。
* 收编各游戏对私有字段 _voice 的只读判定(如"单击是否跳句"的语境判断)。
* @returns {boolean}
*/
isVoicePlaying() {
return this._voice !== null;
}
/**
* 长按快进(旁白临时 1.7 倍速)的 per-game 开关,默认开启(保持全局行为)。
* rhythm/space 等演奏/对局阶段传入 false 豁免——长按不再触发快进;
* 若快进正在生效会立即恢复设置语速。回菜单/结算时传 true 恢复。
* @param {boolean} on
*/
setRateBoostEnabled(on) {
this._rateBoostEnabled = on !== false;
if (!this._rateBoostEnabled) this._voiceBoost(false); // 已在快进则立即还原
}
// ---------------- 内部实现 ----------------
/**
@@ -449,6 +471,7 @@ export class WebAudioEngine {
_voiceBoost(on) {
const cur = this._voice;
if (!cur || cur.stopped) return;
if (on && !this._rateBoostEnabled) return; // per-game 豁免(演奏期不快进)
const target = on ? LONGPRESS_BOOST : settings.get().speed;
if (cur.st.tempo === target) return;
this._updateVoicePos(cur);
+292
View File
@@ -0,0 +1,292 @@
/**
* guide.js — 全局向导层(L3 状态签名 + A2 回合标记 + L4 静默看门狗 + A4 纠错)。
*
* 设计立场(research/03-schemes/schemes.md §1):
* - 语音承载语义,earcon 只做粗标记:8 个签名 earcon 全部**运行时 WebAudio 合成**
* (oscillator+gain 包络,代码即生成脚本,零资产、可复现、天然双平台),
* 参数守方法论 L2 硬边界:总长 0.25~0.4s、单音 ≥0.08s、频带 125Hz~5kHz、
* 家族=波形音色、子类=节奏轮廓;总增益 ≈0.12(比 BGM 背景轻但可辨)。
* - 向导默认开启(settings.guide='full'),老手可关:
* full = earcon + 语音 + 震动;standard = earcon + 震动(无语音);off = 全静。
* **off 时全部 API 静默 no-op,包括不挂任何定时器**——保证既有测试/玩法零干扰。
* - 向导绝不打断内容:插播语音一律 queue:true(等当前对白播完,绝不顶掉)。
*
* 游戏侧用法(构造时创建一次):
* import { Guide } from '../engine/guide.js';
* this.guide = new Guide(audio);
* guide.level(); // 'full' | 'standard' | 'off'(实时读 settings.guide)
* guide.state('battle'); // 进入宏观状态时播状态签名 earcon
* guide.turn({ voice, haptic }); // 轮到你了:turn earcon + 可选轻震 + full 档可选语音
* guide.watchdog({ arm: true, // 静默看门狗:到点轻 nudge 提醒 → 升级回调;
* at: [8000, 20000], say: [...], // say[n] 为第 n 级 full 档语音 id(可省);
* onEscalate: (n) => ..., cond }); // 升级回调(游戏播上下文提示);cond()=false 静默取消
* guide.error({ voice, onceKey, // 纠错:error 震(skipHaptic 可免)+ full 档语音
* skipHaptic }); //(onceKey 同局只播一次)
* guide.disarm(); // 手势入口调用:复位看门狗
* guide.setEventHook(fn); // 转写 harness 用:fn({type,name,voiceId})
*
* 状态签名 earcon 词表(6 状态 + 2 功能,全项目统一,见 schemes.md §1.4):
* | 名 | 音色家族 | 节奏轮廓 | 语义 |
* |---------|----------------|----------------|----------------|
* | menu | 柔和正弦双音 | 下行二度 | 系统界面 |
* | explore | 温暖三角波单音 | 单音长尾 | 探索/安全 |
* | battle | 略紧方波双音 | 上行纯五度短促 | 战斗/对抗 |
* | choice | 清亮正弦三音 | 上行琶音 | 抉择/等待输入 |
* | settle | 柔和三音 | 下行三连 | 结算/告一段落 |
* | danger | 低频双音 | 急促重复 | 危险 |
* | turn | 风铃质感单音 | 单音短 | 轮到你了 |
* | nudge | 极轻木鱼单音 | 单音极短 | 看门狗轻提醒 |
*/
import { settings } from './settings.js';
import { vibrate } from './haptics.js';
/** earcon 总增益峰值(比 BGM 背景轻但可辨)。 */
const BASE_GAIN = 0.12;
/**
* 签名词表:每项 = 一组音符 { t 起始秒, f 频率 Hz, d 时长秒, wave 波形, g 相对增益 }。
* 全部落在 0.25~0.4s / 单音 ≥0.08s / 125Hz~5kHz 边界内;家族=wave,子类=节奏。
*/
const EARCON_SPECS = {
// 柔和正弦双音,下行二度(系统界面)
menu: [
{ t: 0, f: 659.3, d: 0.13, wave: 'sine' },
{ t: 0.15, f: 587.3, d: 0.16, wave: 'sine' },
],
// 温暖三角波单音,单音长尾(探索/安全)
explore: [
{ t: 0, f: 329.6, d: 0.4, wave: 'triangle' },
],
// 略紧方波双音,上行纯五度短促(战斗/对抗;方波刺耳,降相对增益)
battle: [
{ t: 0, f: 220.0, d: 0.11, wave: 'square', g: 0.55 },
{ t: 0.15, f: 330.0, d: 0.12, wave: 'square', g: 0.5 },
],
// 清亮正弦三音,上行琶音(抉择/等待输入)
choice: [
{ t: 0, f: 523.3, d: 0.09, wave: 'sine' },
{ t: 0.105, f: 659.3, d: 0.09, wave: 'sine' },
{ t: 0.21, f: 784.0, d: 0.1, wave: 'sine' },
],
// 柔和三音,下行三连(结算/告一段落)
settle: [
{ t: 0, f: 784.0, d: 0.095, wave: 'sine', g: 0.9 },
{ t: 0.11, f: 659.3, d: 0.095, wave: 'sine', g: 0.85 },
{ t: 0.22, f: 523.3, d: 0.11, wave: 'sine', g: 0.8 },
],
// 低频双音,急促重复(危险)
danger: [
{ t: 0, f: 130.8, d: 0.1, wave: 'triangle' },
{ t: 0.16, f: 130.8, d: 0.12, wave: 'triangle' },
],
// 风铃质感单音,单音短(轮到你了):基音 + 高频泛音给金属光泽
turn: [
{ t: 0, f: 1318.5, d: 0.28, wave: 'sine' },
{ t: 0, f: 2637.0, d: 0.12, wave: 'sine', g: 0.25 },
],
// 极轻木鱼单音,单音极短(看门狗轻提醒)
nudge: [
{ t: 0, f: 880.0, d: 0.08, wave: 'triangle', g: 0.5 },
],
};
export class Guide {
/**
* @param {WebAudioEngine} audio 音频引擎(earcon 接其 master 总线,语音走 play queue)
*/
constructor(audio) {
this.audio = audio;
/** @type {((evt: {type: string, name: string, voiceId: string|null}) => void)|null} */
this._hook = null;
/** @type {Set<string>} onceKey → 同局只播一次 */
this._once = new Set();
/** @type {{at:number[], say:(string|null)[], onEscalate:((n:number)=>void)|null, cond:(()=>boolean)|null, idx:number, timerId:any}|null} */
this._wd = null;
}
/** 当前向导等级(实时读 settings.guide)。 @returns {'full'|'standard'|'off'} */
level() {
return settings.get().guide;
}
/** 注册事件钩子(转写 harness 用):每个动作发 {type,name,voiceId}。 */
setEventHook(fn) {
this._hook = fn;
}
/**
* 复位看门狗(手势入口调用;任何 guide API 调用内部也会复位)。
*/
disarm() {
this._clearWatchdog();
}
// ------------------------------------------------ 对外 API
/**
* L3 状态签名:进入宏观状态时播('menu'|'explore'|'battle'|'choice'|'settle'|'danger')。
* @param {'menu'|'explore'|'battle'|'choice'|'settle'|'danger'} name
*/
state(name) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon(name);
this._emit('state', name, null);
}
/**
* A2 回合标记:earcon(turn)+ 可选轻震 + full 档可选语音(queue,绝不顶掉在播语音)。
* @param {{voice?: string|null, haptic?: boolean}} [opts]
*/
turn(opts = {}) {
if (this.level() === 'off') return;
this._touch();
this._playEarcon('turn');
this._emit('turn', 'turn', null);
if (opts.haptic) vibrate('confirm');
if (opts.voice) this._say('turn', opts.voice);
}
/**
* A4 纠错:error 震(除非 skipHaptic——游戏层已震过时传 true)+ full 档语音。
* @param {{voice?: string|null, onceKey?: string|null, skipHaptic?: boolean}} [opts]
*/
error(opts = {}) {
if (this.level() === 'off') return;
this._touch();
if (!opts.skipHaptic) vibrate('error');
if (!opts.voice) return;
if (opts.onceKey) {
if (this._once.has(opts.onceKey)) return;
if (this.level() === 'full') this._once.add(opts.onceKey); // 只在真的播了才计数
}
if (this.level() !== 'full') return;
this._say('error', opts.voice);
}
/**
* L4 静默看门狗:arm 后按 at 逐级计时,每级播 nudge 轻 earcon(+ say[n-1] 语音,
* full 档),再调 onEscalate(n);级数用尽自动停止。cond() 在触发时刻返回 false
* 则静默取消(游戏已离开该等待态)。任何 guide API 调用 / disarm() / 再次 arm 复位。
* @param {{arm?: boolean, at?: number[], say?: (string|null)[], onEscalate?: (n:number)=>void, cond?: ()=>boolean}} opts
*/
watchdog(opts = {}) {
if (this.level() === 'off') return; // off:绝不注册定时器
if (opts.arm === false) {
this._clearWatchdog();
return;
}
this._clearWatchdog();
this._wd = {
at: [...(opts.at || [])],
say: [...(opts.say || [])],
onEscalate: opts.onEscalate || null,
cond: opts.cond || null,
idx: 0,
timerId: null,
};
this._armNextLevel();
}
// ------------------------------------------------ 内部实现
_quiet() {
return this.level() === 'off';
}
_emit(type, name, voiceId) {
if (!this._hook) return;
try {
this._hook({ type, name, voiceId: voiceId ?? null });
} catch (_) { /* 钩子异常不影响游戏 */ }
}
/** full 档语音插播:queue:true 排到当前对白之后,绝不顶掉正在播的语音。 */
_say(type, voiceId) {
if (this.level() !== 'full') return;
this._emit(type, type, voiceId);
this.audio.play(`voice/${voiceId}`, { queue: true });
}
/** 运行时合成 earcon(oscillator + gain 包络,接引擎 master 总线吃限幅保护)。 */
_playEarcon(name) {
const spec = EARCON_SPECS[name];
const ctx = this.audio && this.audio.ctx;
if (!spec || !ctx || !this.audio._master) return;
const t0 = ctx.currentTime + 0.01;
for (const n of spec) {
const osc = ctx.createOscillator();
osc.type = n.wave;
osc.frequency.value = n.f;
const g = ctx.createGain();
const peak = BASE_GAIN * (n.g ?? 1);
// 快起 → 缓释包络(分两段 linear,听感接近指数衰减且 mock 环境安全)
g.gain.setValueAtTime(0.0001, t0 + n.t);
g.gain.linearRampToValueAtTime(peak, t0 + n.t + 0.008);
g.gain.linearRampToValueAtTime(peak * 0.55, t0 + n.t + n.d * 0.4);
g.gain.linearRampToValueAtTime(0.0001, t0 + n.t + n.d);
osc.connect(g);
g.connect(this.audio._master);
osc.start(t0 + n.t);
osc.stop(t0 + n.t + n.d + 0.02);
}
}
/** guide API 活动复位:看门狗在挂时重排当前级计时(不升级、不清除)。 */
_touch() {
const wd = this._wd;
if (!wd || this._quiet()) return;
if (wd.timerId !== null) clearTimeout(wd.timerId);
this._armNextLevel();
}
_clearWatchdog() {
const wd = this._wd;
if (wd && wd.timerId !== null) clearTimeout(wd.timerId);
this._wd = null;
}
_armNextLevel() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
if (wd.idx >= wd.at.length) {
this._wd = null; // 升级次数用尽:自动停止
return;
}
// at[] 以挂载时刻为基准的绝对延迟(如 [8000, 20000] = 8s 提醒 / 20s 升级),
// 逐级换算为与上一级的间隔
const prev = wd.idx > 0 ? wd.at[wd.idx - 1] : 0;
const delay = Math.max(0, wd.at[wd.idx] - prev);
wd.timerId = setTimeout(() => this._fireWatchdog(), delay);
}
_fireWatchdog() {
const wd = this._wd;
if (!wd || this._quiet()) {
this._wd = null;
return;
}
wd.timerId = null;
if (wd.cond && !wd.cond()) {
this._wd = null; // 已离开等待态:静默取消
return;
}
const level = wd.idx + 1;
wd.idx++;
this._playEarcon('nudge');
this._emit('nudge', 'nudge', null);
const voiceId = wd.say[level - 1] || null;
if (voiceId && this.level() === 'full') {
this._say('watchdog', voiceId);
} else {
this._emit('watchdog', 'watchdog', null);
}
if (wd.onEscalate) wd.onEscalate(level);
this._armNextLevel(); // 还有下一级则续链,否则自动停止
}
}
export default Guide;
+14 -12
View File
@@ -1,37 +1,39 @@
/**
* settings.js — 全局用户设置(纯音频游戏合集共享)。
*
* 三项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-----------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* 四项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-------------------------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 \| 2.2 | 1.0 | voice 层 source.playbackRate(语速;2.2 为视障档) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
* | guide | 'full' \| 'standard' \| 'off' | 'full' | 向导帮助等级(engine/guide.js 实时读取)|
*
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
*
* 用法:
* import { settings } from '../engine/settings.js';
* settings.get(); // -> { voice, speed, bgm }(副本)
* settings.get(); // -> { voice, speed, bgm, guide }(副本)
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
*
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置
* (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用 voice/speed/bgm,
* 向导层(engine/guide.js)实时读 guide。需要自定义响应(如改 UI)才 subscribe。
*/
const STORAGE_KEY = 'soundgame.settings';
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number, guide: 'full'|'standard'|'off' }} SoundGameSettings */
/** @type {SoundGameSettings} */
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 };
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0, guide: 'full' };
const VALID = {
voice: ['male', 'female'],
speed: [1.0, 1.35, 1.7],
speed: [1.0, 1.35, 1.7, 2.2],
bgm: [1.0, 0.3, 0],
guide: ['full', 'standard', 'off'],
};
/** @type {SoundGameSettings} */
+27 -5
View File
@@ -31,6 +31,7 @@
* (0,2) 储物间·amb_storage (1,2) 空房·amb_wind (2,2) 大门·amb_gate(通电后 amb_gate_live)
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
const Phase = {
@@ -76,6 +77,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class EscapeGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链回调失效
this._timers = [];
this._stuckTimer = null;
@@ -103,6 +105,7 @@ export class EscapeGame extends Game {
this._lastProgressAt = Date.now();
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
this._lastL3At = 0;
this._wallBumps = 0; // 连续撞墙计数(向导层提前一级提示用)
// 菜单
this._menuReturn = Phase.ENDING;
this._menuIdx = 0;
@@ -118,11 +121,16 @@ export class EscapeGame extends Game {
this._clearTimers();
this._startStuckTimer();
this.audio.play('bgm/drone');
this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点)
const guided = localStorage.getItem(GUIDE_DONE_KEY) === '1';
if (guided) {
// 二周目:上滑重听引导 / 单击直接开始
this.phase = Phase.REOPEN;
this._playVoice('voice/reopen_prompt', { skippable: false }); // 抉择前提示必须听完
// 抉择前提示必须听完;播完给 turn cue(向导层:REOPEN 静默等待的"轮到你")
this._playVoice('voice/reopen_prompt', {
skippable: false,
onComplete: () => this.guide.turn({}),
});
} else {
this._startGuide();
}
@@ -130,6 +138,7 @@ export class EscapeGame extends Game {
onGesture(g) {
console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y);
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case Phase.REOPEN:
if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); }
@@ -183,6 +192,7 @@ export class EscapeGame extends Game {
this.guideErrs = 0;
this.guideSwipes = 0;
this._guideBusy = false;
this.guide.turn({}); // turn cue:引导步骤解锁输入(W2 播完即静默,向导补标记)
if (onDone) onDone();
};
this._timer(() => {
@@ -309,6 +319,7 @@ export class EscapeGame extends Game {
this.startElapsedMs = Date.now();
this._resetStuck();
this.audio.play('bgm/drone');
this.guide.state('explore');
this._playVoice('voice/replay');
this._playAmbient();
}
@@ -325,7 +336,7 @@ export class EscapeGame extends Game {
// voice 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/
// L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。
// 无 voice 时单击 = 原检查语义。
if (this.audio._voice) {
if (this.audio.isVoicePlaying()) {
if (this._voiceSkippable) this.audio.skipVoice();
break;
}
@@ -342,6 +353,7 @@ export class EscapeGame extends Game {
this.audio.play('sfx/wall');
vibrate('error');
this._playVoice('voice/wall'); // 每次撞墙都播(取消一次性限制)
this._wallBumps++; // 向导层:连续撞墙 = 卡关信号(_checkStuck 提前一级)
return;
}
this.x = nx;
@@ -469,6 +481,7 @@ export class EscapeGame extends Game {
this._lastProgressAt = Date.now();
this._stuckShown = new Set();
this._lastL3At = 0;
this._wallBumps = 0;
}
/** 进展事件:重置卡关计时与已播级别。 */
@@ -480,7 +493,13 @@ export class EscapeGame extends Game {
if (this.phase !== Phase.PLAY) return;
const idleMs = Date.now() - this._lastProgressAt;
const stage = this._stage();
if (idleMs >= 180000) {
// 向导层(schemes §2):连续撞墙 ≥2 次是明确的卡关信号——向导 full 档时
// 三级阈值整体提前一级(L1 即刻、L2 60s、L3 120s);off/standard 保持原阈值。
const early = this._wallBumps >= 2 && this.guide.level() === 'full';
const T_L1 = early ? 0 : 60000;
const T_L2 = early ? 60000 : 120000;
const T_L3 = early ? 120000 : 180000;
if (idleMs >= T_L3) {
// L3 带路:首次或每 60s 重复,按当前坐标重算路线(带路语音不可单击跳过)
if (!this._stuckShown.has('L3') || Date.now() - this._lastL3At >= 60000) {
this._stuckShown.add('L3');
@@ -488,11 +507,11 @@ export class EscapeGame extends Game {
vibrate('warning');
this._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], null, { skippable: false });
}
} else if (idleMs >= 120000 && !this._stuckShown.has('L2')) {
} else if (idleMs >= T_L2 && !this._stuckShown.has('L2')) {
this._stuckShown.add('L2');
vibrate('warning');
this._speak([`voice/stuck_${stage}_2`]);
} else if (idleMs >= 60000 && !this._stuckShown.has('L1')) {
} else if (idleMs >= T_L1 && !this._stuckShown.has('L1')) {
this._stuckShown.add('L1');
vibrate('warning');
this._speak(['voice/stuck_ask', `voice/stuck_${stage}_1`]);
@@ -518,6 +537,7 @@ export class EscapeGame extends Game {
// 状态 BGM:菜单 = bgm/menu(10 号文档,400ms crossfade;与结算 pad 同音量 0.5,
// 同一首引擎不重启,关菜单回 ENDING 时无缝保持)
this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 });
this.guide.state('menu');
this._speak(['voice/menu_open', 'voice/menu_restart'], null, { skippable: false });
}
@@ -546,6 +566,7 @@ export class EscapeGame extends Game {
this.audio.play('bgm/menu', { volume: 0.5 });
} else {
this.audio.play('bgm/drone'); // 关闭菜单切回主题曲
this.guide.state('explore');
}
}
@@ -563,6 +584,7 @@ export class EscapeGame extends Game {
this.audio.play('sfx/gate_open');
this.audio.play('sfx/win_chime', { volume: 0.8 });
this.audio.stopBgm(2500);
this.guide.state('settle'); // 状态签名:结算(逃脱)
this._endingBgmStarted = false;
const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000);
this._saveEscape(totalSec);
+3
View File
@@ -175,6 +175,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { EscapeGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------- 断言与辅助 ----------
let failures = 0;
let passes = 0;
+310
View File
@@ -0,0 +1,310 @@
/**
* 向导层无头测试(web/harness/guide.test.mjs)。
*
* settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为:
* ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音
* (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input
* 10s → gd_relisten、25s → 免扣机会自动重听一次;
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
* ④ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
*
* 运行:tools/node/bin/node web/harness/guide.test.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..');
const GAME_DIRS = {
StorylineGame: path.join(ROOT, 'web', 'storyline'),
SimonGame: path.join(ROOT, 'web', 'simon'),
};
let currentGameDir = GAME_DIRS.StorylineGame;
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
console.error = () => {};
console.warn = () => {};
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0; // 虚拟毫秒
let timerSeq = 1;
const timers = new Map(); // id -> { fireAt, cb }
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
Object.defineProperty(globalThis, 'performance', {
value: { now: () => now }, configurable: true,
});
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 50000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb();
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
const idle = () => advance(null);
/** 清空全局定时器(用例边界卫生:上一局的看门狗不串场)。 */
const resetTimers = () => timers.clear();
// ---------------------------------------------------------------- 浏览器 mock
globalThis.window = { addEventListener: () => {}, AudioContext: null };
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
class MockOscillator {
constructor() {
this.type = 'sine'; this.frequency = new MockParam();
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
}
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
createGain() { return new MockGainNode(); }
createOscillator() { return new MockOscillator(); }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch 读"当前测试游戏"的真实资产目录(语音链靠引擎 endTimer 推进,需要真文件)
globalThis.fetch = async (u) => {
const p = path.join(currentGameDir, decodeURIComponent(String(u)));
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const data = fs.readFileSync(p);
return {
ok: true, status: 200,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
};
};
// ---------------------------------------------------------------- 装载引擎与游戏
const { WebAudioEngine } = await import('../engine/audio.js');
const { settings } = await import('../engine/settings.js');
const { StorylineGame } = await import('../storyline/game.js');
const { SimonGame } = await import('../simon/game.js');
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
/** 组装一局:playLog + 震动/事件捕获(每个用例全新 audio+game)。 */
function makeRig(GameClass) {
currentGameDir = GAME_DIRS[GameClass.name];
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
const game = new GameClass(audio, {});
const events = [];
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
const vibBase = vibrateLog.length;
return {
audio, playLog, game, events, vibBase,
vibrated: (pattern) => vibrateLog.slice(vibBase).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
};
}
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
async function gesture(game, g) { game.onGesture(g); await flush(); }
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
const waitChainDone = (game) => advance(() => !game.voicePlaying);
// ================================================================ ① storyline 看门狗
realLog('== ① storyline:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
const tArm = now;
await advance(() => events.some((e) => e.type === 'nudge'));
assert(now - tArm === 8000, `8s 后 nudge 触发(实际 ${now - tArm}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
'看门狗第 1 级发 gd_continue 语音事件');
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
await advance(() => playLog.some((e) => e.id === 'voice/help'));
assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`);
await advance(() => playLog.some((e) => e.id === 'voice/status1'));
assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)');
assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)');
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
await idle();
const nudges = events.filter((e) => e.type === 'nudge').length;
await idle();
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
// 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次
await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped)
await waitChainDone(game);
await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 n01
await waitChainDone(game);
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
const tWait = now;
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
assert(now - tWait === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${now - tWait}ms)`);
resetTimers();
}
// ================================================================ ③ storyline error onceKey
realLog('== ③ storyline:无效手势纠错 gd_invalid(onceKey 同局只播一次)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // W1 等待态(n00 非选择节点)
await gesture(game, 'left'); // 选择节点外左右滑 = 无效手势
assert(vibrated([60, 40, 60]), '无效手势给 error 震');
assert(events.some((e) => e.type === 'error' && e.voiceId === 'gd_invalid'), '发 gd_invalid 语音事件');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'gd_invalid 播放一次');
await gesture(game, 'right');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'onceKey:同局第二次无效手势不再播语音');
assert(game.nodeId === 'n00' && !game.voicePlaying, '无效手势不推进剧情(原静默语义保持)');
resetTimers();
}
// ================================================================ ② simon turn cue + 看门狗
realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_turn)与 input 看门狗 ==');
{
const { audio, playLog, game, events, vibrated } = makeRig(SimonGame);
await audio.unlock();
game.start();
await advance(() => game.phase === 'tutorial' && game.teachAwait); // T0 六音教学第 1 步
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
await advance(() => game.teachAwait && game.phase === 'tutorial');
await gesture(game, g);
}
await advance(() => game.phase === 'input');
assert(game.round === 1, '教学走完进入第 1 轮 input');
// ② 首轮 turn cue:序列播完 400ms → confirm 震 + turn earcon 事件 + your_turn
assert(events.some((e) => e.type === 'turn' && e.name === 'turn'), 'turn earcon 事件发出');
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
// ①b input 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听
await advance(() => events.some((e) => e.type === 'nudge'));
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
'input 10s 无输入发 gd_relisten 事件');
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
assert(game.chances === 1, '看门狗提示不消耗重听机会');
const chancesBefore = game.chances;
await advance(() => game.phase === 'playback');
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
assert(game.chances === chancesBefore, '自动重听不扣机会');
await advance(() => game.phase === 'input');
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
for (const g of game.sequence) await gesture(game, g);
assert(game.phase === 'roundend', '按序答题进入 roundend');
await advance(() => game.phase === 'input' && game.round === 2);
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
'答题手势复位看门狗:第 2 轮开启前无 nudge');
assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度');
resetTimers();
}
// ================================================================ ④ guide=off 全静
realLog('== ④ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
{
settings.set({ guide: 'off' });
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game);
assert(!game.voicePlaying, 'n00 链播完进入等待态');
assert(events.length === 0, 'off 档零向导事件(state/turn/watchdog/nudge 均无)');
// 静置 30s:无任何向导定时器 → 虚拟时钟完全不走(挂了看门狗定时器就会被推进)
const t0 = now;
await idle();
assert(now === t0, `off 档等待态无任何定时器注册(时钟纹丝不动,t0=${t0} now=${now})`);
await gesture(game, 'left'); // 无效手势
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'off 档不播 gd_invalid');
assert(!vibrated([60, 40, 60]), 'off 档无向导 error 震');
assert(playLog.every((e) => !e.id.includes('gd_')), 'off 档全程无 gd_* 资产加载');
settings.set({ guide: 'full' });
}
// ---------------------------------------------------------------- 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');
+17 -4
View File
@@ -38,8 +38,20 @@ export function installMock(gameDir) {
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.clearInterval = (id) => { timers.delete(id); };
globalThis.setInterval = globalThis.setTimeout; // 转写场景无真实 interval 语义需求
// 真·可重复的虚拟 interval:rhythm 的 40ms 调度泵、escape 的 5s 卡关轮询都依赖它
const intervals = new Set();
globalThis.setInterval = (cb, ms = 0, ...args) => {
const id = timerSeq++;
intervals.add(id);
const step = (...a) => {
if (!intervals.has(id)) return; // 已被 clearInterval
cb(...a);
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
};
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
return id;
};
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 8) {
@@ -104,9 +116,10 @@ export function installMock(gameDir) {
}
class MockAudioContext {
constructor() {
this.state = 'running'; this.currentTime = 0; this.destination = {};
this.sampleRate = 44100;
this.state = 'running'; this.destination = {}; this.sampleRate = 44100;
}
// 跟随虚拟时钟:rhythm 等用 ctx.currentTime 绝对排程的游戏才能在无头环境推进
get currentTime() { return now / 1000; }
createGain() { return new MockGainNode(); }
createOscillator() { return new MockOscillator(); }
createDynamicsCompressor() {
+72
View File
@@ -0,0 +1,72 @@
/**
* 《暗室》新手会话剧本:老玩家二周目不算——这里清空过进度走首局引导(G0-G7),
* 每步听清再做;进 PLAY 后先连撞两次墙(触发向导提前一级的卡关提示),
* 然后找到储物间拿钥匙、去铁柜房开柜(解到第二间),收工。
*/
export function describe(game) {
switch (game.phase) {
case 'REOPEN': return '有人问我回不回来看过引导:单击直接开始,上滑重听引导';
case 'GUIDE': return game._guideBusy ? '引导在说话,照它说的做就行' : `引导第 ${game.guideStep + 1}/8 步,等我做手势`;
case 'PLAY': return `探索中,我在 (${game.x},${game.y})。四向滑挪步,单击摸一遍房间,双击用物品`;
case 'ENDING': return '逃脱了,结算播报中。播完单击再来,下滑开菜单';
case 'MENU': return '菜单里:单击切选项,双击确认,下滑关闭';
default: return '……';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
// 首局:交互式引导 G0-G7(n = 引导步号 guideStep)。
// 注意:卡关轮询 interval 常驻,虚拟时钟永不"空闲"——等待一律用有界/带条件等待。
ctx.status('黑屏,只有引子的声音,先听完');
await ctx.waitCond(() => game.phase === 'GUIDE' && !game._guideBusy); // g0_intro 播完
const steps = [
{ n: 0, do: async () => { ctx.note('照引导单击了一下'); await ctx.gesture('tap'); } },
{ n: 1, do: async () => { ctx.note('随便滑了一步,有脚步声'); await ctx.gesture('up'); } },
{
n: 2,
do: async () => {
await ctx.gesture('left');
await ctx.waitVirtual(1600);
ctx.note('又滑一步,又撞墙了——墙的方位听出来了');
await ctx.gesture('right');
},
},
{ n: 3, do: async () => { ctx.note('这回往左,走通了一步'); await ctx.gesture('left'); } },
{ n: 4, do: async () => { ctx.note('往下滑,进了储物间'); await ctx.gesture('down'); } },
{ n: 5, do: async () => { ctx.note('单击摸一遍房间,摸到钥匙了'); await ctx.gesture('tap'); } },
{ n: 6, do: async () => { ctx.note('双击看了看背包'); await ctx.gesture('doubletap'); } },
];
for (const s of steps) {
await ctx.waitCond(() => game.phase === 'GUIDE' && game.guideStep === s.n && !game._guideBusy);
ctx.status(`引导第 ${s.n + 1}/8 步`);
await s.do();
await ctx.waitVirtual(800); // 缓一缓再继续(推进链由语音 onComplete 驱动)
}
// PLAY:自由探索。引导结束时我站在储物间 (0,2),手里已有钥匙
await ctx.waitCond(() => game.phase === 'PLAY');
ctx.status('引导完毕,正式开始。我在储物间');
const move = async (g, why) => {
await ctx.gesture(g);
await ctx.waitVirtual(1600); // 听脚步/环境音/描述
if (why) ctx.note(why);
};
// 先往北乱撞探探边界:两步走到 (0,0),再连撞两次北墙——向导把卡关提示提前带上来
ctx.silence(3, '辨了辨方向');
await move('up');
await move('up');
await move('up', '北边是墙,撞了一下');
await move('up', '又撞了一下——听到游戏主动来问我了');
await ctx.waitVirtual(5200); // 等卡关轮询的 5s tick:连续撞墙 → 提前一级的暗示
// 去铁柜房:(0,0) → 右×2 = (2,0) → 下 = (2,1),双击用钥匙
await move('right', '往东走,风声换了个方向');
await move('right');
await move('down', '到了铁柜房,柜子锁着');
await ctx.gesture('doubletap'); // 用钥匙 → unlock_cabinet(第二间解完)
await ctx.waitVirtual(3000);
ctx.status('柜子开了,拿到保险丝——解到第二间,收工');
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 《节拍行者》新手会话剧本:首局完整走教学 T1-T3 → 曲目一(90 BPM);
* 曲中下滑呼出菜单逛一圈再恢复;第一乐句故意整句漏掉(连 2 Miss 触发向导
* 「慢了半拍」提示),之后跟着领奏把一曲打完到结算。
*/
export function describe(game) {
switch (game.phase) {
case 'intro': return '开场旁白在播,单击可以跳过';
case 'tut': return game._cues.length
? `教学:听领奏,下一小节照样打(${game._cues.map((c) => c.gesture).join('→')})`
: '教学准备中,听鼓点';
case 'select': return '选曲:左右滑换曲,单击开始,下滑回大厅';
case 'main': {
const left = game._cues.filter((c) => !c.judged).length;
return `演奏中(${game._song?.title ?? ''}):还剩 ${left} 个应答音。领奏停了、轮到空拍,就该我出手了`;
}
case 'menu': return '暂停菜单:单击切选项,双击确认,上滑帮助,下滑恢复演奏';
case 'end': return '结算播报中:听完单击再来一遍,右滑下一首,下滑回大厅';
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game, audio } = ctx;
const t = () => audio.ctx.currentTime;
// 开场:welcome 听完进教学
await ctx.waitCond(() => game.phase === 'tut');
ctx.status('教学开始了,先听领奏');
// 教学 3 步:等领奏排程好,在应答窗口内跟打
//(注意:调度泵 interval 常驻,虚拟时钟永不"空闲"——禁止裸 waitIdle)
for (let step = 0; step < 3; step++) {
await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0);
ctx.status(`教学第 ${step + 1}/3 句`);
for (const cue of game._cues) {
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04); // tap 补偿引擎 ~300ms 延迟
await ctx.waitCond(() => t() >= at || cue.judged);
if (cue.judged) continue;
await ctx.gesture(cue.gesture);
}
await ctx.waitCond(() => game._tutIdx > step || game.phase !== 'tut');
}
await ctx.waitCond(() => game.phase === 'main');
ctx.status('教学完毕,曲目一《启程》90 拍开始');
// 曲中逛菜单:下滑呼出(模态暂停)→ 听选项 → 下滑恢复
await ctx.waitVirtual(2500);
await ctx.gesture('down');
await ctx.waitCond(() => game.phase === 'menu');
ctx.status('曲中暂停,进了菜单');
await ctx.gesture('tap'); // 切换选项(听到「回大厅」)
await ctx.waitIdle();
ctx.note('还是想打完这首,下滑回去');
await ctx.gesture('down'); // 恢复
await ctx.waitCond(() => game.phase === 'main');
// 第一乐句故意整句不打:连 2 次 Miss → 向导 gd_miss
const firstPhrase = game._cues.filter((c) => c.phrase === 0);
ctx.silence(5, '还在数拍子,第一句没敢出手');
await ctx.waitCond(() => firstPhrase.every((c) => c.judged));
ctx.status('第一句全漏了,听到「慢了半拍,下一小节跟上我就行」');
// 之后逐音跟打(tap 注入留出引擎 ~300ms 回调延迟)
for (const cue of game._cues) {
if (cue.judged) continue;
const at = cue.expected - (cue.gesture === 'tap' ? -0.28 : 0.04);
await ctx.waitCond(() => t() >= at || cue.judged);
if (cue.judged) continue;
await ctx.gesture(cue.gesture);
}
await ctx.waitCond(() => game.phase === 'end');
await ctx.waitIdle();
ctx.status('曲子打完了,结算播报中');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* 《剑与声》新手会话剧本:听完序章进村 → 村里把选项听一圈 → 出村去小路
* (顺路调查一次捡钱)→ 岔路口转去溪流触发教学战 → 完整打一场
* (教学四步:识招→攻击→格挡→防御,再自由击杀),战后收工。
*
* 种子 RNG:让教学狼首击必不掉(HP 6、伤害恒 5),保证四步教学完整走完。
*/
Math.random = () => 0.4;
export function describe(game) {
const SCENE_NAME = {
village: '雾口村', path: '林间小路', fork: '岔路口', chest: '枯树洞', stream: '溪流',
hunter: '猎人小屋', deepwood: '密林深处', gate: '黑风寨门前', boss1: '聚义厅',
};
const BAND = { high: '八成以上', mid: '五成上下', low: '三成以下' };
if (game.phase === 'battle' && game._battle) {
const b = game._battle;
if (b.state === 'PLAYER_SELECT' && b.enemy) {
return `战斗中:你的血量${BAND[game._hpBand(game.player.hp, game.maxHp)]},`
+ `敌方血量${BAND[game._hpBand(b.enemy.hp, b.enemy.maxHp)]},等你选招式(左右滑切换,单击出手,下滑逃跑)`;
}
if (b.state === 'ENEMY_TELEGRAPH') return '战斗中:敌方前摇声响起了——双击,格挡!';
return '战斗播报中,先听着';
}
switch (game.phase) {
case 'ask': return '检测到旅程记录:单击继续,右滑开新档';
case 'intro': return '序章旁白在播,讲我怎么用耳朵走路';
case 'explore': return `【${SCENE_NAME[game.scene] ?? game.scene}】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单`;
case 'shop': return '商店里:左右滑看货,单击买,下滑离开';
case 'menu': return '菜单里:左右滑切选项,单击确认';
default: return '……';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
const CMDS = ['attack', 'skill', 'defend', 'item'];
// 序章听完 → 进村
await ctx.waitCond(() => game.phase === 'explore');
ctx.status('进村了,先听场景描述和选项');
// 村里(选项:去小路/商店/休息/听状态):把选项听一圈再对准「去小路」出发
for (let i = 0; i < 3; i++) {
await ctx.gesture('right');
await ctx.waitVirtual(1800); // 听选项名
}
ctx.status('选项听了一圈,往林间小路去');
await ctx.waitIdle(); // 选项名播完(无 interval,可安全等待空闲)
await ctx.gesture('right'); // 转回首个选项「去小路」
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // → path
// 小路(去岔路口/调查/返回):先调查一次捡钱,再左滑回「去岔路口」
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'path');
ctx.status('林间小路');
await ctx.gesture('right'); // 调查四周
await ctx.waitVirtual(1800);
ctx.note('翻找了一阵,捡到一点钱');
await ctx.gesture('tap');
await ctx.waitVirtual(2400);
await ctx.gesture('left'); // 回「去岔路口」
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // → fork
// 岔路口(去枯树洞/去溪流/返回):右滑一次到「去溪流」
await ctx.waitCond(() => game.phase === 'explore' && game.scene === 'fork');
ctx.status('岔路口,水声在东边');
await ctx.gesture('right');
await ctx.waitVirtual(1500);
await ctx.gesture('tap'); // go_stream → 溪流 → 触发教学战
// 教学战:四步(识招 → 攻击 → 格挡 → 防御)+ 自由击杀
await ctx.waitCond(() => game.phase === 'battle' && game._battle?.state === 'PLAYER_SELECT');
ctx.status('遭遇教学狼,战斗开始');
const selectCmd = async (cmd) => {
const b = game._battle;
while (b && b.cmdIdx !== CMDS.indexOf(cmd)) {
await ctx.gesture('right');
await ctx.waitVirtual(1300); // 听指令名
}
};
// T0:左右滑各切一次(听到指令名就算过关)
await ctx.gesture('right');
await ctx.waitVirtual(1300);
await ctx.gesture('right');
await ctx.waitVirtual(1300);
ctx.status('招式名听熟了');
// T1:攻击一次
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
await selectCmd('attack');
ctx.note('单击出手,攻击');
await ctx.gesture('tap');
// T2:格挡(前摇窗口内双击)
await ctx.waitCond(() => game._battle?.state === 'ENEMY_TELEGRAPH');
ctx.status('前摇声响起了');
ctx.note('双击格挡');
await ctx.gesture('doubletap');
// T3:防御一次
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT');
await selectCmd('defend');
ctx.note('这回合先防御,减半伤害');
await ctx.gesture('tap');
// 自由阶段:攻击到狼死(中间照常听前摇、双击格挡)
for (let i = 0; i < 8 && game.phase === 'battle'; i++) {
await ctx.waitCond(() => game._battle?.state === 'PLAYER_SELECT'
|| game._battle?.state === 'ENEMY_TELEGRAPH' || game.phase !== 'battle');
if (game.phase !== 'battle') break;
if (game._battle.state === 'ENEMY_TELEGRAPH') {
ctx.note('双击格挡');
await ctx.gesture('doubletap');
continue;
}
await selectCmd('attack');
ctx.note('单击,攻击');
await ctx.gesture('tap');
}
await ctx.waitCond(() => game.phase === 'explore');
ctx.status('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工');
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、
* 第 1 轮答对、第 2 轮中途忘序列僵住 11 秒(触发向导「双击可重听」提示)、
* 第 3 轮故意答错第一下(消耗机会自动重播),然后答对完成第 3 轮收工。
*/
export function describe(game) {
const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' };
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
switch (game.phase) {
case 'tutorial':
return game.teachAwait
? `教学:该做「${GN[GESTURES[game.teachIdx]]}」了`
: '教学旁白在播,先听着';
case 'announce': return `第 ${game.round} 轮报数呢,听好了`;
case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`;
case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势`;
case 'roundend': return game.chances < (game.round > 4 ? 2 : 1) ? '答错了,正在重播刚才的序列' : '刚答完,播报中';
case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`;
case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则';
default: return '开场,稍等一下';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
const G = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
// 教学 T0-T6:教一个练一个
for (let i = 0; i < G.length; i++) {
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
ctx.status(`教学第 ${i + 1}/6 个音色`);
if (i === 2) {
ctx.note('把「左滑」记混了,先来了个右滑');
await ctx.gesture('right'); // 听纠错语音(t_wrong_left)
await ctx.waitCond(() => game.phase === 'tutorial' && game.teachAwait);
}
await ctx.gesture(G[i]); // 照指令做对
await ctx.waitIdle();
}
// 正式对局:打到完成第 3 轮
let wrongDone = false;
let stallDone = false;
while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover' && game.round < 4) {
if (game.phase === 'input') {
// 第 2 轮开局先失忆僵住 11 秒:触发向导「双击可以重新听一遍序列」提示
if (game.round === 2 && game.inputIdx === 0 && !stallDone) {
stallDone = true;
ctx.status('序列有点长,先僵一会儿');
ctx.silence(11, '想不起来序列了');
await ctx.waitVirtual(11000);
ctx.status('听到提示:需要的话,双击可以重新听一遍序列');
}
// 第 2 轮第二拍故意答错:触发"用一次机会,再听一遍"(复活重听)
if (game.round === 2 && game.inputIdx === 1 && !wrongDone) {
wrongDone = true;
ctx.note('这一下故意点错,听听游戏怎么说');
await ctx.gesture(G[(G.indexOf(game.sequence[game.inputIdx]) + 1) % G.length]);
await ctx.waitIdle(); // retry_use + 自动重播同一序列
continue;
}
ctx.status('轮到我复现了');
await ctx.gesture(game.sequence[game.inputIdx]);
continue;
}
await ctx.waitIdle();
}
ctx.status(`收工:完成了 ${game.round - 1} 轮`);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 《盲航》新手会话剧本:首局完整演练 D1-D4(第二颗故意超时,听当场重教)→
* 正式对局躲过保护期(20s/45s 的加速/护盾引入都照做)→ 保护期结束后故意漏一颗
* (触发归因句 + 向导「同向滑动就能拨开」指令)→ 再正常躲几颗收工。
*/
export function describe(game) {
const DIR = { left: '左边', right: '右边', up: '上面', down: '下面' };
switch (game.phase) {
case 'ask': return '问我直接出发还是重听演练:单击出发,上滑重听';
case 'drill': return game.drillOb
? `演练:${DIR[game.drillOb.dir]}有陨石在逼近,${DIR[game.drillOb.dir]}滑把它拨开`
: '演练旁白在播,先听';
case 'playing': {
const n = game.obstacles.size;
const protect = performance.now() < game.protectionUntil;
return n
? `对局中:${n} 颗陨石在逼近,听方位同向滑开${protect ? '(保护期,撞了也不掉血)' : ''}`
: `对局中:引擎声平稳,等下一颗${protect ? '(保护期)' : ''}`;
}
case 'feature': return game.featureKind === 'boost' ? '暂停了刷怪教新功能:单击试试加速' : '暂停了刷怪教新功能:双击展开护盾';
case 'menu': return '暂停菜单:单击切选项,双击确认,下滑恢复';
case 'over': return `坠毁了,存活数据播报中。单击再飞一次,下滑回大厅`;
default: return '开场,稍等';
}
}
export async function session(ctx, mock) {
const { game } = ctx;
// 演练 D1-D4:逼近音一响就同向拨开(第二颗故意不出手,听当场重教)。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
for (let i = 0; i < 4; i++) {
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.status(`演练 ${i + 1}/4:${{ left: '左', right: '右', up: '上', down: '下' }[game.drillOb.dir]}侧有陨石`);
if (i === 1) {
ctx.note('这颗犹豫了,没拨开——听当场重教');
await ctx.waitCond(() => game.phase === 'drill' && !game.drillOb); // 超时 → drill_retry
await ctx.waitCond(() => game.phase === 'drill' && game.drillIdx === i && !!game.drillOb);
ctx.note('重教听完,这回马上同向滑');
await ctx.gesture(game.drillOb.dir);
continue;
}
ctx.note('逼近音一响,同向滑开');
await ctx.gesture(game.drillOb.dir);
}
ctx.status('演练做完了,要出发了');
// 正式对局:保护期 60s + 结束后再躲一小段就收工(不再模拟,避免挂机撞毁)。
// 每颗陨石生成(呼啸声起)就立即同向拨开——比盯着 1.5s 判定窗口更稳。
//(刷怪链是自驱动的定时器链——禁止裸 waitIdle,等待一律挂条件)
await ctx.waitCond(() => game.phase === 'playing');
ctx.status('出发。引擎声起来了,保护期里先练手');
const dodged = new Set();
let victim = null; // 保护期结束后故意不躲的那颗
let eaten = false;
while ((game.phase === 'playing' || game.phase === 'feature')
&& performance.now() < game.protectionUntil + 8000) {
if (game.phase === 'feature') {
ctx.status('游戏停下来教新功能');
await ctx.waitIdle(); // 引入句听完(feature 态无刷怪链,可安全等待空闲)
ctx.note(game.featureKind === 'boost' ? '照它说的单击加速' : '照它说的双击开护盾');
await ctx.gesture(game.featureKind === 'boost' ? 'tap' : 'doubletap');
continue;
}
if (victim && !game.obstacles.has(victim)) {
ctx.note('被撞了。先听到方位归因,又听到「同向滑动就能拨开」');
victim = null;
continue;
}
// 只在判定窗口将开未开时出手(警报第一响 = 撞击前 1s,窗口内)
const target = [...game.obstacles].find((o) => !dodged.has(o)
&& o.impactAt - performance.now() < 1300);
if (target) {
if (!eaten && performance.now() >= game.protectionUntil) {
eaten = true;
victim = target;
dodged.add(target); // 标记 = 故意不躲
ctx.status('保护期结束了,这颗我故意没动');
continue;
}
dodged.add(target);
await ctx.gesture(target.dir);
continue;
}
await ctx.waitCond(() => (game.phase !== 'playing' && game.phase !== 'feature')
|| (victim && !game.obstacles.has(victim))
|| [...game.obstacles].some((o) => !dodged.has(o) && o.impactAt - performance.now() < 1300));
}
ctx.status('躲过保护期、挨过一次撞,心跳有点快,先到这');
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 《声命线》新手会话剧本:像真人一样等语音播完再动、偶尔走神(触发看门狗)、
* 在非抉择节点试一次无效的左滑(触发向导纠错)、菜单进去逛一圈再出来、
* 抉择点一律选左,一路打到结局。
*/
export function describe(game) {
if (game.mode === 'MENU') return '菜单里:左滑重开、右滑或单击返回剧情、上滑听帮助';
if (game.awaitingRestart) return '结局播完了。单击屏幕,可以回到故事开始的地方';
if (game.choiceArmed) return `「请选择」念完了(节点 ${game.nodeId}):左滑或右滑,做出你的决定`;
if (game.voicePlaying) return '旁白正在播。单击跳过本句,双击重听整个节点';
return `剧情等着你(节点 ${game.nodeId}):单击继续,双击重听`;
}
export async function session(ctx, mock) {
const { game } = ctx;
let invalidTried = false;
let menuDone = false;
// 开场:n00 全听完,愣一会儿——向导看门狗先说话
await ctx.waitIdle();
ctx.status('开场旁白播完了,屏幕黑着,有点不知所措');
ctx.silence(8, '新手没反应过来');
await ctx.waitVirtual(8600); // 触发看门狗:nudge + gd_continue
ctx.status('听到一声轻响和「轻触屏幕,继续」,试着点一下');
await ctx.gesture('tap');
for (let i = 0; i < 60 && !game.awaitingRestart && mock.now < 20 * 60 * 1000; i++) {
await ctx.waitIdle(); // 等当前语音链播完(真人不会抢话)
if (game.awaitingRestart) break;
// 中途进一次菜单,逛一圈再出来
if (!menuDone && game.nodeId === 'n04') {
menuDone = true;
ctx.note('想看看还有什么可按的,下滑呼出菜单');
await ctx.gesture('down');
await ctx.waitIdle();
ctx.status('菜单里');
await ctx.gesture('up'); // 听帮助 + 进度
await ctx.waitIdle();
await ctx.gesture('doubletap'); // 重听菜单项
await ctx.waitIdle();
ctx.note('听懂了,先回去继续剧情');
await ctx.gesture('tap'); // 关菜单(回剧情并重播当前节点)
continue;
}
if (game.choiceArmed) {
ctx.status('抉择点,提示音念完了');
if (mock.now % 2 < 1) await ctx.waitVirtual(3000); // 偶尔犹豫一下
ctx.note('选了左边');
await ctx.gesture('left'); // 抉择点一律选左
continue;
}
// W1:叙事节点播完,静默等待
ctx.status('剧情停下来了,在等我');
if (!invalidTried) {
invalidTried = true;
ctx.note('试着左滑了一下——这里用不上,向导纠正了我');
await ctx.gesture('left');
await ctx.waitIdle();
} else if (i % 4 === 2) {
ctx.silence(8, '走神想剧情去了');
await ctx.waitVirtual(8600); // 偶尔挂机,触发看门狗轻提醒
ctx.status('又听到那声轻响,想起该点屏幕了');
}
await ctx.gesture('tap');
}
await ctx.waitIdle();
ctx.status('结局');
ctx.note('听完结局,单击重开一次看看别的走向');
await ctx.gesture('tap'); // 结算态单击重开
await ctx.waitIdle();
ctx.status('回到开头,会话结束');
}
+26
View File
@@ -66,6 +66,13 @@ const HAPTIC_NAME = {
};
const hapticName = (p) => HAPTIC_NAME[JSON.stringify(p)] || `自定义震型 ${JSON.stringify(p)}`;
// 向导层事件(engine/guide.js setEventHook)→ 玩家可感知的 earcon / 插播语音
const GUIDE_LABEL = {
menu: '系统界面', explore: '探索/安全', battle: '战斗/对抗', choice: '抉择/等待输入',
settle: '结算', danger: '危险', turn: '轮到你', nudge: '看门狗轻提醒',
error: '出错了', watchdog: '看门狗升级',
};
// ---------------------------------------------------------------- 装载引擎与游戏
const { WebAudioEngine } = await import('../engine/audio.js');
@@ -85,6 +92,8 @@ function capture(audio) {
lastBgm = id;
pending.push(`${fmtT()} ♫ BGM 切换 → ${id}${opts?.fadeMs ? `(淡入 ${opts.fadeMs}ms)` : ''}`);
}
} else if (isVoice && id.startsWith('voice/gd_')) {
// 向导语音经事件钩子渲染(▶ 向导语音),此处不重复记行
} else if (isVoice) {
pending.push(t
? `${fmtT()} ▶ 语音:「${t}」`
@@ -96,6 +105,22 @@ function capture(audio) {
};
}
/** 向导实例接入事件流(每个 earcon/插播语音都转写成玩家可感知的一行)。 */
function captureGuide(game) {
if (!game.guide) return;
game.guide.setEventHook((evt) => {
if (evt.voiceId) {
const t = textOf(`voice/${evt.voiceId}`);
pending.push(t
? `${fmtT()} ▶ 向导语音:「${t}」`
: `${fmtT()} ▶ 向导语音 ${evt.voiceId}(无台词表文本)`);
} else {
const label = GUIDE_LABEL[evt.name] || '';
pending.push(`${fmtT()} ♪ 向导音:${evt.name}${label ? `(${label})` : ''}`);
}
});
}
function drain() {
for (const m of mock.vibrateLog.splice(0)) {
pending.push(`${fmtT()} 〰 震动:${hapticName(m.pattern)}`);
@@ -166,6 +191,7 @@ async function run() {
const audio = new WebAudioEngine('audio/');
const game = new GameClass(audio, {});
capture(audio);
captureGuide(game); // 向导层事件 → 转写行(♪ 向导音 / ▶ 向导语音)
await audio.unlock();
lines.push(`${fmtT()} (玩家点开游戏,轻触屏幕开始)`);
game.start();
+30 -4
View File
@@ -37,6 +37,8 @@
"e3_alone": "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。",
"e4_static": "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。",
"skip_hint": "单击可跳过旁白。",
"gd_continue": "轻触屏幕,继续。",
"gd_invalid": "这个操作在这里用不上。",
"n02_linwan": "咳……咳咳……我还活着。晨晨,你在哪儿……你说过,会在北区等我。姐一定,一定找到你。",
"n03_radio": "收音机……还好,没摔坏。等等,这是什么……这段录音,是三天前的……",
"n03_msg": "姐,如果你听到这个,说明你还活着,太好了。听着,跟着广播塔的红色信号灯走,别相信任何自称救援队的人。北区,北区有真正的避难所。我在这儿等你。姐,一定要来。",
@@ -196,7 +198,8 @@
"menu_restart": "重新开始。",
"menu_home": "回大厅。",
"menu_back": "返回游戏。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_relisten": "需要的话,双击可以重新听一遍序列。"
},
"rhythm": {
"welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。",
@@ -230,7 +233,9 @@
"menu_main": "菜单。单击切换选项,双击确认。",
"menu_resume": "继续演奏。",
"menu_lobby": "回大厅。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_answer": "现在跟着我打。",
"gd_miss": "慢了半拍,下一小节跟上我就行。"
},
"space": {
"drill_welcome": "欢迎登上盲航。你只有一双耳朵。",
@@ -285,7 +290,8 @@
"help_recap": "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。",
"settings_hint": "音色、语速、背景音乐,请回大厅修改。",
"paused": "已暂停。",
"skip_hint": "单击可跳过旁白。"
"skip_hint": "单击可跳过旁白。",
"gd_hit_hint": "被撞了。听方位,同向滑动就能拨开。"
},
"rpg": {
"intro_1": "你的眼睛看不见了。但你的耳朵,比谁都灵。",
@@ -401,6 +407,26 @@
"tut_battle_2": "左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。",
"tut_battle_3": "敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。",
"tut_battle_4": "没把握就防御,伤害减半。金疮药可以回血。",
"tut_battle_5": "记住:听清前摇,双击格挡。开始吧!"
"tut_battle_5": "记住:听清前摇,双击格挡。开始吧!",
"tut_b0": "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。",
"tut_b6": "来不及格挡,就选防御。左右滑,选防御,单击。",
"btl_flee_fail": "没甩掉!它追上来了。",
"boss1_warn": "里面就是黑风寨主。你现在回去练练再来,也来得及。",
"gd_parry_miss": "前摇声起的时候双击,才能格挡。",
"gd_stall": "想好了吗?左右滑动选择,单击出手。",
"npc_headman_1": "外乡人,林子里不太平。这五十金,拿着防身。",
"npc_headman_2": "商店就在村里,缺什么就去看看。",
"npc_headman_3": "听说你是用耳朵走路的剑客?保重啊。",
"npc_headman_4": "救救那些被掳走的孩子吧。",
"npc_hunter_1": "站住。……哦,是个用耳朵的。",
"npc_hunter_2": "寨子里有我旧识。带上这个,别死在路上。",
"npc_hunter_3": "黑风寨主的双刀很快,听清他出刀前的动静。",
"npc_hunter_4": "去吧。让我听听,你的传说。",
"npc_elite_1": "哪儿来的瞎子,敢闯黑风寨!",
"npc_elite_half": "你……你到底是什么人!",
"npc_elite_die": "寨主……会替我报仇……",
"npc_boss1_1": "盲剑客?正好,省得挖你的眼睛。",
"npc_boss1_2": "有两下子。可惜,你听不懂死期的声音。",
"npc_boss1_die": "白无瞳……会找到你……"
}
}
+13
View File
@@ -132,6 +132,15 @@
<option value="1">正常</option>
<option value="1.35">快</option>
<option value="1.7">特快</option>
<option value="2.2">特快++</option>
</select>
</div>
<div class="setting-item">
<label for="sel-guide">向导帮助</label>
<select id="sel-guide">
<option value="full">详细</option>
<option value="standard">只听提示音</option>
<option value="off">关闭</option>
</select>
</div>
<div class="setting-item">
@@ -283,12 +292,14 @@
const selVoice = document.getElementById('sel-voice');
const selSpeed = document.getElementById('sel-speed');
const selBgm = document.getElementById('sel-bgm');
const selGuide = document.getElementById('sel-guide');
function reflectSettings() {
const s = settings.get();
selVoice.value = s.voice;
selSpeed.value = String(s.speed);
selBgm.value = String(s.bgm);
selGuide.value = s.guide;
}
function onSettingChange() {
@@ -296,6 +307,7 @@
voice: selVoice.value,
speed: Number(selSpeed.value),
bgm: Number(selBgm.value),
guide: selGuide.value,
};
settings.set(patch); // 始终写 localStorage
// 已登录时同步到服务器(game 键 '_settings')
@@ -309,6 +321,7 @@
selVoice.onchange = onSettingChange;
selSpeed.onchange = onSettingChange;
selBgm.onchange = onSettingChange;
selGuide.onchange = onSettingChange;
// 登录后从服务器拉回设置(服务器为准)
async function pullSettings() {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40 -6
View File
@@ -33,10 +33,15 @@
* 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/
* end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断);
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
* 长按快进豁免(12 文档 §2.2:main+tut 应豁免):引擎当前无 per-game
* 开关(audio.js 全局监听 soundgame:longpress),记为已知限制,不改引擎。
* 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false)
* per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。
* 向导层(research/03-schemes/schemes.md §2):每曲首次领奏→应答切换推
* gd_answer(turn cue);连续 2 次 Miss 播 gd_miss(queue 插播,同曲一次);
* 结算链换 _speakGuarded(资产缺失不卡结算 BGM);状态签名 earcon 与状态
* BGM 同点(select=menu、main=battle、end=settle)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 判定与调度常量(03 文档 §2 施工契约) ----
@@ -133,6 +138,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class RhythmGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | intro | tut | select | main | menu | end
this._runId = 0; // 重开/切歌时令旧的语音链/调度回调失效
this._timer = null;
@@ -154,6 +160,7 @@ export class RhythmGame extends Game {
this._menuIdx = 0;
this._pauseAt = 0;
this._bgmOffset = 0;
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
}
// ---------------- 生命周期 ----------------
@@ -178,6 +185,7 @@ export class RhythmGame extends Game {
stop() {
this._runId++;
this._cleanup();
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
this.audio.stopAll();
}
@@ -358,8 +366,10 @@ export class RhythmGame extends Game {
_enterSelect(rid) {
if (this._runId !== rid) return;
this.phase = 'select';
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
// 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图)
this.audio.play('bgm/menu', { fadeMs: 800 });
this.guide.state('menu'); // 状态签名:选曲界面(与状态 BGM 同点)
this._speak(['voice/select_hint', SONGS[this._songIdx].nameVoice]);
}
@@ -388,12 +398,15 @@ export class RhythmGame extends Game {
_startSong(rid, songIdx) {
if (this._runId !== rid) return;
this._cleanup();
this.audio.setRateBoostEnabled(false); // 演奏期豁免长按快进(12 文档 §2.2)
// 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM
this.audio.stopBgm(400);
this.phase = 'main';
this.guide.state('battle'); // 状态签名:演奏/对抗(与状态 BGM 切换同点)
this._songIdx = songIdx;
this._song = SONGS[songIdx];
this._stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 };
this._consecMiss = 0;
const spb = 60 / this._song.bpm;
const now = this.audio.ctx.currentTime;
@@ -414,6 +427,14 @@ export class RhythmGame extends Game {
if (i === midPhrase) {
this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
}
if (i === 0) {
// 向导层(schemes §2):每曲首次领奏→应答切换推 gd_answer(turn cue,
// 谱面排程处入 _timed,mid_song 同款先例;full 档语音 queue 插播不顶 BGM)
this._timed.push({
time: leadBarT0 + 4 * spb,
fn: () => this.guide.turn({ voice: 'gd_answer' }),
});
}
bar += 2 + (this._song.restAfter[i] || 0);
});
this._sortSched();
@@ -466,11 +487,11 @@ export class RhythmGame extends Game {
/**
* voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。
* 引擎暂无"voice 播放中"公开查询接口,读内部 _voice 令牌判定。
* 用引擎公开的 isVoicePlaying() 判定(原读私有 _voice,清点 §0)。
* @returns {boolean} true = 该手势已消费(跳过了一句)
*/
_trySkipVoice(g) {
if (g !== 'tap' || !this.audio._voice) return false;
if (g !== 'tap' || !this.audio.isVoicePlaying()) return false;
this.audio.skipVoice();
return true;
}
@@ -537,6 +558,7 @@ export class RhythmGame extends Game {
if (cue.judged) return;
cue.judged = true;
cue.result = perfect ? 'perfect' : 'good';
this._consecMiss = 0; // 打上了就断开连续 Miss 链
const s = this._stats;
if (perfect) s.perfect++; else s.good++;
s.combo++;
@@ -552,6 +574,12 @@ export class RhythmGame extends Game {
this._stats.miss++;
this._stats.combo = 0;
vibrate('error'); // 错过=没出声(不播乐器音),震动是唯一反馈
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
this._consecMiss++;
if (this._consecMiss === 2) {
this.guide.error({ voice: 'gd_miss', onceKey: 'rhythm_miss', skipHaptic: true });
}
this._checkPhraseAllPerfect(cue.phrase);
}
@@ -570,6 +598,8 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this.phase = 'menu';
this._menuIdx = 0;
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
// 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._pauseAt = this.audio.ctx.currentTime;
@@ -604,6 +634,7 @@ export class RhythmGame extends Game {
// _startBgm 内部重算 _bgmT0 = resumeAt - offset = 旧网格 + shift
this._startBgm(this._song, resumeAt, this._bgmOffset);
this.phase = 'main';
this.audio.setRateBoostEnabled(false); // 回到演奏期
this._startPump(rid);
}
@@ -627,6 +658,8 @@ export class RhythmGame extends Game {
_finish(rid) {
if (this._runId !== rid) return;
this.phase = 'end';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开演奏期,恢复长按快进
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._sched = [];
this._timed = [];
@@ -675,8 +708,9 @@ export class RhythmGame extends Game {
}
ids.push('voice/restart');
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms
this._speak(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
// 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
}
// ---------------- 进度存储 ----------------
+1 -1
View File
@@ -153,7 +153,7 @@ let audio = null; let game = null; let playLog = []; let gestures = 0;
async function newWorld(progress = null) {
store.clear();
if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress));
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 });
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
nowMs = 0; timers.clear(); vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0;
audio = new WebAudioEngine('audio/');
playLog = [];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -2
View File
@@ -33,6 +33,7 @@
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ================================ 数据表 ================================
@@ -115,6 +116,7 @@ const PREWARM_SFX = [
export class RpgGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | intro | explore | shop | battle | menu | over
this._session = 0; // 会话令牌:大跳转时作废旧计时器与语音链
this._timers = [];
@@ -220,6 +222,7 @@ export class RpgGame extends Game {
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位 PLAYER_SELECT 看门狗(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'tap') { const s = this._loadSave(); if (s) this._continueGame(s); else this._startNewGame(false); }
@@ -496,6 +499,7 @@ export class RpgGame extends Game {
this.phase = 'menu';
this._menuIdx = 0;
this.audio.play('bgm/menu'); // 模态菜单状态音乐(10 文档 §7:amb 继续,"游戏还在,界面开了")
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._speak(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`]);
}
@@ -603,6 +607,14 @@ export class RpgGame extends Game {
b.state = 'PLAYER_SELECT';
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
this.audio.play('sfx/turn_player');
// 向导层静默看门狗(schemes §2):探索/常规战斗本无卡关计时,PLAYER_SELECT
// 挂机永远静默等——60s 无输入 → nudge + gd_stall(一级即止,自动停止)
this.guide.watchdog({
arm: true,
at: [60000],
say: ['gd_stall'],
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT',
});
const cmdIdx = b.cmdIdx;
this._after(650, () => {
if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) {
@@ -674,6 +686,9 @@ export class RpgGame extends Game {
this._enemyResolve();
} else {
vibrate('error'); // 窗口内做错手势:留在窗口
// 向导层(schemes §2):窗口内做错原本仅震动无语音(清点 §6.4 薄弱点)
// → full 档 gd_parry_miss(已震 error,skipHaptic 免重复;onceKey 每局一次)
this.guide.error({ voice: 'gd_parry_miss', onceKey: 'rpg_parry_miss', skipHaptic: true });
}
}
// 其他战斗状态:手势忽略
@@ -931,6 +946,7 @@ export class RpgGame extends Game {
this._save();
this._stopAmb();
this.audio.play('bgm/ending');
this.guide.state('settle'); // 状态签名:章结算(与状态 BGM 同点)
this._speakGuarded(['voice/evt_ch1_end'], () => {
this._enterScene('village');
}, 15000);
@@ -946,12 +962,14 @@ export class RpgGame extends Game {
this._playAmb(`sfx/${SCENES[sceneId].amb}`);
if (sceneId === 'village') this.audio.play('bgm/village', { fadeMs });
else this.audio.play('bgm/explore', { volume: 0.35, fadeMs });
this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点)
}
/** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */
_switchBattleAudio(isBoss) {
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
this.guide.state('battle'); // 状态签名:战斗(与状态 BGM 同点)
}
/**
@@ -1050,8 +1068,8 @@ export class RpgGame extends Game {
// ---------------- 工具 ----------------
/** 当前是否有对白在播(引擎 voice token;探索态单击=skipVoice 的语境判定,12 文档 §1)。 */
_voicePlaying() { return !!this.audio._voice; }
/** 当前是否有对白在播(探索态单击=skipVoice 的语境判定,12 文档 §1)。 */
_voicePlaying() { return this.audio.isVoicePlaying(); } // 公开 API(原读私有 _voice,清点 §0)
/** R6 首战教学链(tut_battle_1..5)全周期只播一次:跨存档持久化,二周目起跳过。 */
_tutIntroSeen() {
+6
View File
@@ -148,6 +148,8 @@ class MockAudioEngine {
this.ctx = new MockAudioContext();
this._sfxBus = { kind: 'sfxBus' };
}
isVoicePlaying() { return this._voice !== null; } // 对齐引擎公开 API(game 侧已改用它)
setRateBoostEnabled() {} // 对齐引擎公开 API(rpg 不调用,保持 mock 完整)
_dur(id) { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进
play(id, opts = {}) {
const layer = id.split('/')[0];
@@ -239,6 +241,10 @@ function vibratedError(fromIdx) {
// ================================ 游戏装配 ================================
const { RpgGame } = await import('../game.js');
const { settings } = await import('../../engine/settings.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audioRegistry = [];
function newGame() {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -1
View File
@@ -33,6 +33,7 @@
* 语义震动(beat/confirm/success/error/warning/pickup/hit)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
@@ -63,9 +64,15 @@ const TEACH_BLUNT = {
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
// 向导层(schemes.md §2):input 阶段静默看门狗——10s 轻提醒+gd_relisten(不改变
// 重听扣机会规则),25s 免扣机会自动重听一次(每局 2 次)
const GUIDE_INPUT_WATCHDOG_AT = [10000, 25000];
const GUIDE_AUTO_RELISTEN_MAX = 2;
export class SimonGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | tutorial | announce | playback | input | roundend | gameover | menu
this.sequence = [];
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
@@ -80,6 +87,7 @@ export class SimonGame extends Game {
this._menuIdx = 0;
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
this._timers = [];
this._guideRelistens = 0; // 向导免机会自动重听已用次数(每局重置)
}
start() {
@@ -93,6 +101,7 @@ export class SimonGame extends Game {
}
onGesture(g) {
this.guide.disarm(); // 任何手势复位 input 静默看门狗(guide=off 时 no-op)
// 单击跳过旁白(12 号文档):voice 播放中单击 = skipVoice()(截断当前句、
// 链播自然进下一句);无语音在播时单击 = 各阶段原语义。跳过不发震动。
if (g === 'tap' && this._tapSkipsVoice()) {
@@ -129,7 +138,7 @@ export class SimonGame extends Game {
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
*/
_tapSkipsVoice() {
if (!this.audio._voice) return false;
if (!this.audio.isVoicePlaying()) return false; // 公开 API(原读私有 _voice,清点 §0)
switch (this.phase) {
case 'tutorial': return !this.teachAwait;
case 'announce':
@@ -224,6 +233,7 @@ export class SimonGame extends Game {
this.sequence = [];
this.round = 0;
this.chances = 1;
this._guideRelistens = 0; // 向导免机会自动重听每局重置
this._nextRound();
}
@@ -302,6 +312,7 @@ export class SimonGame extends Game {
this._timer(() => {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm'); // "轮到你了"
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
if (this.round === 1) {
this.audio.play('voice/your_turn', {
onComplete: () => {
@@ -321,6 +332,24 @@ export class SimonGame extends Game {
_toInput() {
this.phase = 'input';
this.inputIdx = 0;
// 向导层静默看门狗(schemes §2,全 6 款最危险的静默等待):input 阶段无 BGM
// 无超时,挂机永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会
// 规则);25s → 免扣机会自动重听序列一次(每局 2 次),防挂机死等。
this.guide.watchdog({
arm: true,
at: GUIDE_INPUT_WATCHDOG_AT,
say: ['gd_relisten', null],
onEscalate: (n) => { if (n === 2) this._guideAutoRelisten(); },
cond: () => this.phase === 'input',
});
}
/** 向导兜底:免扣机会自动重听当前序列(每局最多 2 次,序列不变进度重输)。 */
_guideAutoRelisten() {
if (this._guideRelistens >= GUIDE_AUTO_RELISTEN_MAX) return;
this._guideRelistens++;
if (this.phase !== 'input') return;
this._playSequence();
}
_onInput(g) {
@@ -392,6 +421,7 @@ export class SimonGame extends Game {
_gameOver() {
this.phase = 'gameover';
this.guide.state('settle'); // 状态签名:结算(与下方 bgm/menu 同点)
const s = this._session;
const score = this.round - 1; // 已完成的轮数
const isRecord = score > this.best;
@@ -441,6 +471,7 @@ export class SimonGame extends Game {
_openMenu() {
this._menuIdx = 0;
this.phase = 'menu';
this.guide.state('menu'); // 状态签名:菜单(与下方 bgm/menu 同点)
// 状态 BGM:菜单挂 menu pad(volume 0.3,淡入 600ms;与结算同一首,引擎不重启)
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 600 });
this._speak(['voice/menu_open', 'voice/menu_restart'], null);
+3
View File
@@ -212,6 +212,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SimonGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audio = new WebAudioEngine('audio/');
const game = new SimonGame(audio, fakeEl());
let gameRef = game; // 手势注入目标(第二场景换新实例时切换)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -3
View File
@@ -30,8 +30,14 @@
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
*
* 向导层(research/03-schemes/schemes.md §2):对局第 1 次受击(保护期外)追加
* 指令式 gd_hit_hint(归因句 hit_<dir> 之外的"该怎么拨");结算链换
* _speakGuarded(资产缺失不卡结算 BGM);长按快进对局期豁免
* (setRateBoostEnabled);状态签名 earcon 与状态 BGM 同点。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
@@ -88,6 +94,7 @@ const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class SpaceGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
@@ -140,12 +147,14 @@ export class SpaceGame extends Game {
stop() {
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this.audio.stopAll();
}
// ---------------- 手势 ----------------
onGesture(g) {
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
switch (this.phase) {
case 'ask':
if (g === 'up') { this.audio.stopVoice(); this._startDrill(); }
@@ -271,9 +280,11 @@ export class SpaceGame extends Game {
this.audio.stopVoice(); // 打断结算/菜单语音链,避免残留到下一局
this._resetRunState();
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 对局期豁免长按快进(演奏/闪避冲突)
this.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
this.audio.play('bgm/engine');
this.guide.state('battle'); // 状态签名:对局(与状态 BGM 同点)
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
@@ -498,6 +509,11 @@ export class SpaceGame extends Game {
const ids = [`voice/hit_${ob.dir}`];
if (this.hits === 2) ids.push('voice/hit_last');
this._speak(ids);
// 向导层(schemes §2):对局第 1 次受击追加指令式提示(hit_<dir> 已归因方位,
// 这句补"该怎么拨";已震 hit 免重复 error 震;onceKey 同局一次)
if (this.hits === 1) {
this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
}
}
}
@@ -515,6 +531,8 @@ export class SpaceGame extends Game {
_openMenu() {
this.phase = 'menu';
this.audio.setRateBoostEnabled(true); // 暂停期恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点)
this._clearTimers(); // 模态暂停:刷怪与进行中陨石全部冻结清除
this.obstacles.clear();
this.swipes = [];
@@ -541,6 +559,8 @@ export class SpaceGame extends Game {
_closeMenu() {
this.phase = 'playing';
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
this._scheduleSpawn(800);
this._rearmRunTimers();
@@ -555,6 +575,8 @@ export class SpaceGame extends Game {
_gameOver() {
this.phase = 'over';
this.guide.state('settle'); // 状态签名:结算(与结算 BGM 同点)
this.audio.setRateBoostEnabled(true); // 离开对局,恢复长按快进
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
@@ -569,8 +591,9 @@ export class SpaceGame extends Game {
const ids = [this._timeBandVoice(aliveSec)];
if (isRecord) ids.push('voice/new_record');
ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint');
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链
this._speak(ids, () => {
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链。
// 链换 _speakGuarded(清点 §5.4 缺口):time_* 等资产缺失也能起结算 BGM
this._speakGuarded(ids, () => {
if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 });
});
});
@@ -644,7 +667,7 @@ export class SpaceGame extends Game {
/** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */
_voiceBusy() {
return !!this.audio._voice;
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
+3
View File
@@ -145,6 +145,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SpaceGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------------- 断言与工具 ----------------
let passed = 0, failed = 0;
function assert(cond, msg) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38 -2
View File
@@ -22,6 +22,7 @@
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
/** 剧情节点。voices 顺序播放;prompt 非空表示这是一个选择节点。 */
function node(voices, opts = {}) {
@@ -81,9 +82,13 @@ const VOICE_WATCHDOG_MS = 30000;
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
const PROMPT_RE = /^voice\/p\d/;
// 向导层(schemes.md §2):W1 静默看门狗阈值——8s 轻提醒+gd_continue,20s 升级重播帮助
const GUIDE_WATCHDOG_AT = [8000, 20000];
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
this.mode = MODE.STORY;
this.nodeId = 'n00';
this.voicePlaying = false;
@@ -99,12 +104,17 @@ export class StorylineGame extends Game {
this.companion = false; // 带上了小满
this.knowTrap = false; // 装死时听到北区陷阱的情报
this.betrayed = false; // 向猎狗交出过坐标
// 向导层:升级帮助播过后不再自动重挂看门狗(L4"之后停止,不轰炸"),
// 玩家下一次手势即复位
this._guideHelped = false;
}
// ---------------------------------------------------------------- Game
start() {
this.audio.play('bgm/dark_drone');
this.guide.state('explore'); // 状态签名:剧情/探索(与状态 BGM 同点,10 号文档)
// 预解码全部短音效(等价 Android 的 SoundPool 音量 0 预热)
this.audio.prewarm(ALL_SFX);
this.playNode('n00');
@@ -112,6 +122,8 @@ export class StorylineGame extends Game {
onGesture(g) {
console.log(`[StorylineGame] onGesture: ${g} mode=${this.mode} node=${this.nodeId} armed=${this.choiceArmed} playing=${this.voicePlaying}`);
this.guide.disarm(); // 任何手势复位静默看门狗(guide=off 时 no-op)
this._guideHelped = false; // 玩家有动作,恢复看门狗自动挂载
if (this.mode === MODE.MENU) this.onMenuGesture(g);
else this.onStoryGesture(g);
}
@@ -134,7 +146,12 @@ export class StorylineGame extends Game {
break;
case 'left':
case 'right': {
if (node.prompt == null) return;
if (node.prompt == null) {
// 无效手势零反馈是全 6 款最盲点(清点 §1.4):向导纠错——error 震 +
// full 档 gd_invalid(onceKey 同局只播一次;off 档整段 no-op)
this.guide.error({ voice: 'gd_invalid', onceKey: 'storyline_invalid' });
return;
}
if (this.choiceArmed) this.choose(g);
else if (this.voicePlaying) this.pendingChoice = g; // 提示音播放中,缓存抉择
break;
@@ -220,10 +237,26 @@ export class StorylineGame extends Game {
this.pendingChoice = null;
this.choose(g);
}
// 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
} else if (node.ending) {
this.awaitingRestart = true;
}
// 否则等待单击继续
// W1/W3 静默等待看门狗(向导层,schemes §2):8s nudge+gd_continue;
// 20s 升级 = 复用 playHelpAndResume(帮助+进度+重播当前节点),升级后不再
// 自动重挂(防循环轰炸),玩家下一次手势恢复。抉择态与语音播放中不挂。
if (node.prompt == null && !this._guideHelped) {
this.guide.watchdog({
arm: true,
at: GUIDE_WATCHDOG_AT,
say: ['gd_continue', null],
onEscalate: (n) => {
if (n !== 2 || this.mode !== MODE.STORY) return;
this._guideHelped = true;
this.playHelpAndResume();
},
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
});
}
}
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
@@ -302,6 +335,7 @@ export class StorylineGame extends Game {
this.audio.stopVoice();
this.audio.play('sfx/ui_menu');
this.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2)
this.guide.state('menu');
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
}
@@ -313,6 +347,7 @@ export class StorylineGame extends Game {
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone
this.guide.state('explore');
this.playNode(this.nodeId); // 回到故事,重播当前节点
}
@@ -325,6 +360,7 @@ export class StorylineGame extends Game {
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
this.guide.state('explore');
this.playNode('n00');
}
+3
View File
@@ -165,6 +165,9 @@ const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { StorylineGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
let gestureCount = 0;
function makeRig() {
const audio = new WebAudioEngine('audio/');