diff --git a/docs/design/00-global-ux.md b/docs/design/00-global-ux.md index b8d3eb80..321ebe31 100644 --- a/docs/design/00-global-ux.md +++ b/docs/design/00-global-ux.md @@ -1,7 +1,7 @@ # 00 · 全局交互规范(Global UX) > **给开发者的实现要点摘要** -> 1. 设置项(voice / speed / bgm)只在大厅页用 `` 可视化 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//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` 大厅已有"设置"面板:三个 ``(旁白音色:女声/男声;语速:正常/快/特快/特快++;背景音乐:正常/小/关闭;向导帮助:详细/只听提示音/关闭),变更即 `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 档)覆盖。 diff --git a/docs/design/14-guide-layer.md b/docs/design/14-guide-layer.md new file mode 100644 index 00000000..66eafb07 --- /dev/null +++ b/docs/design/14-guide-layer.md @@ -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//audio/voice/{female,male}/gd_*.{ogg,m4a}`(管线 +`tools/gen_voice_dual.py --game --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()` 会顺带丢弃排队中的 + 向导句(作废语义,可接受)。 diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index 023ec05f..fe8b5b95 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -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//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() 退订 ``` diff --git a/research/04-playtests/transcripts/escape.md b/research/04-playtests/transcripts/escape.md new file mode 100644 index 00000000..61df5e9b --- /dev/null +++ b/research/04-playtests/transcripts/escape.md @@ -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) diff --git a/research/04-playtests/transcripts/rhythm.md b/research/04-playtests/transcripts/rhythm.md new file mode 100644 index 00000000..e2557d88 --- /dev/null +++ b/research/04-playtests/transcripts/rhythm.md @@ -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) diff --git a/research/04-playtests/transcripts/rpg.md b/research/04-playtests/transcripts/rpg.md new file mode 100644 index 00000000..b8d9d0bb --- /dev/null +++ b/research/04-playtests/transcripts/rpg.md @@ -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 【状态】狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工 【溪流】:左右滑切选项,单击确认,双击重听,上滑查状态,下滑菜单(语音播放中) diff --git a/research/04-playtests/transcripts/simon.md b/research/04-playtests/transcripts/simon.md new file mode 100644 index 00000000..a084716a --- /dev/null +++ b/research/04-playtests/transcripts/simon.md @@ -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) diff --git a/research/04-playtests/transcripts/space.md b/research/04-playtests/transcripts/space.md new file mode 100644 index 00000000..3d7c9188 --- /dev/null +++ b/research/04-playtests/transcripts/space.md @@ -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) diff --git a/research/04-playtests/transcripts/storyline.md b/research/04-playtests/transcripts/storyline.md index 193dc122..d3f4c083 100644 --- a/research/04-playtests/transcripts/storyline.md +++ b/research/04-playtests/transcripts/storyline.md @@ -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) diff --git a/tools/extract_voice_text.py b/tools/extract_voice_text.py index 528a2861..69bd2082 100644 --- a/tools/extract_voice_text.py +++ b/tools/extract_voice_text.py @@ -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 diff --git a/tools/gen_voice_dual.py b/tools/gen_voice_dual.py index ce449ec8..9ef8a5d3 100644 --- a/tools/gen_voice_dual.py +++ b/tools/gen_voice_dual.py @@ -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/.* diff --git a/web/engine/audio.js b/web/engine/audio.js index 0d50191e..33b0b18f 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -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); diff --git a/web/engine/guide.js b/web/engine/guide.js new file mode 100644 index 00000000..cf0687e2 --- /dev/null +++ b/web/engine/guide.js @@ -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} 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; diff --git a/web/engine/settings.js b/web/engine/settings.js index 5b74b6a0..b3692543 100644 --- a/web/engine/settings.js +++ b/web/engine/settings.js @@ -1,37 +1,39 @@ /** * settings.js — 全局用户设置(纯音频游戏合集共享)。 * - * 三项设置: - * | 键 | 取值 | 默认 | 作用 | - * |--------|-----------------------|----------|----------------------------------------| - * | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice// 下资产 | - * | 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// 下资产 | + * | 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} */ diff --git a/web/escape/game.js b/web/escape/game.js index 88303b90..045ed90e 100644 --- a/web/escape/game.js +++ b/web/escape/game.js @@ -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); diff --git a/web/escape/test/playtest.mjs b/web/escape/test/playtest.mjs index 2f602a0a..b0e69822 100644 --- a/web/escape/test/playtest.mjs +++ b/web/escape/test/playtest.mjs @@ -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; diff --git a/web/harness/guide.test.mjs b/web/harness/guide.test.mjs new file mode 100644 index 00000000..02a0e927 --- /dev/null +++ b/web/harness/guide.test.mjs @@ -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('全部断言通过。'); diff --git a/web/harness/mock.mjs b/web/harness/mock.mjs index bfadd16e..0dbbb28a 100644 --- a/web/harness/mock.mjs +++ b/web/harness/mock.mjs @@ -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() { diff --git a/web/harness/sessions/escape.mjs b/web/harness/sessions/escape.mjs new file mode 100644 index 00000000..de538e81 --- /dev/null +++ b/web/harness/sessions/escape.mjs @@ -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('柜子开了,拿到保险丝——解到第二间,收工'); +} diff --git a/web/harness/sessions/rhythm.mjs b/web/harness/sessions/rhythm.mjs new file mode 100644 index 00000000..361a64b3 --- /dev/null +++ b/web/harness/sessions/rhythm.mjs @@ -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('曲子打完了,结算播报中'); +} diff --git a/web/harness/sessions/rpg.mjs b/web/harness/sessions/rpg.mjs new file mode 100644 index 00000000..bee66c6b --- /dev/null +++ b/web/harness/sessions/rpg.mjs @@ -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('狼打完了,胜利播报完回到溪流边。一场完整战斗到此收工'); +} diff --git a/web/harness/sessions/simon.mjs b/web/harness/sessions/simon.mjs new file mode 100644 index 00000000..c0a4aefe --- /dev/null +++ b/web/harness/sessions/simon.mjs @@ -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} 轮`); +} diff --git a/web/harness/sessions/space.mjs b/web/harness/sessions/space.mjs new file mode 100644 index 00000000..50238c38 --- /dev/null +++ b/web/harness/sessions/space.mjs @@ -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('躲过保护期、挨过一次撞,心跳有点快,先到这'); +} diff --git a/web/harness/sessions/storyline.mjs b/web/harness/sessions/storyline.mjs new file mode 100644 index 00000000..7af60e15 --- /dev/null +++ b/web/harness/sessions/storyline.mjs @@ -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('回到开头,会话结束'); +} diff --git a/web/harness/transcribe.mjs b/web/harness/transcribe.mjs index 5b3e30a0..4a0a5e68 100644 --- a/web/harness/transcribe.mjs +++ b/web/harness/transcribe.mjs @@ -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(); diff --git a/web/harness/voice_text.json b/web/harness/voice_text.json index 325348ec..1de8697a 100644 --- a/web/harness/voice_text.json +++ b/web/harness/voice_text.json @@ -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": "白无瞳……会找到你……" } } \ No newline at end of file diff --git a/web/index.html b/web/index.html index 7710873a..5dd3dabe 100644 --- a/web/index.html +++ b/web/index.html @@ -132,6 +132,15 @@ + + + +
+ +
@@ -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() { diff --git a/web/rhythm/audio/voice/female/gd_answer.m4a b/web/rhythm/audio/voice/female/gd_answer.m4a new file mode 100644 index 00000000..3f27f810 Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_answer.m4a differ diff --git a/web/rhythm/audio/voice/female/gd_answer.ogg b/web/rhythm/audio/voice/female/gd_answer.ogg new file mode 100644 index 00000000..54cf7c78 Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_answer.ogg differ diff --git a/web/rhythm/audio/voice/female/gd_miss.m4a b/web/rhythm/audio/voice/female/gd_miss.m4a new file mode 100644 index 00000000..8a9823de Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_miss.m4a differ diff --git a/web/rhythm/audio/voice/female/gd_miss.ogg b/web/rhythm/audio/voice/female/gd_miss.ogg new file mode 100644 index 00000000..7088ded8 Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_miss.ogg differ diff --git a/web/rhythm/audio/voice/male/gd_answer.m4a b/web/rhythm/audio/voice/male/gd_answer.m4a new file mode 100644 index 00000000..8448561c Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_answer.m4a differ diff --git a/web/rhythm/audio/voice/male/gd_answer.ogg b/web/rhythm/audio/voice/male/gd_answer.ogg new file mode 100644 index 00000000..106a1050 Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_answer.ogg differ diff --git a/web/rhythm/audio/voice/male/gd_miss.m4a b/web/rhythm/audio/voice/male/gd_miss.m4a new file mode 100644 index 00000000..0e210c05 Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_miss.m4a differ diff --git a/web/rhythm/audio/voice/male/gd_miss.ogg b/web/rhythm/audio/voice/male/gd_miss.ogg new file mode 100644 index 00000000..4920033d Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_miss.ogg differ diff --git a/web/rhythm/game.js b/web/rhythm/game.js index 827176f0..e9f55146 100644 --- a/web/rhythm/game.js +++ b/web/rhythm/game.js @@ -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 })); } // ---------------- 进度存储 ---------------- diff --git a/web/rhythm/test/playtest.mjs b/web/rhythm/test/playtest.mjs index 26257c14..40f1fc2b 100644 --- a/web/rhythm/test/playtest.mjs +++ b/web/rhythm/test/playtest.mjs @@ -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 = []; diff --git a/web/rpg/audio/voice/female/gd_parry_miss.m4a b/web/rpg/audio/voice/female/gd_parry_miss.m4a new file mode 100644 index 00000000..4192b7cc Binary files /dev/null and b/web/rpg/audio/voice/female/gd_parry_miss.m4a differ diff --git a/web/rpg/audio/voice/female/gd_parry_miss.ogg b/web/rpg/audio/voice/female/gd_parry_miss.ogg new file mode 100644 index 00000000..e9b016ee Binary files /dev/null and b/web/rpg/audio/voice/female/gd_parry_miss.ogg differ diff --git a/web/rpg/audio/voice/female/gd_stall.m4a b/web/rpg/audio/voice/female/gd_stall.m4a new file mode 100644 index 00000000..af5f7d58 Binary files /dev/null and b/web/rpg/audio/voice/female/gd_stall.m4a differ diff --git a/web/rpg/audio/voice/female/gd_stall.ogg b/web/rpg/audio/voice/female/gd_stall.ogg new file mode 100644 index 00000000..7ddf9513 Binary files /dev/null and b/web/rpg/audio/voice/female/gd_stall.ogg differ diff --git a/web/rpg/audio/voice/male/gd_parry_miss.m4a b/web/rpg/audio/voice/male/gd_parry_miss.m4a new file mode 100644 index 00000000..f75eadaa Binary files /dev/null and b/web/rpg/audio/voice/male/gd_parry_miss.m4a differ diff --git a/web/rpg/audio/voice/male/gd_parry_miss.ogg b/web/rpg/audio/voice/male/gd_parry_miss.ogg new file mode 100644 index 00000000..7834c485 Binary files /dev/null and b/web/rpg/audio/voice/male/gd_parry_miss.ogg differ diff --git a/web/rpg/audio/voice/male/gd_stall.m4a b/web/rpg/audio/voice/male/gd_stall.m4a new file mode 100644 index 00000000..3cdd7c67 Binary files /dev/null and b/web/rpg/audio/voice/male/gd_stall.m4a differ diff --git a/web/rpg/audio/voice/male/gd_stall.ogg b/web/rpg/audio/voice/male/gd_stall.ogg new file mode 100644 index 00000000..9e7a2a55 Binary files /dev/null and b/web/rpg/audio/voice/male/gd_stall.ogg differ diff --git a/web/rpg/game.js b/web/rpg/game.js index af3af854..7e01c206 100644 --- a/web/rpg/game.js +++ b/web/rpg/game.js @@ -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() { diff --git a/web/rpg/test/playtest.mjs b/web/rpg/test/playtest.mjs index 1659586a..2445d38e 100644 --- a/web/rpg/test/playtest.mjs +++ b/web/rpg/test/playtest.mjs @@ -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() { diff --git a/web/simon/audio/voice/female/gd_relisten.m4a b/web/simon/audio/voice/female/gd_relisten.m4a new file mode 100644 index 00000000..4e845aa9 Binary files /dev/null and b/web/simon/audio/voice/female/gd_relisten.m4a differ diff --git a/web/simon/audio/voice/female/gd_relisten.ogg b/web/simon/audio/voice/female/gd_relisten.ogg new file mode 100644 index 00000000..21c803d6 Binary files /dev/null and b/web/simon/audio/voice/female/gd_relisten.ogg differ diff --git a/web/simon/audio/voice/male/gd_relisten.m4a b/web/simon/audio/voice/male/gd_relisten.m4a new file mode 100644 index 00000000..658671dd Binary files /dev/null and b/web/simon/audio/voice/male/gd_relisten.m4a differ diff --git a/web/simon/audio/voice/male/gd_relisten.ogg b/web/simon/audio/voice/male/gd_relisten.ogg new file mode 100644 index 00000000..d587953c Binary files /dev/null and b/web/simon/audio/voice/male/gd_relisten.ogg differ diff --git a/web/simon/game.js b/web/simon/game.js index e51ff679..b1768183 100644 --- a/web/simon/game.js +++ b/web/simon/game.js @@ -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); diff --git a/web/simon/test/playtest.mjs b/web/simon/test/playtest.mjs index 6add4ef3..798c15e5 100644 --- a/web/simon/test/playtest.mjs +++ b/web/simon/test/playtest.mjs @@ -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; // 手势注入目标(第二场景换新实例时切换) diff --git a/web/space/audio/voice/female/gd_hit_hint.m4a b/web/space/audio/voice/female/gd_hit_hint.m4a new file mode 100644 index 00000000..afb02586 Binary files /dev/null and b/web/space/audio/voice/female/gd_hit_hint.m4a differ diff --git a/web/space/audio/voice/female/gd_hit_hint.ogg b/web/space/audio/voice/female/gd_hit_hint.ogg new file mode 100644 index 00000000..250a1280 Binary files /dev/null and b/web/space/audio/voice/female/gd_hit_hint.ogg differ diff --git a/web/space/audio/voice/male/gd_hit_hint.m4a b/web/space/audio/voice/male/gd_hit_hint.m4a new file mode 100644 index 00000000..300704e6 Binary files /dev/null and b/web/space/audio/voice/male/gd_hit_hint.m4a differ diff --git a/web/space/audio/voice/male/gd_hit_hint.ogg b/web/space/audio/voice/male/gd_hit_hint.ogg new file mode 100644 index 00000000..569ba9ee Binary files /dev/null and b/web/space/audio/voice/male/gd_hit_hint.ogg differ diff --git a/web/space/game.js b/web/space/game.js index 3d470183..aa9a21c9 100644 --- a/web/space/game.js +++ b/web/space/game.js @@ -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_ 之外的"该怎么拨");结算链换 + * _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_ 已归因方位, + // 这句补"该怎么拨";已震 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 打断则整条作废。 */ diff --git a/web/space/test/playtest.mjs b/web/space/test/playtest.mjs index 20b4064f..cfe5bcba 100644 --- a/web/space/test/playtest.mjs +++ b/web/space/test/playtest.mjs @@ -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) { diff --git a/web/storyline/audio/voice/female/gd_continue.m4a b/web/storyline/audio/voice/female/gd_continue.m4a new file mode 100644 index 00000000..968d28de Binary files /dev/null and b/web/storyline/audio/voice/female/gd_continue.m4a differ diff --git a/web/storyline/audio/voice/female/gd_continue.ogg b/web/storyline/audio/voice/female/gd_continue.ogg new file mode 100644 index 00000000..97f0a654 Binary files /dev/null and b/web/storyline/audio/voice/female/gd_continue.ogg differ diff --git a/web/storyline/audio/voice/female/gd_invalid.m4a b/web/storyline/audio/voice/female/gd_invalid.m4a new file mode 100644 index 00000000..2f64c635 Binary files /dev/null and b/web/storyline/audio/voice/female/gd_invalid.m4a differ diff --git a/web/storyline/audio/voice/female/gd_invalid.ogg b/web/storyline/audio/voice/female/gd_invalid.ogg new file mode 100644 index 00000000..7e964693 Binary files /dev/null and b/web/storyline/audio/voice/female/gd_invalid.ogg differ diff --git a/web/storyline/audio/voice/male/gd_continue.m4a b/web/storyline/audio/voice/male/gd_continue.m4a new file mode 100644 index 00000000..44a80956 Binary files /dev/null and b/web/storyline/audio/voice/male/gd_continue.m4a differ diff --git a/web/storyline/audio/voice/male/gd_continue.ogg b/web/storyline/audio/voice/male/gd_continue.ogg new file mode 100644 index 00000000..6ee13e1c Binary files /dev/null and b/web/storyline/audio/voice/male/gd_continue.ogg differ diff --git a/web/storyline/audio/voice/male/gd_invalid.m4a b/web/storyline/audio/voice/male/gd_invalid.m4a new file mode 100644 index 00000000..7e15bd99 Binary files /dev/null and b/web/storyline/audio/voice/male/gd_invalid.m4a differ diff --git a/web/storyline/audio/voice/male/gd_invalid.ogg b/web/storyline/audio/voice/male/gd_invalid.ogg new file mode 100644 index 00000000..70713f20 Binary files /dev/null and b/web/storyline/audio/voice/male/gd_invalid.ogg differ diff --git a/web/storyline/game.js b/web/storyline/game.js index b3e3c9bb..6b51271c 100644 --- a/web/storyline/game.js +++ b/web/storyline/game.js @@ -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'); } diff --git a/web/storyline/test/playtest.mjs b/web/storyline/test/playtest.mjs index db937b7e..8f592743 100644 --- a/web/storyline/test/playtest.mjs +++ b/web/storyline/test/playtest.mjs @@ -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/');