diff --git a/docs/design/01-escape-voice-ids.md b/docs/design/01-escape-voice-ids.md new file mode 100644 index 00000000..7e900301 --- /dev/null +++ b/docs/design/01-escape-voice-ids.md @@ -0,0 +1,55 @@ +# 01b · 《暗室》语音台词表(voice ids) + +> 主台词表(80 句,双性别)的唯一事实源是 `tools/gen_voice_dual.py` 的 +> `ESCAPE_LINES`(R2 重设计全文见 [01-escape-redesign.md](01-escape-redesign.md); +> 史源 app/src/escape/gen/gen_voice.py)。本文档登记 P1 键盘变体(输入平权 +> 路线 a)——变体只覆盖含操作词的句子,id 集合 ⊆ 主表,复用主表同 id 的 +> instruct/speaker。 +--- + +## 键盘变体(key,P1 输入平权路线 a,30 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/escape/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 30×2 条、×2 格式 = 30×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `g1_move` | 先活动一下。按任意方向键,走一小步。 | +| `g2_wall` | 这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。 | +| `g3_left` | 跟着我指的路走。按左方向键。 | +| `g4_down` | 对。再按下方向键。 | +| `g5_inspect` | 听到水声了吗?按一下空格,仔细检查这个房间。 | +| `g6_doubletap` | 钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。 | +| `g7_goal` | 找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。 | +| `guide_wrong_up` | 不是这个方向。按上方向键。 | +| `guide_wrong_down` | 不是这个方向。按下方向键。 | +| `guide_wrong_left` | 不是这个方向。按左方向键。 | +| `guide_wrong_right` | 不是这个方向。按右方向键。 | +| `guide_blunt_up` | 按一下上方向键。 | +| `guide_blunt_down` | 按一下下方向键。 | +| `guide_blunt_left` | 按一下左方向键。 | +| `guide_blunt_right` | 按一下右方向键。 | +| `reopen_prompt` | 按上方向键,重新听一遍引导。按空格,直接开始。 | +| `stuck_key_2` | 去西南角的储物间,按空格检查,找到钥匙。 | +| `stuck_cabinet_2` | 去东边的铁柜房,按回车,用钥匙打开铁柜。 | +| `stuck_box_2` | 去西北角的电箱房,按回车,把保险丝装进电箱。 | +| `stuck_gate_2` | 去东南角的大门,按回车,打开它,离开这里。 | +| `nav_up` | 按上方向键。 | +| `nav_down` | 按下方向键。 | +| `nav_left` | 按左方向键。 | +| `nav_right` | 按右方向键。 | +| `start_room` | 你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。 | +| `inspect_gate_ready` | 电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。 | +| `use_fail` | 这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。 | +| `again` | 按空格,再来一次。按下方向键,打开菜单。 | +| `help_ops` | 方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。 | +| `skip_hint` | 按空格可跳过旁白。 | diff --git a/docs/design/02-simon-voice-ids.md b/docs/design/02-simon-voice-ids.md new file mode 100644 index 00000000..24083674 --- /dev/null +++ b/docs/design/02-simon-voice-ids.md @@ -0,0 +1,52 @@ +# 02b · 《音序回响》语音台词表(voice ids) + +> 主台词表(78 句,双性别)的唯一事实源是 `tools/gen_voice_dual.py` 的 +> `SIMON_LINES`(R2 重设计全文见 [02-simon-redesign.md](02-simon-redesign.md))。 +> 本文档登记 P1 键盘变体(输入平权路线 a)——变体只覆盖含操作词的句子, +> id 集合 ⊆ 主表,复用主表同 id 的 instruct/speaker。 +--- + +## 键盘变体(key,P1 输入平权路线 a,28 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/simon/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 28×2 条、×2 格式 = 28×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `t1_up` | 按上方向键,是这个声音。 | +| `t2_down` | 按下方向键,是这个声音。 | +| `t3_left` | 按左方向键,是这个声音。 | +| `t4_right` | 按右方向键,是这个声音。 | +| `t5_tap` | 按空格,是这个声音。 | +| `t6_double` | 按回车,是这个声音。 | +| `t_wrong_up` | 不是这个,按上方向键试试。 | +| `t_wrong_down` | 不是这个,按下方向键试试。 | +| `t_wrong_left` | 不是这个,按左方向键试试。 | +| `t_wrong_right` | 不是这个,按右方向键试试。 | +| `t_wrong_tap` | 不是这个,按空格试试。 | +| `t_wrong_double` | 不是这个,按回车试试。 | +| `t_blunt_up` | 按一下上方向键。 | +| `t_blunt_down` | 按一下下方向键。 | +| `t_blunt_left` | 按一下左方向键。 | +| `t_blunt_right` | 按一下右方向键。 | +| `t_blunt_tap` | 轻轻按一下空格。 | +| `t_blunt_double` | 按一下回车。 | +| `t7_retry` | 答错可以消耗机会重听。按上方向键,可以随时重听。 | +| `replay` | 按空格,再来一局。 | +| `rules_prompt` | 按上方向键,重听规则。按下方向键,打开菜单。 | +| `rules` | 上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。 | +| `skip_hint` | 按空格可跳过旁白。 | +| `gd_relisten` | 需要的话,按上方向键可以重新听一遍序列。 | +| `gd_economy` | 记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。 | +| `gd_occ_up` | 这一轮,按上方向键是作答。 | +| `gd_occ_dt` | 这一轮,按回车是作答。 | +| `gd_occ_both` | 这一轮,按上方向键和回车都是作答。 | diff --git a/docs/design/03-rhythm-voice-ids.md b/docs/design/03-rhythm-voice-ids.md index 668656d4..01a07809 100644 --- a/docs/design/03-rhythm-voice-ids.md +++ b/docs/design/03-rhythm-voice-ids.md @@ -69,3 +69,30 @@ --- **合计 31 句 × 2 性别 = 62 条**(其中 grade_*、restart 为重录覆盖)。无数字拼接:拍数(九十/一百零五/一百二十)与准确率档位全部写死在整句里。 +--- + +## 键盘变体(key,P1 输入平权路线 a,9 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/rhythm/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 9×2 条、×2 格式 = 9×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `tut_1` | 听,底鼓。在下一小节的同一个位置,按空格。 | +| `tut_2` | 这次是左和右。跟着打,按左方向键,按右方向键。 | +| `tut_3` | 最后一句,三个音,中间是按回车。 | +| `select_hint` | 左右方向键选曲目,按空格开始,按下方向键回大厅。 | +| `restart` | 按空格再来一遍,按右方向键下一首,按下方向键回大厅。 | +| `menu_main` | 菜单。按空格切换选项,按回车确认。 | +| `skip_hint` | 按空格可跳过旁白。 | +| `gd_miss_early` | 抢拍了。等声音落下再按。 | +| `gd_miss_wrong` | 方向不对。跟着音色按方向键。 | diff --git a/docs/design/04-space-voice-ids.md b/docs/design/04-space-voice-ids.md index 2e3488e5..38f2ee68 100644 --- a/docs/design/04-space-voice-ids.md +++ b/docs/design/04-space-voice-ids.md @@ -103,3 +103,45 @@ **合计 52 句 × 2 性别 = 104 条**(×2 格式 = 208 文件)。 旧 id 处置:R1 生成的 `num*/n_*/intro1-4/hit1/hit2/gameover/restart` 全部弃用(数字拼接与旧教学);`shield_ready`/`shield_block` 沿用 id 重录。平铺旧文件按 R1 约定在切换验证后统一清理。 +--- + +## 键盘变体(key,P1 输入平权路线 a,24 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/space/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 24×2 条、×2 格式 = 24×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `drill_left` | 听,左边有陨石。按左方向键,把它拨开! | +| `drill_right` | 这次在右边。按右方向键! | +| `drill_up` | 上面有东西,声音又尖又高。按上方向键! | +| `drill_down` | 下面是低沉的轰隆。按下方向键! | +| `drill_retry_left` | 那是左边来的,按左方向键。 | +| `drill_retry_right` | 那是右边来的,按右方向键。 | +| `drill_retry_up` | 声音又高又尖,是上面,按上方向键。 | +| `drill_retry_down` | 轰隆声在下面,按下方向键。 | +| `protect_left` | 训练护盾接住了。那颗在左边,听到左边的呼啸,按左方向键。 | +| `protect_right` | 训练护盾接住了。那颗在右边,听到右边的呼啸,按右方向键。 | +| `protect_up` | 训练护盾接住了。那颗在上面,听到高高的呼啸,按上方向键。 | +| `protect_down` | 训练护盾接住了。那颗在下面,听到低沉的轰隆,按下方向键。 | +| `feat_boost` | 试试按空格,引擎会短促加速,直接冲过空隙。 | +| `feat_shield` | 按回车展开护盾,能挡下一次撞击。 | +| `hit_left` | 左边撞上的。听到左边的呼啸,按左方向键。 | +| `hit_right` | 右边撞上的。听到右边的呼啸,按右方向键。 | +| `hit_up` | 上面撞上的。听到高高的呼啸,按上方向键。 | +| `hit_down` | 下面撞上的。听到低沉的轰隆,按下方向键。 | +| `restart_hint` | 按空格再飞一次,按下方向键回大厅。 | +| `second_run_ask` | 按上方向键重新听演练,按空格直接出发。 | +| `menu_main` | 菜单。按空格切换选项,按回车确认。 | +| `help_recap` | 左边来按左方向键,右边来按右方向键,上面来按上方向键,下面来按下方向键。按空格加速,按回车护盾。 | +| `skip_hint` | 按空格可跳过旁白。 | +| `gd_hit_hint` | 被撞了。听方位,按对应方向键就能拨开。 | diff --git a/docs/design/05-rpg-ch1-voice-ids.md b/docs/design/05-rpg-ch1-voice-ids.md index 5487eafd..c20602ea 100644 --- a/docs/design/05-rpg-ch1-voice-ids.md +++ b/docs/design/05-rpg-ch1-voice-ids.md @@ -174,3 +174,37 @@ --- **合计:旁白 104 句 × 2 性别 + NPC 16 句 = 224 条音频**(×2 格式 = 448 文件)。对话功能与语气大纲对照见 [05-rpg.md](05-rpg.md) §5.3,场景/战斗触发逻辑见 [05-rpg-chapter1.md](05-rpg-chapter1.md)。 +--- + +## 键盘变体(key,P1 输入平权路线 a,16 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/rpg/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 16×2 条、×2 格式 = 16×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `sys_continue_ask` | 检测到你的旅程记录。按空格继续,按右方向键开始新的旅程。 | +| `sys_menu` | 菜单。左右方向键选择,按空格确认。 | +| `help_explore` | 左右方向键切换选项,按空格确认。按回车重新听场景,按上方向键查看状态,按下方向键打开菜单。 | +| `help_battle` | 战斗中,左右方向键选招式,按空格出手。听到敌人的前摇,按回车格挡。 | +| `btl_lose_hint` | 记住,听清前摇,按回车格挡。 | +| `tut_b2` | 就是现在,按回车格挡! | +| `tut_b3` | 挡住了!轮到你了。左右方向键选攻击,按空格出剑。 | +| `shop_open` | 商店。左右方向键看看货,按空格购买。 | +| `skip_hint` | 按空格可跳过旁白。 | +| `tut_battle_2` | 左右方向键切换招式,攻击、破空斩、防御、金疮药。按空格,出手。 | +| `tut_battle_3` | 敌人出手前有一声前摇。听到前摇,马上按回车,就能格挡。 | +| `tut_battle_5` | 记住:听清前摇,按回车格挡。开始吧! | +| `tut_b0` | 战斗里,左右方向键选招式,按空格出手。听到这个声音,就是你的回合。先按一按方向键,试试。 | +| `tut_b6` | 来不及格挡,就选防御。左右方向键,选防御,按空格。 | +| `gd_parry_miss` | 前摇声起的时候按回车,才能格挡。 | +| `gd_stall` | 想好了吗?左右方向键选择,按空格出手。 | diff --git a/docs/design/13-playtest-criteria.md b/docs/design/13-playtest-criteria.md index 3b7b0d9c..7dfeaf98 100644 --- a/docs/design/13-playtest-criteria.md +++ b/docs/design/13-playtest-criteria.md @@ -758,3 +758,29 @@ 菜单暂停冻结、上滑 5 段状态链。 R1 起每轮的增量断言(修复回归)在各自设计文档的轮次小节追加,不在本文件重复。 + +## 21. 新游戏登记:《深海回音》(abyss)(new8 批次 2) + +叙事款(radio 同骨架),断言清单与实现核对**住设计文档**(§20 先例): + +- abyss:[17-abyss.md](17-abyss.md) §9(playtest 清单)。要点:四结局氧气剧本验算(lefts 公式 + 剧本 A-D 逐检查点 + oxy 档位句)、第四潜氧气门三形态(双载/单载/取消)、氧气账目真实(floor(oxy/3) 档位、补给恰一次、mid-dive + floor 干涸链)、声呐双通道仲裁(echo/pipe_knock 与 voice 零重叠 + sonarGate 排队)、教学承诺兑现清单 + (金属=钢板/单击=脉冲/四潜/氧气门/扣三格/敲管/补给)、看门狗双轨(乱按 96s 必被抢救 + 有进展零打扰)、 + 结算链 800ms 手势死窗(dungeon H1 内化)、键盘 _k 变体按 inputMode 选播、pendingChoice/p* 不可跳、 + 中断复述、存档 schema。 + +## 22. 新游戏登记:《暗码》(cipher)(new8 批次 3) + +叙事款(radio/abyss 同骨架 + 电文解码小局),断言清单与实现核对**住设计文档**(§21 先例): + +- cipher:[19-cipher.md](19-cipher.md) §9(playtest 清单)。要点:**摩尔斯固定字符断言**(运行时 SYN 模块 `morse.js` + 事件钩子,BAY/TOP/HAND/SPY 逐元素 + 时值容差 ±5ms + 三档速度 u=80/110/160;**不随 settings.speed 变**); + **三层兜底剧本各自走通**(纯耳朵剧本 A / 纯慢放剧本 F / 提示流剧本 E:T1 免费页 + 3 页逐值 book_2/1/0); + **译错归因真话**(T1 dv1_wrong 句内字母与真词一致 + 无限重选零惩罚;正片判定出口零对错语音;ep_* 与 + t/picks flags 单源对应);**暴露度剧本验算**(剧本 A-D 逐 NIGHT 检查点档位句 = wrong+risk 公式,收网门=hot 档 + 阈值 5);四结局矩阵(expose 档 × p9);morseGate 零重叠(摩尔斯与任一 voice 区间);密码本页数账目(逐值 + 整句、页数 0 翻本=error 震+翻空本音零惩罚);看门狗双轨(纯浏览/重听不计进展,乱按 96s 必被抢救); + 结算链 800ms 手势死窗(判定出口/NIGHT/d9/e*);键盘 _k 变体 11 对按 inputMode 选播;pendingChoice/p* 不可跳/ + 摩尔斯首播不可跳;中断复述(含 DECODE 态恢复链 prog_t→book 档→gd_tune2);存档 schema(summary 带 + 暴露档名)。 diff --git a/docs/design/15-radio-voice-ids.md b/docs/design/15-radio-voice-ids.md index a4dd96f0..c614489e 100644 --- a/docs/design/15-radio-voice-ids.md +++ b/docs/design/15-radio-voice-ids.md @@ -228,3 +228,39 @@ - 每个结局(e1..e4)专属主句+钩子各 2 句齐备,尾声救/错过双线齐备 ✓。 - 归因真话律:负向收尾句(z1_bade/k1_lulle/k1_liee/ep_*_no/e4_b)全部指向玩家实际选择,无假归因句 ✓。 - 向导 gd_* 6 句(gd_brief/gd_continue/gd_again/gd_invalid/gd_ring1/gd_ring2)符合 14 号 §6 台词规范(一句一义、无重复句式、单句 ≤15 字)✓。 +--- + +## 键盘变体(key,P1 输入平权路线 a,18 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/radio/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 18×2 条、×2 格式 = 18×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `gd_brief` | 按空格继续节目。请选择的时候,按左或右方向键。 | +| `gd_continue` | 按空格,继续。 | +| `gd_again` | 还在吗?按空格继续。 | +| `gd_ring1` | 热线在闪。按空格,接听。 | +| `gd_ring2` | 灯还亮着,听众在等。按空格,接听。 | +| `sys_help` | 按空格继续,按回车重新听。按上方向键听状态,按下方向键开菜单。请选择时,左右方向键。 | +| `sys_menu` | 菜单。按空格切换选项,按回车确认。 | +| `sys_skip` | 想跳过这句,按空格就行。 | +| `sys_continue_ask` | 检测到你的夜班记录。按空格继续上次,按右方向键开始新的夜晚。 | +| `p1` | 老人在发抖。按左方向键,陪他哼那首老歌。按右方向键,冷静问他路口的路牌。 | +| `p2` | 雨声更密了。按左方向键,留在原地陪他等巡逻队。按右方向键,转接派出所。 | +| `p3` | 按左方向键,告诉她这趟车值得上。按右方向键,把选择还给她。 | +| `p4` | 按左方向键,唱一段摇篮曲哄她睡。按右方向键,轻轻问她怎么了。 | +| `p5` | 孩子的声音在抖。按左方向键,告诉她这不是她的错。按右方向键,答应她明天都会好。 | +| `p6` | 摔门声你听见了。按左方向键,替孩子给她带句话。按右方向键,劝她回家把话说开。 | +| `p7` | 她的呼吸平了。按左方向键,留在直播里,让全城等这个门铃。按右方向键,换垫乐,私下陪她说完。 | +| `p8` | 方向盘比话筒危险。按左方向键,陪他聊聊闺女。按右方向键,让他立刻进服务区。 | +| `p9` | 留言机红灯在闪,是老主播三年前没播完的那句晚安。按左方向键,替他播出去。按右方向键,照常收播。 | diff --git a/docs/design/16-dungeon-voice-ids.md b/docs/design/16-dungeon-voice-ids.md index 1063ee9d..78e0b57d 100644 --- a/docs/design/16-dungeon-voice-ids.md +++ b/docs/design/16-dungeon-voice-ids.md @@ -179,3 +179,41 @@ 通关链 = `settle_win_1 → settle_win_2 → meta_gold_X(同上)→ unlock 段 → settle_again`; 上滑局面复述链 = `st_floor_N → st_hp_X → st_gold_X(band1 且金>0 时为 gold_some)→ st_face_X → st_threat_X`。 主设计触发逻辑对照 [16-dungeon.md](16-dungeon.md) §4-§11;资产规模核对见其 §9。 +--- + +## 键盘变体(key,P1 输入平权路线 a,20 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/dungeon/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 20×2 条、×2 格式 = 20×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `sys_continue_ask` | 检测到探窟记录。按空格继续,按右方向键重新开始。 | +| `tut_d0` | 先学会用耳朵看。按空格,发出声波脉冲。 | +| `tut_walk` | 按住空格不放,你就往前走。 | +| `tut_turn` | 按左右方向键,可以转身。 | +| `tut_replay` | 按回车,再发一次脉冲,不要钱。 | +| `tut_status` | 按上方向键,随时听自己的状态。 | +| `tut_menu` | 按下方向键,打开菜单。 | +| `tut_parry` | 听到它的动静,按回车格挡。 | +| `tut_attack` | 现在按空格,挥拳打它。 | +| `tut_flee_hold` | 想逃跑,按住空格别松手。 | +| `tut_stairs_on` | 楼梯就在脚下。按空格,下潜。 | +| `tut_skip` | 已跳过教学。按空格发脉冲,先听听四周。 | +| `sys_menu` | 菜单。按空格切换,按回车确认。 | +| `help_explore` | 按空格发脉冲,按住空格前进,左右方向键转身。按回车再听,上方向键查状态,下方向键开菜单。 | +| `help_battle` | 敌人前摇声响时按回车格挡,按空格挥击。打不过就按住空格逃跑。 | +| `die_adv_1` | 下次,前摇声响时就按回车。 | +| `die_adv_2` | 打不过,就按住空格逃跑。 | +| `gd_pulse` | 按空格发脉冲,听听四周。 | +| `gd_turn` | 轮到你了。按空格挥击。 | +| `settle_again` | 按空格,再探一次。 | diff --git a/docs/design/17-abyss-voice-ids.md b/docs/design/17-abyss-voice-ids.md new file mode 100644 index 00000000..9d62fcd7 --- /dev/null +++ b/docs/design/17-abyss-voice-ids.md @@ -0,0 +1,251 @@ +# 17b · 《深海回音》(abyss) 语音台词表(voice ids,录音依据) + +> **给音频总监的录制说明** +> - **双性别组**(潜航员 DIVER + 调度/系统 DECK,126 句):落 `web/abyss/audio/voice/female|male/.{ogg,m4a}`,两性别 id 集合完全一致(含全部 `_k` 变体)。 +> - **DIVER(潜航员=玩家)**:female = `Serena`,instruct 常量 `低沉冷静的年轻女声,深潜救援员,干练沉着,水下通讯的轻微气声`;male = `Uncle_Fu`,instruct 常量 `低沉冷静的男声,深潜救援员,干练沉着,水下通讯的轻微气声`。 +> - **DECK(母船调度/系统音/向导 gd_*)**:female = `Vivian`,instruct 常量 `清晰稳定的女声,母船潜水调度员,专业干脆,隔着耳机下达指令`;male = `Dylan`,instruct 常量 `清晰稳定的男声,母船潜水调度员,专业干脆,隔着耳机下达指令`。 +> - 区分度设计:玩家(头戴耳机的自语音)与调度(母船通讯声)共用 speaker 池但**不同 speaker 配对**——female 侧玩家 Serena / 调度 Vivian、male 侧玩家 Uncle_Fu / 调度 Dylan,同场景高频对话的两者永不同 speaker。 +> - **NPC 平铺组**(21 句):单音色、**不做双性别**,平铺落 `web/abyss/audio/voice/.{ogg,m4a}`(引擎平铺回退命中),转码时按 §电台滤波 加带通。船员共 4 人(≤5 约束),同一角色 speaker+instruct 恒定,区分度风险逐个标注: +> | 角色 | speaker | instruct 常量 | 区分度风险 | +> |---|---|---|---| +> | 小舟(23 岁声呐兵,女) | `Serena` | `年轻女声,二十三岁声呐兵,缺氧后发虚,强撑着干脆` | **中**:与 female 玩家同 speaker,靠年龄感 instruct + 电台滤波(420-3200Hz)区分,请试听确认 | +> | 老白(50 岁轮机长,男) | `Uncle_Fu` | `沙哑爽朗的中年男声,五十岁轮机长,忍着伤痛开玩笑` | **中**:与 male 玩家同 speaker,靠沙哑 instruct + 滤波(350-3000Hz)区分,请试听确认 | +> | 陈医官(45 岁船医,女) | `Vivian` | `平静沙哑的中年女声,四十五岁船医,专业镇定,语气温和` | **低**:与 female 调度同 speaker 不同 instruct + 滤波(400-3100Hz),且从不同场 | +> | 高船长(58 岁船长,男) | `Eric` | `低沉苍老的男声,五十八岁船长,疲惫克制,有老舵手的威严` | **低**:male 侧唯一 Eric + 滤波(300-2800Hz) | +> - **电台滤波(转码侧)**:NPC 全部走带通+轻压缩(参数见上表,详表见 17-abyss.md §5.3/§6.1);DIVER/DECK 不滤波(母船级音质)——「电台里的人」与「耳机里的人」靠音质差立住。试听不可读的句子回退未滤波原声(宁窄勿脏)。 +> - **称呼规则**:船员对玩家一律称「救援员/你」,**全表禁用**「姑娘/小哥/大哥/大姐/叔叔」类性别化称呼(玩家性别随用户设置变化)。 +> - **键盘变体(PC 键盘通道,P1 机制)**:带 `_k` 后缀的 id 为键盘措辞版(「按空格」「回车」「左箭头/右箭头/上箭头/下箭头」),与触屏版**成对生成、成对落盘**(`check_all_assets` 校验 `_k` 配对);运行时由 `gesture.inputMode()` 选择:`key` → `_k`,`touch` → ``,`auto` 跟随最近使用通道,`_k` 缺失回退无后缀版。变体共 **11 对**,全部集中在下方 §G0 操作提示句区与 §D1 首个抉择(dv1_teach / p1),以粗体标注。 +> - **数字规避**:潜次/氧气全部分档整句(prog_d1..d4、oxy_full/ok/low/crit、oxy_gain);句内数字(三千米/三格/四潜/十二小时/四小时/一小时/三页)写死在整句里,禁止运行时拼接。 +> - **禁语速词**:instruct 不含任何语速描述(引擎 SoundTouch 变速);NPC 句靠短句+逗号停顿实现「水下慢半拍」听感。 +> - 管线:`tools/gen_voice_dual.py` 照录(SPEAKERS 表需新增 abyss 行);多音字自查见文末注记。 + +## G0 组 · 系统/向导/状态(DECK 双性别 ×37) + +### G0.a 操作提示句(含键盘变体,9 对 18 句——键盘玩家听这一区就够) + +| id | 台词全文 | 触发 | +|---|---|---| +| `gd_brief` | 单击继续简报。要选择时,向左或向右滑。 | 开场链最前(full 档,F8 先例) | +| **`gd_brief_k`** | 按空格继续简报。要选择时,用左右方向键。 | 同上(inputMode=key) | +| `gd_continue` | 单击屏幕,继续。 | 叙事链末看门狗第 1 级(full 25s / standard 13s) | +| **`gd_continue_k`** | 按空格,继续。 | 同上(key) | +| `gd_again` | 还在吗?单击继续。 | 看门狗第 2 级(50s / 30s),新句不逐字重播 | +| **`gd_again_k`** | 还在吗?按空格继续。 | 同上(key) | +| `gd_ping1` | 声呐待命。单击,发脉冲。 | 搜索等待静默 10s(第 1 级) | +| **`gd_ping1_k`** | 声呐待命。按空格,发脉冲。 | 同上(key) | +| `gd_ping2` | 回波不会自己来。单击。 | 搜索等待静默 25s(第 2 级,**必须与第 1 级不同句**) | +| **`gd_ping2_k`** | 回波不会自己来。按空格。 | 同上(key) | +| `sys_help` | 单击继续,双击重新听。上滑听状态,下滑开菜单。选择时,左右滑。 | 上滑帮助 / 菜单内上滑 | +| **`sys_help_k`** | 空格继续,回车重新听。上箭头听状态,下箭头开菜单。选择时,左右箭头。 | 同上(key) | +| `sys_menu` | 菜单。单击切换选项,双击确认。 | 下滑呼出模态菜单 | +| **`sys_menu_k`** | 菜单。空格切换选项,回车确认。 | 同上(key) | +| `sys_skip` | 想跳过这句,单击就行。 | 开场每局一次(skip_hint 先例) | +| **`sys_skip_k`** | 想跳过这句,按空格。 | 同上(key) | +| `sys_continue_ask` | 检测到你的下潜记录。单击继续上次,右滑开始新救援。 | 有存档进游戏 | +| **`sys_continue_ask_k`** | 检测到你的下潜记录。空格继续上次,右箭头开始新救援。 | 同上(key) | + +### G0.b 状态/系统句(无手势词,无变体) + +| id | 台词全文 | 触发 | +|---|---|---| +| `gd_invalid` | 这个手势,水里用不上。 | 无效手势(error 震,idle-only,同局限次) | +| `gd_prog1` | 滑动在这里用不上。想想调度刚说的话。 | 无进展轨 60s(第 1 级,乱按抢救) | +| `gd_prog2` | 别乱。停一下,想想下一步。 | 无进展轨 120s(第 2 级) | +| `sys_menu_resume` | 回到水下。 | 菜单项 | +| `sys_menu_restart` | 重新开始这次救援。 | 菜单项 | +| `sys_menu_lobby` | 保存并回大厅。 | 菜单项(确认后跳转前播) | +| `sys_settings` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内设置提示 | +| `sys_new_dive` | 新的一次下潜,开始了。 | 新救援开始 / 菜单重开 | +| `prog_d1` | 现在是第一次下潜。 | 上滑状态链(按当前潜次选播) | +| `prog_d2` | 现在是第二次下潜。 | 同上 | +| `prog_d3` | 现在是第三次下潜。 | 同上 | +| `prog_d4` | 现在是最后一次下潜。 | 同上 | +| `oxy_full` | 气瓶还足,救得动。 | 氧气 ≥9(跨档时挂上浮结算链;上滑状态链) | +| `oxy_ok` | 氧气还够两潜。 | 氧气 6-8(floor(oxy/3)=2,句义逐格为真) | +| `oxy_low` | 氧气告急,只够最后一潜。 | 氧气 3-5;此后垫 sfx/breath_loop | +| `oxy_crit` | 氧气见底,必须上浮。 | 氧气 0-2;各播报位附 warning 震 | +| `oxy_gain` | 应急瓶接上了,多三格。 | D2 上浮后补给(全局唯一一次) | +| `dry_a` | 气瓶用干了。立刻上浮。 | mid-dive 氧气 floor 至 0,干涸链首句 | +| `dry_b` | 气花在了人身上。人,都上来了。 | 干涸链归因句(真话:气耗在走心选择上) | + +## G1 组 · 开场(双性别 ×4) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `b_open` | 潜航员 | 风停了。我站在问海号甲板上,下面三千米,是海燕号。 | INTRO(配 sfx/amb_deck) | +| `b_rule` | 调度 | 规矩就一条:气不够一潜,人就必须上浮。 | INTRO(一句规矩,P4;与取消门一致) | +| `b_goal` | 调度 | 海燕号,五个人。你的双瓶,够下四潜。 | INTRO(氧气预算承诺) | +| `b_go` | 调度 | 第一潜,找船。耳朵交给声呐。 | INTRO,随后自动下潜 | + +## D1 组 · 第一潜·找船·小舟(教学潜)(双性别 13 + 小舟平铺 5 = 18) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `dv1_down` | 调度 | 下潜开始。水会越来越黑,声音会越来越多。 | 下潜链(垫 sfx/amb_deep) | +| `dv1_teach` | 调度 | 到深度了。声呐静了。单击,发一枚脉冲。 | 搜索等待武装(教「单击=脉冲」,配 turn cue) | +| **`dv1_teach_k`** | 调度 | 到深度了。声呐静了。按空格,发一枚脉冲。 | 同上(key) | +| `dv1_metal` | 调度 | 金属回波,钢板。听见这个,就是海燕号的船身。 | 首枚回波解读(**教「金属回波=钢板」,配 echo_metal_l**) | +| `dv1_link` | 调度 | 回波后面有杂音,是电台!接通了。 | 通讯接通(配 link_beep) | +| `z1_a` | 小舟 | ……有人吗?声呐兵,小舟。我们还活着,全艇五个人。 | 通话主干(垫 sfx/static_burst) | +| `z1_b` | 小舟 | 轮机长老白困在机舱,陈医官和小郑在病舱,船长在指挥舱。 | 通话主干 | +| `z1_knock` | 小舟 | 等等,你听。管子里,有人在敲。 | 线索音引入(**配 sfx/pipe_knock 两遍**) | +| `p1` | 调度 | 管路在响。向左滑,停下来听敲管的暗号。向右滑,先带小舟上浮。 | 抉择提示(播完即可选) | +| **`p1_k`** | 调度 | 管路在响。左箭头,停下来听敲管的暗号。右箭头,先带小舟上浮。 | 同上(key;首个抉择的键位绑定) | +| `d1_listen` | 潜航员 | 都别出声。管子那头,敲得很稳。 | p1 左(knowsPipe=true,-1 气) | +| `d1_code` | 调度 | 是老白的暗号:腿被压住,先泄压,再撬。记住这个敲法。 | p1 左·收(情报句,D2 兑现) | +| `d1_up` | 潜航员 | 小舟,抓住我的肩锁。我们上。 | p1 右 | +| `z1_bye` | 小舟 | 好。上面……是不是天亮了? | p1 右·反应 | +| `p2` | 调度 | 小舟想回去拿声呐日志。向左滑,等她。向右滑,直接上浮。 | 抉择提示 | +| `d1_log` | 小舟 | 日志里有失事前,最后一段录音。给你,替我们收好。 | p2 左(hasLog=true,-1 气) | +| `d1_nolog` | 潜航员 | 日志可以补,人不能等。走了。 | p2 右 | +| `d1_surf` | 调度 | 小舟出水,第一潜收工。往返,扣三格气。 | 上浮结算(配 surf_splash+ascent_chime;教「每潜扣 3」) | + +## D2 组 · 第二潜·机舱·老白(双性别 14 + 老白平铺 4 = 18) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `dv2_down` | 调度 | 第二潜,机舱。顺着敲管的声音走。 | 下潜链 | +| `dv2_found` | 调度 | 碎石回波后面,敲管声密了。机舱到了。 | 搜索解读(配 echo_debris+pipe_knock) | +| `bai_a` | 老白 | 哈哈,救援的?我这腿,跟冷却管缠上了。 | 通话主干 | +| `bai_b` | 老白 | 艇上的药板烧了。病舱那两个,比我悬。 | 通话主干(埋 D3 险) | +| `p3` | 调度 | 钢梁压着腿。向左滑,泄压后慢慢撬。向右滑,切割枪,快切。 | 抉择提示 | +| `d2_pry` | 潜航员 | 老白,忍住。先泄压,再撬。 | p3 左 | +| `d2_pry_ok` | 调度 | 暗号没白听。管子一松,钢梁抬起来了。 | p3 左·收(**条件 knowsPipe=true,-0 气**) | +| `d2_pry_slow` | 调度 | 摸着找泄压阀,多费了一格气。腿,出来了。 | p3 左·收(条件 knowsPipe=false,-1 气) | +| `d2_cut` | 潜航员 | 切割枪。老白,闭眼,憋气。 | p3 右 | +| `d2_flash` | 调度 | 火花炸了一下,虚惊。老白咳着,腿抽出来了。 | p3 右·收(**危机拍:danger 签名+spark_flash 前置 2s**) | +| `bai_out` | 老白 | 腿还在!我的腿还在!走,我给你带路。 | 两路汇合(正反馈) | +| `p4` | 调度 | 老白要去关电池舱阀门。向左滑,陪他去。向右滑,直接上浮。 | 抉择提示 | +| `d2_valve` | 潜航员 | 电池舱我陪你去。这片海不能脏。 | p4 左(valve=true,-2 气;配 valve_close) | +| `d2_valve_e` | 调度 | 阀门关死了。两格气,换一片干净海。 | p4 左·收(账目真实:明说两格) | +| `bai_thanks` | 老白 | 这片海养着一村人。这份情,我记一辈子。 | p4 左·反应 | +| `d2_leave` | 潜航员 | 阀门交给母船。你先上,腿要紧。 | p4 右 | +| `d2_surf` | 调度 | 老白出水。他一直扒着舱门,往水里看。 | 上浮结算 | +| `d2_supply` | 调度 | 潜友阿凯捎来应急瓶。甲板上,粥给你温着。 | 补给剧情(随后 oxy_gain +3,全局唯一) | + +## D3 组 · 第三潜·病舱·陈医官/小郑(双性别 12 + 陈医官平铺 4 = 16) + +> 小郑为无台词角色(重伤固定于吊床,radio 老主播「带子里只有电流声」先例);其状态由调度句 d3_chen_e/d3_stay_lost 承载。 + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `dv3_down` | 调度 | 第三潜,病舱。海燕号在倾斜,别恋战。 | 下潜链 | +| `dv3_groan` | 调度 | 回波发闷。船体在呻吟,它撑不久了。 | 搜索解读(**配 echo_groan,D4 险预告**) | +| `chen_a` | 陈医官 | 你来了。小郑股骨断了,我给他固定好了。 | 通话主干 | +| `chen_b` | 陈医官 | 救援钟一次带一个。我是医生,我最后走。 | 通话主干(抉择铺垫) | +| `p5` | 调度 | 病舱一次只能走一个。向左滑,先带陈医官。向右滑,先带小郑。 | 抉择提示(无气差的双义抉择) | +| `d3_chen` | 潜航员 | 陈医官,你的手在抖。这次,听我的。 | p5 左(陈医官上,stayer=zheng) | +| `chen_up` | 陈医官 | ……好。小郑,等我,上面给你热粥。 | p5 左·反应 | +| `d3_chen_e` | 调度 | 陈医官出水。小郑固定在吊床上,呼吸平稳。 | p5 左·收 | +| `d3_zheng` | 潜航员 | 小郑先走。陈医官,下一潜,就是你的。 | p5 右(小郑上,stayer=chen) | +| `d3_zheng_e` | 调度 | 小郑吊上去了。他一路,都没敢出声。 | p5 右·收 | +| `chen_stay` | 陈医官 | 去吧。我守着灯,灯不灭,人就不散。 | p5 右·留守宣言 | +| `p6` | 调度 | 留守的人要空气。向左滑,留下你的备用瓶。向右滑,约定下一潜来接。 | 抉择提示 | +| `d3_spare` | 潜航员 | 备用瓶留下。灯,给我亮着。 | p6 左(spareGift=true,-2 气,可触发干涸链) | +| `d3_spare_e` | 调度 | 备用瓶挂上了,能多撑十二小时。 | p6 左·收(账目:时间量而非气格) | +| `d3_promise` | 潜航员 | 四小时内,我下来。等我。 | p6 右 | +| `d3_surf` | 调度 | 病舱的人出水。海燕号的呻吟,隔着水都听得见。 | 上浮结算(随后走氧气门) | + +## D4 组 · 第四潜·指挥舱·高船长(高潮)(双性别 18 + 高船长 7 + 陈医官 1 = 26) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `dv4_none` | 调度 | 气不够一潜了。第四潜,取消。 | **氧气门·取消形态**(oxy≤2;800ms 手势死窗) | +| `gao_final` | 高船长 | 别下来。带他们回家,就算救完了。海燕号,通话结束。 | 取消链·船长的最后通讯(随后直达 ◆p9) | +| `dv4_down` | 调度 | 最后一潜,指挥舱。海燕号,撑不住了。 | 下潜链(双载/单载共用) | +| `dv4_slide` | 调度 | 它滑了!抓稳索缆,指挥舱里还有人! | 搜索危机拍(**danger 签名+landslide 前置 2s 渐强+danger 震**) | +| `gao_a` | 高船长 | 救援员。我船上,还剩两个活人。 | 通话主干 | +| `gao_b` | 高船长 | 都带走,我最后上。这是船长的规矩。 | 通话主干 | +| `p7` | 调度 | 双人上浮。向左滑,逐站减压慢慢浮。向右滑,违规直浮,搏一把。 | 抉择提示(**双载形态**:oxy≥4) | +| `p7_chen` | 调度 | 气只够带一个。向左滑,带船长。向右滑,带陈医官。 | 抉择提示(**单载形态·留守=陈医官**:oxy=3) | +| `p7_zheng` | 调度 | 气只够带一个。向左滑,带船长。向右滑,带小郑。 | 抉择提示(单载形态·留守=小郑) | +| `d4_slow` | 潜航员 | 逐站停,慢慢浮。规矩不能破。 | p7 左·双载(-2 气) | +| `d4_slow_e` | 调度 | 减压站一个个过。气瓶在跳,人稳着。 | p7 左·双载·收 | +| `d4_fast` | 潜航员 | 来不及了。直浮! | p7 右·双载 | +| `d4_fast_e` | 调度 | 违规快浮。医疗舱的灯,先给你们开好。 | p7 右·双载·收(回响伏笔) | +| `d4_pick_gao` | 潜航员 | 船长,抓住我。海燕号的事,上面再说。 | p7 左·单载(带船长) | +| `d4_pick_chen` | 潜航员 | 陈医官,轮到你了。抓住,上浮。 | p7 右·单载·留守=陈医官 | +| `d4_pick_zheng` | 潜航员 | 小郑,吊床连人一起。走了。 | p7 右·单载·留守=小郑 | +| `chen_last` | 陈医官 | 去吧。灯我留着,人齐了就熄。 | 单载·带走船长时留守者=陈医官的留别 | +| `d4_stay_lost` | 调度 | 另一位,留在了倾斜的船舱。航标灯,替所有人亮着。 | 单载·带走船长时留守者=小郑的留别(克制措辞) | +| `gao_last` | 高船长 | 航标灯替我留着。就说,船长睡了。 | 单载·带走留守者时船长的留别(p8 跳过,直达上浮) | +| `gao_c` | 高船长 | 三年前……没什么。走吧,别耽误。 | 心结引入(**条件 hasLog=false**) | +| `gao_confess` | 高船长 | 日志你拿了?里面写着。是我下令,多采了一小时。 | 心结引入(**条件 hasLog=true**,替代 gao_c) | +| `p8` | 调度 | 船长的话没说完。向左滑,把你听到的说出来。向右滑,什么都不问,拉他上浮。 | 抉择提示(船长在乘时可 达,30 字全表最长句) | +| `d4_talk` | 潜航员 | 日志我听完了。海燕号为什么沉,我会带上去。走吧。 | p8 左(gaoPeace=true) | +| `gao_peace` | 高船长 | ……好。这一次,听救援员的。 | p8 左·反应 | +| `d4_silent` | 潜航员 | 纪律就是纪律。抓住,上浮。 | p8 右 | +| `d4_surf` | 调度 | 最后一潜收工。问海号的甲板,从没这么亮过。 | 上浮结算 | + +## F9 组 · 签收抉择(心结)(双性别 ×7) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `g9_in` | 调度 | 医疗舱躺满了。你的气瓶剩多少,你比我清楚。 | 收队签收引入(SURFACE(4)/取消链之后) | +| `g9_knot` | 潜航员 | 三年前,也是这条沟。我按时上浮了,搭档没有。 | 心结自述(玩家背景) | +| `p9` | 调度 | 海燕号的船钟,还挂在沉船上。向左滑,用最后的气,下去取。向右滑,进医疗舱。 | 抉择提示(左路需 oxy≥1) | +| `g9_dry` | 调度 | 一格气都不剩了。能带的,你都带回来了。 | **p9 左路封锁句**(oxy<1:播出后自动走右,不摆假选项) | +| `d9_bell` | 潜航员 | 最后一潜。不为救人,为个交代。 | p9 左(bell=true;配 bell_toll 后接 d9_bello) | +| `d9_bello` | 调度 | 船钟出水。海燕号的声音,回家了。 | p9 左·收 | +| `d9_rest` | 潜航员 | 交代,留给明天的人。我去医疗舱。 | p9 右 | + +## EP 组 · 结局与尾声回响(双性别 22;结局 8 + 回响 14) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `e1_a` | 潜航员 | 五个人都在甲板上。船钟的第一声,是替这条沟敲的。 | 结局 e1(saved=5 且 p9=左)主句 | +| `e1_b` | 潜航员 | 三年了,我终于敢往下看。明天,想再潜一次。 | e1 收(钩子) | +| `e2_a` | 潜航员 | 五个人都救上来了。船钟,留在了沟底。 | 结局 e2(saved=5 且 p9=右)主句 | +| `e2_b` | 潜航员 | 没人怪我。可半夜醒来,总听见钟在响。 | e2 收(钩子) | +| `e3_a` | 潜航员 | 没能都带回来。可我到底下去了,敲响了那口钟。 | 结局 e3(saved<5 且 p9=左)主句 | +| `e3_b` | 潜航员 | 钟声替他们,在沟底响了一声。这样,也好。 | e3 收(钩子) | +| `e4_a` | 潜航员 | 我按规矩上浮了。规矩里,没写怎么难过。 | 结局 e4(saved<5 且 p9=右)主句 | +| `e4_b` | 潜航员 | 沟底很安静。安静得,像三年前那天。 | e4 收(钩子;心结回环) | +| `ep_lead` | 调度 | 收队了。调度台给你记一笔——他们,后来怎样了。 | 结局链后尾声引入 | +| `ep_zhou_ok` | 调度 | 小舟:归队报告打了三页。她说,声音都记得。 | 尾声(bond_zhou=1:p1 左或 p2 左) | +| `ep_zhou_no` | 调度 | 小舟:声呐日志没了。她凭记忆,重新描了一遍海图。 | 尾声(bond_zhou=0) | +| `ep_bai_ok` | 调度 | 老白:腿保住了。阀门那夜之后,见人就夸你。 | 尾声(bond_bai=1:p3 左或 p4 左) | +| `ep_bai_no` | 调度 | 老白:腿打了钢钉。他说,瘸着也值。 | 尾声(bond_bai=0) | +| `ep_chen_ok` | 调度 | 陈医官:回了医院。诊室里,挂着一盏海底的灯。 | 尾声(陈医官生还) | +| `ep_chen_lost` | 调度 | 陈医官:她守着灯,到最后一小时。灯,还亮着。 | 尾声(陈医官留守未归) | +| `ep_zheng_ok` | 调度 | 小郑:拆了石膏,第一件事,去看海。 | 尾声(小郑生还) | +| `ep_zheng_lost` | 调度 | 小郑:吊床随船,滑进了沟底。他没来得及看日出。 | 尾声(小郑留守未归) | +| `ep_gao_peace` | 调度 | 高船长:退役申请自己写的。第一行写着:数据不如人。 | 尾声(船长生还且 p8 左) | +| `ep_gao_up` | 调度 | 高船长:上了岸,一直没说话。听说,他在学敲管语。 | 尾声(船长生还且 p8 右) | +| `ep_gao_lost` | 调度 | 高船长:随海燕号留在沟底。航标灯,还亮着。 | 尾声(船长未归) | +| `ep_trench_bell` | 调度 | 沟底:船钟挂在问海号桅杆上。风一过,就响一声。 | 尾声(bell=true) | +| `ep_trench_deep` | 调度 | 沟底:很深,很静。海燕号睡在里面,像睡着了。 | 尾声(bell=false) | + +--- + +**合计:148 句**(GPU 预算 ≤170 ✓) +- 双性别 127 句(DIVER 潜航员 30 + DECK 调度/系统 97,含 `_k` 键盘变体 11)×2 性别 = 254 文件; +- NPC 平铺 21 句(小舟 5 / 老白 4 / 陈医官 5 / 高船长 7)= 21 文件; +- 合计 275 条音频 ×2 格式(ogg+m4a)= **550 文件**。 +- 键盘变体 11 对:gd_brief / gd_continue / gd_again / gd_ping1 / gd_ping2 / sys_help / sys_menu / sys_skip / sys_continue_ask / dv1_teach / p1(成对生成,`check_all_assets` 校验 `_k` 配对与双性别集合一致)。 +- 每结局专属台词齐备:e1/e2/e3/e4 各 2 句 + 尾声回响 14 句(好感/存活/flag 选播)。 +- 每局实走抉择 7-9 个(p7 单载右路跳过 p8;D4 取消路径跳过 p7/p8),抉择提示 p1..p9 共 9 个。 + +## 多音字 / 发音风险自查注记 + +| 风险词 | 处理 | +|---|---| +| 重听 | 全表写「重新听」(sys_help / sys_help_k),无「重听」字样;「重写」规避为「日志可以补」(d1_nolog) | +| 壳(ké/qiào) | 全表不用「壳」,写「钢板 / 船身」(dv1_metal) | +| 脏(zāng/zàng) | 「这片海不能脏」(d2_valve)为 zāng 正确常用读法,保留,试听确认 | +| 闷(mēn/mèn) | 「回波发闷」(dv3_groan)为 mèn 常用读法,保留,试听确认 | +| 颤(chàn/zhàn) | 台词侧用「发抖」(d3_chen);instruct 描述不进语音,不受影响 | +| 塞 / 巷 / 挣 / 血 / 露 / 降(jiàng 外义)/ 率 / 传(zhuàn 义) | 全表未使用(「撑住」「股骨断了」替代;下潜 qián 恒用) | +| 教(jiāo/jiào) | 未使用;「船长」zhǎng、「数据」shùjù、「调度」diàodù 为固定词 | +| 了 / 还 / 得 | 常规句式常见读法,逐句人工试听项(d3_zheng_e「都没敢出声」、d1_surf「扣三格气」等) | +| 数字 | 句内数字全部写死(三千米 / 三格 / 四潜 / 两格 / 十二小时 / 四小时 / 一小时 / 三页 / 一盏),序号与档位用分档整句(prog_d1..d4、oxy_*) | + +## 台词侧自查 + +- 单句 ≤40 字:全表最长 = p8 与 p9(各 30 字),次长 sys_help_k(28 字)、p6(27 字)✓;NPC 句平均 ≤20 字(P4 语速感靠短句+逗号,不用 instruct 语速词)✓。 +- 船员 ≤4 人、speaker+instruct 恒定、区分度风险逐个标注 ✓(小舟 Serena / 老白 Uncle_Fu / 陈医官 Vivian / 高船长 Eric)。 +- 称呼合规:船员对玩家只用「救援员 / 你」,性别化称呼零出现 ✓。 +- 每个结局(e1..e4)专属主句+钩子各 2 句齐备,尾声救/未救双线齐备 ✓。 +- 归因真话律:负向收尾句(dry_b / gao_final / chen_last / d4_stay_lost / gao_last / ep_*_lost / e4_b)全部指向玩家实际选择或真实处境,无「运气不好」类假归因、无惩罚性措辞 ✓。 +- 向导 gd_* 13 句(gd_brief/gd_continue/gd_again/gd_ping1/gd_ping2 各带 _k 变体,gd_invalid/gd_prog1/gd_prog2 无变体)符合 14 号 §6 台词规范(一句一义、无重复句式、单句 ≤18 字)✓。 +- 氧气档位句与数值一一对应(oxy_full≥9 / ok=6-8 / low=3-5 / crit=0-2,均按 floor(oxy/3) 可下潜次数措辞),账目真实 ✓。 diff --git a/docs/design/17-abyss.md b/docs/design/17-abyss.md new file mode 100644 index 00000000..472dc2b6 --- /dev/null +++ b/docs/design/17-abyss.md @@ -0,0 +1,490 @@ +# 17 · 《深海回音》(abyss) — 深海救援叙事 RPG 设计文档 + +> **给开发的实现要点摘要** +> 1. 叙事模式(声命线范式,radio 同骨架):节点链 onComplete 串行 + seqToken 打断作废 + pendingChoice 缓存;复制 `web/radio/` 结构起步,状态机 6 宏状态(INTRO/DIVE/COMMS/SURFACE/ENDING + MENU 覆盖层)。 +> 2. 手势零豁免:全部走全局默认映射(单击=继续/**检查(搜索等待=发声呐脉冲,宪法 §5.1「检查」义,escape 先例)**/跳句、双击=重听、左右滑=抉择、上滑=状态、下滑=模态菜单、长按=引擎快进)。 +> 3. 台词依据 [17-abyss-voice-ids.md](17-abyss-voice-ids.md)(148 句:双性别 127 + 平铺 NPC 21);NPC 水声电台滤波在**资产侧** ffmpeg 带通(radio 先例),引擎零改动。 +> 4. 氧气=步进资源非实时倒计时:只在潜次边界与阈值跨越时播报(分档整句、账目真实);软失败=「被迫取消第四潜/单载」而非玩家死亡,结算链完整播出(含 800ms 手势死窗)。 +> 5. 声呐双通道:回波类信息音与语音**绝不重叠**(游戏层 sonarGate 空闲门,R1 规则 §6.2);并发声流 ≤2。 +> 6. 章程任务 N2(research/new8/00-program.md §2):单局 15-25 分钟、9 抉择点(实走 7-9)、4 结局、分支局部化。 +> 7. 断言清单住本文档 §9(radio §20 先例);13 号文档已登记指针(§21)。 + +## 1. 概述 + +- **一句话**:你是深潜救援员,声呐里是被困海沟的潜艇「海燕号」——氧气在倒数,每次下潜都是抉择:先救谁、花多少气、留不留、上不上去。 +- **题材(声音即世界)**:黑暗深海里视觉归零,**声呐回波=空间、电台人声=生命、氧气=代价**——「用耳朵救人」与纯音频载体天然同构。声呐脉冲/回波/管路敲击/船体呻吟是叙事本体,不是装饰。 +- **单局**:一次完整救援(一条抉择路径)20-22 分钟;四次下潜共享主干,分歧局部化在每潜内部;9 个抉择点(单局实走 7-9 个),4 结局。 +- **核心循环**:下潜(声呐搜索拍:单击发脉冲→回波→调度解读)→ 通讯(船员对话+抉择)→ 带人上浮 → 水面结算(氧气扣账+档位播报)→ 下一潜。跨潜积累氧气存量与船员生死/好感,最后一潜的氧气余量决定救援形态(双载/单载/取消),收队前 p9 决定你自己的心结。 +- **与 radio 的差异点**(骨架同、压力源不同):radio 零压力(口碑只涨不跌);abyss 引入**氧气稀缺**——但全部宽限化:无实时倒计时、无中途死亡、软失败只损失「机会」不损失「已有」、最优省气路径恰好全员生还(P4 友好默认)。 + +## 2. 状态机 + +### 2.1 状态列表 + +| 状态 | 内容 | BGM | guide.state | +|---|---|---|---| +| `INTRO` | 开场链:甲板口播 + 一句规矩 + 今晚目标 | `bgm/deck` | explore | +| `DIVE` | 下潜过程:下潜链(auto)→ 搜索等待(单击=发脉冲,声呐主导)→ 回波+接通 | `bgm/dive` | explore;搜索等待由 turn cue 承担 | +| `COMMS` | 通讯叙事:船员对白链 + 抉择点 + 结果 | `bgm/comms` | explore;抉择点=choice | +| `SURFACE` | 上浮结算:出水确认 + 氧气扣账/档位 + 补给(D2 后),自动链 | `bgm/deck` | settle | +| `ENDING` | 结局链 + 尾声回响(epilogue),结算态 | `bgm/ending` | settle | +| `MENU` | 模态覆盖层(任意叙事态下滑呼出,暂停一切链) | `bgm/deck`(低响度淡入) | menu | + +### 2.2 转移图(文本) + +``` + ┌──────────── 结算单击=新救援 ─────────────┐ + ▼ │ + INTRO ──链完单击──> DIVE(1) ──脉冲找到──> COMMS(1) ──链完──> SURFACE(1) ─┤→ DIVE(2) + │下潜链 auto;搜索等待=单击脉冲 │ …(D2/D3 同型)… + └─────────────────────────────────────────┘ + SURFACE(3) ──氧气门──┬─ oxy≥4: DIVE(4 双载) ──链完──> SURFACE(4) ──链完──> 签收 ◆p9 + ├─ oxy=3: DIVE(4 单载: p7_chen|p7_zheng) ──链完──> SURFACE(4) ─┘ + └─ oxy≤2: 终局取消链(dv4_none→gao_final) ─────────────────────┘ + ▼ + d9 结果链 ──> ENDING(e1..e4 + ep 回响链) + + 任意叙事态(INTRO/DIVE/COMMS/SURFACE/ENDING):下滑 → MENU(模态) + MENU:单击=循环切换选项 → 双击=确认执行 → 上滑=帮助 → 下滑/「回到水下」项=关闭回原状态(重播当前节点) +``` + +- INTRO/DIVE/COMMS/SURFACE/ENDING 均为节点链驱动(radio playNode 模式);下潜链与上浮结算链 `auto:true`,COMMS 叙事节点链末等待单击(radio 实现核对 #1 同口径)。 +- SURFACE 自动链 ≤15s:扣账句(d*_surf)→ 补给句(仅 D2 后)→ 档位句(跨档才播)→ 直接进下一 DIVE;第四潜位置由**氧气门**分三形态(§4.3)。 +- 终局取消链与结局链入口处盖 `_settleGraceUntil = now+800ms` 手势死窗(dungeon H1 热修同款),窗内手势一律吞掉,结算链完整播出。 +- MENU 打开时 `guide.disarm()` + 取消一切看门狗/自动链;关闭后重播当前节点(局面复述,radio closeMenu 先例)。 + +### 2.3 每状态手势映射(6+2 全覆盖,零豁免) + +| 手势 | INTRO | DIVE(下潜/搜索) | COMMS | SURFACE | ENDING | MENU | +|---|---|---|---|---|---|---| +| 上滑 | 帮助+状态,随后重播当前段 | 帮助+状态(潜次+氧档),留在搜索等待 | 帮助+状态,随后重播当前节点 | 帮助+状态,自动链继续 | 帮助+状态(结局名+氧档) | 帮助(sys_help) | +| 下滑 | 模态菜单 | 模态菜单 | 模态菜单 | 模态菜单 | 模态菜单 | 关闭菜单 | +| 左滑 | 无效 → error 震+gd_invalid | 无效 → gd_invalid(喂无进展轨) | 抉择=左 | 无效 → gd_invalid | 无效 → gd_invalid | 无效 → error 震 | +| 右滑 | 无效 → gd_invalid | 无效 → gd_invalid(喂无进展轨) | 抉择=右;ASK 态=新救援 | 无效 → gd_invalid | 无效 → gd_invalid | 无效 → error 震 | +| 单击 | 播放中=跳过本句;链末=开始下潜 | 播放中=跳过本句;**搜索等待=发一枚声呐脉冲** | 播放中=跳过本句(p* 不可跳);链末=继续 | 跳过本句(自动链) | **再来一局**(新救援) | 循环切换选项 | +| 双击 | 重听当前段 | **重听上一条回波+其解读句**(尚无回波=无效,首潜前零静默) | 重听当前节点台词(含抉择提示) | 重听结算链 | 重听结局链 | **确认执行**(菜单约定覆盖重听) | +| 长按 | 引擎接管:旁白临时 1.7×(12 号 §2.2,叙事类不豁免) | 同左 | 同左 | 同左 | 同左 | 同左(快进菜单语音) | +| 长按松开 | 引擎接管:恢复 settings.speed | 同左 | 同左 | 同左 | 同左 | 同左 | + +- 豁免登记:**无**。六个基础手势全部符合全局宪法默认语义——单击在搜索等待=「检查」(宪法 §5.1 原文即含检查,escape 检查房间同款);菜单走 §5.2 R3 模态约定(单击循环+双击确认)。 +- ASK(存档询问态,radio 同款):单击=继续上次 / 右滑=新救援;上滑/下滑/左滑/双击=无效(error 震+gd_invalid)。 +- 抉择 prompt(voice/p1..p9)播放中单击不可跳(PROMPT_RE 门)、左右滑缓存 `pendingChoice` 播完即生效(radio W2 契约照抄)。 +- 继承 radio G5 跳过公平契约原文:**首播必完整(p* 首播不可跳 / 链末关键句保护 / ASK 恢复首句锁)、复播可即掐(锁不随复播重新武装、链中段句可跳、复播滑动照常预缓存)**;p* 复播(双击重听/看门狗复述)仍不可跳、句尾生效。 +- 无效手势零静默:所有「无效」格均有 error 震 + gd_invalid(onceKey 同局限次,F1.4 idle-only:语音在播时跳过语音只发震)。 +- 键盘通道:三通道并行,游戏代码零改动;操作提示句按 `inputMode()` 选 `_k` 变体资产(§7.3、voice-ids 集中分组)。 + +## 3. 抉择树 + +### 3.1 全局骨架(四潜共享主干,分歧局部化) + +``` +INTRO: b_open → b_rule → b_goal → b_go ──> 下潜① +【D1 找船·教学潜】救小舟 · 2 抉择(p1/p2)──> SURFACE(1) ──> 下潜② +【D2 机舱·老白】危机拍#1 · 2 抉择(p3/p4)──> SURFACE(2·补给+3) ──> 下潜③ +【D3 病舱·生死抉择】回波预警 · 2 抉择(p5/p6)──> SURFACE(3) ──氧气门──> 下潜④ 或 终局取消链 +【D4 指挥舱·高潮】滑坡危机 · p7(双形态)+ p8 ──> SURFACE(4) ──> 签收 ◆p9(心结)──> ENDING +``` + +分支纪律:**每潜内部分支、上浮时收敛**到结算句 → SURFACE;跨潜只传标量(oxy、saved{}、stayer、5 个 flag),不生成组合节点。最坏路径与最好路径节点数同型(剧本 A 与剧本 C 深度相同)。 + +### 3.2 分潜结构(◆=抉择点,〔〕=条件差分) + +``` +【D1 找船·小舟】(教学潜) +下潜链 dv1_down → dv1_teach →【搜索等待:单击脉冲】→ echo_metal_l + dv1_metal(教「金属回波=钢板」) + → dv1_link → COMMS: z1_a → z1_b → z1_knock(垫 pipe_knock)→ ◆p1 + ├左 听暗号: d1_listen → pipe_knock → d1_code(knowsPipe=true,-1 气)─┐ + └右 直接上: d1_up → z1_bye ─────────────────────────────────────────┤ + ▼ + 小舟拉住你 → ◆p2 + ├左 等日志: d1_log(hasLog=true,-1 气)─┐ + └右 不等: d1_nolog ────────────────────┴→ 上浮 → d1_surf(扣 3 格)→ 档位 → SURFACE(1) + +【D2 机舱·老白】 +dv2_down →【搜索:脉冲 → echo_debris+pipe_knock → dv2_found】→ bai_a → bai_b → ◆p3 + ├左 泄压慢撬: d2_pry →〔knowsPipe ? d2_pry_ok(-0): d2_pry_slow(-1)〕─┐ + └右 切割快切: d2_cut →【危机拍: danger 签名+spark_flash 前置 2s】→ d2_flash ─┤ + ▼ + bai_out → ◆p4 + ├左 陪关阀门: d2_valve → valve_close → d2_valve_e(valve=true,-2)→ bai_thanks ─┐ + └右 直接上浮: d2_leave ──────────────────────────────────────────────────────────┴→ d2_surf + → d2_supply + oxy_gain(应急瓶+3,全局唯一一次)→ 档位 → SURFACE(2) + +【D3 病舱·陈医官/小郑(小郑无台词,吊床固定)】 +dv3_down →【搜索:脉冲 → echo_groan → dv3_groan(船体呻吟预警)】→ chen_a → chen_b → ◆p5 + ├左 先带陈医官: d3_chen → chen_up → d3_chen_e(小郑留守)─┐ + └右 先带小郑: d3_zheng → d3_zheng_e → chen_stay(stayer=chen)┤ + ▼ + ◆p6 + ├左 留备用瓶: d3_spare → d3_spare_e(spareGift=true,-2,floor 至 0 触发干涸链)─┐ + └右 约定来接: d3_promise ────────────────────────────────────────────────────────┴→ d3_surf → 档位 → SURFACE(3) + +【SURFACE(3) 氧气门】oxy≥4 → D4 双载 | oxy=3 → D4 单载 | oxy≤2 → 终局取消链(dv4_none → gao_final,800ms 死窗)→ ◆p9 + +【D4 指挥舱·高船长】 +dv4_down →【搜索:脉冲 → echo_groan 渐强 → 危机拍: danger+landslide 前置 2s → dv4_slide】→ gao_a → gao_b + 双载形态 → ◆p7(快慢浮) + ├左 慢浮: d4_slow → d4_slow_e(-2)─┐ + └右 快浮: d4_fast → d4_fast_e ──────┤ + 单载形态 → ◆p7_chen|p7_zheng(按 stayer 选提示,谁上) + ├左 带船长: d4_pick_gao →〔stayer=chen ? chen_last : d4_stay_lost〕─┐ + └右 带留守者: d4_pick_chen|d4_pick_zheng → gao_last(p8 跳过)─────┴→ 直达 d4_surf + ▼(船长在乘时) + 〔hasLog ? gao_confess : gao_c〕→ ◆p8 + ├左 说出来: d4_talk → gao_peace(gaoPeace=true)─┐ + └右 不问: d4_silent ────────────────────────────┴→ d4_surf → 档位 → SURFACE(4) + +【签收】g9_in → g9_knot → ◆p9 + ├左 取船钟(需 oxy≥1): d9_bell → bell_toll → d9_bello(bell=true)─┐ + └右 进医疗舱: d9_rest ────────────────────────────────────────────────┤ + 〔oxy<1 时左路封锁: g9_dry 播出后自动走右,不摆假选项〕 ▼ +ENDING: e*_a → e*_b(按 savedCount × p9 四选一)→ ep_lead → ep_*(按好感/存活/flag 选播) +``` + +### 3.3 分支局部化说明 + +- 跨潜状态全部是标量:`oxy`(int)、`saved`(5 个 0/1)、`stayer`(null/chen/zheng)、flag 5 个(knowsPipe/hasLog/valve/spareGift/gaoPeace)+ p9 派生 bell。没有任何「潜 i 分支 × 潜 j 分支」组合节点。 +- 条件差分全部是**节点内选播**(radio w1_say/w1_say2 先例):d2_pry_ok/slow(knowsPipe)、gao_c/gao_confess(hasLog)、p7 双/单形态(氧气门)、p7_chen/p7_zheng 与留别句(stayer)、ep_* 选播。树形不变。 +- 树规模:148 句台词、约 70 个节点;任一路径实走 7-9 个抉择点(D4 取消路径少 p7/p8;单载右路少 p8)。 + +### 3.4 四结局达成条件 + +| 结局 | 条件 | 主题 | 开环钩子 | +|---|---|---|---| +| e1 海燕归钟 | saved=5 且 p9=左 | 全员生还+船钟出水,告别完整 | 「明天,想再潜一次」 | +| e2 满载的黎明 | saved=5 且 p9=右 | 人都救回来了,钟留在沟底 | 「半夜总听见钟在响」 | +| e3 最后一潜 | saved<5 且 p9=左 | 没能都救回,但下去敲响了钟 | 「钟声替他们响了一声」 | +| e4 按规程上浮 | saved<5 且 p9=右 | 规程之内,难过之外 | 「安静得像三年前那天」 | + +- 尾声回响(P2 回响感):epilogue 逐人点名——小舟/老白/陈医官/小郑/船长各按「好感/生死」双线播(voice-ids EP 组),沟底一句按 bell 二选一;玩家每一潜的救与错过都被点名(radio ep_* 同款)。 + +## 4. 数值设计 + +### 4.1 变量 + +| 变量 | 类型 | 初值 | 说明 | +|---|---|---|---| +| `oxy`(氧气格) | int | 12 | 步进资源:只在事件点扣(下潜/抉择),无实时倒计时 | +| `saved` | map | zhou:0 其余 0 | 小舟/老白/陈医官/小郑/高船长 各 0/1 | +| `stayer` | str | null | p5 后留守病舱者('chen'/'zheng') | +| `knowsPipe/hasLog/valve/spareGift/gaoPeace/bell` | bool | false | p1 左/p2 左/p4 左/p6 左/p8 左/p9 左 置位 | +| `supply` | bool | false | D2 上浮后应急瓶已发(全局唯一) | +| `endings{}` / `runs` | map/int | — | 结局收集与重玩计数(存档展示用) | + +### 4.2 抉择计分表(左=走心·多花气 / 右=克制·省气) + +| 抉择 | 左(走心) | 右(克制) | 备注 | +|---|---|---|---| +| p1 听暗号/直接上 | -1 气,knowsPipe=true | 0 | 左路获 D2 情报(下潜 EFF 的唯一免气左路) | +| p2 等日志/不等 | -1 气,hasLog=true | 0 | hasLog 兑现 D4 船长坦白差分 | +| p3 慢撬/快切 | knowsPipe?0:-1 气 | 0 | 右路挂危机拍#1(火花虚惊,两路都活着) | +| p4 关阀门/直接走 | -2 气,valve=true | 0 | 左路兑现在回响(干净海) | +| p5 先带医官/先带小郑 | 0(chen 上,stayer=zheng) | 0(zheng 上,stayer=chen) | 唯一无气差的双义抉择;stayer 决定 D4 单载差分 | +| p6 留备用瓶/约定来接 | -2 气,spareGift=true(可触发干涸链) | 0 | 左路兑现在留守者存活回响 | +| p7 慢浮/快浮(双载) | -2 气 | 0 | 右路兑现在 e1/e2 回响(医疗舱灯) | +| p7 带船长/带留守者(单载) | 0 | 0 | 另一人随海燕号滑入沟底(措辞克制) | +| p8 说出来/不问 | 0,gaoPeace=true | 0 | hasLog 时 gao_confess 差分前置 | +| p9 取船钟/进医疗舱 | 需 oxy≥1,bell=true | 0 | oxy<1 时左路封锁(g9_dry,不摆假选项) | + +### 4.3 氧气经济(单位/消耗/宽限/软失败) + +| 项 | 规则 | +|---|---| +| 单位 | 「格」(内部 int)。初始 12(叙事=双瓶满气,够四次往返);D2 上浮后应急瓶 +3(`oxy_gain` 播报,全局唯一),总预算 15 | +| 固定消耗 | 每次下潜开始扣 3(往返);D4 双载额外 +1(慢浮+减压成本按 p7 计) | +| 抉择消耗 | 见 §4.2;**mid-dive 扣至负数一律 floor 到 0**,不中断当前救援(人已挂上吊索,照常带上) | +| 阈值播报 | 分档整句(下表),**只在跨档时播**(挂上浮结算链尾);上滑 status 随时可查;oxy_crit 各播报位附 warning 震(radio G2 同款) | +| 第四潜氧气门 | SURFACE(3) 结算时:oxy≥4 → 双载(带船长+留守者);oxy=3 → 单载(p7 谁上);oxy≤2 → **终局取消链**(第四潜取消,船长与留守者随海燕号滑入沟底——措辞克制、零描写) | +| 宽限设计 | ① 学习期零惩罚:D1 两个左路合计 -2,恰好被 D2 后应急瓶 +3 兜底,教学潜永不亏局;② 到点不是死:软失败只损失「机会」(少带一人/少下一潜),已救上的人**永不失去**;③ 无实时倒计时、无 mid-dive 打断(P4 无高压);④ p9 左路是「氧气的礼物」——省下的气兑换终局抉择 | +| 干涸链(软失败结算) | mid-dive floor 至 0 触发:dry_a →(当前救援照常完成)→ 上浮结算内 dry_b(归因真话:指向实际花掉最后两格的那个左选择)。链入口 800ms 手势死窗 | + +**氧气档位表(账目真实:每格取值下句义皆为真)** + +| 档 | oxy 区间 | 整句 | 真实性核验 | +|---|---|---|---| +| full | ≥9(floor(oxy/3)≥3) | 「气瓶还足,救得动。」 | ≥3 潜气量 ✓ | +| ok | 6-8(floor=2) | 「氧气还够两潜。」 | 恰可下 2 潜 ✓ | +| low | 3-5(floor=1) | 「氧气告急,只够最后一潜。」 | 恰可下 1 潜 ✓ | +| crit | 0-2(floor=0) | 「氧气见底,必须上浮。」 | mid-dive=必须上浮 ✓ / 水面=一潜都下不去 ✓ | + +- 档位以 `floor(oxy/3)`(可下潜次数)划界而非绝对半数——**句子说的就是判定用的**(radio rep_low 教训内化:措辞与数值对得上)。 +- 低氧混音签名:tier=low/crit 期间 DIVE/COMMS 垫 `breath_loop`(低响度呼吸声,SA3 循环);引擎无运行时滤波,BGM 闷化降级为该呼吸层(显式降级,不阻塞管线)。 + +### 4.4 船员好感/存活矩阵 + +| 船员 | 好感来源(bond) | 存活条件 | 回响句 | +|---|---|---|---| +| 小舟(声呐兵) | p1 左 或 p2 左 | **恒活**(D1 教学潜保底救上) | ep_zhou_ok / ep_zhou_no | +| 老白(轮机长) | p3 左 或 p4 左 | **恒活**(D2 恒可成——预算数学保证,见 §9 验算) | ep_bai_ok / ep_bai_no | +| 陈医官 | p5 左(当场救上);或留守时 p6 左 | p5 左即活;p5 右留守 → D4 双载或被选则活,否则失 | ep_chen_ok / ep_chen_lost | +| 小郑(无台词角色) | —(吊床上的沉默存在,radio 老主播「带子里只有电流声」先例) | p5 右即活;p5 左留守 → D4 双载或被选则活,否则失 | ep_zheng_ok / ep_zheng_lost | +| 高船长 | p8 左(gaoPeace) | D4 双载 或 单载选左则活;单载右/终局取消则失 | ep_gao_peace / ep_gao_up / ep_gao_lost | + +- 好感只影响回响选播(情绪差分),**不影响结局判定**——结局由 savedCount × p9 决定,账目单一可验算。 +- 生死分支覆盖 3 人(陈医官/小郑/高船长),措辞全部克制(「滑进了沟底」「灯还亮着」),无临场描写;失败归因走干涸链/取消链的真话句。 + +### 4.5 氧气收支验算(设计期穷举,playtest 剧本 A-D 引用) + +设 `lefts` = 前 3 潜左路气耗之和(p7/p9 不在前 3 潜): + +- SURFACE(3) 时 oxy = 15 − 9(基础)− lefts = **6 − lefts** → 双载 ⟺ lefts≤2;单载 ⟺ lefts=3;取消 ⟺ lefts≥4。 +- 双载收队 oxy_end = 2 − lefts(p7 右)或 0 −(lefts−2)(p7 左,floor 0)→ p9 左可用 ⟺ lefts≤1。 +- 单载收队 oxy_end = 3 − lefts = 0(lefts=3)→ p9 左恒封锁 → 单载路径必 e4。 +- 取消收队 oxy_end = 6 − lefts ∈ {2,1,0}(lefts=4,5,≥6)→ lefts≤5 时 p9 左可用 → e3。 +- 前 3 潜恒可完成的预算证明:D1 最大支出 5(12→7);D2 最大支出 6(7→1)后应急瓶 +3(→4)≥3 ✓;D3 恒可下(最差 4→1,p6 左 floor 0 走干涸链)✓。**教学潜与机舱潜永不被氧气卡死**(P4)。 + +## 5. 音频三层映射 + +### 5.1 BGM(状态→曲目,Mureka instrumental,4 首 ≈ $0.12) + +| 状态 | 曲目 | 切换 | Mureka prompt 草稿(英文,seamless loop) | +|---|---|---|---| +| INTRO/SURFACE/MENU | `bgm/deck` | 开局淡入 800ms;MENU 呼出低响度淡入 500ms | "Night sea surface from a rescue ship deck, sparse cello and low piano over gentle waves, distant rigging creak and engine hum, melancholic but purposeful, no vocals, seamless loop, 60-90 seconds" | +| DIVE | `bgm/dive` | crossfade 800ms 进;接通 COMMS 淡至 comms 600ms | "Deep ocean descent ambience, dark ambient drone with a slow sonar-like pulse, immense water pressure, distant whale-like tones, subtle tension, no vocals, seamless loop, 60-90 seconds" | +| COMMS | `bgm/comms` | crossfade 600ms;上浮淡回 deck | "Underwater radio contact scene, warm muted piano over a deep water drone, faint hydrophone static, intimate and quietly hopeful, no vocals, seamless loop, 60-90 seconds" | +| ENDING | `bgm/ending` | p9 结果链播完后淡入 1500ms | "Dawn on the sea after a deep sea rescue, warm strings and soft piano, exhausted hope and quiet triumph, no vocals, seamless loop, 60-90 seconds" | + +- `bgm=0` 时全部静默(引擎约定);menu 用 deck 同曲(省 1 首,海面场景同构);共性后缀(10 号 §8)照 radio 追加。 + +### 5.2 SFX 清单(SA3=Stable Audio 3 / SYN=程序合成;one-shot 真峰值 ≤-3dBFS;amb 循环由游戏层 `buffer.loop` 实现,radio phone_ring 先例) + +| id | 用途 | 来源 | prompt(英文)/ 合成参数 | +|---|---|---|---| +| `sfx/sonar_ping` | 你发出的声呐脉冲(搜索单击) | SYN | 1.4→0.9kHz 正弦 chirp 220ms,指数衰减,-6dBFS(回波家族统一音头) | +| `sfx/echo_metal_l` / `_r` | 钢板回波(左/右声像;「金属回波=海燕号」承诺音) | SA3 | "Sonar echo returning from a steel submarine hull, resonant metallic ping with slow decay, panned slightly left(→right), underwater, 2 seconds" | +| `sfx/echo_debris` | 碎石回波(D2 机舱外) | SA3 | "Sonar echo returning from an underwater rubble and debris field, diffuse granular reverberation, 2 seconds" | +| `sfx/echo_groan` | 船体呻吟(D3 预警/D4 滑坡前奏,可变增益复用) | SA3 | "Stressed submarine hull groaning deep underwater, long metallic moan under immense pressure, 4 seconds" | +| `sfx/pipe_knock` | 管路敲击暗号(老白 SOS) | SYN | 三短三长三短固定节拍金属敲击(节拍精确可复现、断言可数) | +| `sfx/link_beep` | 电台接通 | SYN | 双音上行 0.3s | +| `sfx/static_burst` | 通讯静电/断续 | SA3 | "Shortwave radio static burst with faint ghost of a distorted voice, 2 seconds" | +| `sfx/amb_deep` | 深潜底噪 loop | SA3 | "Deep ocean ambient loop, low pressure hum, distant current wash, occasional far creak, seamless loop" | +| `sfx/amb_deck` | 甲板底噪 loop(风+浪+缆绳) | SA3 | "Night ship deck ambience, wind, gentle waves against hull, distant rigging creak and engine hum, seamless loop" | +| `sfx/breath_loop` | 低氧呼吸 loop(仅 low/crit 档垫,混音签名) | SA3 | "Slow scuba regulator breathing loop, calm but slightly labored, underwater, seamless loop" | +| `sfx/spark_flash` | D2 危机拍:切割火花+氢气虚惊 | SA3 | "Underwater cutting torch sparks with a brief muffled gas pop, 2 seconds" | +| `sfx/valve_close` | 重阀关闭(p4 左) | SA3 | "Heavy submarine valve wheel shutting, metal rotation clunk and water seal, 2 seconds" | +| `sfx/landslide` | D4 危机拍:海底滑坡轰鸣(前置 2s 渐强) | SA3 | "Underwater landslide rumble, rolling sediment and rocks, ominous low roar building, 4 seconds" | +| `sfx/surf_splash` | 破水出水(每潜上浮) | SA3 | "Diver breaking through the sea surface, splash and one gasp, 2 seconds" | +| `sfx/ascent_chime` | 出水确认音 | SYN | 上行三音琶音 0.4s | +| `sfx/bell_toll` | 船钟(p9 左 payoff 音) | SA3 | "Old ship brass bell, single clean toll with warm resonance, 3 seconds" | +| `sfx/ui_confirm` / `ui_menu` | 菜单/确认(程序合成兜底) | SYN | 同全局先例 | + +- 预警窗纪律:D2 火花与 D4 滑坡均**前置 2s 起渐强**(L2),danger 签名与音效同窗(radio C5 刹车先例);D4 另叠 `danger` 震循环(每遍间隔 ≥400ms)。 +- 承诺兑现绑定:dv1_metal 教「金属回波=钢板」后,凡叙事提到船身/找到船的节点必播 echo_metal*;D2 找人必播 pipe_knock(§6.3 清单 + §9 断言)。 + +### 5.3 Voice 划分(详见 17-abyss-voice-ids.md) + +| 组 | 声部 | 落位 | 数量 | +|---|---|---|---| +| 潜航员(你)DIVER | 双性别(female=Serena / male=Uncle_Fu + DIVER instruct 常量) | `voice/female\|male/` | 30 句 | +| 调度/系统/向导 DECK | 双性别(female=Vivian / male=Dylan + DECK instruct 常量) | `voice/female\|male/` | 97 句 | +| 船员 NPC ×4 | 平铺单音色(小舟=Serena / 老白=Uncle_Fu / 陈医官=Vivian / 高船长=Eric),**不做双性别** | `voice/` 根(平铺回退命中)+ 电台滤波转码 | 21 句 | + +- 船员 ≤4 个单音色约束满足;玩家自己的声音跟随全局 `settings.voice`;调度是向导人设(L4 角色化系统声,radio 导播同型)。 +- 合计 148 句(≤170 GPU 预算 ✓,含 `_k` 键盘变体 11 对);音频文件 = 127×2 + 21 = 275 条(×2 格式 = 550 文件)。 + +## 6. 声呐双通道设计(本作核心差异层) + +### 6.1 引擎能力边界(对照 web-engine-api.md) + +| 能力 | 引擎现状 | 方案 | +|---|---|---| +| voice 运行时 EQ/滤波 | ❌(voice 链无滤波插入点) | **资产侧转码滤波**(radio §6.2 先例):ffmpeg `highpass+lowpass+压缩器` 逐船员定档(§5.3/voice-ids) | +| sfx 循环 | ❌ 即发即弃 | 游戏层 `buffer.loop=true` 持句柄(amb_deep/amb_deck/breath_loop),切换档位时停(radio `_ringSrc` 先例) | +| 语音 duck 垫乐 | ✅ 内置(duck 20%,300ms) | 直接依赖;BGM 不再手工让路 | +| sfx vs voice 互让 | ❌ sfx 总线独立,无 duck | **游戏层 sonarGate 空闲门**(§6.2 R1):信息类回波只在 `!audio.isVoicePlaying()` 时播出,否则挂队列表、voice onComplete/句间轮询补发 | +| sfx 声像 | ❌ 运行时无 pan API | 资产侧 pan(echo_metal_l/_r 两条,escape 先例);其余方向信息由调度语音明说(「左前方」) | +| 变速不变调 | ✅ SoundTouch | 直接依赖(长按 1.7× / 2.2× 视障档) | + +### 6.2 通道仲裁规则(并发声流 ≤2 约束) + +- **通道定义**:声呐通道 = sfx 层的脉冲/回波/敲击/呻吟/环境 loop;通讯通道 = voice 层(调度+船员+你)。BGM 是第三层背景(voice 播时引擎自动 duck)。 +- **R1 回波不叠语音**:信息类回波(echo_*、pipe_knock、groan 渐强段)与任何 voice 播放区间**零重叠**——sonarGate 只在 voice idle 时放行,在播则排队到句间空隙(≥600ms);玩家主动脉冲只在搜索等待(voice 恒静)受理,按构造不重叠。 +- **R2 声呐主导段**(DIVE 搜索等待):前景=脉冲+回波余音+amb_deep,BGM=dive 曲低响度;voice 只出调度短句(解读句),且解读句固定在回波**结束后**接播(回波→静默一拍→解读,给耳朵留定位时间)。 +- **R3 常驻脉冲退位**:语音在播时,环境内的周期性声呐元素(母船远程脉冲垫)降 -6dB 或暂停(游戏层 gain 控制),不消失(保持声场存在感,A2 无静默死局)。 +- **R4 危机拍优先**:D2/D4 危机音(spark/landslide)为即发即弃 sfx,可叠在 amb 上;danger 签名同窗;当前 voice 句播完后危机链才插续(radio C5 同款;呼救类短句若必须抢时,走 sfx 层不占 voice)。 +- **R5 回波可重听**:搜索等待双击=重听「上一条回波+其解读句」(P1 关键线索可重听);COMMS 双击=重听当前节点(radio 同款)。 +- 声流账:任意时刻 ≤2 条前景流(voice 串行 1 + 声呐信息音 1),BGM/amb 为背景层——满足 ASA ≤2 注意流约束。 + +### 6.3 教学承诺清单(dungeon「铁器=楼梯」教训:每句承诺的机制必须真实存在且可感知) + +| 教学句 | 承诺 | 兑现点(断言锁定) | +|---|---|---| +| dv1_metal「金属回波,钢板。听见这个,就是海燕号的船身」 | 金属回波=找到船 | D1 找到/D4 接近指挥舱均播 echo_metal*;全剧本任何「船身」节点零漏播 | +| dv1_teach「单击,发一枚脉冲」(+gd_ping1) | 单击=脉冲 | 搜索等待单击恒出 sonar_ping(非跳句、非无效);无超时不惩罚 | +| b_goal「五个人。你的双瓶,够下四潜」 | 5 人/4 潜 | 基础消耗恰 3 格×4=12(§4.5 验算);剧本 A 恰 4 潜走完 | +| b_rule「气不够一潜,人就必须上浮」 | 氧气门 | 取消门恰在 oxy<3(§4.3/§4.5);剧本 C/D 实证 | +| d1_surf「往返,扣三格气」 | 每潜扣 3 | 每次下潜开始扣 3(账目断言) | +| d1_code「记住这个敲法」 | 敲管=老白的信号 | D2 搜索以 pipe_knock 引导找到机舱 | +| oxy_gain「应急瓶接上了,多三格」 | +3 | 恰一次、恰 +3、恰在 D2 上浮后 | + +## 7. 向导层接入 + +### 7.1 状态签名 earcon / turn cue + +| 时机 | 向导动作 | 说明 | +|---|---|---| +| INTRO 进入 | `guide.state('explore')` | 叙事态签名 | +| 搜索等待武装(下潜链末) | `guide.turn({haptic:true})`(无语音) | turn 签名+confirm 震=「轮到你发脉冲」;首次前 dv1_teach 已口播 | +| 每个抉择点 p* 起播 | `guide.state('choice')` | 选项句本身即 turn cue(W2 契约零插播) | +| SURFACE 进入 | `guide.state('settle')` | 结算签名 | +| ENDING 进入 | `guide.state('settle')` | 同上 | +| MENU 打开 | `guide.state('menu')` | 菜单签名 | +| D2/D4 危机拍 | `guide.state('danger')` | 音效前置 2s 渐强同窗(全游戏仅两处) | +| 无效手势 | `guide.error({voice:'gd_invalid', onceKey})` | error 震 + full 档语音,idle-only(F1.4) | + +- earcon 词表:只用全项目统一 8 音(menu/explore/battle/choice/settle/danger/turn/nudge),**零新增**;battle 不用。 + +### 7.2 看门狗双轨(radio F1 + dungeon E0-1 教训:乱按也要被抢救) + +**无操作轨**(guide watchdog,任何手势复位;两级不同句、用尽即停、永不惩罚): + +| 等待点 | full 档 | standard 档 | 级别内容 | +|---|---|---|---| +| 搜索等待无脉冲 | [10, 25] | [10, 25](onEscalate 游戏层补播) | ① gd_ping1 ② gd_ping2(**必须不同句**);两级后仅 amb+余音 | +| INTRO/COMMS/SURFACE 链末等待 | [25, 50] | [13, 30] | ① gd_continue ② gd_again(storyline F1.1 同款) | +| 抉择点 p* 播完无操作 | [25, 50] | [25, 50] | 重播当前 p* 提示句(13 号 §16 先例;空点单击=重挂计时) | +| ENDING 结算等待 | [25, 50] | [13, 30] | gd_continue / gd_again | + +**无进展轨**(游戏层计时,dungeon E0-1 ②同型;仅挂搜索等待与 COMMS 链末两个可饿死点): + +| 项 | 规则 | +|---|---| +| 进展定义 | 节点推进 / 发出脉冲 / 抉择结算。**无效手势(滑错、空点)不计进展**——无操作轨被它们复位、无进展轨不被它们复位,乱按 96s 也必被抢救 | +| 阈值 | 60s → gd_prog1;120s → gd_prog2;用尽即停(每等待点一轮) | +| 有真实进展 | 全程零 prog 播出(不误伤正常玩家,dungeon 断言同款) | +| 双轨去重 | 两轨短句内容同级交叉登记;同一等待点先播过 gd_ping* 则无进展轨跳过同义级 | + +- 向导插播一律 `queue:true`,绝不顶掉在播对白;error/watchdog 类语音 idle-only(在播则跳过且过期丢弃,F1.4)。 +- 单句语音看门狗(资产缺失兜底):30s 超时且 `isVoicePlaying()===false` 才自动续链,长句续约不掐(radio N1 先例照抄)。 + +### 7.3 键盘变体机制(PC 键盘玩家) + +- 运行时按 `gesture.inputMode()`(settings.input auto/touch/key)选 `_k` 变体资产:key → `voice//_k.*`,touch → 无后缀版;**auto 跟随最近使用通道**(keydown 后即切 key)。`_k` 缺失回退无后缀版(资产侧保证成对,check_all_assets 校验)。 +- 变体清单(11 对,集中分组见 voice-ids §G0):gd_brief / gd_continue / gd_again / gd_ping1 / gd_ping2 / sys_help / sys_menu / sys_skip / sys_continue_ask / dv1_teach / p1。 +- 抉择提示 p2..p9 措辞统一「向左滑/向右滑」;键盘玩家由 p1_k 与 sys_help_k 完成首次绑定(左=←,右=→),后续提示语义不变。 +- 启动遮罩文案引擎自理(「按空格开始」/「轻触开始」),游戏零改动。 + +### 7.4 继承 radio 三轮验证机制(照抄不重设计) + +| 机制 | 来源 | 落点 | +|---|---|---| +| 帮助链去重(同等待点第 2 次上滑只震+nudge) | radio G1 | `_helpSeen` 按等待点键控 | +| 状态链顺序「状态在前、帮助在后」 | radio G6 | prog_dN → oxy 档 → sys_help | +| 空点确认轻震(不可跳句上单击补 beat [8]) | radio G4 | p*/链末句/F4 锁各 tap 屏蔽分支 | +| oxy_crit 播报位附 warning 震 | radio G2 | 全部 crit 出口(含上滑复听) | +| 菜单报名游戏侧排队零截断 | radio 实现修复 #3 | `_menuQueue` 排水 | +| stop() 先 `seqToken++` 作废在途链 | radio 实现修复 #6 | 同款 | +| 新局清 choiceArmed/pendingChoice | radio 实现修复 #7 | newRun() | +| 长按误报无效(longpress 全状态合法) | radio 实现修复 #2 | 仅 left/right(ASK 另含 up/down/doubletap)报无效 | + +## 8. 存档设计 + +### 8.1 进度 data 结构(PUT /api/progress {game:'abyss'}) + +```json +{ + "schema": 1, + "updatedAt": "2026-09-09T12:00:00Z", + "summary": "第2潜·机舱 · 氧气6格 · 结局1/4", + "phase": "COMMS", + "node": "bai_b", + "dive": 2, + "oxy": 6, + "supply": true, + "saved": { "zhou": 1, "bai": 0, "chen": 0, "zheng": 0, "gao": 0 }, + "stayer": null, + "flags": { "knowsPipe": true, "hasLog": false, "valve": false, "spareGift": false, "gaoPeace": false, "bell": false }, + "endings": { "e2": true }, + "runs": 3 +} +``` + +- 写入时机:每潜 DIVE 进入、每个抉择结算后、SURFACE 进入、ENDING 进入、菜单「保存并回大厅」。 +- 断点续玩:读档 → sys_continue_ask(_k)(单击=继续上次 / 右滑=新救援);继续=恢复 phase+node+全部数值并**重播当前节点链**=一句话局面复述(L5);重进 ≤5s 可玩;恢复首句受 F4 跳句锁(radio G5 例外条款)。 +- summary 格式:进行中 `第N潜·<舱段> · 氧气K格 · 结局X/4`;收队 `救援收队·<结局名> · 结局X/4`(大厅卡片直接显示)。 + +## 9. playtest 断言清单(登记 13 号 §21 指针;`settings.set({guide:'off'})` 纯玩法验证,向导行为住 guide.test) + +**关键路径**(剧本 A:全右+左p9 → e1,≤70 次手势): +1. 遮罩点按 → INTRO 链(b_open 起,voice 层有播放记录)→ 链末单击 → DIVE(1) 下潜链 auto → 搜索等待武装(turn 事件 + amb_deep 循环起播)。 +2. 搜索单击 → sonar_ping → 回波静默一拍 → echo_metal_l → dv1_metal(承诺音)→ link_beep → COMMS(1):z1_a/b/knock → p1 右 → p2 右 → 上浮:surf_splash+ascent_chime → d1_surf → oxy=12-3=9 → oxy_full 播出(账目断言)。 +3. DIVE(2):脉冲 → echo_debris+pipe_knock → dv2_found → bai_a/b → p3 右(danger 签名前置 2s 与 spark_flash 同窗断言)→ p4 右 → d2_surf → d2_supply → oxy_gain(6→9,恰一次)。 +4. DIVE(3):脉冲 → echo_groan → dv3_groan → chen_a/b → p5 右 → p6 右 → d3_surf(oxy=6,ok 档)。 +5. 氧气门:6≥4 → DIVE(4) 双载:滑坡危机拍(landslide 前置 2s 渐强 + danger 签名 + danger 震)→ gao_a/b → p7 右 → gao_c → p8 右 → d4_surf → oxy=2(crit 档+warning 震)。 +6. 签收:g9_in → g9_knot → p9 左(oxy=2≥1 可用)→ d9_bell + bell_toll → ENDING(e1):e1_a/b + ep_lead + ep 链 → 结算单击 → 新救援回 INTRO。 + +**边界断言**: +- **四结局可达(含氧气逐点验算)**:剧本 B(同 A 但 p9 右)→ e2;剧本 C(p1左 p2左 p4左,余右):D1 后 oxy=7(ok)/D2 后 2→supply→5(gain+low 档)/D3 后 2(crit)→ 氧气门走**终局取消链**(dv4_none→gao_final 完整播出)→ p9 左(2≥1)→ e3(saved=3);剧本 D(p1左 p4左,余右):D1 后 8(ok)/D2 后 3→supply→6(ok)/D3 后 3(low)→ **单载形态**(p7 按 stayer 选 p7_chen|p7_zheng)→ p7 左=带船长 → 留守者留别句(chen_last|d4_stay_lost)→ p8 右 → oxy_end=0 → p9 左**封锁**:g9_dry 播出后自动右 → e4(saved=4)。各局 oxy 逐检查点与 §4.5 公式一致。 +- **氧气账目真实**:每次扣减后播出的档位句与 floor(oxy/3) 桶一致;上滑 status 链顺序 prog_dN → oxy 档 → sys_help(G6);supply 全局恰一次;剧本 E(p2左 p3[无knows]左 p4左 p6左)mid-dive floor 至 0 → dry_a/dry_b 播出且当前救援者照常救上(人已挂上吊索不失去)。 +- **双通道仲裁**:全剧本转写断言——echo_*/pipe_knock/groan 信息音播出时刻与任一 voice 播放区间**零重叠**(sonarGate 排队语义:晚于在播句结束);搜索等待单击恒=脉冲(sonar_ping 计数=1,无跳句无 error);双击搜索等待=回波+解读句同序重播;语音在播时 amb_deep 持续、远程脉冲垫降 -6dB(R3)。 +- **教学承诺兑现**(§6.3 清单逐条):dv1_metal 后所有「船身」节点 echo_metal* 零漏播;D2 找到机舱前 pipe_knock 恒在;b_rule 与取消门一致(剧本 C 证明);b_goal 四潜=剧本 A 恰 4 潜;每潜起播扣 3 断言。 +- **看门狗双轨**:搜索等待快进 10s→gd_ping1、25s→gd_ping2(两级 id ≠,绝不逐字重播;两级后仅 amb);**乱按 96s**(每 2s 注入一个无效滑动):无操作轨零播出(被手势复位)+ 无进展轨 60s→gd_prog1、120s→gd_prog2(乱按饿不死);有真实进展(单击脉冲推进)全程零 prog 播出;COMMS 链末 25s/50s → gd_continue/gd_again;p* 播完 25s/50s 重播 p* 两次后静默;`guide=off` 全程零定时器零向导事件。 +- **键盘变体**:settings.input='key' 会话播出 gd_brief_k/sys_help_k/gd_ping1_k/dv1_teach_k/p1_k 且对应无后缀版零播出;'touch' 反之;'auto':keydown 后下一句用 _k。 +- **中断复述**:杀进程重进(存档 node=bai_b)→ sys_continue_ask → 单击 → 重播该节点链(恢复首句不可跳,第二句起可跳);右滑=新救援(runs 口径同结算重开)。 +- **结算链死窗(H1 内化)**:终局取消链/干涸链/e* 结局链开播后 800ms 内注入在途 tap(mock 补派 touchend+300ms 同款序列)→ 手势被吞、链完整有序播出、结算出口手势照常。 +- **pendingChoice**:p* 播放中左右滑 → 播完立即生效(不丢、不重复、连滑取最后一次)。 +- **p* 不可跳**:p* 播放中单击 → 不跳句(PROMPT_RE 门)+ beat 轻震(G4)。 +- **idle-only**:向导 gd_* 在对白在播时跳过且不占 onceKey(F1.4);queue 句排在当前句后零截断。 +- **菜单**:下滑开菜单 → 上滑帮助 → 双击「回到水下」→ phase/node 不变、当前节点重播;单击循环顺序 = [回到水下, 重新开始这次救援, 保存并回大厅];确认回大厅 → sys_menu_lobby 播出且 PUT/localStorage 有最新 data。 +- **资产缺失**:屏蔽任意 voice 资产 → 30s 看门狗自动续链到结局不卡死;NPC 滤波资产缺失回退原声可播。 +- **数字规范**:prog_*/oxy_*/gd_* 全部整句字面量,静态审计无运行时拼接碎片。 +- **低氧混音签名**:tier=low/crit 期间 breath_loop 在播、升档/收队即停。 + +**失败即不通过**:任何等待 >5s 无声(静默死局);无效手势零反馈;回波与语音重叠;结局计数与 §3.4 不符;氧气档位句与 oxy 值不符。 + +## 10. 节奏曲线(每次下潜目标时长) + +| 段 | 内容 | 目标时长 | 设计目标 | +|---|---|---|---| +| 开场+下潜链 | 甲板简报+第一潜下水+搜索 | 1-1.5 min | 首输入 <60s(开场链 ~20s 链末单击,或 ~40s 搜索首脉冲);规矩一句讲完(P4) | +| D1 找船 | 教学潜:脉冲/回波/接通/2 抉择 | 4-4.5 min | **首个正反馈 ≤3min**(~2.5min 金属回波+接通小舟=「看见它了」);两抉择条条有出路;左路气耗被补给兜底=学习期零惩罚 | +| D2 机舱 | 老白:撬/切+阀门抉择 | 3.5-4 min | 首个危机拍(火花,前置 2s);补给+3 兑现宽限;暖拍(热粥,P4) | +| D3 病舱 | 生死抉择+回波预警 | 3.5-4 min | 船体呻吟埋 D4 险;留守约定=段落边界(P3 中断友好:每潜一个完整存档颗粒) | +| D4 指挥舱 | 滑坡高潮+氧气门三形态 | 4-4.5 min | 紧张峰值:danger+滑坡渐强+双人竞速;生死分支收敛 | +| 终局+结局 | p9 心结+结局链+逐人回响 | 2.5-3 min | 氧气余量兑换终局抉择;ep 点名(P2 回响);开环钩子拉下一局 | + +- 合计 ≈ 20-22 min(15-25 达标);每 30-60s 一个反馈颗粒(回波/解读短句/结果句/档位句),无沉默尴尬期(P3)。 +- 难度曲线 = 道德复杂度(教学二选一 → 情报投资 → 生死取舍 → 自己的心结),操作复杂度恒定(永远二选一+单击脉冲)——P4「规则一遍听懂」由恒定操作面保证。 + +## 11. 自检清单(策划交付自验;☐ = 待开发/测试轮填) + +| # | 硬约束/教训 | 落地 | ✓ | +|---|---|---|---| +| 1 | FTUE 首输入 <60s | 开场链 ~20s 链末单击;~40s 搜索首脉冲(§10) | ☑ | +| 2 | FTUE 首个正反馈 ≤3min | ~2.5min 金属回波+接通小舟(§10 D1) | ☑ | +| 3 | 学习期零惩罚 | D1 左路 -2 被 D2 后 +3 兜底;搜索无超时惩罚;D1/D2 恒可完成(§4.5) | ☑ | +| 4 | 失败三问归因+归因真话律 | 干涸链 dry_b 指向实际左选择;gao_final/d4 留别句克制不假安慰;§4.4 无假归因句 | ☑ | +| 5 | 资源账目真实(radio rep_low 教训) | 氧档以 floor(oxy/3) 划界、每档句义逐格为真(§4.3 表);补给恰一次 +3;扣 3 恰每次下潜 | ☑ | +| 6 | 手势宪法 6 手势默认语义零豁免 | §2.3 全覆盖;单击搜索=「检查」(宪法 §5.1+escape 先例);菜单 §5.2 模态约定 | ☑ | +| 7 | 长按显式登记 | 无豁免:长按=引擎快进(12 号叙事类不豁免),§2.3 在案 | ☑ | +| 8 | 向导:签名/turn cue/看门狗两级不同句 | §7.1/§7.2;gd_ping1≠gd_ping2;gd_continue→gd_again | ☑ | +| 9 | 看门狗双轨(dungeon E0-1/radio F1 教训) | 无操作轨+无进展轨(§7.2);乱按 96s 必被抢救;有进展零打扰 | ☑ | +| 10 | 插播 queue 不顶对白;纠错 idle-only 过期丢弃 | §7.2(F1.4 契约) | ☑ | +| 11 | 死亡/失败结算链完整(dungeon H1 教训) | 终局取消链/干涸链/结局链 800ms 手势死窗;已救者永不失去(§2.2/§4.3) | ☑ | +| 12 | 教学承诺兑现(dungeon「铁器=楼梯」教训) | §6.3 承诺清单 7 条逐条绑兑现点+断言 | ☑ | +| 13 | 单次播报 ≤3 选项 | 全部抉择恒 2 选项;菜单 3 项循环 | ☑ | +| 14 | 并发声流 ≤2 + 双通道仲裁 | voice 串行+声呐信息音互斥(R1-R5,§6.2);断言零重叠 | ☑ | +| 15 | earcon ≤7、统一词表、语义靠语音 | 只用统一 8 音中 7 个(battle 不用),零新增 | ☑ | +| 16 | 台词一句一文件 ≤40 字、数字分档整句、无运行时拼接 | voice-ids 全表 ≤30 字(最长 p8=30);prog/oxy 分档整句;句内数字写死 | ☑ | +| 17 | 多音字规避/同 instruct 常量/无语速词 | voice-ids 头部+文末自查(重写→补、脏/晃/闷标注试听) | ☑ | +| 18 | PC 键盘变体(提示句集中+标注) | 11 对 _k 变体集中分组(voice-ids §G0);inputMode() 运行时选择 | ☑ | +| 19 | P1:可打断/重听(含回波)/status key/不当白痴 | 跳句+双击(回波可重听 R5)+上滑状态链+成功零补救教学 | ☑ | +| 20 | P2:3 分钟见效/结局回响 | ~2.5min 首个改变世界的时刻;ep 逐人点名+bell 差分;e1 巧妙路径(p1左+p3左 省气取钟) | ☑ | +| 21 | P3:3 分钟颗粒/中断复述/单手 | 一潜=3.5-4.5min 完整段落+边界自动存档;重进重播当前节点;全手势免视单手 | ☑ | +| 22 | P4:一遍懂/容错/无高压倒计时/不骂人 | 一句规矩;无实时倒计时(步进资源+跨档才播);软失败零死亡;NPC 句 ≤24 字+逗号停顿 | ☑ | +| 23 | 抉择树 4 结局可走通验算 | §4.5 公式+§9 剧本 A-D 逐 oxy 检查点(e1:lefts≤1+双载;e2:lefts≤2;e3:lefts=4-5+取消;e4:单载 lefts=3 或取消右) | ☑ | +| 24 | 单局 15-25 min | §10 曲线 ≈20-22 min | ☑ | +| 25 | 分支不爆炸(radio 纪律) | 跨潜仅标量(oxy/saved/stayer/6 flag);~70 节点固定深度(§3.3) | ☑ | +| 26 | 台词 ≤170、船员 ≤4 单音色 | 148 句(双 127+NPC 21)/4 个船员音色(voice-ids 账目) | ☑ | +| 27 | 引擎边界诚实 | §6.1 能力表:滤波/loop/声像/互让全写明资产侧或降级方案 | ☑ | +| 28 | 存档带 summary、断点续玩 | §8:data+summary 格式、≥5 恢复、局面复述 | ☑ | + +### 批 1 教训内化确认(5 条全落地) + +| 教训 | 本作机制 | 断言 | +|---|---|---| +| 教学承诺必须兑现 | §6.3 七条承诺清单(金属=钢板/单击=脉冲/四潜/氧气门/扣三格/敲管/补给) | §9「教学承诺兑现」块 | +| 看门狗双轨 | §7.2 无操作轨+无进展轨;无效手势不复位进展轨 | §9「看门狗双轨」块 | +| 档位措辞真实 | §4.3 档位=floor(oxy/3) 可下潜次数,句义逐格为真 | §9「氧气账目真实」块 | +| 失败结算链完整不被打断 | 800ms 手势死窗盖取消链/干涸链/结局链;seqToken 作废在途链 | §9「结算链死窗」块 | +| PC 键盘操作提示 | 11 对 _k 变体+inputMode() 选择+集中分组标注 | §9「键盘变体」块 | diff --git a/docs/design/18-detective-voice-ids.md b/docs/design/18-detective-voice-ids.md new file mode 100644 index 00000000..f2b89794 --- /dev/null +++ b/docs/design/18-detective-voice-ids.md @@ -0,0 +1,268 @@ +# 18b · 《雨夜专案》语音台词表(voice ids,录音依据) + +> **给音频总监的录制说明** +> - **旁白双性别组**(106 句):旁白只做系统/指引/教学/状态/现场勘验(探长自勘)/裁决宣读,**不念证词**。落 `web/detective/audio/voice/female|male/.{ogg,m4a}`,两性别 id 集合完全一致。 +> - female = `Serena`,instruct 常量:`沉稳清晰的女声,专案组探长,冷静克制,像在雨夜读卷宗` +> - male = `Uncle_Fu`,instruct 常量:`低沉沉稳的男声,专案组探长,冷静克制,像在雨夜读卷宗` +> - 两常量全表统一(含教学/指证/结案/结算),禁止句级变体、禁语速词(引擎 SoundTouch 变速)。 +> - **NPC 平铺组**(52 句):证词=证人 NPC 平铺单音色、**不做双性别**,落 `web/detective/audio/voice/.{ogg,m4a}`(引擎平铺回退命中)。NPC ≤5;同一角色 speaker+instruct 恒定;**不加电话滤波**(区别于 radio——本作区分度靠 speaker+instruct+游戏侧签名 earcon `sig_*` 前缀,见主设计 §8.2/§3.7)。区分度风险逐个标注: +> | 角色 | speaker | instruct 常量 | 区分度风险 | +> |---|---|---|---| +> | 老葛(62 岁门卫) | `Eric` | `苍老沙哑的男声,守夜人,慢悠悠,带倦意` | 低 | +> | 小顺(19 岁学徒) | `Dylan` | `年轻清亮的男声,学徒,怯生生的,慌张诚实` | 低 | +> | 周先生(45 岁账房,pack A 真凶) | `Uncle_Fu` | `平板谨慎的男声,账房,字斟句酌,被追问时渐慌` | **中**:与 male 旁白同 speaker——sig_zhou 前缀+账房腔+证词链内零旁白插话,试听确认 | +> | 温太太(50 岁房东) | `Vivian` | `快嘴泼辣的中年女声,房东太太,嗓门亮,热心肠` | 低 | +> | 秦法医 | `Serena` | `冷静干练的女声,验尸官,临床腔,短句平直` | **中**:与 female 旁白同 speaker——sig_fore 前缀+报告固定语境,试听确认 | +> - **声音演技核心**:周先生 z_react_1..4(记岔→支吾→拒答→想走)与 z_break_1..3(认罪三段)、pack B 的 b_shun_react/b_shun_break——情绪全部写在句内文本(省略号/破折号引导),instruct 恒定不变。 +> - **数字规避**:进度/疑点/冗余指证全部分档整句(st_asked 7 档 / st_doubt 5 档 / st_miss 3 档);**句序号一律不播**(指证选句用重播该句代替报「第几句」,交互侧落实拼接禁令);句内时刻数字写死在整句里(十点二十、九点四十)。 +> - **称呼中性**:旁白称玩家「你」,不出现性别化称呼(旁白性别随设置变化不影响证词内容)。 +> - **多音字自查**:全表无「血」「爪」「挨」「壳」;「重新听」统一写法(不用「重听」);已排雷:「当废铜卖了」→「卖给收废品的了」(当 dāng/dàng 歧义)。低风险 3 处标注:「记得真真的」「账算得比谁都精」的「得」(de,助词)、「微驼背」的「背」(bèi)、「疑点,一处」的「处」(chù)——试听异常再改写。 +> - **键盘变体(V 组)**:操作提示句在 `gesture.inputMode()==='key'` 时改播 `_k` 变体(touch 回基础句);变体与基础句 id 一一对应、集中登记见文末 V 组。 +> - 管线:`tools/gen_voice_dual.py` 照录(SPEAKERS 需新增 detective 行 + DETECTIVE_NPC 平铺表);生成后 `--convert` 落盘 + `tools/check_all_assets.py` 终检(双格式配对、双性别集合一致)。 + +## G0 组 · 系统/菜单/帮助(旁白 ×11) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `sys_menu` | 菜单。单击切换,双击确认。 | 下滑呼出模态菜单 | +| `sys_menu_resume` | 回到证词板。 | 菜单项 | +| `sys_menu_cf` | 去指证。 | 菜单项(长按捷径的冗余入口,P4) | +| `sys_menu_save` | 保存并回大厅。 | 菜单项 | +| `sys_menu_help` | 重新听帮助。 | 菜单项 | +| `sys_menu_restart` | 重新查这桩案子。 | 菜单项 | +| `sys_saved` | 进度已保存。 | 存档写入后 | +| `settings_hint` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内设置提示 | +| `sys_help` | 左右滑换卷宗,单击听取,双击重新听。长按指证,上滑状态,下滑菜单。 | 菜单内上滑 / 帮助项 | +| `sys_continue_ask` | 检测到未结的案子。单击继续,右滑换一桩新案。 | 有存档进游戏(TITLE) | +| `sys_new_case` | 新案开卷。 | 新案开始 / 菜单重开 | + +## G1 组 · 开场/教学(旁白 ×12) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `intro_1` | 雨下了一整夜。清晨,聚宝斋后院,古董商沈万川死了。 | 开场链 1(TITLE 上滑重听) | +| `intro_2` | 你是专案组探长。这桩案子,归你。 | 开场链 2 | +| `intro_3` | 卷宗六份:四位证人的证词,现场勘验,法医报告。 | 开场链 3 | +| `tut_d0` | 先熟悉证词板。左右滑,换卷宗。 | D0 前 | +| `tut_listen` | 单击,听取当前卷宗。 | D1 前 | +| `tut_replay` | 双击,重新听这一句。 | D2 前 | +| `tut_status` | 上滑,随时听进度:问了几份,疑点几处。 | D3 前 | +| `tut_confront` | 按住屏幕,进入指证。去找两句对不上的证词。 | D4 前(豁免 E2 教学) | +| `tut_cf_try` | 练习一次。选一份证词,单击确认;再选一份,任意一句。 | D5(判定被教学截获) | +| `tut_cf_ok` | 指证就是这样。练习不算数,真矛盾要靠耳朵找。 | D5 完成(success 震) | +| `tut_done` | 教学完了。开始查案。 | D6(进 BOARD) | +| `tut_skip` | 已跳过教学。左右滑换卷宗,单击听取。 | 下滑跳过教学 | + +## G2 组 · 状态/条目(旁白 ×20) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `st_asked_0` | 六份卷宗,一份都没听。 | 上滑状态链(已听条目数 0) | +| `st_asked_1` | 已听一份,还有五份。 | 已听 1 | +| `st_asked_2` | 已听两份,还有四份。 | 已听 2 | +| `st_asked_3` | 已听三份,还有三份。 | 已听 3 | +| `st_asked_4` | 已听四份,还有两份。 | 已听 4 | +| `st_asked_5` | 已听五份,还有一份。 | 已听 5 | +| `st_asked_6` | 六份都听完了。 | 已听 6 | +| `st_doubt_0` | 疑点,还没有。 | 状态链第 2 段(疑点 0) | +| `st_doubt_1` | 疑点,一处。 | 疑点 1 | +| `st_doubt_2` | 疑点,两处。 | 疑点 2 | +| `st_doubt_3` | 疑点,三处。 | 疑点 3 | +| `st_doubt_4` | 疑点,四处,齐了。 | 疑点 4(齐时追加 hint_fin 一次) | +| `item_w1` | 门卫老葛的证词。 | 条目切换(前缀 sig_ge) | +| `item_w2` | 学徒小顺的证词。 | 条目切换(sig_shun) | +| `item_w3` | 账房周先生的证词。 | 条目切换(sig_zhou) | +| `item_w4` | 房东温太太的证词。 | 条目切换(sig_wen) | +| `item_scene` | 现场勘验。 | 条目切换(sig_scene) | +| `item_fore` | 法医报告。 | 条目切换(sig_fore) | +| `item_case` | 结案陈词。 | 条目切换(无签名,turn earcon);单击进 FINISH | +| `intro_w3` | 周先生,管了十年的账,见人三分笑。 | 周证词链首(旁白报名,防与 male 旁白混淆的强化锚点) | + +## G3 组 · 现场勘验(旁白 ×6,探长自勘,sig_scene 前缀链播) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `sc_0` | 你打伞进了后院。雨水顺着瓦檐,淌成一道帘子。 | 现场链 1(无轴) | +| `sc_1` | 柴房门口,沈万川面朝下倒着。后脑一处伤,现场没有凶器。 | struggle=none | +| `sc_2` | 后门门闩完好,是死者自己开的门。生人叫不开这扇门。 | door=victim_opened | +| `sc_3` | 泥地上只有进来的脚印,没有出去的。墙根的花盆,翻了一个。 | wall=climbed | +| `sc_4` | 库房铜秤立在架上,秤砣不见了。 | scale=present(M4 对立端) | +| `sc_5` | 柴房墙上,挂钥匙的铜钩,空着。 | 无轴(叙事佐证,不参与判定) | + +## G4 组 · 指证(旁白 ×8) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `cf_start` | 指证矛盾。先选第一份证词。左右滑换,单击确认。 | 进 CONFROST(turn earcon 同点) | +| `cf_pick_line` | 左右滑,一句一句听。 | 选句相位入口(首次) | +| `cf_confirm` | 就这句?单击,定下来。 | 选句两段确认(重播该句后接播) | +| `cf_second` | 再选与它矛盾的那份证词。 | 第一份定下(自动重播所选句一次) | +| `cf_self` | 同一句证词,和自己比不出矛盾。 | 判定:同一句(真话) | +| `cf_same` | 这两句说得一致,不矛盾。 | 判定:同轴同值(真话) | +| `cf_diff` | 这两句,说的不是一件事。 | 判定:无共享轴(真话) | +| `cf_hit` | 对不上!矛盾成立,记入疑点。 | 判定:命中(contra_hit 音先响) | + +## G5 组 · 结案三问(旁白 ×18) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `fin_start` | 结案陈词。先指认真凶:四位证人,选一个。 | 进 FINISH(choice earcon) | +| `fin_who_confirm` | 就指认他?单击,定下来。 | 真凶问两段确认(重播选项名后接播) | +| `fin_clear` | 不在场证明成立。证据放人,回到证词板。 | 指认错(acc_NPC 句后接播;无限再机会) | +| `fin_right` | 证据链闭合。周先生,你还有什么话说? | pack A 指认对 → 认罪链起(rain_swell 同点) | +| `fin_right_b` | 证据链闭合。小顺,你还有什么话说? | pack B 指认对 | +| `fin_ask_weapon` | 第二问:凶器是什么? | 凶器问入口 | +| `wp_1` | 松木劈柴。 | 凶器选项(顺序由 seed 洗牌) | +| `wp_2` | 铜秤砣。 | 凶器选项(正确答案,两包共用) | +| `wp_3` | 铁门闩。 | 凶器选项 | +| `wp_4` | 青瓷花盆。 | 凶器选项 | +| `fin_wp_right` | 对。创口的铜屑,不见了的秤砣,都指向它。 | 凶器答对 | +| `fin_wp_wrong` | 不对。铜屑不答应。记下了。 | 凶器答错(真话归因,不重答) | +| `fin_ask_motive` | 第三问:动机是什么? | 动机问入口 | +| `mv_1` | 监守自盗。 | 动机选项(正确答案,两包共用) | +| `mv_2` | 寻仇泄愤。 | 动机选项 | +| `mv_3` | 劫财入户。 | 动机选项 | +| `fin_mv_right` | 对。库里短的东西,就是引线。 | 动机答对 | +| `fin_mv_wrong` | 不对。账上短的东西,才是引线。记下了。 | 动机答错(真话归因) | + +## G6 组 · 结算(旁白 ×9) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `settle_open` | 案件归档。雨,停了。 | 结算链首句(gavel 后) | +| `rate_s` | 教科书结案。四处疑点,一击即中。 | S 档(条件见主设计 §7.2,措辞=账目) | +| `rate_a` | 干净利落的结案。 | A 档 | +| `rate_b` | 案子结了,卷宗上有疏漏。 | B 档 | +| `rate_c` | 案子是结了,卷宗上补丁不少。 | C 档(保底/多错) | +| `st_miss_0` | 指证,一次没错。 | 冗余指证 0 次 | +| `st_miss_1` | 指证,错过一两回。 | 1-2 次 | +| `st_miss_2` | 指证,错了不少。 | ≥3 次 | +| `settle_hook` | 同一场雨里,还有别的案子。单击,接新案。 | 结算尾(链锁解除后单击生效;键盘态播 settle_hook_k) | + +## G7 组 · 看门狗/卡关/纠错(旁白 ×12) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `gd_board` | 还在证词板。左右滑,换卷宗。 | BOARD 无操作轨第 1 级(10s,full,idle-only) | +| `gd_cf` | 还在选证词。左右滑,单击确认。 | CONFRONT 无操作轨第 1 级(15s) | +| `gd_fin` | 在想?左右滑选,单击定。 | FINISH 三问无操作轨第 1 级(20s) | +| `gd_invalid` | 这个操作,这里用不上。 | 无效手势纠错(onceKey 同局 1 次) | +| `hint_a` | 探长,还没听的卷宗,先过一遍耳朵。 | 无进展轨 L2(90s,有未听条目时) | +| `hint_b` | 探长,证词里有对不上的地方,逐句比对。 | 无进展轨 L2(90s,条目听齐但疑点未齐) | +| `hint_c1` | 试试指证:周先生说熄了灯,温太太说灯还亮着。 | 无进展轨 L3(150s,M1 保底,每句全局限 2 次) | +| `hint_c2` | 试试指证:周先生说铜秤卖了,现场铜秤还在。 | L3(M4 保底) | +| `b_hint_c1` | 试试指证:小顺说熄了灯,温太太说灯还亮着。 | pack B L3(M1b 保底) | +| `b_hint_c2` | 试试指证:小顺说铜秤卖了,现场铜秤还在。 | pack B L3(M4b 保底) | +| `hint_fin` | 疑点齐了。去结案陈词。 | 4 疑点齐(一次)/ 无进展轨 L3(疑点齐时) | +| `recap_go` | 接着查。 | 中断复述链尾 | + +## V 组 · 键盘变体(旁白 ×10;`gesture.inputMode()==='key'` 时替代同名基础句) + +| id | 替代基础句 | 台词全文 | +|---|---|---| +| `sys_help_k` | sys_help | 方向键换卷宗,空格听取,回车重新听。按住空格指证,上键状态,下键菜单。 | +| `tut_d0_k` | tut_d0 | 先熟悉证词板。左右方向键,换卷宗。 | +| `tut_listen_k` | tut_listen | 空格,听取当前卷宗。 | +| `tut_replay_k` | tut_replay | 回车,重新听这一句。 | +| `tut_confront_k` | tut_confront | 按住空格,进入指证。 | +| `gd_board_k` | gd_board | 还在证词板。左右方向键,换卷宗。 | +| `gd_cf_k` | gd_cf | 还在选证词。左右方向键,空格确认。 | +| `cf_pick_line_k` | cf_pick_line | 左右方向键,一句一句听。 | +| `cf_confirm_k` | cf_confirm | 就这句?空格,定下来。 | +| `settle_hook_k` | settle_hook | 同一场雨里,还有别的案子。按空格,接新案。 | + +## N1 组 · 老葛证词(NPC Eric 平铺 ×6,sig_ge 前缀,链播句间 0.8s) + +| id | 台词全文 | 线索标签 | +|---|---|---| +| `w1_1` | 昨儿夜里雨大,我守着街门,一步没敢挪窝。 | 无 | +| `w1_2` | 九点四十,雨点子刚砸下来,小顺打着伞出的街门。 | rain=2140, dep_s=2140 | +| `w1_3` | 十点一刻,一位先生收着黑伞进了后巷。个儿高,微驼背。 | visitor=male_tall_hunched | +| `w1_4` | 九点五十,周账房出的街门,跟我说了声,回家。 | dep_z=2150 | +| `w1_5` | 后半夜,温太太还来门房借过一回热水。 | 无 | +| `w1_6` | 听说店里账上短了银子,沈老板撂了话,月底清账。 | motive=short | + +## N2 组 · 小顺证词(NPC Dylan 平铺 ×6,sig_shun 前缀) + +| id | 台词全文 | 线索标签 | +|---|---|---| +| `w2_1` | 夜里,师父打发我去城南,给刘掌柜送匣子。 | away_s=south | +| `w2_2` | 雨太大,刘掌柜留我住的,今早才回店。 | away_s=south | +| `w2_3` | 库房钥匙?师父嫌沉,平日就挂在柴房墙上的铜钩。 | key=hook | +| `w2_4` | 师父的账,向来只信周先生一个人。 | 无 | +| `w2_5` | 后门那闩,生人叫不开。熟人来了,师父才亲自去开。 | door=victim_opened | +| `w2_6` | 周先生常念叨,账房的活,越到夜里越清静。 | 无 | + +## N3 组 · 周先生证词(NPC Uncle_Fu 平铺 ×6,sig_zhou 前缀;pack A 真凶,4 句谎) + +| id | 台词全文 | 线索标签 | +|---|---|---| +| `z_1` | 我七点进店上夜账。九点五十对完,锁了前门回家。 | dep_z=2150 | +| `z_2` | 临走,我顺手熄了店堂的灯。那会儿,店里就剩老板。 | lamp=off(谎①,M1) | +| `z_3` | 库房钥匙?沈老板贴身带着,从不离身。 | key=body(谎②,M2) | +| `z_4` | 那伤,是松木劈柴砸的。铜秤?上个月卖给收废品的了。 | weapon=wood, scale=sold(谎③④,M3/M4) | +| `z_5` | 我到家倒头就睡,夜里的事,一概不知。 | 无 | +| `z_6` | 你们要是不信,去问我家婆娘。 | 无 | + +## N4 组 · 温太太证词(NPC Vivian 平铺 ×6,sig_wen 前缀) + +| id | 台词全文 | 线索标签 | +|---|---|---| +| `wen_1` | 雨是九点四十下的。我正收晾的衣裳,记得真真的。 | rain=2140 | +| `wen_2` | 十点二十,我出门倒垃圾。聚宝斋店堂的灯,亮着呢。 | lamp=on_2220(M1 对立端) | +| `wen_3` | 刚回屋,柴房后墙咯噔一响,有人爬墙!花盆翻了一个。 | wall=climbed | +| `wen_4` | 吓得我蒙头半宿没睡着,天亮才敢吱声。 | 无 | +| `wen_5` | 店里短银子的事,街坊都有耳闻。上礼拜他还摔了账本。 | motive=short | +| `wen_6` | 周账房那人,见人三分笑,账算得比谁都精。 | 无 | + +## N5 组 · 法医报告(NPC Serena 平铺 ×5,sig_fore 前缀) + +| id | 台词全文 | 线索标签 | +|---|---|---| +| `fo_1` | 验尸报告,念给你听。 | 无 | +| `fo_2` | 死亡时间,昨夜十点到十点半。 | death=2200_2230 | +| `fo_3` | 后脑单次钝器伤,一击毙命。创口嵌有铜屑,凶器是铜器。 | weapon=bronze(M3 对立端) | +| `fo_4` | 死者双手干净,没有搏斗痕迹。 | struggle=none | +| `fo_5` | 鞋底是干的。当夜,死者没出过后门。 | 无 | + +## N6 组 · 反应/被指认/认罪(NPC 平铺 ×11) + +| id | 声部 | 台词全文 | 触发/语境 | +|---|---|---|---| +| `z_react_1` | 周先生 | 灯?……许是我记岔了。人上了岁数,记性不济。 | 真凶第 1 处矛盾坐实 | +| `z_react_2` | 周先生 | 钥匙的事……他、他有时候,也贴身带着的…… | 第 2 处 | +| `z_react_3` | 周先生 | 秤!秤的事,你们别再问了…… | 第 3 处 | +| `z_react_4` | 周先生 | ……随你们怎么问。我要回去了。 | 第 4 处 | +| `acc_ge` | 老葛 | 我守了一夜门!温太太来借热水,她能作证! | 错指老葛 | +| `acc_shun` | 小顺 | 我宿在城南刘掌柜家,他全家都能作证! | 错指小顺(pack A) | +| `acc_wen` | 温太太 | 我这把年纪翻墙?先问问那墙答不答应! | 错指温太太 | +| `acc_zhou` | 周先生 | 我十点就到家了!我家婆娘能作证! | 错指周先生(pack B) | +| `z_break_1` | 周先生 | 别念了……我说。库房短的三件货,是我抵了赌债。 | pack A 认罪 1 | +| `z_break_2` | 周先生 | 他说月底清账,要报官。我求他,他不肯。 | 认罪 2 | +| `z_break_3` | 周先生 | 我只想把钥匙放回去,把被盗做真。抡起秤砣的时候,他认出了我。 | 认罪 3 | + +## N7 组 · 变体包 B fork(NPC 平铺 ×12;pack B 真凶=小顺) + +| id | 声部 | 台词全文 | 线索标签/用途 | +|---|---|---|---| +| `b_zhou_1` | 周先生 | 我对完账就回家了,夜里雨大,路上没停。 | 无(替换 z_2 槽位) | +| `b_zhou_2` | 周先生 | 钥匙?平时就挂在柴房铜钩上,十年了。 | key=hook(M2b 对立端) | +| `b_zhou_3` | 周先生 | 柴房我常去,没什么不对劲的。 | 无 | +| `b_shun_1` | 小顺 | 我走的时候,顺手熄了店堂的灯。 | lamp=off(谎①,M1b) | +| `b_shun_2` | 小顺 | 钥匙,师父贴身带着,从不离身。 | key=body(谎②,M2b) | +| `b_shun_3` | 小顺 | 那伤,八成是柴房劈柴砸的。铜秤早卖给收废品的了。 | weapon=wood, scale=sold(谎③④,M3b/M4b) | +| `b_shun_4` | 小顺 | 库房的货,我一件都没碰过。 | 无(欲盖弥彰 filler) | +| `b_shun_react_1` | 小顺 | 灯?我、我走的时候明明熄了…… | 第 1 处矛盾坐实 | +| `b_shun_react_2` | 小顺 | 别问了……别问了…… | 第 2-4 处(复用) | +| `b_shun_break_1` | 小顺 | 匣子是我送的,可我半路折回来了…… | 认罪 1 | +| `b_shun_break_2` | 小顺 | 我把棉袄反穿,弓着背,葛大爷没认出我。 | 认罪 2(解释 w1_3 访客描述) | +| `b_shun_break_3` | 小顺 | 库房的货,我拿过三件换了钱。他说要对账……秤砣下去的时候,他看着我。 | 认罪 3 | + +--- + +**合计:158 句 ≤160 上限**(旁白 106 ×2 性别 = 212 条 + NPC 平铺 52 条 = **264 条音频** ×2 格式 = 528 文件)。 +分组账:G0 11 + G1 12 + G2 20 + G3 6 + G4 8 + G5 18 + G6 9 + G7 12 + V 10 = 106(旁白);N1-N5 29 + N6 11 + N7 12 = 52(NPC)。 +指证命中链 = `contra_hit(sfx)→ cf_hit → react_n`;miss 链 = `contra_miss(sfx)→ cf_same/cf_diff/cf_self`; +认罪链(pack A)= `fin_right → z_break_1..3 → gavel → settle_open → rate_X → st_doubt_n → st_miss_档 → settle_hook`; +状态链 = `st_asked_n → st_doubt_m`;中断复述链 = `st_asked_n →(未听 ≤3 时)item_* 名链 → st_doubt_m → recap_go`。 +矩阵验算与触发逻辑对照 [18-detective.md](18-detective.md) §3-§7;资产规模核对见其 §8。 diff --git a/docs/design/18-detective.md b/docs/design/18-detective.md new file mode 100644 index 00000000..96cd4a74 --- /dev/null +++ b/docs/design/18-detective.md @@ -0,0 +1,600 @@ +# 18 · 《雨夜专案》完整设计(detective / Rainy Night Case) + +> **给开发者的实现要点摘要** +> 1. 本格推理 RPG:**核心动词=听证词找矛盾**。案卷 7 条目(4 证人证词 + 现场勘验 + 法医报告 + 结案陈词),全部音频呈现;证词=证人 NPC 平铺单音色(声音演技核心,5 个 speaker 池满配 + 专属签名 earcon 防混淆),旁白双性别只做系统/指引/现场自勘。每句证词预打**线索轴标签**,指证判定=两句是否「同轴异值」——判定与归因全部从数据单源推导,机制上不可能出现「台词说对得上、逻辑上对不上」的假话。 +> 2. 手势:证词板左右滑换条目、单击听取/停止、**双击=重听当前句**(证词按句切分,单句 ≤8s,链播句间 0.8s——≤40s 听觉负荷以「句粒度可停可重听」兑现)、上滑状态链(已问几份/疑点几处)、下滑菜单(无豁免)、**长按=进入指证**(仅限无声空闲时受理,与长按快进零冲突,豁免 E1/E2 登记 §2.6)。指证/结案菜单走「左右滑预览+单击两段确认」非模态范式,双击保持全局重听语义——**全游戏零手势偏离,仅 2 条已教学豁免**。 +> 3. 单局 15-25 分钟;结案=三问(真凶/凶器/动机),指认错误有不在场证明复述+真话归因+无限再机会,**无惩罚性失败,案子保底必结**(无进展轨看门狗三级提示最终直接给出可指证的矛盾对)。评级 S-C 账目全部真实(疑点数/冗余指证分档播报,播报值=运行值)。 +> 4. 随机性全走种子 PRNG(mulberry32,`opts.seed`/`opts.pack` 可注入):选案包(pack A 周先生真凶 / pack B 小顺真凶——同模板同证词骨架、fork 句替换)、洗牌只动 filler 句位与问题选项顺序、提示轮询顺序。**矩阵不变式:全案卷「同轴异值」语句对集合 ≡ 预设计矛盾表**(dev 断言 + playtest 断言双保险)。 +> 5. 台词表 [18-detective-voice-ids.md](18-detective-voice-ids.md):旁白 106 句 ×2 性别 + NPC 平铺 52 句 = **158 句 ≤160 上限**;操作提示句带键盘变体(`_k` 后缀,`gesture.inputMode()` 选播,V 组集中登记)。 +> 6. 规模对标 radio:单文件 game.js 预计 900-1100 行;bgm 3 + sfx 12 + voice 264 条(×2 格式 = 528 文件)。裁决仪式链不可打断(VERDICT 态 800ms 手势死窗 + 链锁,dungeon H1 同款)。 + +--- + +## 1. 概述 + +- **一句话**:你是专案组探长,一桩雨夜命案。翻听六份卷宗,比对证词,指出矛盾,锁定真凶。 +- **题材**:本格推理。核心命题「证词会撒谎,证据不会」——纯音频天然适合:证词本来就是"说"出来的信息,翻卷宗=翻语音段落,比对=重听+指证;视觉游戏需要的证词板 UI 在这里是一套**规整的听觉导航**(条目→句→指证对)。方法论 §4.1 无现成行(新模式探索),状态层重点自定为:**信息可回读**(每句可重听)+ **导航规整**(同一套左右滑/单击范式贯穿)+ **归因真话**(指证判定从数据推导)。 +- **为什么适合纯音频**:① 记忆与比对的工作记忆负载全在听觉通道——删 BGM 抢占(对白 duck 引擎自动);② 证人=声音演技(speaker+instruct 区分 + 签名 earcon),认人是听感本身;③ 本格逻辑链可完全用「谁说了什么」表述,无空间/动作信息需要降维。 +- **单局**:一周目(含教学)约 15-20 分钟;二周目(跳教学+变体案)约 13-15 分钟。结案必达(保底),评级 S-C 由过程质量决定。 +- **与现有 8 游戏的关系**:选项列表导航承接 rpg(左右滑+单击确认)、证词重听承接 simon/escape(重听=生命线)、状态档位链承接 dungeon(上滑 5 段链)、看门狗双轨承接 dungeon E0-1/E0-2(无操作轨+无进展轨);指证菜单是新交互增量(两级选择+两段确认),变体案包是内容增量。 + +## 2. 手势映射与豁免登记 + +### 2.1 证词板(BOARD,主循环) + +BOARD 分**空闲**(无语音在播)与**播放**(条目链播中)两个子行为: + +| 手势 | BOARD 空闲 | BOARD 播放 | +|---|---|---| +| 左滑 | 上一条目(循环):签名 earcon + 条目名 | 静默忽略(纯聆听保护,simon playback 先例) | +| 右滑 | 下一条目(循环):签名 earcon + 条目名 | 静默忽略 | +| 单击 | 播放当前条目(从首个未听句起,听过的整体重听) | **停止播报**,回空闲 | +| 双击 | 重听当前条目最后一句 | **重听当前句**(≤8s,免费零消耗) | +| 上滑 | 状态链(§2.5) | 状态链(打断证词;双击可重听当前句) | +| 下滑 | 模态菜单(§2.4) | 模态菜单(打开即停播报) | +| 长按 | **进入指证**(CONFRONT;仅空闲受理) | 引擎快进(旁白 1.7×,保持默认,不豁免) | +| 长按松开 | — | 恢复常速 | + +条目共 7 项:老葛证词 → 小顺证词 → 周先生证词 → 温太太证词 → 现场勘验 → 法医报告 → 结案陈词。每项播报=专属签名 earcon(0.3-0.5s,§8.2)+ 条目名整句;切到「结案陈词」+单击 → FINISH。 + +### 2.2 指证(CONFRONT)与结案(FINISH)——同一导航范式 + +两态统一为「列表预览 + 两段确认」非模态菜单(00 §5.2 非模态约定,rpg 探索同款),**双击全局语义=重听当前句,不偏离**: + +| 手势 | CONFRONT(选证词对) | FINISH(三问) | +|---|---|---| +| 左/右滑 | 切换:选条目阶段=6 证词条目(同 2.1 循环);选句阶段=上一句/下一句(播该句全文) | 切换选项(播选项名) | +| 单击 | ①选条目阶段=确认条目 → 进选句;②选句阶段=进入确认(重播该句+`cf_confirm`);③确认态=**定下这句**(第一份选定后自动进第二份,流程同前;第二份定下 → 判定) | 凶器/动机问:确认选项即答题;真凶问:进入确认(`fin_who_confirm` 重播+确认,二次单击定下=正式指认) | +| 双击 | 重听当前预览句(宪法语义保留) | 重听当前问句/选项 | +| 上滑 | 状态链(简版:疑点数) | 同左 | +| 下滑 | 菜单(可「返回证词板」退出指证) | 菜单 | +| 长按 | error 震(选择语境不作输入) | error 震 | + +指证判定结果(§3.5)播出后自动回 BOARD 空闲。全程无倒计时、无尝试次数上限。 + +### 2.3 标题(TITLE)/ 结算(SETTLE)/ 裁决(VERDICT) + +| 手势 | TITLE | VERDICT(认罪链播中) | SETTLE | +|---|---|---|---| +| 单击 | 继续(有档)/ 新案(无档) | **吞掉**(手势死窗+链锁,§7.4) | 接新案(pack 翻转+新 seed) | +| 右滑 | 换一桩新案(重掷 seed/pack,meta 保留,run 清空) | 吞掉 | error | +| 双击 | 重听 continue_ask | 吞掉 | 重听结算链 | +| 上滑 | 重听案情简介(intro_1..3) | 吞掉 | 重听评级段 | +| 下滑 | 菜单 | 吞掉 | 菜单 | +| 左滑/长按 | error | 吞掉 | error | + +### 2.4 模态菜单(遵守 00 文档 §5.2 全球约定,无豁免) + +下滑呼出(同时停播报/停看门狗)→ 单击循环切换(每切播项名)→ 双击确认 → 上滑=帮助 → 下滑=关闭。选项 5 项:返回证词板 / 去指证(=长按捷径的菜单冗余入口,P4 容错)/ 保存并回大厅 / 重新听帮助 / 重新查这桩案子。本作无实时玩法,菜单=天然暂停。 + +### 2.5 每状态手势映射总表(6 手势 + 长按松开,全状态覆盖) + +| 手势 | TITLE | DRILL | BOARD停 | BOARD播 | CONFRONT | FINISH | VERDICT | MENU | SETTLE | +|---|---|---|---|---|---|---|---|---|---| +| 单击 | 继续/新案 | 步骤作答 | 播放条目 | 停止 | 确认(两段) | 确认/答题 | 吞掉 | 循环切项 | 接新案 | +| 双击 | 重听 | 重听教学句 | 重听末句 | 重听当前句 | 重听预览句 | 重听问句 | 吞掉 | 确认执行 | 重听结算链 | +| 左滑 | error | 教学指定 | 上一条目 | 忽略 | 上一项/上一句 | 上一项 | 吞掉 | 上一项 | error | +| 右滑 | 换新案 | 教学指定 | 下一条目 | 忽略 | 下一项/下一句 | 下一项 | 吞掉 | 下一项 | error | +| 上滑 | 案情简介 | 状态(教过后) | 状态链 | 状态链 | 状态链(简) | 重听问句 | 吞掉 | 帮助 | 评级段 | +| 下滑 | 菜单 | 跳过教学 | 菜单 | 菜单 | 菜单 | 菜单 | 吞掉 | 关闭 | 菜单 | +| 长按 | error | 步骤作答 | 进入指证 | 快进(引擎) | error | error | 吞掉 | error | error | +| 长按松开 | — | — | — | 恢复常速 | — | — | — | — | — | + +无效手势一律 error 震;full 档 `gd_invalid`(onceKey 同局 1 次,之后仅 error 震);idle-only(纠错/看门狗句在播时跳过,引擎 guide 已实现)。 + +### 2.6 豁免登记(必须显式) + +| # | 豁免条目 | 理由(先例) | +|---|---|---| +| E1 | BOARD 单击=播放/停止(非"确认") | "检查/翻阅"类非即时操作,300ms tap 延迟无碍(escape 单击=检查、dungeon 单击=脉冲同款);停止播报是纯聆听期的标准出口 | +| E2 | BOARD 空闲长按=进入指证(核心动词快捷入口) | 绑定条件=无语音在播(`!audio.isVoicePlaying()`),与长按快进(仅在播时生效)**在时间上互斥、零冲突**,故不需 dungeon E3 式的对局期豁免;播放中长按仍走引擎快进。教学 D5 讲清(tut_confront),菜单另有「去指证」冗余入口 | + +教学(DRILL)逐步讲清 E1/E2;无 immediateTap;模态菜单无豁免。 + +## 3. 核心动词:听证词找矛盾(信息设计) + +### 3.1 信息结构总览 + +``` +案卷(7 条目)── 4 证人证词(各 6 句,NPC 平铺) + ├─ 现场勘验(6 句,旁白=探长自勘) + ├─ 法医报告(5 句,NPC 秦法医) + └─ 结案陈词(FINISH 入口) +每句 = 1 个音频文件 + 数据侧「线索轴:值」标签(0-2 个) +矛盾 = 两句共享同一轴且值不同;全案卷该集合 ≡ 预设计矛盾表(4 条) +``` + +- 证词链播:句间 0.8s 停顿;**句粒度重听**(双击)+ 句粒度停止(单击)——单段连续听觉负荷 ≤35s 且随时可停可回,满足「证词单段 ≤40s、可分段」要求。 +- 三套信息单源自洽(dungeon §3.2 同思路):证词=人的主张、现场/法医=物证主张、状态链=进度账目——进度账目(已问 X/疑点 n)从 `heard/doubts` 实时读出,播报值=运行值(账目真实)。 + +### 3.2 线索轴定义(pack A《雨夜·聚宝斋》) + +案情:古董商沈万川死于自家后院柴房,后脑钝器伤,当夜暴雨。 + +| 轴 | 含义 | 取值 | +|---|---|---| +| rain | 雨何时开始 | 21:40 | +| dep_s | 小顺离店时刻 | 21:40 | +| away_s | 小顺当晚去处 | 城南 | +| visitor | 后巷访客 | 22:15 高个微驼男性 | +| dep_z | 周先生离店时刻 | 21:50 | +| **lamp** | 店堂灯 | 熄 / 亮 | +| **key** | 库房钥匙位置 | 挂柴房铜钩 / 贴身 | +| **weapon** | 凶器材质 | 松木劈柴 / 铜器 | +| **scale** | 铜秤下落 | 已卖 / 在库房 | +| wall | 柴房后墙 | 有人翻爬 | +| door | 后门开法 | 死者亲自为熟人开(规范值 victim_opened) | +| death | 死亡时间 | 22:00-22:30 | +| motive | 账上短银 | 月底要清账 | +| struggle | 搏斗痕迹 | 无 | + +(粗体 4 轴=矛盾承载轴;其余轴全部同值互证或单断言,见 3.4 验算。) + +### 3.3 案卷正文(pack A;全部台词 ≤31 字,全文与台词表一致) + +**老葛(门卫)**:w1_1「昨儿夜里雨大,我守着街门,一步没敢挪窝。」w1_2「九点四十,雨点子刚砸下来,小顺打着伞出的街门。」w1_3「十点一刻,一位先生收着黑伞进了后巷。个儿高,微驼背。」w1_4「九点五十,周账房出的街门,跟我说了声,回家。」w1_5「后半夜,温太太还来门房借过一回热水。」w1_6「听说店里账上短了银子,沈老板撂了话,月底清账。」 + +**小顺(学徒)**:w2_1「夜里,师父打发我去城南,给刘掌柜送匣子。」w2_2「雨太大,刘掌柜留我住的,今早才回店。」w2_3「库房钥匙?师父嫌沉,平日就挂在柴房墙上的铜钩。」w2_4「师父的账,向来只信周先生一个人。」w2_5「后门那闩,生人叫不开。熟人来了,师父才亲自去开。」w2_6「周先生常念叨,账房的活,越到夜里越清静。」 + +**周先生(账房,真凶)**:z_1「我七点进店上夜账。九点五十对完,锁了前门回家。」z_2「临走,我顺手熄了店堂的灯。那会儿,店里就剩老板。」z_3「库房钥匙?沈老板贴身带着,从不离身。」z_4「那伤,是松木劈柴砸的。铜秤?上个月卖给收废品的了。」z_5「我到家倒头就睡,夜里的事,一概不知。」z_6「你们要是不信,去问我家婆娘。」 + +**温太太(房东)**:wen_1「雨是九点四十下的。我正收晾的衣裳,记得真真的。」wen_2「十点二十,我出门倒垃圾。聚宝斋店堂的灯,亮着呢。」wen_3「刚回屋,柴房后墙咯噔一响,有人爬墙!花盆翻了一个。」wen_4「吓得我蒙头半宿没睡着,天亮才敢吱声。」wen_5「店里短银子的事,街坊都有耳闻。上礼拜他还摔了账本。」wen_6「周账房那人,见人三分笑,账算得比谁都精。」 + +**现场勘验(旁白)**:sc_0「你打伞进了后院。雨水顺着瓦檐,淌成一道帘子。」sc_1「柴房门口,沈万川面朝下倒着。后脑一处伤,现场没有凶器。」sc_2「后门门闩完好,是死者自己开的门。生人叫不开这扇门。」sc_3「泥地上只有进来的脚印,没有出去的。墙根的花盆,翻了一个。」sc_4「库房铜秤立在架上,秤砣不见了。」sc_5「柴房墙上,挂钥匙的铜钩,空着。」 + +**法医报告(秦法医)**:fo_1「验尸报告,念给你听。」fo_2「死亡时间,昨夜十点到十点半。」fo_3「后脑单次钝器伤,一击毙命。创口嵌有铜屑,凶器是铜器。」fo_4「死者双手干净,没有搏斗痕迹。」fo_5「鞋底是干的。当夜,死者没出过后门。」 + +(sc_5 铜钩空为叙事佐证,**不挂 key 轴**——「平日挂墙上」是习惯断言、「今晨钩空」是状态断言,二者不构成值冲突;数据侧 sc_5 clues={}。) + +### 3.4 矛盾预设计表 + 矩阵验算(playtest 直证对象) + +**矛盾表(pack A,4 条)**: + +| # | 轴 | 语句 A(真凶侧) | 值 A | 语句 B | 值 B | hit 反应 | +|---|---|---|---|---|---|---| +| M1 | lamp | z_2 周先生「顺手熄了店堂的灯」 | 熄 | wen_2 温太太「十点二十……灯,亮着呢」 | 亮 | z_react_1 | +| M2 | key | z_3 周先生「贴身带着,从不离身」 | 贴身 | w2_3 小顺「平日就挂在柴房墙上的铜钩」 | 挂墙上 | z_react_2 | +| M3 | weapon | z_4 周先生「是松木劈柴砸的」 | 木 | fo_3 法医「创口嵌有铜屑,凶器是铜器」 | 铜 | z_react_3 | +| M4 | scale | z_4 周先生「铜秤上个月卖给收废品的了」 | 已卖 | sc_4 现场「铜秤立在架上,秤砣不见了」 | 在 | z_react_4 | + +注:M3/M4 共用语句 z_4(一句两断言),指证时选 z_4 两次、分别对 fo_3 与 sc_4——设计内行为。指证对**无序**(任一端在前都判 hit)。 + +**矩阵验算(全轴枚举)**——「同轴异值对 ≡ {M1..M4}」: + +| 轴 | 断言语句(值) | 结论 | +|---|---|---| +| rain | w1_2(21:40)/ wen_1(21:40) | 同值一致(互证) | +| dep_s | w1_2(21:40) | 单断言 | +| away_s | w2_1(城南)/ w2_2(城南) | 一致 | +| visitor | w1_3 | 单断言 | +| dep_z | z_1(21:50)/ w1_4(21:50) | 一致 | +| lamp | z_2(熄)/ wen_2(亮) | **M1,唯一冲突对** | +| key | z_3(贴身)/ w2_3(挂墙上) | **M2,唯一冲突对** | +| weapon | z_4(木)/ fo_3(铜) | **M3,唯一冲突对** | +| scale | z_4(已卖)/ sc_4(在架) | **M4,唯一冲突对** | +| wall | wen_3(有人爬墙)/ sc_3(翻墙离开) | 一致 | +| door | w2_5(victim_opened)/ sc_2(victim_opened) | 一致(习惯断言与状态断言取同一规范值) | +| death | fo_2 | 单断言(wen_3 的 22:25 落在窗内,旁证) | +| motive | w1_6 / wen_5 | 一致 | +| struggle | sc_1(无)/ fo_4(无) | 一致 | +| (无轴) | w1_1、w1_5、w2_4、w2_6、z_5、z_6、wen_4、wen_6、sc_0、sc_5、fo_1、fo_5 | 叙事/filler,不参与判定 | + +验算结论:除 M1-M4 外不存在任何同轴异值对;一致对 6 组(cf_same 的真话来源)、无轴句 13 句(cf_diff 的真话来源)——三条归因档全部真实可达。 + +### 3.5 指证交互与判定算法 + +``` +judge(a, b): + if a.id === b.id → cf_self(同一句,比自己) + shared = axes(a) ∩ axes(b) + if shared 为空 → cf_miss(钝音)+ cf_diff「说的不是一件事」 + else if 存在轴 v: a[v] ≠ b[v]: + if {a,b} ∈ 矛盾表(无序) → contra_hit + cf_hit + react_n + doubts[m]=true(疑点+1) + else → 数据错误(dev 断言禁止;playtest 失败项) + else(共享轴全同值) → cf_miss + cf_same「说得一致,不矛盾」 +每次判定(无论中未中)→ cfMiss 判定计数、自动回 BOARD;无资源惩罚、无次数上限 +``` + +- hit 反应句按真凶**累计被坐实次数**取 z_react_1..4(递进:记岔了→支吾→拒绝→想走),声音演技核心段;pack B 用 b_shun_react_1..2(第 3/4 次复用 react_2)。 +- 疑点 4/4 齐时播一次 `hint_fin`(引导去结案);疑点数进状态链与存档,账目同源。 + +### 3.6 归因真话律(指证/指认双处) + +| 场景 | 播报 | 真话依据 | +|---|---|---| +| 指证 miss·同轴同值 | cf_same | 轴数据同值(如 w1_2 vs wen_1 都说雨 21:40 起) | +| 指证 miss·不同轴 | cf_diff | 无共享轴(绝大多数跨话题句对) | +| 指证 miss·同一句 | cf_self | 选择器同一句 | +| 指认错人 | 该证人 acc_NPC(自述不在场证明)+ fin_clear | 各无辜者的 alibi 在证词中已铺(w2_1/w2_2 小顺、w1_1/w1_5 老葛、wen_4+visitor 男性访客排除温太太)+ pack B 的 acc_zhou | +| 凶器答错 | fin_wp_wrong(「铜屑不答应」) | fo_3 物证 | +| 动机答错 | fin_mv_wrong | w1_6/wen_5 账短银 | + +**禁止**:任何「差一点」「再想想」式无信息归因;播报值与数据值必须一致(playtest 失败项锁定)。 + +### 3.7 信息密度与记忆设计 + +- 单次播报 ≤3 选项:所有列表(条目/句/凶器/动机/嫌疑人)都是**逐项预览**(左右滑一次听一项),绝不通读清单;帮助句(sys_help)例外(dungeon help 先例)。 +- 证人区分三板斧:① 5 speaker 满配 + instruct 常量(§8.3);② 每证人**专属签名 earcon**(条目切换/指证选择/状态复述时前置,0.3-0.5s);③ 周先生(与男旁白同 speaker)与秦法医(与女旁白同 speaker)是仅有的两处重叠,靠签名+语境+instruct 兜底——重叠从不在相邻句出现(证词链内无旁白插话)。 +- P1 工作记忆:疑点无需记忆——状态链随时报数;已选第一份证词在选第二份时**自动重播一次**(选句确认后链上接播),不需要玩家脑内持有。 + +## 4. 状态机 + +### 4.1 状态列表 + +`TITLE / DRILL / BOARD / CONFRONT / FINISH / VERDICT / SETTLE` + `MENU`(挂起 overlay,记录返回态)。BOARD 含空闲/播放两子行为(非独立态)。FINISH 子相位:who → weapon → motive;CONFRONT 子相位:item1 → line1 → confirm → item2 → line2 → confirm → judged。 + +### 4.2 转移图 + +``` +start ──> TITLE ──单击(有档)──> BOARD(恢复存档+局面复述) + │单击(无档) ├──────> DRILL(仅首次, meta.drillDone 控制) ──完成/下滑跳过──> BOARD + │右滑=换新案(重掷 seed/pack) ──> BOARD(新案) + v + BOARD(证词板) ⇄ 条目链播(子行为) + │ 长按(空闲) / 菜单「去指证」 │ 选中「结案陈词」条目+单击 + v v + CONFRONT ──判定(hit/miss)──> BOARD FINISH(三问) + │(经菜单「返回证词板」退出) ├─指认错→ acc 链 + fin_clear → BOARD + │ └─指认对→ 凶器问→动机问→ VERDICT + VERDICT(认罪链, 手势死窗+链锁) ──> SETTLE ──单击──> TITLE(新案: pack=1-pack) + 任意正交态 ──下滑──> MENU ──关闭/返回──> 原状态 +``` + +### 4.3 语音链推进契约 + +- 证词链=onComplete 串行(句间 0.8s 定时器);**兜底看门狗**:单句 8s 无 onComplete(资产缺失)→ 告警续链(「屏蔽任意 voice 资产流程不卡死」契约)。 +- 裁决链(VERDICT)与结算链(SETTLE)为**独占链**:链首停一切看门狗,链内不受理手势(§7.4)。 +- 教学链 `_speakGuarded` 串行(radio/dungeon 同款,杜绝同 tick 双调度抢占——D0-1 教训已固化引擎保险丝)。 + +## 5. 教学编排(DRILL,首局内嵌) + +每步=一句短旁白→等待实操→反馈;做错重教同一步(新短句 ≤2 次)后自动演示通过;下滑=跳过教学(`tut_skip`),`meta.drillDone` 持久化,二周目不再进。教学寄生于真实案卷(D2 就听老葛真实证词,无假内容——教学承诺必须兑现)。 + +| 步 | 旁白 | 等待动作 | 通过条件/反馈 | +|---|---|---|---| +| D0 | intro_1→intro_2→intro_3→tut_d0 | 左滑或右滑任一 | 签名 earcon+条目名;confirm 震。**首输入 ≤60s 达成点 ≈t+20s** | +| D1 | tut_listen | 单击 | 老葛证词链播(真实内容) | +| D2 | tut_replay | 双击 | 当前句重播(免费) | +| D3 | tut_status | 上滑 | 状态链(st_asked_1→st_doubt_0) | +| D4 | tut_confront | 长按 | 进 CONFRONT + cf_start | +| D5 | tut_cf_try | 选条目1(滑+单击)→选句(滑)→单击确认→选条目2→选句→单击确认 | **判定被教学截获**:任何选择组合都播 tut_cf_ok(练习不算数、不计 cfMiss、不动 doubts),回 BOARD。**首正反馈 ≤3min 达成点(success 震)** | +| D6 | tut_done | — | 进 BOARD 自由调查;此后上滑/菜单随时可用 | + +- D5 教学局不真判定的原因:教学时玩家只听过 1 份证词,真判定必 miss,会把「指证=挫败」印进第一印象;练习路径承诺「真矛盾要靠耳朵找」并在 2 份证词听齐后自然兑现(预期玩家首个真 hit 在 t+5-8min)。 +- FTUE 核对:遮罩→intro 三句(~13s)→D0 完成约 t+20s(<60s ✓);D5 完成 ≈t+2.5min(≤3min ✓);全程零惩罚(无资源、无倒计时)✓。 + +## 6. 案卷数据结构与随机性 + +### 6.1 JSON 草案(pack A 全量 + pack B fork;台词全文见台词表) + +```json +{ + "schema": 1, + "packs": [ + { + "id": 0, "name": "雨夜·聚宝斋", "culprit": "w3", "culpritName": "周先生", + "items": [ + { "id": "w1", "kind": "witness", "name": "item_w1", "sig": "sig_ge", + "intro": null, "lines": [ + { "id": "w1_1", "shuffle": false, "clues": {} }, + { "id": "w1_2", "shuffle": true, "clues": { "rain": "2140", "dep_s": "2140" } }, + { "id": "w1_3", "shuffle": false, "clues": { "visitor": "male_tall_hunched" } }, + { "id": "w1_4", "shuffle": true, "clues": { "dep_z": "2150" } }, + { "id": "w1_5", "shuffle": true, "clues": {} }, + { "id": "w1_6", "shuffle": false, "clues": { "motive": "short" } } ] }, + { "id": "w2", "kind": "witness", "name": "item_w2", "sig": "sig_shun", + "intro": null, "lines": [ + { "id": "w2_1", "shuffle": false, "clues": { "away_s": "south" } }, + { "id": "w2_2", "shuffle": true, "clues": { "away_s": "south" } }, + { "id": "w2_3", "shuffle": false, "clues": { "key": "hook" } }, + { "id": "w2_4", "shuffle": true, "clues": {} }, + { "id": "w2_5", "shuffle": false, "clues": { "door": "victim_opened" } }, + { "id": "w2_6", "shuffle": true, "clues": {} } ] }, + { "id": "w3", "kind": "witness", "name": "item_w3", "sig": "sig_zhou", + "intro": "intro_w3", "lines": [ + { "id": "z_1", "shuffle": false, "clues": { "dep_z": "2150" } }, + { "id": "z_2", "shuffle": false, "clues": { "lamp": "off" } }, + { "id": "z_3", "shuffle": false, "clues": { "key": "body" } }, + { "id": "z_4", "shuffle": false, "clues": { "weapon": "wood", "scale": "sold" } }, + { "id": "z_5", "shuffle": true, "clues": {} }, + { "id": "z_6", "shuffle": true, "clues": {} } ] }, + { "id": "w4", "kind": "witness", "name": "item_w4", "sig": "sig_wen", + "intro": null, "lines": [ + { "id": "wen_1", "shuffle": false, "clues": { "rain": "2140" } }, + { "id": "wen_2", "shuffle": false, "clues": { "lamp": "on_2220" } }, + { "id": "wen_3", "shuffle": false, "clues": { "wall": "climbed" } }, + { "id": "wen_4", "shuffle": true, "clues": {} }, + { "id": "wen_5", "shuffle": false, "clues": { "motive": "short" } }, + { "id": "wen_6", "shuffle": true, "clues": {} } ] }, + { "id": "scene", "kind": "scene", "name": "item_scene", "sig": "sig_scene", + "lines": [ + { "id": "sc_0", "shuffle": false, "clues": {} }, + { "id": "sc_1", "shuffle": false, "clues": { "struggle": "none" } }, + { "id": "sc_2", "shuffle": false, "clues": { "door": "victim_opened" } }, + { "id": "sc_3", "shuffle": false, "clues": { "wall": "climbed" } }, + { "id": "sc_4", "shuffle": false, "clues": { "scale": "present" } }, + { "id": "sc_5", "shuffle": false, "clues": {} } ] }, + { "id": "fore", "kind": "forensic", "name": "item_fore", "sig": "sig_fore", + "lines": [ + { "id": "fo_1", "shuffle": false, "clues": {} }, + { "id": "fo_2", "shuffle": false, "clues": { "death": "2200_2230" } }, + { "id": "fo_3", "shuffle": false, "clues": { "weapon": "bronze" } }, + { "id": "fo_4", "shuffle": false, "clues": { "struggle": "none" } }, + { "id": "fo_5", "shuffle": false, "clues": {} } ] } + ], + "contradictions": [ + { "id": "m1", "a": "z_2", "b": "wen_2", "axis": "lamp", "hint": "hint_c1" }, + { "id": "m2", "a": "z_3", "b": "w2_3", "axis": "key", "hint": null }, + { "id": "m3", "a": "z_4", "b": "fo_3", "axis": "weapon", "hint": null }, + { "id": "m4", "a": "z_4", "b": "sc_4", "axis": "scale", "hint": "hint_c2" } + ], + "verdict": { "accuseLine": "fin_right", + "break": ["z_break_1", "z_break_2", "z_break_3"] }, + "wrongAccuse": { "w1": "acc_ge", "w2": "acc_shun", "w4": "acc_wen" }, + "reacts": ["z_react_1", "z_react_2", "z_react_3", "z_react_4"], + "questions": { + "weapon": { "options": ["wp_1", "wp_2", "wp_3", "wp_4"], "answer": "wp_2" }, + "motive": { "options": ["mv_1", "mv_2", "mv_3"], "answer": "mv_1" } } + }, + { + "id": 1, "name": "雨夜·聚宝斋(变体)", "culprit": "w2", "culpritName": "小顺", + "base": 0, + "overrides": { + "w2": { "lines": [ + { "id": "w2_1", "shuffle": false, "clues": { "away_s": "south" } }, + { "id": "w2_2", "shuffle": true, "clues": { "away_s": "south" } }, + { "id": "b_shun_1", "shuffle": false, "clues": { "lamp": "off" } }, + { "id": "b_shun_2", "shuffle": false, "clues": { "key": "body" } }, + { "id": "b_shun_3", "shuffle": false, "clues": { "weapon": "wood", "scale": "sold" } }, + { "id": "b_shun_4", "shuffle": true, "clues": {} } ] }, + "w3": { "lines": [ + { "id": "z_1", "shuffle": false, "clues": { "dep_z": "2150" } }, + { "id": "b_zhou_1", "shuffle": true, "clues": {} }, + { "id": "b_zhou_2", "shuffle": false, "clues": { "key": "hook" } }, + { "id": "b_zhou_3", "shuffle": true, "clues": {} }, + { "id": "z_5", "shuffle": true, "clues": {} }, + { "id": "z_6", "shuffle": true, "clues": {} } ] }, + "contradictions": [ + { "id": "m1b", "a": "b_shun_1", "b": "wen_2", "axis": "lamp", "hint": "b_hint_c1" }, + { "id": "m2b", "a": "b_shun_2", "b": "b_zhou_2", "axis": "key", "hint": null }, + { "id": "m3b", "a": "b_shun_3", "b": "fo_3", "axis": "weapon", "hint": null }, + { "id": "m4b", "a": "b_shun_3", "b": "sc_4", "axis": "scale", "hint": "b_hint_c2" } ], + "verdict": { "accuseLine": "fin_right_b", + "break": ["b_shun_break_1", "b_shun_break_2", "b_shun_break_3"] }, + "wrongAccuse": { "w1": "acc_ge", "w3": "acc_zhou", "w4": "acc_wen" }, + "reacts": ["b_shun_react_1", "b_shun_react_2", "b_shun_react_2", "b_shun_react_2"] + } + } + ] +} +``` + +### 6.2 seed 用法 + +``` +rng = mulberry32(seed) // 与 dungeon 同族 +pack = opts.pack ?? (opts.seed != null ? seed % 2 : null) +正式局:seed = Math.floor(Math.random()*1e9);pack 首局=0,结案后新案 pack = 1 - pack(强制换案) +洗牌(生成期一次性,存入 run.layout): + ① 各证人内部 shuffle:true 的 filler 句在其槽位集合内置换(首句与全部矛盾承载句固定) + ② 凶器问 4 选项、动机问 3 选项:Fisher-Yates by rng + ③ 保底提示 hint 轮询顺序:permuted by rng +复现断言口径:同 (seed, pack) 两次构造 → game._case 深相等(布局/选项序/提示序逐项相等,不写魔法值) +``` + +### 6.3 变体生成规则(pack B 逻辑摘要) + +- **同模板**:同一桩雨夜命案、同 4 证人位、同现场/法医/温太太/老葛证词(音频全复用)、同 4 轴矛盾结构(lamp/key/weapon/scale)。 +- **不同真凶**:pack B 真凶=学徒小顺。fork=替换两份证词的句集:小顺换成 4 句谎(b_shun_1..4,复用其真实离店叙事作伪 alibi),周先生换成 3 句中性真话(b_zhou_1..3,其中 b_zhou_2「钥匙挂铜钩」成为 M2b 的对立端)。 +- **矩阵自洽**:pack B 的验算与 pack A 同构——lamp 轴唯一冲突对 (b_shun_1, wen_2);key 轴唯一冲突对 (b_shun_2, b_zhou_2);weapon/scale 由 b_shun_3(一句两断言)对 fo_3/sc_4。老葛 w1_3 的访客描述(高个微驼)由 b_shun_break_2「棉袄反穿,弓着背」解释,不产生新轴冲突。 +- **动机/凶器答案跨包一致**:监守自盗(小顺拿库房货换钱,b_shun_break_3 交代)与铜秤砣——问题反馈句两包共用。 +- **内容成本**:fork 仅 12 句 NPC + 1 句旁白(fin_right_b)+ 2 句提示(b_hint_c1/2)。 + +### 6.4 矩阵不变式(dev/playtest 双保险) + +启动时与测试中断言:`{(a,b,axis) : a,b 同轴且值不同} ≡ contradictions`。任何台词改稿若引入新的同轴异值对,此断言立即失败——防止「文案事故变成指证归因假话」。 + +## 7. 评级系统与裁决仪式 + +### 7.1 结案三问(FINISH) + +1. **真凶**(4 选 1:老葛/小顺/周先生/温太太;两段确认)。错→该证人 acc_NPC 自述不在场证明 + fin_clear → 回 BOARD;**无限再机会**,wrongWho 计数。 +2. **凶器**(4 选 1:松木劈柴/铜秤砣/铁门闩/青瓷花盆;选项序由 seed 洗牌)。对=fin_wp_right,错=fin_wp_wrong(真话归因,不重答,记账)。 +3. **动机**(3 选 1:监守自盗/寻仇泄愤/劫财入户)。同上。 + +### 7.2 评级标准(S-C) + +变量:`doubts`(疑点 0-4)、`weaponOk`、`motiveOk`、`wrongWho`(指认错次数)、`cfMiss`(冗余指证次数)、`hints`(保底提示已播数)。 + +| 档 | 条件(自上而下首个命中) | 播报 | +|---|---|---| +| S | doubts=4 ∧ weaponOk ∧ motiveOk ∧ wrongWho=0 ∧ cfMiss≤2 ∧ hints=0 | rate_s「教科书结案。四处疑点,一击即中。」 | +| A | doubts≥3 ∧ weaponOk ∧ motiveOk ∧ wrongWho≤1 ∧ hints≤1 | rate_a「干净利落的结案。」 | +| B | doubts≥2 ∧ wrongWho≤2 | rate_b「案子结了,卷宗上有疏漏。」 | +| C | 其余(案子必然已结) | rate_c「案子是结了,卷宗上补丁不少。」 | + +- 结案必达:无失败态;C 档覆盖「全靠保底提示/疑点<2/指认错 ≥3」。 +- 措辞-账目对账:S 的「一击即中」=wrongWho 0;「四处疑点」=doubts 4;C 的「补丁」=hints≥2 或 cfMiss≥3 或 wrongWho≥3——每句评级文案与触发条件一一对应(E1-2 档位措辞真实教训)。 + +### 7.3 结算账目播报(真实) + +SETTLE 链:`settle_open → rate_X → st_doubt_n(疑点实数)→ st_miss_档(cfMiss: 0/1-2/≥3 → 三档真话)→ settle_hook(单击接新案,键盘态用 settle_hook_k)`。所有分档句按运行值选播,播报值=存储值(playtest 锁定)。 + +### 7.4 裁决仪式(VERDICT,结案宣判完整弧线) + +指认正确后进入,**链不可打断**(dungeon H1 教训:结算链不许被在途手势截杀): + +``` +fin_right(证据链闭合)→ sfx/rain_s swell 起 +→ z_break_1 → z_break_2 → z_break_3(认罪,声音演技高潮;bgm/verdict) +→ sfx/gavel(法槌)→ settle_open → SETTLE(接 §7.3 链) +``` + +- **手势死窗**:进 VERDICT 即盖 `VERDICT_GRACE_MS=800` 戳(同 dungeon SETTLE_GRACE:touchstart+450ms 长按判定+300ms 双击窗 ≈750ms,800 全盖);死窗内一切手势吞掉(ghost 手势不算输入、不碰看门狗)。 +- **链锁**:VERDICT→SETTLE 全链播完(settle_hook 落声)前,单击/右滑/长按一律吞掉;链完成后单击=接新案(settle_hook 承诺「单击,接新案」言行一致——simon round1-live 教训)。双击=重听结算链仅在链完成后受理。 + +## 8. 音频三层 + +### 8.1 BGM(3 首,Mureka instrumental,-20 LUFS,ogg+m4a,无缝循环;雨声底烤进曲目,省第三声流) + +| id | 状态映射 | Mureka prompt(英文) | +|---|---|---| +| bgm/menu | TITLE / MENU / SETTLE(结算 volume 0.5) | "Rainy night noir jazz ballad, slow brushed drums, lazy saxophone, soft rain ambience, melancholy and calm, no vocals, seamless loop" | +| bgm/board | DRILL / BOARD / CONFRONT / FINISH | "Moody jazz trio for a detective's office, muted piano walking, upright bass, soft rain on window, focused and mysterious, no vocals, seamless loop" | +| bgm/verdict | VERDICT(认罪链起播,交叉淡入) | "Tense noir jazz climax, brooding low strings under sparse piano, rain intensifying then settling, dramatic restraint, no vocals, seamless loop" | + +切换全走引擎 crossfade;对白 duck 20% 引擎自动。choice/danger 不换曲(状态区分由 guide earcon 承担,氛围连续性优先)。预算:3 曲 ×2 候选 = 6 任务 $0.18(项目上限 $2.00 内)。 + +### 8.2 SFX(12 个;SA3=Stable Audio 3,SYN=程序合成;双格式、真峰值 ≤-3dBFS) + +| 类别 | id | 方式 | 说明 | +|---|---|---|---| +| 卷宗 | `paper_turn` | SA3 | 翻卷宗页(条目切换内容反馈,"single paper page turn on desk, dry, close") | +| 证人签名 | `sig_ge` | SA3 | 搪瓷缸顿桌一下(老葛) | +| | `sig_shun` | SYN | 布掸子拍柜台两下(小顺) | +| | `sig_zhou` | SA3 | 算盘珠拨两响(周先生) | +| | `sig_wen` | SYN | 门环叩两下(温太太) | +| | `sig_fore` | SYN | 纸页翻动+钢笔划线(法医报告) | +| | `sig_scene` | SA3 | 雨中皮鞋踩水两步+伞抖水(现场,0.5s) | +| 指证 | `contra_hit` | SYN | 不和谐金属二度相击+盖章尾(矛盾证实音,全游戏最强确认音) | +| | `contra_miss` | SYN | 钝木鱼一下(对不上——非 error 音色,不威吓) | +| 裁决 | `gavel` | SA3 | 法槌一击("wooden gavel single strike, small room reverb") | +| | `rain_swell` | SA3/SYN | 雨声渐强再落(认罪链垫,6s one-shot) | +| 看门狗 | `turn_tick` | SYN | 880Hz 木鱼族轻响(无进展轨 L1 复用 dungeon 配方,自存一份) | + +并发纪律:签名 earcon(≤0.5s)→ 条目名语音,串行不叠;contra_hit 先响、cf_hit 语音后接。声流恒 ≤2(BGM + voice)。 + +### 8.3 voice(158 句:旁白 106 ×2 性别 + NPC 平铺 52) + +- **旁白**(系统/指引/教学/状态/现场自勘/裁决宣读):female=Serena / male=Uncle_Fu,instruct 常量全表统一(见台词表头);数字全分档整句(st_asked 7 档/st_doubt 5 档/st_miss 3 档),**句序号一律不播**(指证选句用重播该句代替报数——数字拼接禁令的交互侧落实)。 +- **NPC 平铺单音色**(证词=证人 NPC;不做双性别;落 `voice/` 根目录,引擎平铺回退命中): + +| 角色 | speaker | instruct 常量 | 区分度风险 | +|---|---|---|---| +| 老葛(门卫 62) | Eric | 苍老沙哑的男声,守夜人,慢悠悠,带倦意 | 低:与两侧旁白均不同 speaker | +| 小顺(学徒 19) | Dylan | 年轻清亮的男声,学徒,怯生生的,慌张诚实 | 低:同上 | +| 周先生(账房,真凶 A) | Uncle_Fu | 平板谨慎的男声,账房,字斟句酌,被追问时渐慌 | **中**:与 male 旁白同 speaker——靠 sig_zhou 前缀+账房腔 instruct+证词链内零旁白插话;试听确认 | +| 温太太(房东 50) | Vivian | 快嘴泼辣的中年女声,房东太太,嗓门亮,热心肠 | 低:与两侧旁白均不同 speaker | +| 秦法医 | Serena | 冷静干练的女声,验尸官,临床腔,短句平直 | **中**:与 female 旁白同 speaker——靠 sig_fore 前缀+报告语境固定(只在整个案卷固定条目出现);试听确认 | + +- **声音演技核心**:周先生 z_react_1..4(记岔了→支吾→拒答→想走)与 z_break_1..3(认罪三段)是本作表演高潮;instruct 恒定、情绪写在句内文本(标点与省略号引导),不靠句级 instruct 变体(音色漂移禁令)。pack B 对应 b_shun_react/b_shun_break。 +- 全表、触发时机、键盘变体(V 组)与多音字自查见 [18-detective-voice-ids.md](18-detective-voice-ids.md)。 + +## 9. 向导层接入(engine/guide.js,不新增 earcon 词表) + +### 9.1 状态签名(进入状态时播) + +| 状态 | guide.state() | 时点 | +|---|---|---| +| TITLE / MENU / SETTLE | `menu` | 进标题、开菜单、进结算 | +| DRILL / BOARD | `explore` | 进教学、进证词板(含指证判定后返回) | +| CONFRONT / FINISH | `choice` | 进指证、进结案三问 | +| VERDICT | `settle` | 认罪链起 | +| danger | 不使用 | 本作无生命/倒计时压力(登记) | + +### 9.2 turn cue 与看门狗(双轨——批次 1 教训固化) + +| 等待点 | 机制 | at | say(full 档;第 2 级一律纯 nudge) | +|---|---|---|---| +| CONFROST/FINISH 各选择相位入口 | guide.turn | — | turn earcon + confirm 震 | +| BOARD 空闲 | watchdog 无操作轨 | [10s, 25s] | gd_board / null | +| CONFROST 选句/确认 | watchdog 无操作轨 | [15s, 30s] | gd_cf / null | +| FINISH 三问 | watchdog 无操作轨 | [20s, 40s] | gd_fin / null | +| DRILL 每步 | watchdog 无进展轨(共享) | [30s/60s/90s] | earcon → 短句 → tut_skip 自动滚出(E0-2 先例) | + +### 9.3 无进展轨(调查态卡关三级,**证词板乱按也要被抢救**) + +- 进展事件=听完新条目 / 指证判定 / 结案相位推进(`_progressTrack()` 挂点:条目链 onComplete、judge()、FINISH 相位切换)。**只由进展重置**——乱按(疯狂换条目不听完、播放中狂点停起)永远攒不满进展,三级必达: +- [45s] L1:turn_tick earcon(不占台词通道)→ [90s] L2:`hint_a`(还有未听条目时)/ `hint_b`(都听过但疑点未齐)→ [150s] L3:**保底给出可指证的矛盾对**(hint_c1/hint_c2 按未发现清单轮询,seed 定序);4 疑点齐 → `hint_fin`(去结案)。L3 后每 150s 重复(每句全局限 2 次,之后静默——不轰炸)。 +- 无操作轨(手势重置)与无进展轨并行取先到;两轨短句内容同级交叉登记防重复。纠错/提示 idle-only、过期丢弃(引擎已实现)。 + +## 10. 存档设计 + +```json +{ + "schema": 1, "game": "detective", "updatedAt": 0, + "summary": "雨夜·聚宝斋 · 已问3/6 · 疑点2/4", + "meta": { "drillDone": true, "casesClosed": 1, "bestGrade": "A", "lastPack": 0 }, + "run": { + "seed": 42, "pack": 0, "layout": { "w1": [...], "wpOpts": [...], "hintOrder": [...] }, + "heard": ["w1", "scene", "fore"], + "doubts": [false, true, false, false], + "cfMiss": 1, "wrongWho": 0, "zHits": 1, + "hintsGiven": ["hint_c1"], "weaponAns": null, "motiveAns": null + } +} +``` + +- summary 生成规则:`{案名} · 已问{heard.length}/6 · 疑点{doubts 中 true 数}/4`(结案后 run=null,summary=`已结案{casesClosed}起 · 最佳{bestGrade}`)。 +- 保存时机:条目链播完 / 指证判定后 / 菜单「保存并回大厅」/ 结案入 meta。localStorage + PUT 服务器。 +- 读取:有 run → TITLE 播 sys_continue_ask,单击继续 → 恢复(layout 复用存档值,不重洗)→ **局面复述链**:`st_asked_n →(未听条目 ≤3 个时逐个播 item_* 名;>3 个只报计数)→ st_doubt_m → recap_go`,≤10s 回到可操作;右滑=换新案(seed/pack 重掷,meta 保留)。 +- 归因真话依据:评级各变量全部持久化(doubts/cfMiss/wrongWho/hintsGiven/answers),结算播报从同源读取。 + +## 11. playtest 断言清单(登记 13 号文档新增 §detective;mock 对齐 web/rpg/test/playtest.mjs;一律 guide:'off') + +**关键路径**(seed=42, pack=0,≤200 手势): +1. 启动→TITLE(无档)→单击→DRILL:D0..D6 逐步断言 voice 序与 drillStep 递进;D5 任意选择组合都播 tut_cf_ok 且 doubts/cfMiss 零变化(教学承诺兑现);下滑跳过路径 + meta.drillDone 二周目不再进。 +2. **seed 复现**:(42,0) 两次构造 → `_case` 深相等(条目/句序/filler 洗牌/选项序/提示序);(42,1) 与 (43,1) 布局不同。 +3. **矩阵不变式**:对 `_case` 全句对枚举轴交集——同轴异值对集合 ≡ contradictions(pack A 与 pack B 各验一遍)。 +4. 听取:6 条目链完整(句序=layout)、双击=当前句重播零消耗、单击停止、播完 st_asked 升档;播放中左右滑静默忽略(无 error 震)。 +5. **指证 4 矛盾逐条走通**:M1-M4 各自导航→两段确认→contra_hit+cf_hit+react_n(zHits 递进取句)→doubts 置位;M3/M4 共用 z_4(选 z_4 两次分别对 fo_3/sc_4);指证对无序(反向选也 hit)。 +6. **指证 miss 三档真话**:同轴同值对(w1_2 vs wen_1)→cf_same;无共享轴对(z_5 vs sc_1)→cf_diff;同句→cf_self;cfMiss 计数、无任何资源/次数惩罚。 +7. **指认全路径**:错指小顺/老葛/温 → acc_NPC+fin_clear+回 BOARD(无限次);对指周先生 → 凶器问(选项序=layout)→ 动机问 → VERDICT。 +8. **裁决死窗**:指认对瞬间同拍与 +300ms 各补一记在途 tap → 均被吞(仍 VERDICT);认罪链 4 句+gavel+settle 链完整有序;链后单击=接新案(pack 翻转、seed 重掷、meta 入账)。 +9. **评级账目**:注入 outcomes 矩阵(doubts∈{0..4}×weapon×motive×wrongWho×cfMiss×hints 代表组合 ≥10 组)→ 断言 grade 判定与 §7.2 表一致 + 结算链播报句(rate_X/st_doubt_n/st_miss_档)与运行值一致(假话即失败)。 +10. **看门狗双轨**:BOARD 10s/25s(gd_board→nudge);无操作 96s 乱按零进展 → 无进展轨 45s earcon/90s 短句/150s hint_c(给出未发现矛盾对且该对可走通指证);有真实进展零提示;4 疑点齐后 150s → hint_fin。 +11. **中断复述**:存档重进→sys_continue_ask→单击→复述链序(st_asked→未听 ≤3 时 item 名链→st_doubt→recap_go);上滑同链可重放。 +12. **二周目变体**:seed'/pack=1 → 矛盾表指向 b_shun_*/b_zhou_2、culprit=w2、错指周先生走 acc_zhou、认罪链 b_shun_break、hint 为 b_hint_c1/2;矩阵不变式复验。 +13. **键盘变体**:`settings.input='key'` 后播 sys_help_k/tut_d0_k 等 V 组(inputMode 联动);'touch' 回基础句。 +14. 菜单/帮助/存档:模态菜单循环-确认-关闭;「保存并回大厅」→PUT data.summary 命中生成规则;「去指证」= 长按同效。 + +**边界断言**: +- 教学期零惩罚:D5 任意乱选不产生 cfMiss/doubts 变化;全程无倒计时(全 run 无 timeout 惩罚断言)。 +- 长按豁免:BOARD 播放中长按=引擎快进(不进指证);空闲长按→CONFRONT;CONFRONT/FINISH 长按=error 震。 +- 状态链随时可用(含播放中,打断后双击可重听当前句);播报值=heard/doubts 实值。 +- 指证第二份选择时自动重播第一份选定句(P1 记忆契约)。 +- 屏蔽任意 voice 资产:链 8s 兜底推进,流程到结案不死锁。 +- guide=off:全静、零定时器;资产齐套:voice 双性别 id 集合一致 + NPC 平铺存在 + sfx/bgm 双格式。 +- VERDICT/SETTLE 链内上滑/下滑/双击全部吞掉(链锁),链完成前无任何状态逃逸。 + +**失败即不通过**:归因假话(cf_same/cf_diff/评级句与数据实值不符);矩阵外冲突对存在;同 (seed,pack) 两次构造不一致;教学练习计入账目;裁决链被手势截杀或链句缺失;保底 hint 给出的矛盾对走不通指证。 + +## 12. 节奏曲线与单局核算 + +| 阶段 | 时长 | 内容 | 奖励点(≤60s 一个) | +|---|---|---|---| +| 开场链 | 0:20 | intro 三句 | — | +| DRILL | 0:20-2:40 | D0-D6(老葛证词首听在 D1) | D5 完成 success 震(t+2.5min) | +| 自由调查 | 2:40-9:00 | 5 条目听取+句级重听+状态核对 | 每条目听毕 st_asked 升档 | +| 指证穿插 | 5:00-12:00 | 4 矛盾(两证词齐后即可开始,玩家自定节奏) | 每个 hit:contra_hit+真凶 react 递进 | +| 结案三问 | 12:00-14:00 | 真凶/凶器/动机 | 指认对=fin_right(高潮起) | +| 裁决+结算 | 14:00-15:30 | 认罪链+法槌+评级 | 评级播报+开环钩子 | + +- 一周目 15-20 分钟(重听多的松玩家 ≤25);二周目(跳教学+已熟导航)13-15 分钟。15-30 分钟章程达标。 +- 悬念曲线:听证词(信息积累)→ 首个 hit(「证词会撒谎」兑现)→ react 递进(真凶渐慌)→ 三问(主动权全在玩家)→ 认罪(演技高潮)——每 30-60s 必有可感知进展(条目数/疑点数两个可见账本)。 +- 开环钩子:settle_hook「同一场雨里,还有别的案子」+ pack 强制翻转(二周目必遇变体真凶)。 + +## 13. 自检清单 + +| # | 硬约束/教训 | 自检 | +|---|---|---| +| 1 | FTUE 首输入 <60s | D0 完成约 t+20s ✓(§5) | +| 2 | 首个正反馈 ≤3min | D5 教学完成 success 震 ≈t+2.5min;首个真 hit 预期 t+5-8min(本作类型内最近可达)✓ | +| 3 | 学习期零惩罚 | 无资源无倒计时全域;D5 练习不算账 ✓ | +| 4 | 失败三问归因+真话律 | 指证 miss 三档从轴数据推导;指认错=不在场证明复述;凶器/动机错=物证归因;评级句与账目一一对应 ✓(§3.6/§7,playtest 失败项锁定) | +| 5 | 手势宪法三件套不豁免 | 上滑状态/下滑菜单/双击重听全状态保留;仅 E1/E2 登记且教学讲清 ✓(§2.6) | +| 6 | 向导层 | 签名 4 态(choice 用、danger 登记不用);双轨看门狗(无操作+无进展,乱按必被抢救);保底 hint 给可走通矛盾对;idle-only/queue/onceKey ✓(§9) | +| 7 | 教学承诺兑现 | tut 承诺的每个手势在正片同语义可用;settle_hook「单击接新案」言行一致(死窗+链锁后)✓ | +| 8 | 结算链完整 | VERDICT 手势死窗 800ms+链锁,认罪→法槌→评级全链不可截(H1 教训)✓(§7.4) | +| 9 | 档位措辞真实 | S-C 条件与文案一一映射;st_asked/st_doubt/st_miss 播报值=运行值 ✓(§7.2/7.3) | +| 10 | 信息密度 | 所有列表逐项预览(≤3 选项/次);证词句粒度 ≤8s、链播 ≤35s 可停可重听(≤40s 听觉负荷)✓(§3.7) | +| 11 | P4 容错 | 无惩罚性失败;错指无限再机会;保底必破案(hint_c 直接给对子);零新手势 ✓ | +| 12 | P1 视障 | 句级免费重听;导航规整(条目→句→指证同一范式);状态链随时可查;账目真实;5 speaker+签名 earcon 防混淆(2 处重叠风险已登记缓解);2.2× 语速引擎既有 ✓ | +| 13 | P3 通勤 | 中断复述(已问谁/待问谁 ≤3 名直报/疑点几处);单手免视;任意时刻菜单退出存档;3 分钟颗粒=一份证词完整听取+比对 ✓ | +| 14 | P2 大学生 | react 递进/认罪演出钩子;pack 翻转二周目变体;评级 S 追求(全疑点+零冗余)✓ | +| 15 | seed 可复现 | (seed,pack) 注入+布局洗牌全 PRNG+深相等断言;变体包数据驱动 ✓(§6.2) | +| 16 | 矛盾矩阵验算 | 全轴枚举表:同轴异值对 ≡ M1-M4;一致对 6/单断言/无轴句清单 ✓(§3.4/§6.4 不变式) | +| 17 | 指证全路径可走通 | hit(4 矛盾×双向)/miss 三档/同句/错指三人(A)/错指含周(B)/三问对错/保底 hint 对——全部有专属资产与断言 ✓(§11 路径 5-10、12) | +| 18 | 台词规范 | 全句 ≤40 字(实测 ≤31);数字全分档整句;句序号不播(重播代替报数);「重新听」写法;同角色 speaker+instruct 恒定;总句数 158 ≤160 ✓(台词表) | +| 19 | 键盘通道 | 操作提示句 V 组 10 变体集中登记,inputMode() 选播;空格/回车/方向键全手势可达(§5.5 平行通道,游戏层零改动)✓ | +| 20 | 引擎能力边界 | 无实时节点(链播 onComplete+setTimeout);长按快进与长按指证时间互斥;资产缺失静默跳过+8s 兜底;30MB 预算内 ✓ | diff --git a/docs/design/19-cipher-voice-ids.md b/docs/design/19-cipher-voice-ids.md new file mode 100644 index 00000000..64323980 --- /dev/null +++ b/docs/design/19-cipher-voice-ids.md @@ -0,0 +1,258 @@ +# 19b · 《暗码》(cipher) 语音台词表(voice ids,录音依据) + +> **给音频总监的录制说明** +> - **双性别组**(报务员 OP + 北风电台 WIND,134 句):落 `web/cipher/audio/voice/female|male/.{ogg,m4a}`,两性别 id 集合完全一致(含全部 `_k` 变体)。 +> - **OP(报务员=玩家)**:female = `Serena`,instruct 常量 `压低而冷静的年轻女声,报务员,值夜班,克制的紧张`;male = `Uncle_Fu`,instruct 常量 `压低而冷静的男声,报务员,值夜班,克制的紧张`。 +> - **WIND(北风电台=上线联络+系统音/向导 gd_*)**:female = `Vivian`,instruct 常量 `清晰平稳的女声,电台联络声,隔着电流,字字清楚`;male = `Dylan`,instruct 常量 `清晰平稳的男声,电台联络声,隔着电流,字字清楚`。 +> - 区分度设计:你(耳机旁的自语)与北风(电波里传来的控制声)共用 speaker 池但**不同 speaker 配对**——female 侧你 Serena / 北风 Vivian、male 侧你 Uncle_Fu / 北风 Dylan,同场景高频对话的两者永不同 speaker(radio 主播/导播先例)。 +> - **NPC 平铺组**(5 句):单音色、**不做双性别**,平铺落 `web/cipher/audio/voice/.{ogg,m4a}`(引擎平铺回退命中)。有台词人物仅 2 人:北风(已并入 WIND 双性别组,向导人设 L4,radio 导播同型)+ 处长(明线上级,本表 NPC)。另有**无台词角色**「快手七」(对家报务手,存在=电波里的手法音,零台词零资产,abyss 小郑先例)。**不加滤波**(detective 先例:处长是当面训话,区分度靠 speaker+instruct+语境;「电波里的人」与「身边的人」靠 WIND/OP 音色对立立住): +> | 角色 | speaker | instruct 常量 | 区分度风险 | +> |---|---|---|---| +> | 处长(明线上级,50 余岁) | `Eric` | `威严冷淡的中年男声,电讯处长,官腔,话里有话` | **低**:male 侧唯一 Eric,与两侧双性别组均不同 speaker | +> - **称呼规则**:处长对玩家一律称「值机员/你」,北风称「你」,**全表禁用**「姑娘/小哥/先生/同志」类性别化称呼(玩家性别随设置变化)。 +> - **键盘变体(PC 键盘通道,P1 机制)**:带 `_k` 后缀的 id 为键盘措辞版(「按空格」「回车」「左箭头/右箭头/上箭头/下箭头」),与触屏版**成对生成、成对落盘**(`check_all_assets` 校验 `_k` 配对);运行时由 `gesture.inputMode()` 选择:`key` → `_k`,`touch` → ``,`auto` 跟随最近使用通道,`_k` 缺失回退无后缀版。变体共 **11 对**,全部集中在 §G0.a 操作提示句区与 §M1 首个候选导航/首个叙事抉择(dv1_teach2 / p2),以粗体标注。 +> - **数字规避**:电文序号/密码本页数/暴露档位全部分档整句(prog_t1..t4、book_3..0、exp_calm/low/mid/hot);句内数字(民国二十九年/零点/丑时/三时/两点五十九分/一刻钟)写死在整句里,禁止运行时拼接。 +> - **禁语速词**:instruct 不含任何语速描述(引擎 SoundTouch 变速);NPC 句靠短句+逗号停顿实现「官腔慢半拍」听感。 +> - **摩尔斯不在本表**:电文/候选/重听/慢放的滴答声为运行时程序合成(`web/cipher/morse.js`,19-cipher.md §2.1),零文件资产,勿排产。 +> - 管线:`tools/gen_voice_dual.py` 照录(SPEAKERS 表需新增 cipher 行 + CIPHER_NPC 平铺表);多音字自查见文末注记。 + +## G0 组 · 系统/向导/状态(WIND 双性别 ×39) + +### G0.a 操作提示句(含键盘变体,9 对 18 句——键盘玩家听这一区就够) + +| id | 台词全文 | 触发 | +|---|---|---| +| `gd_brief` | 单击继续值机。要选词时,向左或向右滑。 | 开场链最前(full 档,F8 先例) | +| **`gd_brief_k`** | 按空格继续值机。要选词时,用左右方向键。 | 同上(inputMode=key) | +| `gd_continue` | 单击屏幕,继续。 | 叙事链末看门狗第 1 级(full 25s / standard 13s) | +| **`gd_continue_k`** | 按空格,继续。 | 同上(key) | +| `gd_again` | 还在吗?单击继续。 | 看门狗第 2 级(50s / 30s),新句不逐字重播 | +| **`gd_again_k`** | 还在吗?按空格继续。 | 同上(key) | +| `gd_tune1` | 电文在等。左右滑,听候选词。 | DECODE 浏览静默 10s(第 1 级) | +| **`gd_tune1_k`** | 电文在等。左右方向键,听候选词。 | 同上(key) | +| `gd_tune2` | 双击,把电文再听一遍。再双击,会放慢。 | DECODE 浏览静默 25s(第 2 级,**必须与第 1 级不同句**;同时是慢放机制的常驻教学) | +| **`gd_tune2_k`** | 按回车,把电文再听一遍。再按一次,会放慢。 | 同上(key) | +| `sys_help` | 单击选定,双击重新听电文。上滑听状态,下滑开菜单。选择时,左右滑。 | 上滑帮助链尾 / 菜单内上滑 | +| **`sys_help_k`** | 空格选定,回车重新听电文。上箭头听状态,下箭头开菜单。选择时,左右箭头。 | 同上(key) | +| `sys_menu` | 菜单。单击切换选项,双击确认。 | 下滑呼出模态菜单 | +| **`sys_menu_k`** | 菜单。空格切换选项,回车确认。 | 同上(key) | +| `sys_skip` | 想跳过这句,单击就行。 | 开场每局一次(skip_hint 先例) | +| **`sys_skip_k`** | 想跳过这句,按空格。 | 同上(key) | +| `sys_continue_ask` | 检测到你的值机记录。单击继续上次,右滑开始新的夜班。 | 有存档进游戏 | +| **`sys_continue_ask_k`** | 检测到你的值机记录。空格继续上次,右箭头开始新的夜班。 | 同上(key) | + +### G0.b 状态/系统句(无手势词,无变体) + +| id | 台词全文 | 触发 | +|---|---|---| +| `gd_invalid` | 这个手势,值机室里用不上。 | 无效手势(error 震,idle-only,同局限次) | +| `gd_prog1` | 光切词没有用。双击,先听电文。 | 无进展轨 60s(第 1 级,乱按抢救) | +| `gd_prog2` | 别急。停下来,把电文听完整。 | 无进展轨 120s(第 2 级) | +| `sys_menu_resume` | 回到电波里。 | 菜单项 | +| `sys_menu_book` | 翻密码本,换一个字母。 | 菜单项(长按捷径的冗余入口,P4) | +| `sys_menu_restart` | 重新开始今晚。 | 菜单项 | +| `sys_menu_lobby` | 保存并回大厅。 | 菜单项(确认后跳转前播) | +| `sys_settings` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内设置提示 | +| `sys_new_night` | 新的夜班,开始了。 | 新夜班开始 / 菜单重开 | + +### G0.c 分档状态句(整句账目,逐值为真) + +| id | 台词全文 | 触发 | +|---|---|---| +| `prog_t1` | 现在是今晚第一封电文。 | 上滑状态链(按当前封选播) | +| `prog_t2` | 现在是第二封,内部通报。 | 同上 | +| `prog_t3` | 现在是第三封,交接电文。 | 同上 | +| `prog_t4` | 第四封,最后一封。 | 同上 | +| `book_3` | 密码本,还剩三页。 | 状态链(余页=3;开局) | +| `book_2` | 密码本,还剩两页。 | 余页=2 | +| `book_1` | 密码本,只剩最后一页。 | 余页=1 | +| `book_0` | 密码本,一页都不剩了。 | 余页=0 | +| `exp_calm` | 这一晚,还没人看你第二眼。 | 暴露度 0(跨档时挂夜结算链尾;状态链) | +| `exp_low` | 处长端着茶,多看了你一眼。 | 暴露度 1-2 | +| `exp_mid` | 走廊里的脚步声,慢了下来。 | 暴露度 3-4 | +| `exp_hot` | 门外,好像有人换了岗。 | 暴露度 ≥5;各播报位附 warning 震 | + +## G1 组 · 开场(双性别 3 + 处长平铺 1) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `b_open` | 你 | 民国二十九年,江城。电讯处的夜班,从零点的电波声开始。 | INTRO(配 sfx/amb_room) | +| `b_rule` | 北风 | 北风问候你。规矩就一条:听不清,就再听一遍。 | INTRO(一句规矩,P4;重听/慢放承诺) | +| `b_goal` | 北风 | 今晚频道里有四封密电。每一个字,都要过你的耳朵。 | INTRO(四封承诺;随后第一封起播) | +| `b_chief` | 处长 | 四封电文,一封都不许漏。你的耳朵,是处里最值钱的东西。 | INTRO 收尾(处长夜令,明线口径;配 sfx/tea_cup) | + +## M1 组 · 第一封·船期·教学(双性别 19,含 _k 2) + +> 真词 **BAY=货船**;候选 DAY=客车 / RAY=巡逻艇;辨析位=第 1 字母。T1 译错**无限重选零惩罚**,恒收敛真词。 + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `rx1_in` | 北风 | 第一封。江上船队的调度,拍得很稳。 | RX 引入(配 sfx/tune_seek,随后摩尔斯首播 BAY) | +| `dv1_teach` | 北风 | 电文进来了。先双击,把它重新听一遍。 | 首播后 DECODE 教学 ①(教「双击=重听」) | +| `dv1_teach2` | 北风 | 左右滑,听密码本上的候选词。单击,选定一个。 | DECODE 教学 ②(教候选导航+两段确认首击) | +| **`dv1_teach2_k`** | 北风 | 左右方向键,听密码本上的候选词。空格,选定一个。 | 同上(key;候选导航键位绑定) | +| `dv1_ask` | 北风 | 密码本第一页。三个词,哪个是它? | 抉择提示(choiceArmed;进浏览自动预听候选 1) | +| `cand_bay` | 北风 | 货船。 | 候选预听报名(候选摩尔斯 620Hz 对照档之后接播;全 DECODE 复用本封) | +| `cand_day` | 北风 | 客车。 | 同上 | +| `cand_ray` | 北风 | 巡逻艇。 | 同上 | +| `dv1_right` | 北风 | 货船,译对了。药品,装在货船的底舱。 | T1 译对判定(success 震;首个正反馈 ≈t+2min) | +| `dv1_wrong` | 北风 | 不对。听清楚:B,一长,三短。 | T1 译错即时归因(真话:真词首字母的摩尔斯写死在句里;无限重选) | +| `hint_t1` | 北风 | 我教你一页:第一个字母,是 B。一长,三短。 | T1 翻密码本(**首次免费不扣页**,Prime→Teach;配 sfx/paper_page) | +| `cf_confirm` | 北风 | 就这个词?再单击,定下来。 | 两段确认第 1 击(全 DECODE 共用;随后电文重播原速一遍) | +| `p2` | 北风 | 药品船,丑时过江。向左滑,把船期报给北风。向右滑,压进值机日志。 | 叙事抉择提示(播完即可选) | +| **`p2_k`** | 北风 | 药品船,丑时过江。左箭头,把船期报给北风。右箭头,压进值机日志。 | 同上(key;首个叙事抉择键位绑定) | +| `r1a_send` | 你 | 北风,北风。货船,丑时过江,接住。 | p2 左(报船期;配 sfx/key_send) | +| `r1a_e` | 北风 | 收到。江上有人等你。第一封,做得稳。 | p2 左·收(med_ok 预埋;expose+0) | +| `r1b_hold` | 你 | 先压一压。这一船的事,得看清楚再说。 | p2 右(压日志;expose+1) | +| `r1b_e` | 北风 | 日志下面压着的,是三箱药。船,不会等你。 | p2 右·收(诚实复盘,非惩罚) | +| `n1` | 北风 | 零点半。挂钟走了半圈,雨没有停。 | NIGHT(1) 过渡句(随后跨档 exp_* 如有) | + +## M2 组 · 第二封·名单(双性别 16 + 处长平铺 1) + +> 真词 **TOP=黑名单**;候选 TAP=窃听令 / TIP=线人费;辨析位=第 2 字母。正片起**译错不揭穿**:rev_* 按玩家所选播「你的认知」;expose +1。 + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `rx2_in` | 北风 | 第二封。手法很急,是内部通报。 | RX 引入(摩尔斯首播 TOP) | +| `dv2_ask` | 北风 | 密码本第二页。三个词,选一个。 | 抉择提示 | +| `cand_top` | 北风 | 黑名单。 | 候选预听报名 | +| `cand_tap` | 北风 | 窃听令。 | 同上 | +| `cand_tip` | 北风 | 线人费。 | 同上 | +| `rev_top` | 北风 | 黑名单。头一个代号,叫码头。 | 译 TOP·揭晓(你的认知:码头=你的交通员;驱动 p4_known) | +| `rev_tap` | 北风 | 窃听令。今夜,有人要装耳朵。 | 译 TAP·揭晓(误读分支;expose+1) | +| `rev_tip` | 北风 | 线人费。月底之前,有人要收钱。 | 译 TIP·揭晓(误读分支;expose+1) | +| `p4_known` | 北风 | 名单上有码头。向左滑,暗号示警,让他连夜搬家。向右滑,按流程呈报处长。 | 抉择提示(**条件 t2=top**) | +| `p4_blind` | 北风 | 一封通报。向左滑,压进抽屉,谁的耳朵也别装。向右滑,按流程呈报处长。 | 抉择提示(条件 t2≠top) | +| `r2a_act` | 你 | 有的字,不能落进卷宗。 | p4 左·暗处理(known=示警码头 / blind=压下通报;expose+1) | +| `r2a_e` | 北风 | 这一页,从今天起,不存在了。 | p4 左·收(ep_list_ok 预埋) | +| `r2b_act` | 你 | 通报誊清,呈处长。 | p4 右·呈报(mateListed=true;expose+0) | +| `r2b_e` | 北风 | 名单进了卷宗。码头,自己多保重。 | p4 右·收(ep_list_no 预埋) | +| `m2_chief` | 处长 | 嗯,译得很全。去吧。 | p4 右·处长收报(配 sfx/pen_scratch;通用真话:你誊了什么它就收什么) | +| `hint_t2` | 北风 | 翻到这页:第二个字母,是 O。三长。 | 翻密码本(扣 1 页;辨析位=O ⟹ 候选唯一) | +| `n2` | 北风 | 一点整。处长的灯,还亮着。 | NIGHT(2) 过渡句 | + +## M3 组 · 第三封·交接(双性别 16) + +> 真词 **HAND=今夜交接**;候选 SAND=明晨交接 / LAND=地点变更;辨析位=第 1 字母(H 四短 vs S 三短,全作最狠听辨——慢放价值兑现段)。 + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `rx3_in` | 北风 | 第三封。很短,手法有点抖。 | RX 引入(摩尔斯首播 HAND) | +| `dv3_ask` | 北风 | 密码本第三页。三个词,选一个。 | 抉择提示 | +| `cand_hand` | 北风 | 今夜交接。 | 候选预听报名 | +| `cand_sand` | 北风 | 明晨交接。 | 同上 | +| `cand_land` | 北风 | 地点变更。 | 同上 | +| `rev_hand` | 北风 | 今夜交接。丑时,老码头。 | 译 HAND·揭晓(你的行动按「今夜」展开) | +| `rev_sand` | 北风 | 明晨交接。天亮再说。 | 译 SAND·揭晓(误读:你按「明晨」行动;expose+1) | +| `rev_land` | 北风 | 地点变更。等下一封。 | 译 LAND·揭晓(误读:你按「等」行动;expose+1) | +| `p6` | 北风 | 交接在你手里。向左滑,亲自去码头跑一趟。向右滑,留书暗号,托给下一班。 | 抉择提示(固定文本,known/blind 共用——行动时机由你的认知决定) | +| `r3a_go` | 你 | 口令我背得出。今夜,我亲自去。 | p6 左·亲自去(expose+2;按你的认知时间出门) | +| `r3a_e` | 北风 | 值班室空了一刻钟。值机日志,停了一行。 | p6 左·收(expose+2 的纸面依据;成败留尾声) | +| `r3b_act` | 你 | 暗号夹进交接簿,让雨替我跑这一趟。 | p6 右·留书(expose+0) | +| `r3b_e_ok` | 北风 | 交接赶上了。袋子,连夜上了船。 | p6 右·收(**条件 t3=hand**;ep_hand_ok 预埋) | +| `r3b_e_no` | 北风 | 交接的袋子,在码头放到了天亮。 | p6 右·收(条件 t3≠hand——误译的戏剧性反讽:约定时间本身错了) | +| `hint_t3` | 北风 | 翻到这页:第一个字母,是 H。四短。 | 翻密码本(扣 1 页) | +| `n3` | 北风 | 一点三刻。雨小了,频道里很静。 | NIGHT(3) 过渡句(随后走氧气门式签收) | + +## M4 组 · 第四封·处置令·高潮(双性别 15 + 处长平铺 3) + +> 真词 **SPY=内线确认**;候选 SAY=约谈 / SLY=内部排查;辨析位=第 2 字母。rev_spy 挂全作唯一 danger 拍(step_halt 前置 2s);RELAY(4) 起 BGM 切 `bgm/closing`。 + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `rx4_in` | 北风 | 第四封。最后一个手法,你听过——快手七。 | RX 引入(无台词角色登场=手法音;摩尔斯首播 SPY) | +| `dv4_ask` | 北风 | 密码本第四页。三个词,选一个。 | 抉择提示 | +| `cand_spy` | 北风 | 内线确认。 | 候选预听报名 | +| `cand_say` | 北风 | 约谈。 | 同上 | +| `cand_sly` | 北风 | 内部排查。 | 同上 | +| `rev_spy` | 北风 | 内线确认,今夜三时收网。代号,耳机。 | 译 SPY·揭晓(**danger 拍**:「耳机」=你自己的代号;expose+0 但驱动 p9_known) | +| `rev_say` | 北风 | 明早九时,约谈相关人员。 | 译 SAY·揭晓(误读:危机被读成了例行公事;expose+1) | +| `rev_sly` | 北风 | 即日起,排查站内三人。 | 译 SLY·揭晓(误读;expose+1) | +| `m4_chief_a` | 处长 | 值机员,今晚的频道,很热闹啊。 | 处长试探 ①(p8 前铺垫;配 sfx/tea_cup) | +| `m4_chief_b` | 处长 | 你的耳朵最好。听见什么,记得……要报。 | 处长试探 ②(施压;配 sfx/pen_scratch) | +| `p8_known` | 北风 | 三点之前。向左滑,拍一封假电,说北风昨夜已撤。向右滑,手离开放键器,装傻。 | 抉择提示(**条件 t4=spy**;全表最长句 30 字) | +| `p8_blind` | 北风 | 收台之前。向左滑,回一封例行电文,搅一搅水。向右滑,什么都不做。 | 抉择提示(条件 t4≠top;p9 将按 blind 版走) | +| `r4a_feed` | 你 | 戴上手键。这一封,借快手七的手法。 | p8 左·反喂(**配 sfx/key_send**;expose+2,feed=true) | +| `r4a_e` | 北风 | 假电出了门。对家的耳朵,听得出换人了吗。 | p8 左·收(悬而未决——真相在尾声) | +| `r4b_act` | 你 | 手,离开放键器。我只是个值机的。 | p8 右·装傻(expose+0) | +| `r4b_e` | 北风 | 有时,什么都不做,就是做了。 | p8 右·收 | +| `hint_t4` | 北风 | 翻到这页:第二个字母,是 P。短,两长,短。 | 翻密码本(扣 1 页) | +| `m4_chief_c` | 处长 | 行。去睡吧,明天还有明天的事。 | p8 右·处长收场(仅右路播出) | + +## F9 组 · 签收抉择·最后一电(双性别 9) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `g9_in` | 北风 | 频道要收了。最后一件事,是你自己。 | 签收引入(NIGHT(3)/M4 之后) | +| `g9_knot` | 你 | 三年前,我译错了一封电。那之后,我再没见过我的上线。 | 心结自述(玩家背景;与「译错也是剧情」主题闭环) | +| `p9_known` | 北风 | 收网之前。向左滑,开机,发最后一电。向右滑,关机,装睡到天亮。 | 终局抉择提示(**条件 t4=spy**) | +| `p9_blind` | 北风 | 北风在等答复。向左滑,开机,把今晚发出去。向右滑,例行收台。 | 终局抉择提示(条件 t4≠spy——你不知道那是什么电) | +| `d9_send` | 你 | 北风,北风。处置令,今夜三时。撤。 | p9 左·发报(**配 sfx/key_send**;expose+2,结局判定用 p9 前值) | +| `d9_send_e` | 北风 | 收到了。你的手法,我听了一整晚。快走。 | p9 左·低暴露收(**p9 前 expose≤4**;e1 入口) | +| `d9_caught_e` | 北风 | 信号被咬住了。别回头,走水路。快。 | p9 左·高暴露收(**≥5**;danger 签名 + e3 入口) | +| `d9_sleep` | 你 | 机关上了。雨声里,只剩挂钟。 | p9 右·关机 | +| `d9_sleepe` | 北风 | 天亮之前,还有两个钟头。守住。 | p9 右·收 | + +## EP 组 · 结局与尾声回响(双性别 17;结局 8 + 回响 9) + +| id | 声部 | 台词全文 | 触发 | +|---|---|---|---| +| `e1_a` | 你 | 电波追不上我了。码头的灯,一盏一盏地灭。 | 结局 e1(≤4 且发报)主句(配 sfx/dawn_bell) | +| `e1_b` | 你 | 新的密码本,在城外渡口等我。黎明,比想象中安静。 | e1 收(钩子) | +| `e2_a` | 你 | 交了班,一切照旧。没人知道今晚频道里发生过什么。 | 结局 e2(≤4 且关机)主句 | +| `e2_b` | 你 | 只是北风的呼叫,到天亮也没人接。下一次,我会接吗。 | e2 收(钩子) | +| `e3_a` | 你 | 我把警报拍了三遍。门开的时候,挂钟正好敲三点。 | 结局 e3(≥5 且发报)主句(配 sfx/lock_click) | +| `e3_b` | 你 | 审讯室的灯,也很亮。可码头那边,一个人都没少。 | e3 收(钩子;你的暴露换同伴撤尽) | +| `e4_a` | 你 | 两点五十九分,脚步停在门口。我把最后一页密码,塞进了炉子。 | 结局 e4(≥5 且关机)主句(配 sfx/lock_click) | +| `e4_b` | 你 | 耳机还挂着。里面有一封电文,永远译不完了。 | e4 收(钩子) | +| `ep_lead` | 北风 | 天亮了。北风给你记了最后一笔账——今晚的事,都有了去向。 | 尾声引入 | +| `ep_med_ok` | 北风 | 药品:货船丑时靠岸,三箱药,一箱不少。 | 尾声(t1=bay 且 p2=左) | +| `ep_med_no` | 北风 | 药品:没人接住那三箱药。船,白白过了江。 | 尾声(其余——压下或译错,真话) | +| `ep_list_ok` | 北风 | 名单:码头连夜搬了家。卷宗里,少了一个人。 | 尾声(p4=左) | +| `ep_list_no` | 北风 | 名单:天亮时,多了一个红圈。 | 尾声(p4=右——呈报的真实代价) | +| `ep_hand_ok` | 北风 | 交接:胶卷上了船,手很稳。 | 尾声(t3=hand) | +| `ep_hand_no` | 北风 | 交接:码头的灯,白亮了一夜。 | 尾声(t3≠hand) | +| `ep_spy_ok` | 北风 | 处置令:你译对了它。北风,撤在你前头。 | 尾声(t4=spy) | +| `ep_spy_no` | 北风 | 处置令:差的那三个字母,差了一整条命。 | 尾声(t4≠spy——全作归因真话的收束句) | + +--- + +**合计:139 句**(预算 ≤150 ✓) +- 双性别 134 句(OP 你 20 + WIND 北风/系统 114,含 `_k` 键盘变体 11)×2 性别 = 268 文件; +- NPC 平铺 5 句(处长)= 5 文件; +- 合计 273 条音频 ×2 格式(ogg+m4a)= **546 文件**。 +- 键盘变体 11 对:gd_brief / gd_continue / gd_again / gd_tune1 / gd_tune2 / sys_help / sys_menu / sys_skip / sys_continue_ask / dv1_teach2 / p2(成对生成,`check_all_assets` 校验 `_k` 配对与双性别集合一致)。 +- 每结局专属台词齐备:e1/e2/e3/e4 各 2 句 + 尾声回响 9 句(译文/抉择 flag 选播)。 +- 每局实走 9 个抉择点(4 译词 p1/p3/p5/p7 恒 3 候选 + p2/p4/p6/p8/p9 恒 2 选项,无跳点路径)。 +- 摩尔斯滴答声零文件(运行时 `morse.js` 合成,19-cipher.md §2.1),本表不含。 + +## 多音字 / 发音风险自查注记 + +| 风险词 | 处理 | +|---|---| +| 重听 | 全表写「重新听」(sys_help / gd_tune2),无「重听」字样 | +| 长(cháng/zhǎng) | 「一长三短」「三长」「两长」为 cháng 正确常用读法,保留,试听确认 | +| 处(chǔ/chù) | 「处长」「处置令」为固定词,保留,试听确认 | +| 卷(juǎn/juàn) | 「胶卷」juǎn 与「卷宗」juàn 两读并存(ep_hand_ok/r2a_act),逐句人工试听项 | +| 塞(sāi/sè) | 「塞进了炉子」(e4_a)为 sāi 常用读法,保留,试听确认 | +| 行(xíng/háng) | 「行。去睡吧」(m4_chief_c)为 xíng,试听确认 | +| 着(zhe/zháo) | 「耳机还挂着」「守住」为常见读法,逐句试听项 | +| 得(de/dé) | 「译得很全」「打得稳」等助词 de,逐句试听项 | +| 教(jiāo/jiào) | 「我教你一页」为 jiāo 常用读法,保留 | +| 答(dá/dā) | 「在等答复」为 dá,保留 | +| 差(chà/chā/chāi) | 「差的那三个字母」「差了一整条命」为 chà,试听确认 | +| 圈(quān/juàn) | 「多了一个红圈」为 quān,保留 | +| 英文字母 | hint_t1..t4 / dv1_wrong 句内单字母(B/O/H/P)朗读为风险项,逐句试听;误读时改写为「字母 B」式带「字母」前缀重生成 | +| 挣/血/露/巷/降/率/传(zhuàn 义) | 全表未使用(「暴露」不进台词,仅存档 summary) | +| 了 / 还 / 中 | 常规句式常见读法,逐句人工试听项(「收住了吗」「挂钟走了半圈」等) | +| 数字 | 句内数字全部汉字写死(民国二十九年/零点/零点半/一点整/一点三刻/丑时/三时/明早九时/三点/两点五十九分/一刻钟/两个钟头/三箱/三年前),序号与档位用分档整句(prog_t*/book_*/exp_*) | + +## 台词侧自查 + +- 单句 ≤40 字:全表最长 = p8_known(30 字),次长 p6/p4_known(29 字)、sys_help(27 字)✓;NPC 处长句 ≤23 字(最长 b_chief,P4 语速感靠短句+逗号,不用 instruct 语速词)✓。 +- 有台词人物 2 人(北风=WIND 双性别组、处长=平铺),无台词角色快手七零资产 ✓;同一角色 speaker+instruct 恒定、区分度风险逐个标注 ✓。 +- 称呼合规:处长对玩家只用「值机员/你」,北风称「你」,性别化称呼零出现 ✓。 +- 每个结局(e1..e4)专属主句+钩子各 2 句齐备,尾声救/错过双线齐备(ep_* 由 flags 单源选播)✓。 +- 归因真话律:dv1_wrong 的字母/码与真词 BAY 一致;rev_* 只播玩家认知不冒认真相;ep_med_no/ep_hand_no/ep_spy_no 与 flags 一致;无「运气不好」类假归因、无惩罚性措辞 ✓。 +- 分档账目真实:book_3/2/1/0 与余页一一对应;exp_calm/low/mid/hot 与 0/1-2/3-4/≥5 一一对应;prog_t1..t4 分封整句 ✓。 +- 向导 gd_* 13 句(gd_brief/gd_continue/gd_again/gd_tune1/gd_tune2 各带 _k 变体,gd_invalid/gd_prog1/gd_prog2 无变体)符合 14 号 §6 台词规范(一句一义、无重复句式、单句 ≤18 字)✓。 diff --git a/docs/design/19-cipher.md b/docs/design/19-cipher.md new file mode 100644 index 00000000..a8c21c78 --- /dev/null +++ b/docs/design/19-cipher.md @@ -0,0 +1,552 @@ +# 19 · 《暗码》(cipher) — 1940 密战叙事 RPG 设计文档 + +> **给开发的实现要点摘要** +> 1. 叙事模式(声命线范式,radio/abyss 同骨架):节点链 onComplete 串行 + seqToken 打断作废 + pendingChoice 缓存;复制 `web/radio/`(或 `web/abyss/`)结构起步,状态机 6 宏状态(INTRO/RX/DECODE/RELAY/NIGHT/ENDING + MENU 覆盖层)。 +> 2. 核心增量 = **电文解码小局嵌叙事**(DECODE 态):摩尔斯音由**运行时程序合成模块**(`morse.js`,guide earcon 同先例)直接排程 oscillator——**不走音频文件**(有损编码在 70-160ms 短音上有 ms 级抖动,毁时值精确);事件钩子供 playtest 固定字符断言。 +> 3. 手势:全局默认映射 + **唯一豁免 E1**(DECODE 空闲长按=翻密码本求提示,与引擎快进时间互斥,detective E2 同款);菜单走 00 号 §5.2 模态约定。摩尔斯三层兜底:正常译(原速+候选对照)/ 慢放(双击第 2 次 0.5×)/ 提示直译(密码本 3 页,逐值账目)。 +> 4. 暴露度 = 步进隐藏资源(radio 口碑同型):译错 +1/封(教学封 T1 恒 0)、危险抉择 +1/+2;分档整句、跨档才播、上滑可查;**档位措辞逐值为真**(book_1..0 / exp 四档均按实数措辞)。 +> 5. 译错归因真话:教学封 T1 译错**即时**归因(真词字母的摩尔斯写死在句里)+ 无限重选零惩罚;正片 T2-T4 译错**不揭穿**(进入剧情误读分支),真相在尾声回响兑现(ep_* 与 flags 单源对应,detective 信息单源思想)。 +> 6. 台词依据 [19-cipher-voice-ids.md](19-cipher-voice-ids.md)(139 句:双性别 134 + 平铺 NPC 5);键盘变体 11 对 `_k` 集中标注;结算链 800ms 手势死窗(abyss/地下城 H1 同款)。 +> 7. 章程任务 N3(research/new8/00-program.md §2):单局 15-25 分钟、9 抉择点(4 译词 3 选 + 5 叙事 2 选)、4 结局、分支局部化。断言清单住本文档 §9;13 号文档已登记指针(§22)。 + +## 1. 概述 + +- **一句话**:1940 年江城,你是电讯处的夜班报务员,也是「北风」安在敌台内部的一双耳朵——一晚四封密电过你的手,每个词怎么译、报不报、报给谁,都由你按下。 +- **题材(声音即世界)**:谍战的本体就是**听**——摩尔斯的滴答是情报本身、报务员的「手法」是 fingerprint、电台静电是世界底色。「用耳朵当武器」与纯音频载体天然同构:视觉谍战需要的文件/密写/表情,在这里全部是声音。 +- **单局**:一个完整夜班(一条抉择路径)19-22 分钟;四封电文共享主干(截获→解码→处置→夜结算),分歧局部化在每封内部;9 个抉择点(4 译词 + 5 叙事,全部恒在),4 结局。 +- **核心循环**:截获(调谐+电文摩尔斯首播)→ 解码小局(听音辨词:候选对照/慢放/提示三层兜底)→ 译文揭晓(**你的认知**,对错不揭穿)→ 处置抉择(报北风/压日志/呈处长/亲自交接/反喂假电)→ 夜结算(暴露度跨档播报)→ 下一封。终局 p9 用「发最后一电」把整晚收在你的电键上。 +- **与 radio/abyss 的差异点**(骨架同、压力源不同):radio 零压力、abyss 资源稀缺(氧气);cipher 的压力 = **隐藏的怀疑账本**(暴露度)+ **认知风险**(你永远不确定自己译对了没有——译错不是失败,是走另一条真实存在的剧情)。全部宽限化:无倒计时、无中途死亡、软失败只损失「情报效果」不损失玩家已有物。 + +## 2. 摩尔斯解码小局(信息设计,本作核心差异层) + +### 2.1 电码呈现规格 + +**电码表**(标准摩尔斯;本作 4 封电文只用到 13 个字母,模块内置全 A-Z 表,断言只锁用到的): + +| 字母 | 码 | 字母 | 码 | 字母 | 码 | 字母 | 码 | +|---|---|---|---|---|---|---|---| +| A | ·− | H | ···· | O | −−− | T | − | +| B | −··· | I | ·· | P | ·−−· | Y | −·−− | +| D | −·· | L | ·−·· | R | ·−· | S | ··· | +| N | −· | | | | | | | + +**时值与音色(运行时合成,`web/cipher/morse.js`)**: + +| 参数 | 值 | 说明 | +|---|---|---| +| 单位 u | 原速 80ms(dit=1u 音、dah=3u 音、字内隙 1u、字母隙 3u) | ≈15 WPM 等效;**u 是常量写死,不随 settings.speed**(见边界表) | +| 慢放档 | u=160ms(0.5×),字母隙加宽至 6u | 双击第 2 次触发;三层兜底第 2 层 | +| 候选对照档 | u=110ms,字母隙 5u | 密码本「念给你听」的质感 | +| 音色 | 电文 750Hz / 候选 620Hz,正弦 + 5ms cos 边沿,gain 0.5 | **频率差 = 「电波里的」vs「密码本上的」**,防听感混淆 | +| 首尾 | lead-in 400ms 静音,结尾 400ms 静音 | 排程器自然包含 | +| 时长例 | BAY 原速 ≈2.6s / HAND ≈2.8s / TOP ≈2.8s;慢放 ≈2× | 单次听觉负荷远低于 40s 红线 | +| 事件钩子 | `morse.play(word,{hz,unit})` 逐元素回调 `{type:'dit'|'dah',letter}` | playtest **固定字符断言**挂点(§9) | + +- 为什么运行时合成而非 SA3/SYN 文件:①时值精确是玩法命根(H···· vs S··· 只差一个 80ms 的 dit,ogg/m4a 有损编码在短音上有数 ms 抖动与预卷,文件方案直接否决);②三档速度 × 双频率 × 13 字母的组合用文件不可维护;③零资产缺失风险。guide earcon(14 号裁定 3)运行时合成先例。 + +### 2.2 电文与候选设计表(信息单源:真词/候选/辨析位写死) + +| 封 | 电文名 | 真词(义) | 候选(义) | 共享 | 辨析位 | 密码本提示揭示 | +|---|---|---|---|---|---|---| +| T1 | 船期 | **BAY**(货船) | DAY(客车)/ RAY(巡逻艇) | _AY | 1:B(−···)/D(−··)/R(·−·) | 第 1 字母是 B,一长三短 | +| T2 | 名单 | **TOP**(黑名单) | TAP(窃听令)/ TIP(线人费) | T_P | 2:O(−−−)/A(·−)/I(··) | 第 2 字母是 O,三长 | +| T3 | 交接 | **HAND**(今夜交接) | SAND(明晨交接)/ LAND(地点变更) | _AND | 1:H(····)/S(···)/L(·−··) | 第 1 字母是 H,四短 | +| T4 | 处置令 | **SPY**(内线确认) | SAY(约谈)/ SLY(内部排查) | S_Y | 2:P(·−−·)/A(·−)/L(·−··) | 第 2 字母是 P,短两长短 | + +- **候选纪律**:三候选等字母数、共享其余字母、唯一辨析位;**提示揭示辨析位字母 ⟹ 候选唯一**(每行验算成立,playtest 断言「提示流可全对」)。 +- **难度曲线**:T1(B/D 差一个尾 dit,教学期有慢放+免费提示兜底)→ T2(中间字母 O/A/I 时值差异大,最易)→ T3(H/S/L:H 与 S 只差一个 80ms 尾 dit,全作最狠听辨)→ T4(P/L 需听完整字母,中高)。曲线说明:难度=听辨精度,操作面恒定(左右滑+单击+双击)。 +- 电文摩尔斯(固定字符断言对象):BAY=`−··· / ·− / −·−−`;TOP=`− / −−− / ·−−·`;HAND=`···· / ·− / −· / −··`;SPY=`··· / ·−−· / −·−−`。 + +### 2.3 解码交互流(DECODE 态内) + +``` +RX 链:rx*_in(引入)→ sfx/tune_seek → 电文摩尔斯首播(原速 750Hz,auto,不可跳) + → guide.turn → dv*_ask(提示句,choiceArmed)→ 浏览等待 +浏览:左右滑 = 循环切换候选(每切自动预听:候选摩尔斯 620Hz 对照档 → 静默一拍 → cand_* 词义句) + 双击 = 重听电文(原速);【再双击】= 慢放 0.5×(两级内容不同:速度即差异) + 长按(语音空闲)= 翻密码本(§2.5) + 单击 = 进确认:cf_confirm + 电文重播原速一遍 → 再单击 = 定下(判定出口 800ms 死窗) +确认态:左右滑 = 取消回浏览;双击 = 照常重听电文 +``` + +- **三层兜底**(成本递增的梯度,全部可独立走通,P3 嘈杂/P4 疲惫的双保险): + +| 层 | 手段 | 成本 | 覆盖 | +|---|---|---|---| +| 1 正常译 | 原速电文 + 候选对照预听匹配 | 最快 | 安静环境/耳力好 | +| 2 慢放 | 双击第 2 次 0.5×,**无限次免费** | 时间 | 通勤嘈杂听不清长短音(P3);无限兜底层 | +| 3 提示直译 | 密码本 3 页,每页直译 1 封的辨析位字母 | 资源(页) | P4 保底:提示+候选对照 ⟹ 唯一解(§2.2 验算) | + +- 教学期(T1):dv1_teach/dv1_teach2 先教「双击重听→再双击慢放」「左右滑候选、单击选定」;T1 判定有**即时反馈**(对=success 震+dv1_right;错=dv1_wrong 即时真话归因+无限重选,零惩罚)——正片 T2-T4 判定**无对错反馈**,直入剧情(译错是你不知道的分支)。教学承诺「练习有答案,正片靠耳朵」在 dv1_right 说破。 + +### 2.4 密码本机制(提示额度经济) + +| 项 | 规则 | +|---|---| +| 页数 | 初始 3 页(叙事=北风给你的三页「电码补遗」);**全局不再补页** | +| 兑换 | DECODE 空闲长按(E1 豁免)或菜单「翻密码本」项(P4 冗余入口);消耗 1 页 → 播 hint_*(「第 N 个字母,是 ○」+ 摩尔斯口述),留在浏览态 | +| 教学免费 | T1 第 1 次翻页**不扣**(hint_t1:「我教你一页」)——Prime→Teach 承诺兑现 | +| 账目真实 | 状态链随时可查:book_3「还剩三页」/ book_2「还剩两页」/ book_1「只剩最后一页」/ book_0「一页都不剩了」——**逐值整句,不拼接** | +| 页数用尽 | 再翻 = error 震 + sfx/paper_page(翻空本声),无语音(零静默、不轰炸) | +| 与结局关系 | 页数不进结局判定(只换准确度);纯提示流(T1 免费+3 页)可全对(§5.5 剧本 E 验算) | + +### 2.5 译错归因真话(detective 信息单源思想) + +| 场景 | 播报 | 真话依据 | +|---|---|---| +| T1 译错(教学) | dv1_wrong「不对。听清楚:B,一长,三短。」 | 真词字母的摩尔斯**写死在句里**,与 §2.2 电码表一致;无限重选零惩罚 | +| 正片译错 | 不揭穿:rev_* 按你选的候选播「你的认知」(rev_top/tap/tip…) | 旁白只说你以为的事;真词唯一、由数据表单源决定 | +| 后果分支 | 你的行动按认知展开(报错船期/留错时间/呈报错令);暴露度 +1 | 「译文进了纸面,与对家底档不符」——事件表单源(§5.2) | +| 真相揭晓 | 尾声 ep_hand_no「码头的灯,白亮了一夜」/ ep_spy_no「差的那三个字母,差了一整条命」 | ep_* 由 flags(t3==hand 等)选播,**播报=存储**;禁止「运气不好」类假归因 | + +## 3. 状态机 + +### 3.1 状态列表 + +| 状态 | 内容 | BGM | guide.state | +|---|---|---|---| +| `INTRO` | 开场链:夜班开场 + 一句规矩 + 今晚目标 + 处长夜令 | `bgm/radio_room` | explore | +| `RX` | 截获:引入句 + 调谐 + 电文摩尔斯首播(auto 链) | `bgm/radio_room`(摩尔斯期间 −6dB) | explore;摩尔斯首播后由 turn cue 承担 | +| `DECODE` | 解码小局:浏览/确认/重听/慢放/提示(§2.3) | 同上 | choice | +| `RELAY` | 处置叙事:rev_* 揭晓链 + 后果 + 叙事抉择 p2/p4/p6/p8 | `bgm/radio_room`;RELAY(4) 起 crossfade 至 `bgm/closing` | explore;抉择点=choice | +| `NIGHT` | 夜结算:n* 过渡句 + 暴露档位跨档播报(如有),auto ≤12s | `bgm/night` | settle | +| `ENDING` | 结局链 + 尾声回响(epilogue),结算态 | `bgm/ending` | settle | +| `MENU` | 模态覆盖层(任意叙事态下滑呼出,暂停一切链) | `bgm/radio_room` 低响度淡入 | menu | + +- 附 `ASK`(存档询问态,radio 同款):单击=继续上次 / 右滑=新夜班;上滑/下滑/左滑/双击=无效(error 震+gd_invalid)。 + +### 3.2 转移图(文本) + +``` + ┌──────────── 结算单击=新夜班 ────────────────┐ + ▼ │ + INTRO ──链完──> RX(1) ──首播完──> DECODE(1) ──两段确认+判定──> RELAY(1) ──链完──> NIGHT ─┤→ RX(2) + (调谐+引入+摩尔斯首播 浏览/确认/重听/慢放/提示 揭晓+p*+结果链 │ …(T2/T3 同型)… + auto,首播不可跳) (T1 判定有即时反馈) │ + └──────────────────────────────────────────────────────────────────────┘ + RELAY(4) ──链完──> 签收 g9_in → g9_knot → ◆p9 ──> d9 结果链 ──> ENDING(e1..e4 + ep 回响链) + + 任意叙事态(INTRO/RX/DECODE/RELAY/NIGHT/ENDING):下滑 → MENU(模态) + MENU:单击=循环切换选项 → 双击=确认执行 → 上滑=帮助 → 下滑/「回到电波」项=关闭回原状态 + (RELAY 关闭后重播当前节点;DECODE 关闭后重挂浏览并自动预听当前候选——局面复述) +``` + +- RX 首播链与 NIGHT 链 `auto:true`;DECODE 浏览为武装等待;RELAY 叙事节点链末等待单击(radio 实现核对 #1 同口径)。 +- 判定出口(DECODE 第 2 击确认)、NIGHT 链、d9 结果链、e* 结局链入口处盖 `_settleGraceUntil = now+800ms` 手势死窗(dungeon H1/abyss 同款),窗内手势一律吞掉——揭晓与结算链完整播出。 +- MENU 打开时 `guide.disarm()` + 取消一切看门狗/自动链 + 停摩尔斯排程;关闭后按原状态复述(L5 局面复述,radio closeMenu 先例)。 + +### 3.3 每状态手势映射(6+2 全覆盖) + +| 手势 | INTRO/RX | DECODE 浏览 | DECODE 确认 | RELAY | NIGHT | ENDING | MENU | +|---|---|---|---|---|---|---|---| +| 上滑 | 帮助+状态,随后重播当前段 | 状态链(prog_t→book 档→exp 档→sys_help,状态在前),留在浏览 | 同左 | 帮助+状态,随后重播当前节点 | 帮助+状态,自动链继续 | 帮助+状态(结局名+档) | 帮助(sys_help) | +| 下滑 | 模态菜单 | 模态菜单 | 模态菜单 | 模态菜单 | 模态菜单 | 模态菜单 | 关闭菜单 | +| 左滑 | 无效 → error 震+gd_invalid | 上一候选(循环,预听);**首播中=beat 轻震**(摩尔斯不可跳) | 取消确认,回浏览 | 抉择=左 | 无效 → gd_invalid | 无效 → gd_invalid | 无效 → error 震 | +| 右滑 | 无效 → gd_invalid | 下一候选(循环,预听) | 取消确认,回浏览 | 抉择=右 | 无效 → gd_invalid | 无效 → gd_invalid | 无效 → error 震 | +| 单击 | 播放中=跳过本句;链末=继续 | 进确认:cf_confirm + 电文重播 | **定下判定**(800ms 死窗 → 揭晓链) | 播放中=跳过本句(p* 不可跳);链末=继续 | 跳过本句(自动链) | **再来一局**(新夜班) | 循环切换选项 | +| 双击 | 重听当前段 | **重听电文(原速);第 2 次=慢放 0.5×**(任何其他手势重置计数) | 同左(确认态保留) | 重听当前节点台词(含抉择提示) | 重听夜结算链 | 重听结局链 | **确认执行**(菜单约定覆盖重听) | +| 长按 | 引擎接管:旁白临时 1.7×(12 号 §2.2,叙事类不豁免) | **空闲=翻密码本(E1 豁免)**;语音在播=引擎快进 | 同左 | 引擎快进 | 引擎快进 | 引擎快进 | 引擎快进(菜单语音) | +| 长按松开 | 引擎接管:恢复 settings.speed | 同左 | 同左 | 同左 | 同左 | 同左 | 同左 | + +**豁免登记(必须显式,仅 1 条)**: + +| # | 豁免 | 理由(先例) | +|---|---|---| +| E1 | DECODE 空闲长按 = 翻密码本求提示(核心资源动词快捷入口) | 绑定条件=无语音在播(`!audio.isVoicePlaying()`),与长按快进(仅在播时生效)**时间互斥、零冲突**(detective E2 同款);摩尔斯为 sfx 域不触发快进;菜单「翻密码本」提供冗余入口(P4 容错) | + +- 其余全部符合宪法默认语义:单击=确认/继续(两段确认=确认的两击)、双击=重听(重听对象=电文)、左右滑=选择/移动、上滑=状态、下滑=菜单(00 号 §5.2 模态约定);菜单=单击循环+双击确认。 +- 继承 radio G5 跳过公平契约原文:**首播必完整**(摩尔斯首播不可跳、p* 不可跳、链末关键句保护、ASK 恢复首句锁)、**复播可即掐**(锁不随复播重新武装、链中段句可跳、复播滑动照常预缓存)。 +- 抉择 prompt(p2..p9)播放中单击不可跳(PROMPT_RE 门)+ beat 轻震(G4);左右滑缓存 `pendingChoice` 播完即生效(radio W2 契约)。 +- 无效手势零静默:所有「无效」格 error 震 + gd_invalid(onceKey 同局限次,F1.4 idle-only)。 +- **首播保护窗**:电文摩尔斯首播发生在 RX 链尾、DECODE 入口前(§3.2),期间一切玩法手势(左右滑/单击/双击)= beat 轻震(G4),不判无效、不惩罚——摩尔斯走 sfx 域本无跳句通道,「不可跳」由构造保证;首播完 400ms 内 guide.turn + dv*_ask 落地,浏览武装。 + +### 3.4 摩尔斯/语音双通道仲裁(abyss 声呐双通道 R1-R5 同型) + +- **通道定义**:摩尔斯通道 = morse.js 的 osc 排程(电文+候选)+ sfx 层(静电/电键);语音通道 = voice 层(你+北风+处长)。BGM 第三层背景(voice 播时引擎 duck)。 +- **R1 摩尔斯不叠语音**:摩尔斯排程只在 `!audio.isVoicePlaying()` 时起振;在播则挂队列表、voice onComplete/句间轮询补发(abyss sonarGate 同款)。候选预听链 = 候选摩尔斯 → 静默一拍(≥500ms)→ cand_* 词义句(给耳朵留对照时间)。 +- **R2 电文主导段**(首播/重听/慢放):前景=摩尔斯,BGM dip;voice 只在摩尔斯**结束后**接播(dv*_ask 固定在首播后)。 +- **R3 BGM 退位**:摩尔斯排程期间游戏层把 BGM 降 −6dB(abyss 远程脉冲垫先例),不消失(A2 无静默死局)。 +- **R4 危机拍优先**:T4 揭晓 danger 拍(step_halt 前置 2s 渐强 + danger 签名 + danger 震);当前 voice 句播完后危机链才插续(radio C5/abyss D2 同款)。 +- **R5 摩尔斯可重听**:DECODE 双击=重听电文(P1 关键线索可重听;第 2 次=慢放是本作对 R5 的加强)。 +- 声流账:任意时刻 ≤2 条前景流(voice 串行 1 + 摩尔斯/信息音 1),BGM/amb 为背景层——ASA ≤2 约束 ✓。 + +**引擎能力边界(对照 docs/web-engine-api.md)**: + +| 能力 | 引擎现状 | 方案 | +|---|---|---| +| 摩尔斯精确时值 | sfx 走文件(有损编码 ms 抖动) | **运行时 osc 模块**(§2.1;guide earcon 先例),三档速度一个参数 | +| morse 与 voice 互让 | ❌ sfx 总线独立无 duck | 游戏层 morseGate 空闲门(R1) | +| BGM 手动 dip | ❌ 引擎只对 voice duck | 游戏层 gain:摩尔斯期间 −6dB(R3) | +| voice 运行时滤波 | ❌ | **不使用**(detective 先例:区分度靠 speaker+instruct+语境;处长=当面训话不需滤波) | +| sfx 循环 | ❌ 即发即弃 | 游戏层 `buffer.loop=true`(amb_room/static_bed,radio `_ringSrc` 先例) | +| 变速不变调 | ✅ SoundTouch(**仅 voice**) | **摩尔斯不受 settings.speed/长按快进影响**(u 恒定)——边界登记:P1 2.2× 档语速下电文仍原速,慢放兜底;列入 live 真人耳测项 | + +## 4. 抉择树 + +### 4.1 全局骨架(四封电文共享主干,分歧局部化) + +``` +INTRO: b_open → b_rule → b_goal → b_chief ──> RX(1) +【T1 船期·教学】BAY 译词 ◆p1 + 报/压 ◆p2 ──> NIGHT(1) ──> RX(2) +【T2 名单】TOP 译词 ◆p3 + 暗处理/呈报 ◆p4(known/blind 差分)──> NIGHT(2) ──> RX(3) +【T3 交接】HAND 译词 ◆p5 + 亲自/留书 ◆p6 ──> NIGHT(3) ──> RX(4) +【T4 处置令·高潮】SPY 译词 ◆p7 + 处长试探 ◆p8(反喂/装傻,known/blind 差分)──> 签收 ◆p9 ──> ENDING +``` + +分支纪律:**每封电文内部分支、夜结算时收敛**;跨电文只传标量(expose、hints、4 个译文 flag、4 个抉择 flag),不生成组合节点。最坏路径与最好路径节点数同型。 + +### 4.2 分封结构(◆=抉择点,〔〕=条件差分) + +``` +【T1 船期·教学】 +RX(1): rx1_in → tune_seek → 【摩尔斯首播 BAY,不可跳】→ dv1_teach → dv1_teach2 → dv1_ask → 浏览 + ◆p1 译词(3 选 1,两段确认) + ├候选 BAY(对): dv1_right(success 震)──────────────────────┐ + └候选 DAY/RAY(错): dv1_wrong(即时真话归因)→ 回浏览无限重选 ┘(T1 恒收敛为 BAY) + RELAY: rev 由 dv1_right 承担 → ◆p2 + ├左 报北风: r1a_send → r1a_e(med_ok 预埋)──┐ + └右 压日志: r1b_hold → r1b_e(expose+1)────┴→ NIGHT(1): n1 → 〔跨档 exp_*〕→ RX(2) + +【T2 名单】 +RX(2): rx2_in → 首播 TOP → dv2_ask → 浏览 → ◆p3 译词 + ├TOP(对): rev_top「黑名单。头一个代号,叫码头」→ p4_known ──┐ + └TAP/TIP(错,不揭穿): rev_tap / rev_tip → p4_blind ─────────┤(expose 译错+1) + ◆p4: ├左 暗处理: r2a_act → r2a_e(expose+1;known=warn 码头 / blind=压下)┐ + └右 呈报: r2b_act → m2_chief → r2b_e(mateListed)────────────────┴→ NIGHT(2): n2 … + +【T3 交接】 +RX(3): rx3_in → 首播 HAND → dv3_ask → 浏览 → ◆p5 译词 + ├HAND(对): rev_hand → 〔p6 行动按「今夜」〕────────────┐ + └SAND/LAND(错): rev_sand / rev_land → 〔按误译时间行动〕┤(expose+1) + ◆p6: ├左 亲自去: r3a_go → r3a_e(expose+2)──────────────┤ + └右 留书暗号: r3b_act →〔t3==hand ? r3b_e_ok : r3b_e_no〕┴→ NIGHT(3): n3 … + +【T4 处置令·高潮】 +RX(4): rx4_in → 首播 SPY → dv4_ask → 浏览 → ◆p7 译词 + ├SPY(对): rev_spy(danger 拍:step_halt 前置 2s+签名+震)→ p8_known ──┐ + └SAY/SLY(错): rev_say / rev_sly → p8_blind ──────────────────────────┤(expose+1) + 处长试探: m4_chief_a → m4_chief_b → ◆p8 + ├左 反喂假电: r4a_feed(配 key_send)→ r4a_e(expose+2,feed=true)──┐ + └右 装傻: r4b_act → m4_chief_c → r4b_e ────────────────────────────────┴→ 签收 +【签收】g9_in → g9_knot → ◆p9〔t4==spy ? p9_known : p9_blind〕 + ├左 发最后一电: d9_send(key_send)→〔pre≤4 ? d9_send_e : d9_caught_e〕→ ENDING(e1|e3) + └右 关机: d9_sleep → d9_sleepe → ENDING(e2|e4) + +ENDING: e*_a → e*_b(按 expose 档 × p9 四选一)→ ep_lead → ep_med_* / ep_list_* / ep_hand_* / ep_spy_*(按 flags 选播) +``` + +### 4.3 分支局部化说明 + +- 跨电文状态全部是标量:`expose`(int)、`hints`(int 0-3)、译文 flag `t1..t4`(每封所选候选 id;T1 恒真词)、抉择 flag `p2..p9`(左/右)。无任何「电文 i × 电文 j」组合节点。 +- 条件差分全部是**节点内选播**(radio w1_say/w1_say2 先例):p4/p8/p9 的 known/blind 双提示(由 t2/t4 决定)、r3b_e_ok/no(由 t3 决定)、d9_send_e/caught_e(由 expose 档决定)、ep_* 8 句(由 4 组 flag 决定)。树形不变。 +- 树规模:139 句台词、约 75 个节点;任一路径恒走 9 个抉择点(4 译词 + p2/p4/p6/p8/p9)——比 radio/abyss 更规整(无跳点路径),操作面完全恒定(P4)。 + +### 4.4 四结局达成条件 + +| 结局 | 条件 | 主题 | 开环钩子 | +|---|---|---|---| +| e1 最后一电 | p9 前 expose ≤4 且 p9=左 | 警报发出、北风撤尽,你随挑水的队伍出城 | 「新的密码本,在城外渡口等我」 | +| e2 值完这个班 | p9 前 expose ≤4 且 p9=右 | 平安交班,一切照旧——但北风的呼叫没人接 | 「下一次呼叫,你接不接?」 | +| e3 孤灯 | p9 前 expose ≥5 且 p9=左 | 发报被测向,你拍了三遍警报,坐等门开;码头撤空 | 「审讯室的灯,也很亮」 | +| e4 长夜 | p9 前 expose ≥5 且 p9=右 | 两点五十九分,脚步停在门口;最后一页密码进了炉子 | 「耳机里还有一封译不完的电文」 | + +- 处置令的烈度随暴露档缩放(fiction 自洽):低暴露=处长没有足够把握(约谈/排查);高暴露=今夜三时收网。 +- 尾声回响(P2):ep_lead 后逐封点名——药品(接住/白过江)、名单(少了一个人/多了红圈)、交接(上船/白亮一夜)、处置令(译对/差三个字母)——玩家每一封的译与错都被点名。 + +## 5. 数值设计 + +### 5.1 变量 + +| 变量 | 类型 | 初值 | 说明 | +|---|---|---|---| +| `expose`(暴露度) | int | 0 | 步进隐藏资源:译错/危险抉择累积,只涨不跌;档位句播报(§5.3) | +| `hints`(密码本余页) | int | 3 | 翻一页 −1;T1 首次免费(不扣);全局不补 | +| `t1..t4`(译文) | str | null | 各封所选候选 id;T1 教学期恒收敛真词;t2/t3/t4 记玩家认知 | +| `p2/p4/p6/p8/p9` | 'L'/'R' | null | 叙事抉择;派生 med_ok/mateListed/handOk/feed 等 flag | +| `endings{}` / `runs` | map/int | — | 结局收集与重玩计数(存档展示用) | + +### 5.2 暴露度累积事件表(单源,账目可验算) + +| 事件 | 条件 | Δexpose | 叙事依据(真话) | +|---|---|---|---| +| T1 译错 | 教学期无限重选,恒收敛 | **0** | 学习期零惩罚(A5) | +| T2/T3/T4 译错 | 选非真词(无论后续左右) | **+1/封** | 你的译文进了纸面(值机日志/呈报/交接条),与对家底档不符 | +| p2 右·压日志 | T1 | +1 | 值机日志少一封,晨检对不上号 | +| p4 左·暗处理 | T2 | +1 | 卷宗缺页/抽屉藏报,暗手的痕迹 | +| p6 左·亲自去 | T3 | +2 | 值班室空岗一刻钟,日志停了一行 | +| p8 左·反喂 | T4 | +2 | 拍假电的机器记录抹不掉(哪怕手法伪装了) | +| p9 左·发报 | 终局 | +2(**结局判定用 p9 前值**) | 开机信号进了值机室的登记 | + +- 上限:3(译错)+1+1+2+2 = 9(p9 前);下限 0。全对全保守 = 0(calm 档),符合「一晚干净值机」。 +- 每次事件**不播数字**(隐藏资源);危机感由档位句+氛围音承载;状态链随时可查档位(radio 口碑同型)。 + +### 5.3 暴露度档位表(措辞逐值为真) + +| 档 | expose | 整句 | 真实性核验 | +|---|---|---|---| +| calm 平安 | 0 | 「这一晚,还没人看你第二眼。」 | 0 事件 ✓ | +| low 疑云 | 1-2 | 「处长端着茶,多看了你一眼。」 | 1-2 个纸面破绽=被注意 ✓ | +| mid 被盯 | 3-4 | 「走廊里的脚步声,慢了下来。」 | 3-4 个破绽=例行留意 ✓ | +| hot 危局 | ≥5 | 「门外,好像有人换了岗。」 | ≥5=行动级怀疑(与 p9 收网门 ≥5 一致)✓ | + +- 跨档才播(挂 NIGHT 链尾);上滑状态链随时可查;hot 档各播报位附 warning 震(abyss oxy_crit/G2 同款)。 +- **p9 收网门 = hot 档阈值 5**:档位句说的就是结局判定用的(radio rep_low/abyss floor(oxy/3) 教训内化——措辞与数值同一个源)。 + +### 5.4 密码本账(逐值整句) + +| 余页 | 整句 | +|---|---| +| 3 | 「密码本,还剩三页。」 | +| 2 | 「密码本,还剩两页。」 | +| 1 | 「密码本,只剩最后一页。」 | +| 0 | 「密码本,一页都不剩了。」 | + +### 5.5 暴露度与四结局验算(设计期穷举,playtest 剧本引用) + +设 `wrong` = T2/T3/T4 译错封数(0-3),`risk` = 危险抉择分(p2右1 + p4左1 + p6左2 + p8左2,0-6): + +- **p9 前 expose = wrong + risk** ∈ [0, 9];结局门:≤4 → e1/e2(p9 左/右);≥5 → e3/e4。 +- **剧本 A(全对全右,p9 左)**:wrong=0,risk=0 → expose=0(calm)→ p9_known 左 → d9_send_e → **e1**;ep 全 ok(med_ok/list_no/hand_ok/spy_ok——p4 右呈报=名单进卷宗,ep_list_no 是呈报的真实代价)。 +- **剧本 B(同 A,p9 右)**:expose=0 → **e2**(d9_sleep/d9_sleepe)。 +- **剧本 C(全错全左,p9 左)**:wrong=3,risk=1+2+2=5 → expose=8(hot)→ p9_blind 左 → d9_caught_e(danger 拍)→ **e3**;逐检查点:NIGHT(1) 后 0(p2 左不加分)→ calm;NIGHT(2) 后 2(wrong T2=1 + p4 左=1)→ low;NIGHT(3) 后 4(+wrong T3 +p6 左 2)→ mid;签收前 8(+wrong T4 +p8 左 2)→ hot。跨档播报 3 次。 +- **剧本 D(同 C,p9 右)**:expose=8 → **e4**。 +- **剧本 E(提示流 P4)**:T1 免费提示(hints 3 不动)+ T2/T3/T4 各 1 页(3→2→1→0,book_2/1/0 逐值播出)→ 提示揭示辨析位 ⟹ 候选唯一 → 全对;全右 → expose=0 → e1/e2 任选。**纯提示流可通关 ✓**。 +- **剧本 F(慢放流 P3)**:T2-T4 每封双击×2 慢放后译对,零提示 → 全对;expose=0。**纯慢放可通关 ✓**。 +- 边界带验算:wrong=2 + risk=2(p2右+p4左)= 4 → 仍 e1/e2(宽容:两处失误不毁局,P4);wrong=1 + risk=4 = 5 → e3/e4(风险路径必热)。 + +## 6. 音频三层映射 + +### 6.1 BGM(状态→曲目,Mureka instrumental,4 首 ≈ $0.12;共性后缀 10 号 §8 照 radio 追加) + +| 状态 | 曲目 | 切换 | Mureka prompt 草稿(英文,seamless loop) | +|---|---|---|---| +| INTRO/RX/DECODE/RELAY/MENU | `bgm/radio_room` | 开局淡入 800ms;MENU 低响度淡入 500ms;摩尔斯期间 −6dB(R3) | "1940s late night wireless station room, warm tube radio hum, distant muffled swing jazz through a wall, rain on window, clock tick, smoky and quiet, no vocals, seamless loop, 60-90 seconds" | +| RELAY(4) 起(T4 高潮至签收) | `bgm/closing` | crossfade 800ms 进 | "Tense noir waltz for a spy's last night, muted brushed drums, low strings, subtle telegraph tick rhythm, restrained dread, no vocals, seamless loop, 60-90 seconds" | +| NIGHT | `bgm/night` | crossfade 600ms 进;进下一 RX 淡回 600ms | "Quiet night interlude, sparse felt piano over warm vinyl static and soft rain, lonely but safe, no drums, no vocals, seamless loop, 60-90 seconds" | +| ENDING | `bgm/ending` | d9 结果链播完后淡入 1500ms | "Dawn after a long night of rain, warm strings and soft piano, exhausted hope, quiet resolution, no vocals, seamless loop, 60-90 seconds" | + +- `bgm=0` 时全部静默(引擎约定);menu 用同曲低响度(省 1 首,值班室同场景同构)。 + +### 6.2 SFX 清单(SA3=Stable Audio 3 / SYN=程序合成;one-shot 真峰值 ≤-3dBFS;amb 循环由游戏层 `buffer.loop` 实现) + +| id | 用途 | 来源 | prompt(英文)/ 合成参数 | +|---|---|---|---| +| `sfx/tune_seek` | 调谐找频(每封 RX 起) | SA3 | "Hand turning a vintage radio tuning dial, static sweeps, faint morse ghost, 3 seconds" | +| `sfx/static_bed` | 电台静电底噪 loop(RX/DECODE) | SA3 | "Shortwave radio static bed, soft crackle and hum, seamless loop" | +| `sfx/amb_room` | 值班室 amb loop(挂钟+雨+暖炉) | SA3 | "1940s office night ambience, wall clock tick, rain on window, faint stove hum, seamless loop" | +| `sfx/paper_page` | 密码本翻页(提示兑换/开局发本/页数用尽) | SA3 | "Thin paper page turn of a small codebook, dry, close" | +| `sfx/key_send` | 你的电键拍发(p8 左反喂 / p9 左发报) | SA3 | "Vintage brass telegraph key sending morse, spring clatter and clicks, 4 seconds" | +| `sfx/pen_scratch` | 处长记事笔(试探戏/呈报) | SA3 | "Fountain pen scratching on paper, short strokes, 2 seconds" | +| `sfx/tea_cup` | 茶杯落桌(处长场标记音) | SA3 | "Tea cup set down on wooden desk, porcelain clink, soft" | +| `sfx/step_halt` | 门外脚步停住(T4 danger 拍 / hot 档) | SA3 | "Footsteps in corridor stopping outside a door, heavy boots, tense, 4 seconds" | +| `sfx/lock_click` | 门锁/枪机(e3/e4 链) | SA3 | "Door lock slowly turning, metal click, ominous, 2 seconds" | +| `sfx/dawn_bell` | 黎明远钟(e1/e2 链) | SA3 | "Distant morning bell over light rain, calm after a long night, 4 seconds" | +| `sfx/ui_confirm` / `ui_menu` | 菜单/确认(程序合成兜底) | SYN | 同全局先例 | +| **(摩尔斯)** | 电文/候选/重听/慢放全部音 | **SYN 运行时模块** | §2.1 时值表;**零文件资产** | + +- 预警窗纪律:T4 danger 拍 step_halt **前置 2s 渐强**(L2),danger 签名与音效同窗(radio 刹车/abyss 滑坡先例);e3 链首 danger 签名一次。 +- 承诺绑定:rx4_in 教「快手七的手法你听过」后,p9 左 d9_send 与 p8 左 r4a_feed 必播 key_send(§7.5 清单+断言)。 + +### 6.3 Voice 划分(详见 19-cipher-voice-ids.md) + +| 组 | 声部 | 落位 | 数量 | +|---|---|---|---| +| 报务员(你)OP | 双性别(female=Serena / male=Uncle_Fu + OP instruct 常量) | `voice/female\|male/` | 20 句 | +| 北风电台(上线联络+系统/向导)WIND | 双性别(female=Vivian / male=Dylan + WIND instruct 常量) | `voice/female\|male/` | 114 句 | +| 处长 NPC | 平铺单音色(Eric),**不做双性别** | `voice/` 根(平铺回退命中) | 5 句 | + +- 人物账:有台词人物 = 你 + 北风(上线联络人,向导人设 L4,radio 导播同型)+ 处长(明线上级)——**上级/联络人 ≤2 约束满足**;另有 1 个**无台词角色**「快手七」(对家报务手,存在=电波里的手法音,abyss 小郑先例)。 +- 合计 139 句(≤150 预算 ✓,含 `_k` 键盘变体 11 对);音频文件 = 134×2 + 5 = 273 条(×2 格式 = 546 文件)。 + +## 7. 向导层接入 + +### 7.1 状态签名 earcon / turn cue + +| 时机 | 向导动作 | 说明 | +|---|---|---| +| INTRO 进入 | `guide.state('explore')` | 叙事态签名 | +| DECODE 进入(首播后) | `guide.state('choice')` + `guide.turn({haptic:true})` | 摩尔斯首播完 → turn 签名+confirm 震=「轮到你选词」;dv*_ask 随后落地 | +| 每个叙事抉择 p* 起播 | `guide.state('choice')` | 选项句本身即 turn cue(W2 契约零插播) | +| NIGHT 进入 | `guide.state('settle')` | 夜结算签名 | +| ENDING 进入 | `guide.state('settle')` | 同上 | +| MENU 打开 | `guide.state('menu')` | 菜单签名 | +| T4 揭晓 danger 拍 / e3 链首 | `guide.state('danger')` | 全游戏仅两处(step_halt 前置 2s 同窗 + danger 震) | +| 无效手势 | `guide.error({voice:'gd_invalid', onceKey})` | error 震 + full 档语音,idle-only(F1.4) | + +- earcon 词表:只用全项目统一 8 音中的 7 个(menu/explore/choice/settle/danger/turn/nudge),**零新增**;battle 不用。 + +### 7.2 看门狗双轨(radio F1 + dungeon E0-1 教训:乱按也要被抢救) + +**无操作轨**(guide watchdog,任何手势复位;两级不同句、用尽即停、永不惩罚): + +| 等待点 | full 档 | standard 档 | 级别内容 | +|---|---|---|---| +| DECODE 浏览无操作 | [10, 25] | [10, 25](onEscalate 游戏层补播) | ① gd_tune1「电文在等。左右滑,听候选词。」② gd_tune2「双击,把电文再听一遍。再双击,会放慢。」(**必须不同句**) | +| INTRO/RELAY 链末等待 | [25, 50] | [13, 30] | ① gd_continue ② gd_again(storyline F1.1 同款) | +| 抉择点 p* 播完无操作 | [25, 50] | [25, 50] | 重播当前 p* 提示句(13 号 §16 先例;空点单击=重挂计时) | +| ENDING 结算等待 | [25, 50] | [13, 30] | gd_continue / gd_again | + +**无进展轨**(游戏层计时,仅挂 DECODE 浏览一个可饿死点): + +| 项 | 规则 | +|---|---| +| 进展定义 | 译词判定落定 / 叙事抉择结算 / 节点推进 / 翻密码本。**无效手势与纯浏览(切候选/重听)不计进展**——乱滑 96s 也必被抢救 | +| 阈值 | 60s → gd_prog1「光切词没有用。双击,先听电文。」;120s → gd_prog2「别急。停下来,把电文听完整。」;用尽即停(每封一轮) | +| 有真实进展 | 全程零 prog 播出(不误伤) | +| 双轨去重 | 两轨短句内容同级交叉登记;同一等待点先播过 gd_tune* 则无进展轨跳过同义级 | + +- 向导插播一律 `queue:true`,绝不顶掉在播对白;error/watchdog 类语音 idle-only(在播则跳过且过期丢弃,F1.4)。 +- 单句语音看门狗(资产缺失兜底):30s 超时且 `isVoicePlaying()===false` 才自动续链,长句续约不掐(radio N1 先例照抄)。 + +### 7.3 键盘变体机制(PC 键盘玩家) + +- 运行时按 `gesture.inputMode()`(settings.input auto/touch/key)选 `_k` 变体资产:key → `voice//_k.*`,touch → 无后缀版;auto 跟随最近使用通道。`_k` 缺失回退无后缀版(check_all_assets 校验配对)。 +- 变体清单(11 对,集中分组见 voice-ids §G0.a + M1):gd_brief / gd_continue / gd_again / gd_tune1 / gd_tune2 / sys_help / sys_menu / sys_skip / sys_continue_ask / **dv1_teach2**(首个候选导航绑定)/ **p2**(首个叙事抉择绑定)。 +- 抉择提示 p4..p9 措辞统一「向左滑/向右滑」;键盘玩家由 p2_k 与 sys_help_k 完成首次绑定,后续语义不变。启动遮罩文案引擎自理,游戏零改动。 + +### 7.4 继承 radio/abyss 三轮验证机制(照抄不重设计) + +| 机制 | 来源 | 落点 | +|---|---|---| +| 帮助链去重(同等待点第 2 次上滑只震+nudge) | radio G1 | `_helpSeen` 按等待点键控 | +| 状态链顺序「状态在前、帮助在后」 | radio G6 | prog_t → book 档 → exp 档 → sys_help | +| 空点确认轻震(不可跳上单击补 beat [8]) | radio G4 | p*/摩尔斯首播/链末句各 tap 屏蔽分支 | +| exp_hot 播报位附 warning 震 | radio G2 / abyss | 全部 hot 出口(含上滑复听) | +| 菜单报名游戏侧排队零截断 | radio 实现修复 #3 | `_menuQueue` 排水 | +| stop() 先 `seqToken++` 作废在途链 + 停摩尔斯排程 | radio 实现修复 #6 | 同款(摩尔斯句柄一并 stop) | +| 新局清 choiceArmed/pendingChoice/双击慢放计数 | radio 实现修复 #7 | newNight() | +| 长按误报无效(longpress 全状态合法) | radio 实现修复 #2 | 仅 left/right(ASK 另含 up/down/doubletap)报无效 | + +### 7.5 教学承诺清单(dungeon「铁器=楼梯」教训:每句承诺必须真实存在且可感知) + +| 教学句 | 承诺 | 兑现点(断言锁定) | +|---|---|---| +| dv1_teach「先双击,把它重新听一遍」 | 双击=重听电文 | DECODE 全程恒可用(原速) | +| gd_tune2/dv1_teach「再双击,会放慢」 | 第 2 次双击=慢放 0.5× | 慢放计数器任何封恒在;时值断言 u=160 | +| dv1_teach2「左右滑,听候选词。单击,选定一个」 | 候选预览/两段确认 | 全部 DECODE 恒同语义(含确认态取消) | +| hint_t1「我教你一页:第一个字母,是 B」 | T1 提示免费 + 提示给辨析位字母 | T1 翻页不扣;hint_* 与 §2.2 辨析位一致 | +| b_rule「规矩就一条:听不清,就再听一遍」 | 重听/慢放无限免费 | 全局无重听惩罚;慢放不限次 | +| b_goal「今晚频道里有四封密电」 | 恒 4 封 | 剧本 A 恰 4 封走完(断言) | +| b_chief「四封电文,一封都不许漏」 | 同上(明线口径) | 同一计数 | +| rx4_in「快手七的手法你听过」→r4a_feed「借快手七的手法」 | 提到发报必闻电键声 | p8 左/p9 左必播 key_send(零漏播断言) | +| dv1_right「练习有答案」(正片无对错反馈) | 教学期即时反馈、正片不揭穿 | T1 判定出口有反馈句;T2-T4 判定出口零对错语音(断言) | + +## 8. 存档设计 + +### 8.1 进度 data 结构(PUT /api/progress {game:'cipher'}) + +```json +{ + "schema": 1, + "updatedAt": "2026-09-09T12:00:00Z", + "summary": "第2封·名单 · 暴露:疑云 · 结局1/4", + "phase": "DECODE", + "node": "dv2_ask", + "tel": 2, + "expose": 2, + "hints": 2, + "t": { "t1": "bay", "t2": null, "t3": null, "t4": null }, + "picks": { "p2": "L", "p4": null, "p6": null, "p8": null, "p9": null }, + "endings": { "e2": true }, + "runs": 3 +} +``` + +- 写入时机:每封 RX 进入、译词判定落定后、每个抉择结算后、NIGHT 进入、ENDING 进入、菜单「保存并回大厅」。 +- 断点续玩:读档 → sys_continue_ask(_k)(单击=继续上次 / 右滑=新夜班;两者 runs 均 +1,统一口径修 radio 遗留);继续 = 恢复 phase+node+全部数值并**局面复述**:RELAY 恢复=重播当前节点链;**DECODE 恢复=prog_t → book 档 → gd_tune2 → 重挂浏览(自动预听当前候选,电文可双击重听)**——一句话 ≤5s 回到可玩(L5);恢复首句受 F4 跳句锁(radio G5 例外条款)。 +- summary 格式:进行中 `第N封·<电文名> · 暴露:<档名:平安/疑云/被盯/危局> · 结局X/4`;已收台 `已收台·<结局名> · 结局X/4`(大厅卡片直接显示,人话)。 + +## 9. playtest 断言清单(登记 13 号 §22 指针;`settings.set({guide:'off'})` 纯玩法验证,向导行为住 guide.test) + +**关键路径**(剧本 A:全对全右+p9 左 → e1,≤60 次手势): +1. 遮罩点按 → INTRO 链(b_open 起,voice 层有播放记录)→ rx1_in → tune_seek → **摩尔斯固定字符断言**:事件序列 == BAY 编码(`dah,dit,dit,dit / dit,dah / dit,dah,dah,dah`),750Hz,u=80±5ms,BGM 同时段 −6dB(R3)→ dv1_teach → dv1_teach2 → dv1_ask → choiceArmed。 +2. DECODE 浏览:右滑 ×3 循环切换,每次预听 = 候选摩尔斯(620Hz,u=110,与 voice **零重叠**——morseGate 断言)→ cand_* 词义句;双击 → 电文原速重播;再双击 → 慢放(u=160,字母隙 6u,750Hz);单击 → cf_confirm + 电文重播 → 单击 → 判定出口 800ms 死窗内注入在途 tap 被吞。 +3. T1 译对:dv1_right(success 震)→ RELAY:p2 左(pendingChoice 播完生效)→ r1a_send/r1a_e → n1 → NIGHT(expose=0 calm 档,零跨档播报)→ RX(2)。 +4. T2:摩尔斯==TOP 断言;译对 → rev_top → **p4_known** 播出(t2=='top')→ 右 → r2b_act → m2_chief → r2b_e → n2。 +5. T3:摩尔斯==HAND;译对 → rev_hand → p6 右 → r3b_e_ok(t3=='hand')→ n3。 +6. T4:摩尔斯==SPY;译对 → rev_spy(danger 拍断言:step_halt 前置 2s 渐强 + danger 签名 + danger 震)→ m4_chief_a/b → **p8_known** → 右 → r4b_act → m4_chief_c → r4b_e。 +7. 签收:g9_in → g9_knot → **p9_known** 左 → d9_send + key_send → pre=0 ≤4 → d9_send_e → ENDING(e1):e1_a/b + ep_lead + ep_med_ok/ep_list_no/ep_hand_ok/ep_spy_ok → 结算单击 → 新夜班回 INTRO(runs+1)。 + +**边界断言**: +- **四结局可达(含暴露度逐点验算)**:剧本 B(同 A,p9 右)→ e2;剧本 C(T2/T3/T4 全译错 + p4/p6/p8 全左):NIGHT(1) 后 expose=0(calm)/NIGHT(2) 后 2(low,跨档播 exp_low)/NIGHT(3) 后 4(mid)/签收前 8(hot,exp_hot+warning 震)→ p9_blind 左 → d9_caught_e → e3;剧本 D(同 C,p9 右)→ e4(lock_click 链)。各检查点档位句与 §5.3 区间一致。 +- **三层兜底各自可走通**:剧本 E(提示流)——T1 翻页免费(hints 仍 3),T2/T3/T4 各翻 1 页(book_2/1/0 逐值播出,逐页 −1 断言),按提示选定全对 → e1;第 4 封后页数 0 再长按 → error 震 + paper_page,无语音、零惩罚;剧本 F(慢放流)——每封双击×2 慢放后译对,零提示,全对(慢放不限次断言)。 +- **摩尔斯正确性**:四封电文事件序列逐元素 == §2.2 表(BAY/TOP/HAND/SPY);候选预听 == 所选候选编码;慢放时值 = 原速 2×(容差 ±5ms);**settings.speed=2.2 会话下 u 仍 80ms**(速度边界断言)。 +- **译错归因真话**:T1 错选 DAY/RAY → dv1_wrong 播出且句内字母/码与真词 B 一致 → 无限重选直至对(expose 零变化);剧本 C/D 的 ep_spy_no/ep_hand_no 与 t3/t4 flags 一致;正片判定出口零对错语音(教学承诺)。 +- **morseGate**:全剧本转写断言——摩尔斯起振时刻与任一 voice 播放区间零重叠(排队语义:晚于在播句结束);候选词义句在候选摩尔斯结束后 ≥500ms 接播。 +- **看门狗双轨**:DECODE 快进 10s→gd_tune1、25s→gd_tune2(两级 id ≠,绝不逐字重播;两级后仅静电底噪);**乱按 96s**(每 2s 注入一次切候选滑动+双击重听):无操作轨零播出(被手势复位)+ 无进展轨 60s→gd_prog1、120s→gd_prog2;有真实进展(译词落定推进)全程零 prog 播出;RELAY 链末 25s/50s → gd_continue/gd_again;p* 播完 25s/50s 重播 p* 两次后静默;`guide=off` 全程零定时器零向导事件。 +- **键盘变体**:settings.input='key' 会话播出 gd_brief_k/sys_help_k/gd_tune1_k/dv1_teach2_k/p2_k 且对应无后缀版零播出;'touch' 反之;'auto':keydown 后下一句用 _k。 +- **中断复述**:杀进程重进(存档 phase=DECODE, tel=3, hints=1)→ sys_continue_ask → 单击 → 恢复链 prog_t3 → book_1 → gd_tune2 → 浏览重挂 + 当前候选自动预听(电文双击可重听);恢复首句不可跳,第二句起可跳;右滑=新夜班(runs +1 口径同结算重开)。 +- **pendingChoice**:p* 播放中左右滑 → 播完立即生效(不丢、不重复、连滑取最后一次)。 +- **p* 不可跳**:p* 播放中单击 → 不跳句(PROMPT_RE 门)+ beat 轻震(G4)。 +- **idle-only**:向导 gd_* 在对白在播时跳过且不占 onceKey(F1.4);queue 句排在当前句后零截断。 +- **菜单**:下滑开菜单 → 上滑帮助 → 双击「回到电波」→ phase/node 不变、原状态复述;单击循环顺序 = [回到电波, 翻密码本, 重新开始今晚, 保存并回大厅];菜单「翻密码本」确认 = 长按同效(回 DECODE 执行提示流程);确认回大厅 → sys_menu_lobby 播出且 PUT/localStorage 有最新 data。 +- **资产缺失**:屏蔽任意 voice 资产 → 30s 看门狗自动续链到结局不卡死;摩尔斯为运行时合成,屏蔽 sfx/bgm 全部不影响电文可听(模块零依赖断言)。 +- **数字规范**:prog_*/book_*/exp_* 全部整句字面量,静态审计无运行时拼接碎片。 + +**失败即不通过**:任何等待 >5s 无声(静默死局);无效手势零反馈;摩尔斯字符序列/时值与 §2.2 表不符;摩尔斯与语音重叠;结局计数与 §4.4 不符;档位句与 expose 实值不符;归因假话(dv1_wrong 字母错、ep_* 与 flags 不符)。 + +## 10. 节奏曲线(每封电文目标时长) + +| 段 | 内容 | 目标时长 | 设计目标 | +|---|---|---|---| +| 开场 | 夜班开场 + 一句规矩 + 处长夜令 | 1-1.5 min | 首输入 <60s(INTRO 链 ~25s 后第一封电文首播,浏览滑动即首输入 ~t+45s);规矩一句讲完(P4) | +| T1 船期 | 教学封:两层重听教学 + 免费提示示范 + p2 | 3.5-4 min | **首个正反馈 ≤3min**(译对 dv1_right ≈ t+2min);译错即时归因+无限重选零惩罚 | +| T2 名单 | known/blind 差分首现 | 3-3.5 min | 「你不知道自己译没译对」的第一次心跳;呼吸口(听辨最易) | +| T3 交接 | 最狠听辨(H vs S 差一个 dit) | 3.5-4 min | 慢放价值兑现段(P3 嘈杂场景实测点);NIGHT(3) 档位句把压力可视化 | +| T4 处置令 | 高潮:danger 拍 + 处长试探 + 反喂 | 4-4.5 min | 紧张峰值但无倒计时(P4);bgm/closing 交叉淡入 | +| 签收+结局 | 心结 p9 + 发最后一电 + ep 点名 | 2.5-3 min | 整晚的「听」在你自己的电键上闭环(P2 回响);开环钩子拉下一局 | + +- 合计 ≈ 19-22 min(15-25 达标);每 30-60s 一个反馈颗粒(候选预听/摩尔斯/揭晓句/结果句/档位句),无沉默尴尬期(P3)。 +- 难度曲线 = 听辨精度(教学 B/D → 最易 O/A/I → 最狠 H/S → 中高 P/L)+ 认知重量(练手 → 名单有同伴 → 时间就是命 → 处置令是你),操作复杂度恒定(左右滑+单击+双击,永远如此)——P4「规则一遍听懂」由恒定操作面保证。 + +## 11. 自检清单(策划交付自验;☐ = 待开发/测试轮填) + +| # | 硬约束/教训 | 落地 | ✓ | +|---|---|---|---| +| 1 | FTUE 首输入 <60s | INTRO 链 ~25s 后第一封首播,~t+45s 首次滑动(§10) | ☑ | +| 2 | FTUE 首个正反馈 ≤3min | 译对 dv1_right ≈ t+2min(§10 T1) | ☑ | +| 3 | 学习期零惩罚 | T1 译错无限重选零资源;DECODE 无超时惩罚;密码本 T1 免费 | ☑ | +| 4 | 失败三问归因+归因真话律 | dv1_wrong 字母真话;正片不揭穿+ep_* 单源对应;§2.5 无假归因 | ☑ | +| 5 | 资源账目真实(radio rep_low 教训) | book_1..0 逐值整句;exp 四档措辞逐值为真;档位阈值=收网门(§5.3/5.4) | ☑ | +| 6 | 手势宪法 6 手势默认语义,豁免显式登记 | §3.3 全覆盖;唯一豁免 E1(长按=密码本,时间互斥 detective E2 同款);菜单 §5.2 模态 | ☑ | +| 7 | 长按显式登记 | E1 在案(空闲=提示/在播=引擎快进,互斥);其余长按=快进不豁免 | ☑ | +| 8 | 向导:签名/turn cue/看门狗两级不同句 | §7.1/7.2;gd_tune1≠gd_tune2;gd_continue→gd_again | ☑ | +| 9 | 看门狗双轨(dungeon E0-1/radio F1 教训) | 无操作轨+无进展轨;纯浏览不计进展,乱按 96s 必被抢救;有进展零打扰 | ☑ | +| 10 | 插播 queue 不顶对白;纠错 idle-only 过期丢弃 | §7.2(F1.4 契约) | ☑ | +| 11 | 死亡/失败结算链完整(dungeon H1 教训) | 判定出口/NIGHT/d9/e* 链 800ms 手势死窗;seqToken+停摩尔斯(§3.2/7.4) | ☑ | +| 12 | 教学承诺兑现(dungeon「铁器=楼梯」教训) | §7.5 九条承诺清单逐条绑兑现点+断言 | ☑ | +| 13 | 单次播报 ≤3 选项 | 译词恒 3 候选逐项预听(≤3 ✓);叙事抉择恒 2;菜单 4 项循环 | ☑ | +| 14 | 并发声流 ≤2 + 双通道仲裁 | voice 串行+摩尔斯互斥(R1-R5,§3.4);断言零重叠 | ☑ | +| 15 | earcon ≤7、统一词表、语义靠语音 | 只用统一 8 音中 7 个(battle 不用),零新增 | ☑ | +| 16 | 台词一句一文件 ≤40 字、数字分档整句、无运行时拼接 | voice-ids 全表 ≤30 字(最长 p8_known);prog/book/exp 分档整句;句内数字汉字写死 | ☑ | +| 17 | 多音字规避/同 instruct 常量/无语速词 | voice-ids 头部+文末自查(长/处/圈/卷/塞/着 试听项) | ☑ | +| 18 | PC 键盘变体(提示句集中+标注) | 11 对 _k 集中分组(voice-ids §G0.a+M1);inputMode() 运行时选择 | ☑ | +| 19 | P1:可打断/重听(含电文)/status key/不当白痴 | 跳句+双击(电文重听+慢放 R5)+上滑状态链+成功零补救教学 | ☑ | +| 20 | P2:3 分钟见效/结局回响 | ~2min 首个「世界因你而动」(药船接应);ep 逐封点名+真词差分 | ☑ | +| 21 | P3:3 分钟颗粒/中断复述/单手/嘈杂 | 一封=3-4min 完整段落+边界存档;重进一句话复述(含 DECODE 态);慢放无限=嘈杂兜底 | ☑ | +| 22 | P4:一遍懂/容错/无高压倒计时/不骂人 | 一句规矩;无倒计时无死亡;译错也是剧情;操作面恒定;NPC 句 ≤23 字 | ☑ | +| 23 | 抉择树 4 结局可走通验算 | §5.5 公式+剧本 A-D 逐检查点(e1/e2: wrong+risk≤4;e3/e4: ≥5) | ☑ | +| 24 | 单局 15-25 min | §10 曲线 ≈19-22 min | ☑ | +| 25 | 分支不爆炸(radio 纪律) | 跨封仅标量(expose/hints/t1..t4/picks);~75 节点固定深度(§4.3) | ☑ | +| 26 | 台词 ≤150、上级/联络人 ≤2 | 139 句(双 134+NPC 5)/处长+北风两人(voice-ids 账目) | ☑ | +| 27 | 引擎边界诚实 | §3.4 能力表:摩尔斯文件方案否决理由/滤波不使用/速度边界写明 | ☑ | +| 28 | 存档带 summary、断点续玩 | §8:data+summary 格式、DECODE 态恢复、局面复述 | ☑ | + +### 批 1 教训内化确认(5 条全落地) + +| 教训 | 本作机制 | 断言 | +|---|---|---| +| 教学承诺必须兑现 | §7.5 九条承诺清单(重听/慢放/候选/单击/免费页/四封/电键声/教学反馈边界) | §9「摩尔斯正确性」「教学承诺」块 | +| 看门狗双轨 | §7.2 无操作轨+无进展轨;纯浏览不复位进展轨 | §9「看门狗双轨」块 | +| 档位措辞真实 | §5.3 exp 四档逐值为真且阈值=收网门;§5.4 book 逐值 | §9「档位句与实值一致」 | +| 失败结算链完整不被打断 | 800ms 死窗盖判定出口/NIGHT/d9/e*;seqToken+停摩尔斯 | §9 关键路径 2 + 死窗断言 | +| PC 键盘操作提示 | 11 对 _k+inputMode() 选择+集中分组标注 | §9「键盘变体」块 | + +### 本作特有风险预防(摩尔斯听觉门槛) + +| 风险 | 三层防线 | 验证 | +|---|---|---| +| P3 通勤嘈杂听不清长短音 | ①双击重听原速(无限)②再双击慢放 0.5×(无限、字母隙加倍)③密码本提示直译辨析位字母(3 页,T1 免费)——**三层均可独立走通** | §9 剧本 E/F + 三层兜底断言 | +| P1 视障玩家音频编码接受度高,但语速档位边界 | 摩尔斯不受 settings.speed 影响(u 恒定)——2.2× 档语速下电文仍原速,慢放兜底;**边界登记 + live 真人耳测项**(转写层测不了时值听感,methodology §5 2026-09 教训:媒体播放类必须真人耳测+引擎级断言) | §9「摩尔斯正确性」速度边界断言 + live 轮清单 | +| 摩尔斯时值精度 | 运行时 osc 合成(文件方案否决:有损编码 ms 抖动);事件钩子逐元素断言 | §9 固定字符断言 | +| 「不知道译对没译错」的认知焦虑 | 教学期说破「练习有答案,正片靠耳朵」;档位句+ep 回响给最终真相;译错零惩罚只是分支 | §9「译错归因真话」 | diff --git a/docs/design/README.md b/docs/design/README.md index ccee906f..3237787a 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -15,6 +15,19 @@ | [04-space-rebuild.md](04-space-rebuild.md) + [04-space-voice-ids.md](04-space-voice-ids.md) | 《盲航》重构:交互式演练、同向闪避、60 秒保护期、52 句台词表 | ⑨ | | [05-rpg.md](05-rpg.md) + [05-rpg-chapter1.md](05-rpg-chapter1.md) + [05-rpg-ch1-voice-ids.md](05-rpg-ch1-voice-ids.md) + [05-rpg-addendum.md](05-rpg-addendum.md) | 新 RPG《剑与声》:总设计 + 第一章施工稿(场景图/战斗状态机/数值/存档/资产 prompt)+ 120 句台词表 + 增补(战斗震动配合、无声前摇协议、层叠规则) | ⑩ | +## new8 系列设计文档(8 款新 RPG,research/new8/00-program.md) + +| 文档 | 内容 | +|---|---| +| [15-radio.md](15-radio.md) + voice-ids | 《不眠频率》深夜电台热线叙事(批次 1 交付,4/4 人设满分档):9 抉择 4 结局、口碑经济、电话滤波资产侧带通 | +| [16-dungeon.md](16-dungeon.md) + voice-ids | 《回声地窟》听声辨位 Roguelike(批次 1 交付,0/4→4/4 翻案):脉冲回声、seed 生成、双轨看门狗、元进度遗产 | +| [17-abyss.md](17-abyss.md) + voice-ids | 《深海回音》深海救援叙事(批次 2):声呐双通道仲裁、氧气软失败经济(512 组合穷举验算)、148 句 | +| [18-detective.md](18-detective.md) + voice-ids | 《雨夜专案》本格推理(批次 2):证词矛盾=数据单源推导、评级 S-C、二周目变体案、158 句 | +| [19-cipher.md](19-cipher.md) + voice-ids | 《暗码》1940 密战叙事(批次 3):摩尔斯运行时合成解码、暴露度经济、三层兜底、139 句 | +| 20~22 | 《深夜茶馆》《守塔人》《星环舰长》(批次 3/4,设计进行中,落地后补登记) | + +各 voice-ids 文档的「键盘变体」小节由 KEY_LINES 程序化生成(tools/gen_voice_dual.py 单源),含 PC 键盘措辞(按空格/按回车/按方向键)。 + ## 审查档案(各轮落实度与裁定记录) | 文档 | 内容 | diff --git a/docs/design/storyline-voice-ids.md b/docs/design/storyline-voice-ids.md new file mode 100644 index 00000000..a44a880b --- /dev/null +++ b/docs/design/storyline-voice-ids.md @@ -0,0 +1,44 @@ +# storyline-voice-ids · 《声命线》语音台词表(voice ids) + +> 主台词表(41 句旁白/系统音,双性别)的唯一事实源是 `tools/gen_voice_dual.py` +> 的 `STORYLINE_LINES`(角色台词为平铺单音色,沿用 `web/storyline/audio/voice/` +> 原文件,不在双性别管线内;主表史源 app/src/storyline/gen/gen_voice.py)。 +> 本文档登记 P1 键盘变体(输入平权路线 a)——变体只覆盖含操作词的句子, +> id 集合 ⊆ 主表,复用主表同 id 的 instruct/speaker。 +--- + +## 键盘变体(key,P1 输入平权路线 a,19 句) + +「生效输入模式=键盘」(settings.input=key,或 auto 且键盘在跟/纯桌面初始判定, +web/engine/gesture.js inputMode())时,引擎 voice 候选链前置加载 +`web/storyline/audio/voice//key/.{ogg,m4a}`,变体缺失回退主资产——触屏措辞零改动。 +> +> 措辞规范(02-input-parity.md §4.3 路线 a + P1 裁定):向左滑→按左方向键(四向同理, +> WASD 不进台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格; +> 复合短语(左右滑→左右方向键、滑动→方向键)按语义等价展开;只改操作词,其余 +> 内容一字不动,不新增「重」类多音字。本表与 `tools/gen_voice_dual.py` 的 +> `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> 资产:×2 性别 = 19×2 条、×2 格式 = 19×4 文件;管线 `gen_voice_dual.py --only-variants` +> 生成 → `--convert` 落盘,终检 `tools/check_all_assets.py`(key 两性别一致且 ⊆ 主集)。 + +| id | 键盘台词全文(触屏原句见本表上方对应 id) | +|---|---| +| `help` | 操作说明。按空格:继续剧情。按回车:重听刚才的台词。按上方向键:听这段说明和当前进度。按下方向键:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,按左方向键或者按右方向键,做出你的决定。选择无法撤销。 | +| `menu` | 菜单。按左方向键:重新开始整个故事。按右方向键,或者按空格:回到故事。按上方向键:听操作说明。 | +| `p1` | 请选择。按左方向键:先检查背包里的收音机。按右方向键:先摸黑寻找出口。 | +| `p2` | 请选择。按左方向键:钻进旁边的通风管道。按右方向键:躺进路边的尸体堆里装死。 | +| `p3` | 请选择。按左方向键:循着哭声过去看看。按右方向键:不去管它,直接去北区。 | +| `p4` | 请选择。按左方向键:带上小满一起走。按右方向键:留下食物,独自上路。 | +| `p5` | 请选择。按左方向键:走桥下的污水涵洞。按右方向键:爬桥上悬着的钢索。 | +| `p6` | 请选择。按左方向键:交出坐标,保命要紧。按右方向键:拒绝他,想办法周旋。 | +| `p7` | 请选择。按左方向键:追上去,警告避难所。按右方向键:自己逃命,越远越好。 | +| `p8` | 请选择。按左方向键:用公开频道广播,让全城都听见。按右方向键:用加密频段,只发给避难所。 | +| `n00_intro` | 大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:按空格,继续剧情;按回车,重听上一句;按上方向键,听你的状态和帮助;按下方向键,呼出菜单。当我说“请选择”的时候,按左方向键,或者按右方向键,做出你的决定。选择一旦做出,就无法回头。现在,按空格,睁开眼睛。 | +| `e1_dawn` | 加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。按空格,可以回到故事开始的地方。 | +| `e2_price` | 你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。按空格,重新开始。 | +| `e3_alone` | 你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。按空格,重新开始。 | +| `e4_static` | 公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。 | +| `skip_hint` | 按空格可跳过旁白。 | +| `gd_continue` | 按空格,继续。 | +| `gd_brief` | 按空格继续剧情。请选择的时候,按左右方向键。 | +| `gd_again` | 还在吗?按空格继续。 | diff --git a/tools/check_all_assets.py b/tools/check_all_assets.py index ab3a92a8..3f1146c0 100644 --- a/tools/check_all_assets.py +++ b/tools/check_all_assets.py @@ -5,7 +5,10 @@ 检查 8 个游戏 web//audio/: 1. 每个 .ogg 都有同名 .m4a(反之亦然) 2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外) - 3. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0 + 3. 键盘变体目录 voice//key/(P1 路线 a): + male/female 两性别 key 集合一致;key 集合 ⊆ 主(性别目录)集合——变体只 + 覆盖含操作词的句子,其余 id 由引擎回退主资产 + 4. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0 输出 Markdown 报告到 stdout(重定向进 docs/audio/final-asset-check.md)。 """ import os @@ -27,8 +30,8 @@ def ids(d, ext): def main(): problems = [] print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,2026-09-01)\n") - print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 |") - print("|---|---|---|---|---|---|") + print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 | key male/female 一致且 ⊆ 主集 |") + print("|---|---|---|---|---|---|---|") for g in GAMES: base = os.path.join(ROOT, g, "audio") oggs, m4as, zero, bad = [], [], [], [] @@ -72,17 +75,30 @@ def main(): problems.append(f"{g}: voice 缺 female/{i}") for i in sorted(vf - vm): problems.append(f"{g}: voice 缺 male/{i}") + # 键盘变体目录(P1 路线 a):两性别 key 集合一致 + key ⊆ 主集合 + km = ids(os.path.join(base, "voice", "male", "key"), ".ogg") + kf = ids(os.path.join(base, "voice", "female", "key"), ".ogg") + if km == kf and km <= vm: + kpair = f"✅ 各 {len(km)} ⊆ 主集" if km else "—(无变体目录)" + else: + kpair = f"❌ male={len(km)} female={len(kf)}" + for i in sorted(km - kf): + problems.append(f"{g}: key 变体缺 female/key/{i}") + for i in sorted(kf - km): + problems.append(f"{g}: key 变体缺 male/key/{i}") + for i in sorted((km | kf) - (vm | vf)): + problems.append(f"{g}: key 变体 {i} 不在主集合(key 必须 ⊆ 主集)") flat = ids(os.path.join(base, "voice"), ".ogg") flat_note = f"(平铺 {len(flat)} 个角色/NPC 句,豁免)" if g in FLAT_OK and flat else \ (f"(⚠️ 非豁免游戏存在平铺 {len(flat)} 个)" if flat and g not in FLAT_OK else "") - print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} |") + print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} | {kpair} |") print() if problems: print(f"## 问题清单({len(problems)} 条)\n") for p in problems: print(f"- {p}") return 1 - print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg/radio 平铺角色句按约定豁免)。") + print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg/radio 平铺角色句按约定豁免)、键盘变体目录两性别一致且 ⊆ 主集合。") return 0 diff --git a/tools/extract_voice_text.py b/tools/extract_voice_text.py index 69bd2082..cc7d5b1a 100644 --- a/tools/extract_voice_text.py +++ b/tools/extract_voice_text.py @@ -31,6 +31,14 @@ for game, lines in gvd.GAMES.items(): table[fid] = text out[game] = table +# 键盘变体(P1 路线 a):独立顶层表 "__key__",不与主表混放——现有消费者 +# (mock.mjs / transcribe.mjs / live/control.mjs 的 VOICE_TEXT[game][id] 查找) +# 行为零改动;键盘措辞会话(input=key)按 VOICE_TEXT.__key__[game][id] 取期望文本。 +out["__key__"] = { + game: {fid: text for fid, text in gvd.KEY_LINES[game]} + for game in gvd.GAMES +} + def parse_legacy(py_path: Path) -> dict: """AST 解析旧版 gen_voice.py:提取 ('id', ("Speaker", "instruct"), "text") 元组。 @@ -57,5 +65,6 @@ for game, legacy in [("storyline", ROOT / "app/src/storyline/gen/gen_voice.py"), dest = ROOT / "web" / "harness" / "voice_text.json" dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8") -total = sum(len(v) for v in out.values()) -print(f"OK {dest.relative_to(ROOT)} games={len(out)} lines={total}") +total = sum(len(v) for k, v in out.items() if k != "__key__") +key_total = sum(len(v) for v in out["__key__"].values()) +print(f"OK {dest.relative_to(ROOT)} games={len(out) - 1} lines={total} key_variants={key_total}") diff --git a/tools/gen_voice_dual.py b/tools/gen_voice_dual.py index 7f0a3d9f..f2b9af06 100644 --- a/tools/gen_voice_dual.py +++ b/tools/gen_voice_dual.py @@ -9,6 +9,12 @@ - 输出 staging wav: tools/gen_voice_dual_wav///.wav --convert 后落地: web//audio/voice//.{ogg,m4a} (原有平铺 voice/*.ogg 保留不动,等引擎切换后由后续轮次清理。) + - 键盘变体(P1 路线 a,research/platform/02-input-parity.md §4.3):KEY_LINES 只登记 + 含操作词的句子(key 集合 ⊆ 主集合),复用主表同 id 的 instruct/speaker; + staging wav: tools/gen_voice_dual_wav///key/.wav + --convert 后落地: web//audio/voice//key/.{ogg,m4a} + (引擎 audio.js 在生效输入模式=键盘时前置该目录,触屏主资产零改动。) + 生成建议:--only-variants 只跑变体(--check/--convert 对变体始终生效)。 质量控制: - 每条 assert 时长 > 0.3s、峰值 > 0.05;统一峰值归一 0.89。 @@ -897,6 +903,218 @@ DUNGEON_LINES = [ NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC} +# ---------------------------------------------------------------- 键盘变体(P1 路线 a) +# 「生效输入模式=键盘」的玩家听按键措辞:向左滑→按左方向键(四向同理,WASD 不进 +# 台词);单击/轻触屏幕→按空格;双击→按回车;按住/长按→按住空格。只改操作词, +# 其余内容一字不动(复合短语「左右滑」→「左右方向键」等按语义等价展开;确需 +# 补一两个连接字才成句处已在 docs/design/*-voice-ids.md「键盘变体」小节逐句登记)。 +# 主表为叙事误用(非操作指令)的句子不做变体:storyline n16/n16b(「滑了过去」 +# 「按住他的膝盖」)、n17/n17b(「更滑」)。 +# 双表一致铁律:本表与 docs/design/*-voice-ids.md「键盘变体」小节必须逐字一致。 +KEY_LINES = { + "storyline": [ + ("help", "操作说明。按空格:继续剧情。按回车:重听刚才的台词。按上方向键:听这段说明和当前进度。按下方向键:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,按左方向键或者按右方向键,做出你的决定。选择无法撤销。"), + ("menu", "菜单。按左方向键:重新开始整个故事。按右方向键,或者按空格:回到故事。按上方向键:听操作说明。"), + ("p1", "请选择。按左方向键:先检查背包里的收音机。按右方向键:先摸黑寻找出口。"), + ("p2", "请选择。按左方向键:钻进旁边的通风管道。按右方向键:躺进路边的尸体堆里装死。"), + ("p3", "请选择。按左方向键:循着哭声过去看看。按右方向键:不去管它,直接去北区。"), + ("p4", "请选择。按左方向键:带上小满一起走。按右方向键:留下食物,独自上路。"), + ("p5", "请选择。按左方向键:走桥下的污水涵洞。按右方向键:爬桥上悬着的钢索。"), + ("p6", "请选择。按左方向键:交出坐标,保命要紧。按右方向键:拒绝他,想办法周旋。"), + ("p7", "请选择。按左方向键:追上去,警告避难所。按右方向键:自己逃命,越远越好。"), + ("p8", "请选择。按左方向键:用公开频道广播,让全城都听见。按右方向键:用加密频段,只发给避难所。"), + ("n00_intro", "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:按空格,继续剧情;按回车,重听上一句;按上方向键,听你的状态和帮助;按下方向键,呼出菜单。当我说“请选择”的时候,按左方向键,或者按右方向键,做出你的决定。选择一旦做出,就无法回头。现在,按空格,睁开眼睛。"), + ("e1_dawn", "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。按空格,可以回到故事开始的地方。"), + ("e2_price", "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。按空格,重新开始。"), + ("e3_alone", "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。按空格,重新开始。"), + ("e4_static", "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。"), + ("skip_hint", "按空格可跳过旁白。"), + ("gd_continue", "按空格,继续。"), + ("gd_brief", "按空格继续剧情。请选择的时候,按左右方向键。"), + ("gd_again", "还在吗?按空格继续。"), + ], + "escape": [ + ("g1_move", "先活动一下。按任意方向键,走一小步。"), + ("g2_wall", "这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。"), + ("g3_left", "跟着我指的路走。按左方向键。"), + ("g4_down", "对。再按下方向键。"), + ("g5_inspect", "听到水声了吗?按一下空格,仔细检查这个房间。"), + ("g6_doubletap", "钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。"), + ("g7_goal", "找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。"), + ("guide_wrong_up", "不是这个方向。按上方向键。"), + ("guide_wrong_down", "不是这个方向。按下方向键。"), + ("guide_wrong_left", "不是这个方向。按左方向键。"), + ("guide_wrong_right", "不是这个方向。按右方向键。"), + ("guide_blunt_up", "按一下上方向键。"), + ("guide_blunt_down", "按一下下方向键。"), + ("guide_blunt_left", "按一下左方向键。"), + ("guide_blunt_right", "按一下右方向键。"), + ("reopen_prompt", "按上方向键,重新听一遍引导。按空格,直接开始。"), + ("stuck_key_2", "去西南角的储物间,按空格检查,找到钥匙。"), + ("stuck_cabinet_2", "去东边的铁柜房,按回车,用钥匙打开铁柜。"), + ("stuck_box_2", "去西北角的电箱房,按回车,把保险丝装进电箱。"), + ("stuck_gate_2", "去东南角的大门,按回车,打开它,离开这里。"), + ("nav_up", "按上方向键。"), + ("nav_down", "按下方向键。"), + ("nav_left", "按左方向键。"), + ("nav_right", "按右方向键。"), + ("start_room", "你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。"), + ("inspect_gate_ready", "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。"), + ("use_fail", "这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。"), + ("again", "按空格,再来一次。按下方向键,打开菜单。"), + ("help_ops", "方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。"), + ("skip_hint", "按空格可跳过旁白。"), + ], + "simon": [ + ("t1_up", "按上方向键,是这个声音。"), + ("t2_down", "按下方向键,是这个声音。"), + ("t3_left", "按左方向键,是这个声音。"), + ("t4_right", "按右方向键,是这个声音。"), + ("t5_tap", "按空格,是这个声音。"), + ("t6_double", "按回车,是这个声音。"), + ("t_wrong_up", "不是这个,按上方向键试试。"), + ("t_wrong_down", "不是这个,按下方向键试试。"), + ("t_wrong_left", "不是这个,按左方向键试试。"), + ("t_wrong_right", "不是这个,按右方向键试试。"), + ("t_wrong_tap", "不是这个,按空格试试。"), + ("t_wrong_double", "不是这个,按回车试试。"), + ("t_blunt_up", "按一下上方向键。"), + ("t_blunt_down", "按一下下方向键。"), + ("t_blunt_left", "按一下左方向键。"), + ("t_blunt_right", "按一下右方向键。"), + ("t_blunt_tap", "轻轻按一下空格。"), + ("t_blunt_double", "按一下回车。"), + ("t7_retry", "答错可以消耗机会重听。按上方向键,可以随时重听。"), + ("replay", "按空格,再来一局。"), + ("rules_prompt", "按上方向键,重听规则。按下方向键,打开菜单。"), + ("rules", "上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。"), + ("skip_hint", "按空格可跳过旁白。"), + ("gd_relisten", "需要的话,按上方向键可以重新听一遍序列。"), + ("gd_economy", "记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。"), + ("gd_occ_up", "这一轮,按上方向键是作答。"), + ("gd_occ_dt", "这一轮,按回车是作答。"), + ("gd_occ_both", "这一轮,按上方向键和回车都是作答。"), + ], + "rhythm": [ + ("tut_1", "听,底鼓。在下一小节的同一个位置,按空格。"), + ("tut_2", "这次是左和右。跟着打,按左方向键,按右方向键。"), + ("tut_3", "最后一句,三个音,中间是按回车。"), + ("select_hint", "左右方向键选曲目,按空格开始,按下方向键回大厅。"), + ("restart", "按空格再来一遍,按右方向键下一首,按下方向键回大厅。"), + ("menu_main", "菜单。按空格切换选项,按回车确认。"), + ("skip_hint", "按空格可跳过旁白。"), + ("gd_miss_early", "抢拍了。等声音落下再按。"), + ("gd_miss_wrong", "方向不对。跟着音色按方向键。"), + ], + "space": [ + ("drill_left", "听,左边有陨石。按左方向键,把它拨开!"), + ("drill_right", "这次在右边。按右方向键!"), + ("drill_up", "上面有东西,声音又尖又高。按上方向键!"), + ("drill_down", "下面是低沉的轰隆。按下方向键!"), + ("drill_retry_left", "那是左边来的,按左方向键。"), + ("drill_retry_right", "那是右边来的,按右方向键。"), + ("drill_retry_up", "声音又高又尖,是上面,按上方向键。"), + ("drill_retry_down", "轰隆声在下面,按下方向键。"), + ("protect_left", "训练护盾接住了。那颗在左边,听到左边的呼啸,按左方向键。"), + ("protect_right", "训练护盾接住了。那颗在右边,听到右边的呼啸,按右方向键。"), + ("protect_up", "训练护盾接住了。那颗在上面,听到高高的呼啸,按上方向键。"), + ("protect_down", "训练护盾接住了。那颗在下面,听到低沉的轰隆,按下方向键。"), + ("feat_boost", "试试按空格,引擎会短促加速,直接冲过空隙。"), + ("feat_shield", "按回车展开护盾,能挡下一次撞击。"), + ("hit_left", "左边撞上的。听到左边的呼啸,按左方向键。"), + ("hit_right", "右边撞上的。听到右边的呼啸,按右方向键。"), + ("hit_up", "上面撞上的。听到高高的呼啸,按上方向键。"), + ("hit_down", "下面撞上的。听到低沉的轰隆,按下方向键。"), + ("restart_hint", "按空格再飞一次,按下方向键回大厅。"), + ("second_run_ask", "按上方向键重新听演练,按空格直接出发。"), + ("menu_main", "菜单。按空格切换选项,按回车确认。"), + ("help_recap", "左边来按左方向键,右边来按右方向键,上面来按上方向键,下面来按下方向键。按空格加速,按回车护盾。"), + ("skip_hint", "按空格可跳过旁白。"), + ("gd_hit_hint", "被撞了。听方位,按对应方向键就能拨开。"), + ], + "rpg": [ + ("sys_continue_ask", "检测到你的旅程记录。按空格继续,按右方向键开始新的旅程。"), + ("sys_menu", "菜单。左右方向键选择,按空格确认。"), + ("help_explore", "左右方向键切换选项,按空格确认。按回车重新听场景,按上方向键查看状态,按下方向键打开菜单。"), + ("help_battle", "战斗中,左右方向键选招式,按空格出手。听到敌人的前摇,按回车格挡。"), + ("btl_lose_hint", "记住,听清前摇,按回车格挡。"), + ("tut_b2", "就是现在,按回车格挡!"), + ("tut_b3", "挡住了!轮到你了。左右方向键选攻击,按空格出剑。"), + ("shop_open", "商店。左右方向键看看货,按空格购买。"), + ("skip_hint", "按空格可跳过旁白。"), + ("tut_battle_2", "左右方向键切换招式,攻击、破空斩、防御、金疮药。按空格,出手。"), + ("tut_battle_3", "敌人出手前有一声前摇。听到前摇,马上按回车,就能格挡。"), + ("tut_battle_5", "记住:听清前摇,按回车格挡。开始吧!"), + ("tut_b0", "战斗里,左右方向键选招式,按空格出手。听到这个声音,就是你的回合。先按一按方向键,试试。"), + ("tut_b6", "来不及格挡,就选防御。左右方向键,选防御,按空格。"), + ("gd_parry_miss", "前摇声起的时候按回车,才能格挡。"), + ("gd_stall", "想好了吗?左右方向键选择,按空格出手。"), + ], + "radio": [ + ("gd_brief", "按空格继续节目。请选择的时候,按左或右方向键。"), + ("gd_continue", "按空格,继续。"), + ("gd_again", "还在吗?按空格继续。"), + ("gd_ring1", "热线在闪。按空格,接听。"), + ("gd_ring2", "灯还亮着,听众在等。按空格,接听。"), + ("sys_help", "按空格继续,按回车重新听。按上方向键听状态,按下方向键开菜单。请选择时,左右方向键。"), + ("sys_menu", "菜单。按空格切换选项,按回车确认。"), + ("sys_skip", "想跳过这句,按空格就行。"), + ("sys_continue_ask", "检测到你的夜班记录。按空格继续上次,按右方向键开始新的夜晚。"), + ("p1", "老人在发抖。按左方向键,陪他哼那首老歌。按右方向键,冷静问他路口的路牌。"), + ("p2", "雨声更密了。按左方向键,留在原地陪他等巡逻队。按右方向键,转接派出所。"), + ("p3", "按左方向键,告诉她这趟车值得上。按右方向键,把选择还给她。"), + ("p4", "按左方向键,唱一段摇篮曲哄她睡。按右方向键,轻轻问她怎么了。"), + ("p5", "孩子的声音在抖。按左方向键,告诉她这不是她的错。按右方向键,答应她明天都会好。"), + ("p6", "摔门声你听见了。按左方向键,替孩子给她带句话。按右方向键,劝她回家把话说开。"), + ("p7", "她的呼吸平了。按左方向键,留在直播里,让全城等这个门铃。按右方向键,换垫乐,私下陪她说完。"), + ("p8", "方向盘比话筒危险。按左方向键,陪他聊聊闺女。按右方向键,让他立刻进服务区。"), + ("p9", "留言机红灯在闪,是老主播三年前没播完的那句晚安。按左方向键,替他播出去。按右方向键,照常收播。"), + ], + "dungeon": [ + ("sys_continue_ask", "检测到探窟记录。按空格继续,按右方向键重新开始。"), + ("tut_d0", "先学会用耳朵看。按空格,发出声波脉冲。"), + ("tut_walk", "按住空格不放,你就往前走。"), + ("tut_turn", "按左右方向键,可以转身。"), + ("tut_replay", "按回车,再发一次脉冲,不要钱。"), + ("tut_status", "按上方向键,随时听自己的状态。"), + ("tut_menu", "按下方向键,打开菜单。"), + ("tut_parry", "听到它的动静,按回车格挡。"), + ("tut_attack", "现在按空格,挥拳打它。"), + ("tut_flee_hold", "想逃跑,按住空格别松手。"), + ("tut_stairs_on", "楼梯就在脚下。按空格,下潜。"), + ("tut_skip", "已跳过教学。按空格发脉冲,先听听四周。"), + ("sys_menu", "菜单。按空格切换,按回车确认。"), + ("help_explore", "按空格发脉冲,按住空格前进,左右方向键转身。按回车再听,上方向键查状态,下方向键开菜单。"), + ("help_battle", "敌人前摇声响时按回车格挡,按空格挥击。打不过就按住空格逃跑。"), + ("die_adv_1", "下次,前摇声响时就按回车。"), + ("die_adv_2", "打不过,就按住空格逃跑。"), + ("gd_pulse", "按空格发脉冲,听听四周。"), + ("gd_turn", "轮到你了。按空格挥击。"), + ("settle_again", "按空格,再探一次。"), + ], +} + + +def key_variants(game): + """key 变体表 [(fid, instruct_pair, text), ...]。 + + 约束(启动即校验,防两表漂移): + - key id 必须存在于该游戏主台词表(key 集合 ⊆ 主集合),复用其 instruct + 与 VOICE_SPK speaker(radio HOST/PROD 配对自动生效); + - key 表内 id 不得重复。 + """ + ins_by_id = {fid: ins for fid, ins, _t in GAMES[game]} + seen = set() + out = [] + for fid, text in KEY_LINES[game]: + if fid not in ins_by_id: + raise SystemExit(f"key 变体 {game}/{fid} 不在主台词表中(key 集合必须 ⊆ 主集合)") + if fid in seen: + raise SystemExit(f"key 变体重复 id:{game}/{fid}") + seen.add(fid) + out.append((fid, ins_by_id[fid], text)) + return out + # radio 双性别行的逐句 speaker 覆盖(HOST=主播 / PROD=导播,同性别两个音色, # 见 15-radio-voice-ids.md 录制说明:同场景高频对话的两者永不同 speaker)。 VOICE_SPK = {"radio": {}} @@ -970,8 +1188,12 @@ def split_text(text, limit=SPLIT_LIMIT): return [o for o in out if o.strip()] -def staging_path(game, gender, fid): - return os.path.join(STAGING, game, gender, fid + ".wav") +def staging_path(game, gender, fid, variant=""): + """staging wav 路径;variant='key' 时落 /key/ 子目录。""" + parts = [STAGING, game, gender] + if variant: + parts.append(variant) + return os.path.join(*parts, fid + ".wav") def valid_wav(path): @@ -991,7 +1213,71 @@ def valid_wav(path): # ---------------------------------------------------------------- 生成 -def generate(games, genders, only): +def _generate_lines(model, game, gender, lines, variant=""): + """一组双性别台词(主表或 key 变体)生成到 staging /[/]/。""" + import numpy as np + import soundfile as sf + + speaker = SPEAKERS[game][gender] + ins_idx = 0 if gender == "male" else 1 + spk_map = VOICE_SPK.get(game, {}) + outdir = os.path.join(STAGING, game, gender, variant) if variant else os.path.join(STAGING, game, gender) + os.makedirs(outdir, exist_ok=True) + + # 展开成 (fid, chunk_idx, n_chunks, text, instruct, speaker),跳过已完成 + items = [] + for fid, ins_pair, text in lines: + path = staging_path(game, gender, fid, variant) + if valid_wav(path): + continue + spk = spk_map.get(fid, (speaker, speaker))[ins_idx] + chunks = split_text(text) + for ci, ctext in enumerate(chunks): + items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx], spk)) + if not items: + print(f"[{game}/{gender}/{variant or '-'}] nothing to do", flush=True) + return + + # 短句(数字类)优先、同 instruct 聚合成整批,减少 prosody 漂移 + shorts = [it for it in items if len(it[3]) <= 2] + longs = [it for it in items if len(it[3]) > 2] + queue = ([("short", shorts[i:i + BATCH_SHORT]) for i in range(0, len(shorts), BATCH_SHORT)] + + [("long", longs[i:i + BATCH_LONG]) for i in range(0, len(longs), BATCH_LONG)]) + + results = {} # fid -> {chunk_idx: wav} + for bi, (kind, batch) in enumerate(queue): + wavs, sr = model.generate_custom_voice( + text=[b[3] for b in batch], + language=["Chinese"] * len(batch), + speaker=[b[5] for b in batch], + instruct=[b[4] for b in batch], + max_new_tokens=2048, + ) + for (fid, ci, _n, _t, _i, _s), wav in zip(batch, wavs): + results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32) + print(f"[{game}/{gender}/{variant or '-'}] batch {bi + 1}/{len(queue)} ({kind}, n={len(batch)})", flush=True) + + gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32) + for fid, ins_pair, text in lines: + path = staging_path(game, gender, fid, variant) + if valid_wav(path): + continue + got = results.get(fid, {}) + n_chunks = len(split_text(text)) + assert len(got) == n_chunks, f"{game}/{gender}/{variant}/{fid}: 缺 chunk {len(got)}/{n_chunks}" + wav = got[0] + for ci in range(1, n_chunks): + wav = np.concatenate([wav, gap, got[ci]]) + peak = float(np.max(np.abs(wav))) + assert peak > 0.05, f"{game}/{gender}/{variant}/{fid}: peak {peak:.3f}" + wav = wav / peak * PEAK_NORM + dur = len(wav) / sr + assert dur > 0.3, f"{game}/{gender}/{variant}/{fid}: dur {dur:.2f}s" + sf.write(path, wav, sr) + print(f"[ok] {game}/{gender}/{variant}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True) + + +def generate(games, genders, only, variants_only=False): import numpy as np import soundfile as sf import torch @@ -1005,70 +1291,24 @@ def generate(games, genders, only): print("model loaded", flush=True) for game in games: - lines = GAMES[game] + if not variants_only: + lines = GAMES[game] + if only: + lines = [x for x in lines if x[0] in only] + for gender in genders: + _generate_lines(model, game, gender, lines) + # 键盘变体(key 集合 ⊆ 主集合,复用主表 instruct/speaker) + kvars = key_variants(game) if only: - lines = [x for x in lines if x[0] in only] + kvars = [x for x in kvars if x[0] in only] for gender in genders: - speaker = SPEAKERS[game][gender] - ins_idx = 0 if gender == "male" else 1 - spk_map = VOICE_SPK.get(game, {}) - outdir = os.path.join(STAGING, game, gender) - os.makedirs(outdir, exist_ok=True) + _generate_lines(model, game, gender, kvars, variant="key") - # 展开成 (fid, chunk_idx, n_chunks, text, instruct, speaker),跳过已完成 - items = [] - for fid, ins_pair, text in lines: - path = staging_path(game, gender, fid) - if valid_wav(path): - continue - spk = spk_map.get(fid, (speaker, speaker))[ins_idx] - chunks = split_text(text) - for ci, ctext in enumerate(chunks): - items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx], spk)) - if not items: - print(f"[{game}/{gender}] nothing to do", flush=True) - continue - - # 短句(数字类)优先、同 instruct 聚合成整批,减少 prosody 漂移 - shorts = [it for it in items if len(it[3]) <= 2] - longs = [it for it in items if len(it[3]) > 2] - queue = ([("short", shorts[i:i + BATCH_SHORT]) for i in range(0, len(shorts), BATCH_SHORT)] - + [("long", longs[i:i + BATCH_LONG]) for i in range(0, len(longs), BATCH_LONG)]) - - results = {} # fid -> {chunk_idx: wav} - for bi, (kind, batch) in enumerate(queue): - wavs, sr = model.generate_custom_voice( - text=[b[3] for b in batch], - language=["Chinese"] * len(batch), - speaker=[b[5] for b in batch], - instruct=[b[4] for b in batch], - max_new_tokens=2048, - ) - for (fid, ci, _n, _t, _i, _s), wav in zip(batch, wavs): - results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32) - print(f"[{game}/{gender}] batch {bi + 1}/{len(queue)} ({kind}, n={len(batch)})", flush=True) - - gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32) - for fid, ins_pair, text in lines: - path = staging_path(game, gender, fid) - if valid_wav(path): - continue - got = results.get(fid, {}) - n_chunks = len(split_text(text)) - assert len(got) == n_chunks, f"{game}/{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}" - wav = got[0] - for ci in range(1, n_chunks): - wav = np.concatenate([wav, gap, got[ci]]) - peak = float(np.max(np.abs(wav))) - assert peak > 0.05, f"{game}/{gender}/{fid}: peak {peak:.3f}" - wav = wav / peak * PEAK_NORM - dur = len(wav) / sr - assert dur > 0.3, f"{game}/{gender}/{fid}: dur {dur:.2f}s" - sf.write(path, wav, sr) - print(f"[ok] {game}/{gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True) - - # NPC 单音色平铺(staging /npc/,convert 落 flat voice/) - npc_lines = NPCS.get(game) + # NPC 单音色平铺(staging /npc/,convert 落 flat voice/)。 + # --only-variants 时跳过:R5 起部分 NPC 资产已先行落盘而 staging 无 wav + # (见 RPG_LINES R5 注释),变体跑批不应顺手重录已验收 NPC 台词—— + # 否则 --convert 会用新 take 覆盖 web/ 下已验收资产。 + npc_lines = None if variants_only else NPCS.get(game) if npc_lines: outdir = os.path.join(STAGING, game, "npc") os.makedirs(outdir, exist_ok=True) @@ -1098,13 +1338,13 @@ def check(games): import numpy as np import soundfile as sf - problems = [] - for game in games: - print(f"=== {game} ===") - for fid, _ins, _text in GAMES[game]: + def check_set(game, lines, variant=""): + problems = [] + prefix = f"key/" if variant else "" + for fid, _ins, _text in lines: durs = {} for gender in ("male", "female"): - path = staging_path(game, gender, fid) + path = staging_path(game, gender, fid, variant) if not os.path.exists(path): durs[gender] = None continue @@ -1112,16 +1352,26 @@ def check(games): peak = float(np.max(np.abs(data))) durs[gender] = len(data) / sr if durs[gender] <= 0.3 or peak <= 0.05: - problems.append(f"{game}/{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}") + problems.append(f"{game}/{gender}/{prefix}{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}") m, f = durs["male"], durs["female"] if m is None or f is None: - problems.append(f"{game}/{fid}: 缺 {'male' if m is None else 'female'}") + problems.append(f"{game}/{prefix}{fid}: 缺 {'male' if m is None else 'female'}") continue ratio = max(m, f) / min(m, f) flag = " <-- OUTLIER" if ratio > 3 else "" if ratio > 3: - problems.append(f"{game}/{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}") - print(f" {fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}") + problems.append(f"{game}/{prefix}{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}") + print(f" {prefix}{fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}") + return problems + + problems = [] + for game in games: + print(f"=== {game} ===") + problems += check_set(game, GAMES[game]) + kvars = key_variants(game) + if kvars: + print(f" --- key 变体 {len(kvars)} 句 ---") + problems += check_set(game, kvars, variant="key") print() if problems: print("PROBLEMS:") @@ -1151,6 +1401,20 @@ def convert(games): subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) total += 1 + # 键盘变体:落 voice//key/ 子目录(引擎 key 模式候选链前置) + kdst = os.path.join(dst_dir, "key") + for fid, _ins, _text in key_variants(game): + src = staging_path(game, gender, fid, "key") + if not os.path.exists(src): + print(f"[convert] WARN missing {game}/{gender}/key/{fid}.wav") + continue + os.makedirs(kdst, exist_ok=True) + base = os.path.join(kdst, fid) + subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, + "-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) + subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, + "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) + total += 1 # NPC 平铺(staging /npc/ → audio/voice/.*;radio 按 NPC_BANDPASS 带通) for fid, _sp, _ins, _t in NPCS.get(game, []): src = os.path.join(STAGING, game, "npc", fid + ".wav") @@ -1175,6 +1439,8 @@ def main(): choices=list(GAMES.keys()) + ["all"]) ap.add_argument("--gender", default="both", choices=["male", "female", "both"]) ap.add_argument("--only", default="", help="逗号分隔的 id 列表,只生成这些") + ap.add_argument("--only-variants", action="store_true", + help="只生成键盘变体(key)台词,跳过主表(--check/--convert 对变体始终生效)") ap.add_argument("--check", action="store_true") ap.add_argument("--convert", action="store_true") args = ap.parse_args() @@ -1188,7 +1454,7 @@ def main(): if args.convert: convert(games) return - generate(games, genders, only) + generate(games, genders, only, variants_only=args.only_variants) if __name__ == "__main__": diff --git a/web/dungeon/audio/voice/female/key/die_adv_1.m4a b/web/dungeon/audio/voice/female/key/die_adv_1.m4a new file mode 100644 index 00000000..4cc5a963 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/die_adv_1.m4a differ diff --git a/web/dungeon/audio/voice/female/key/die_adv_1.ogg b/web/dungeon/audio/voice/female/key/die_adv_1.ogg new file mode 100644 index 00000000..381098c7 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/die_adv_1.ogg differ diff --git a/web/dungeon/audio/voice/female/key/die_adv_2.m4a b/web/dungeon/audio/voice/female/key/die_adv_2.m4a new file mode 100644 index 00000000..65eefe84 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/die_adv_2.m4a differ diff --git a/web/dungeon/audio/voice/female/key/die_adv_2.ogg b/web/dungeon/audio/voice/female/key/die_adv_2.ogg new file mode 100644 index 00000000..fe6f609f Binary files /dev/null and b/web/dungeon/audio/voice/female/key/die_adv_2.ogg differ diff --git a/web/dungeon/audio/voice/female/key/gd_pulse.m4a b/web/dungeon/audio/voice/female/key/gd_pulse.m4a new file mode 100644 index 00000000..44b428ec Binary files /dev/null and b/web/dungeon/audio/voice/female/key/gd_pulse.m4a differ diff --git a/web/dungeon/audio/voice/female/key/gd_pulse.ogg b/web/dungeon/audio/voice/female/key/gd_pulse.ogg new file mode 100644 index 00000000..30fe6018 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/gd_pulse.ogg differ diff --git a/web/dungeon/audio/voice/female/key/gd_turn.m4a b/web/dungeon/audio/voice/female/key/gd_turn.m4a new file mode 100644 index 00000000..69b79dbb Binary files /dev/null and b/web/dungeon/audio/voice/female/key/gd_turn.m4a differ diff --git a/web/dungeon/audio/voice/female/key/gd_turn.ogg b/web/dungeon/audio/voice/female/key/gd_turn.ogg new file mode 100644 index 00000000..2ca7444d Binary files /dev/null and b/web/dungeon/audio/voice/female/key/gd_turn.ogg differ diff --git a/web/dungeon/audio/voice/female/key/help_battle.m4a b/web/dungeon/audio/voice/female/key/help_battle.m4a new file mode 100644 index 00000000..4ccae569 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/help_battle.m4a differ diff --git a/web/dungeon/audio/voice/female/key/help_battle.ogg b/web/dungeon/audio/voice/female/key/help_battle.ogg new file mode 100644 index 00000000..81203318 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/help_battle.ogg differ diff --git a/web/dungeon/audio/voice/female/key/help_explore.m4a b/web/dungeon/audio/voice/female/key/help_explore.m4a new file mode 100644 index 00000000..4bfd7a98 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/help_explore.m4a differ diff --git a/web/dungeon/audio/voice/female/key/help_explore.ogg b/web/dungeon/audio/voice/female/key/help_explore.ogg new file mode 100644 index 00000000..84277d87 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/help_explore.ogg differ diff --git a/web/dungeon/audio/voice/female/key/settle_again.m4a b/web/dungeon/audio/voice/female/key/settle_again.m4a new file mode 100644 index 00000000..0c500328 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/settle_again.m4a differ diff --git a/web/dungeon/audio/voice/female/key/settle_again.ogg b/web/dungeon/audio/voice/female/key/settle_again.ogg new file mode 100644 index 00000000..799fdbcb Binary files /dev/null and b/web/dungeon/audio/voice/female/key/settle_again.ogg differ diff --git a/web/dungeon/audio/voice/female/key/sys_continue_ask.m4a b/web/dungeon/audio/voice/female/key/sys_continue_ask.m4a new file mode 100644 index 00000000..fbbef534 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/sys_continue_ask.m4a differ diff --git a/web/dungeon/audio/voice/female/key/sys_continue_ask.ogg b/web/dungeon/audio/voice/female/key/sys_continue_ask.ogg new file mode 100644 index 00000000..3ee28a1e Binary files /dev/null and b/web/dungeon/audio/voice/female/key/sys_continue_ask.ogg differ diff --git a/web/dungeon/audio/voice/female/key/sys_menu.m4a b/web/dungeon/audio/voice/female/key/sys_menu.m4a new file mode 100644 index 00000000..7195b87b Binary files /dev/null and b/web/dungeon/audio/voice/female/key/sys_menu.m4a differ diff --git a/web/dungeon/audio/voice/female/key/sys_menu.ogg b/web/dungeon/audio/voice/female/key/sys_menu.ogg new file mode 100644 index 00000000..6f9bcdba Binary files /dev/null and b/web/dungeon/audio/voice/female/key/sys_menu.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_attack.m4a b/web/dungeon/audio/voice/female/key/tut_attack.m4a new file mode 100644 index 00000000..0f034231 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_attack.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_attack.ogg b/web/dungeon/audio/voice/female/key/tut_attack.ogg new file mode 100644 index 00000000..7046a6a4 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_attack.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_d0.m4a b/web/dungeon/audio/voice/female/key/tut_d0.m4a new file mode 100644 index 00000000..1a593415 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_d0.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_d0.ogg b/web/dungeon/audio/voice/female/key/tut_d0.ogg new file mode 100644 index 00000000..2aaefcad Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_d0.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_flee_hold.m4a b/web/dungeon/audio/voice/female/key/tut_flee_hold.m4a new file mode 100644 index 00000000..3033ebf2 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_flee_hold.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_flee_hold.ogg b/web/dungeon/audio/voice/female/key/tut_flee_hold.ogg new file mode 100644 index 00000000..0297ebef Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_flee_hold.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_menu.m4a b/web/dungeon/audio/voice/female/key/tut_menu.m4a new file mode 100644 index 00000000..865eb707 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_menu.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_menu.ogg b/web/dungeon/audio/voice/female/key/tut_menu.ogg new file mode 100644 index 00000000..7f4a7859 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_menu.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_parry.m4a b/web/dungeon/audio/voice/female/key/tut_parry.m4a new file mode 100644 index 00000000..f912ed2c Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_parry.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_parry.ogg b/web/dungeon/audio/voice/female/key/tut_parry.ogg new file mode 100644 index 00000000..039c1bfc Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_parry.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_replay.m4a b/web/dungeon/audio/voice/female/key/tut_replay.m4a new file mode 100644 index 00000000..a0d2bced Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_replay.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_replay.ogg b/web/dungeon/audio/voice/female/key/tut_replay.ogg new file mode 100644 index 00000000..d2289c83 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_replay.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_skip.m4a b/web/dungeon/audio/voice/female/key/tut_skip.m4a new file mode 100644 index 00000000..89da8623 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_skip.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_skip.ogg b/web/dungeon/audio/voice/female/key/tut_skip.ogg new file mode 100644 index 00000000..bbf934ef Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_skip.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_stairs_on.m4a b/web/dungeon/audio/voice/female/key/tut_stairs_on.m4a new file mode 100644 index 00000000..d6fd1894 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_stairs_on.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_stairs_on.ogg b/web/dungeon/audio/voice/female/key/tut_stairs_on.ogg new file mode 100644 index 00000000..0e7a118c Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_stairs_on.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_status.m4a b/web/dungeon/audio/voice/female/key/tut_status.m4a new file mode 100644 index 00000000..ad1a615c Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_status.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_status.ogg b/web/dungeon/audio/voice/female/key/tut_status.ogg new file mode 100644 index 00000000..7469ba35 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_status.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_turn.m4a b/web/dungeon/audio/voice/female/key/tut_turn.m4a new file mode 100644 index 00000000..261ad068 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_turn.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_turn.ogg b/web/dungeon/audio/voice/female/key/tut_turn.ogg new file mode 100644 index 00000000..23a599db Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_turn.ogg differ diff --git a/web/dungeon/audio/voice/female/key/tut_walk.m4a b/web/dungeon/audio/voice/female/key/tut_walk.m4a new file mode 100644 index 00000000..bd60a70f Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_walk.m4a differ diff --git a/web/dungeon/audio/voice/female/key/tut_walk.ogg b/web/dungeon/audio/voice/female/key/tut_walk.ogg new file mode 100644 index 00000000..ce395f97 Binary files /dev/null and b/web/dungeon/audio/voice/female/key/tut_walk.ogg differ diff --git a/web/dungeon/audio/voice/male/key/die_adv_1.m4a b/web/dungeon/audio/voice/male/key/die_adv_1.m4a new file mode 100644 index 00000000..e12b4b2a Binary files /dev/null and b/web/dungeon/audio/voice/male/key/die_adv_1.m4a differ diff --git a/web/dungeon/audio/voice/male/key/die_adv_1.ogg b/web/dungeon/audio/voice/male/key/die_adv_1.ogg new file mode 100644 index 00000000..c6671dbd Binary files /dev/null and b/web/dungeon/audio/voice/male/key/die_adv_1.ogg differ diff --git a/web/dungeon/audio/voice/male/key/die_adv_2.m4a b/web/dungeon/audio/voice/male/key/die_adv_2.m4a new file mode 100644 index 00000000..c3615251 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/die_adv_2.m4a differ diff --git a/web/dungeon/audio/voice/male/key/die_adv_2.ogg b/web/dungeon/audio/voice/male/key/die_adv_2.ogg new file mode 100644 index 00000000..3287baf5 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/die_adv_2.ogg differ diff --git a/web/dungeon/audio/voice/male/key/gd_pulse.m4a b/web/dungeon/audio/voice/male/key/gd_pulse.m4a new file mode 100644 index 00000000..d4eec05f Binary files /dev/null and b/web/dungeon/audio/voice/male/key/gd_pulse.m4a differ diff --git a/web/dungeon/audio/voice/male/key/gd_pulse.ogg b/web/dungeon/audio/voice/male/key/gd_pulse.ogg new file mode 100644 index 00000000..2cc7835b Binary files /dev/null and b/web/dungeon/audio/voice/male/key/gd_pulse.ogg differ diff --git a/web/dungeon/audio/voice/male/key/gd_turn.m4a b/web/dungeon/audio/voice/male/key/gd_turn.m4a new file mode 100644 index 00000000..ada10f6f Binary files /dev/null and b/web/dungeon/audio/voice/male/key/gd_turn.m4a differ diff --git a/web/dungeon/audio/voice/male/key/gd_turn.ogg b/web/dungeon/audio/voice/male/key/gd_turn.ogg new file mode 100644 index 00000000..993f83c3 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/gd_turn.ogg differ diff --git a/web/dungeon/audio/voice/male/key/help_battle.m4a b/web/dungeon/audio/voice/male/key/help_battle.m4a new file mode 100644 index 00000000..79563a68 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/help_battle.m4a differ diff --git a/web/dungeon/audio/voice/male/key/help_battle.ogg b/web/dungeon/audio/voice/male/key/help_battle.ogg new file mode 100644 index 00000000..e6e452b6 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/help_battle.ogg differ diff --git a/web/dungeon/audio/voice/male/key/help_explore.m4a b/web/dungeon/audio/voice/male/key/help_explore.m4a new file mode 100644 index 00000000..2cb1bc02 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/help_explore.m4a differ diff --git a/web/dungeon/audio/voice/male/key/help_explore.ogg b/web/dungeon/audio/voice/male/key/help_explore.ogg new file mode 100644 index 00000000..263a7785 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/help_explore.ogg differ diff --git a/web/dungeon/audio/voice/male/key/settle_again.m4a b/web/dungeon/audio/voice/male/key/settle_again.m4a new file mode 100644 index 00000000..f1f407b6 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/settle_again.m4a differ diff --git a/web/dungeon/audio/voice/male/key/settle_again.ogg b/web/dungeon/audio/voice/male/key/settle_again.ogg new file mode 100644 index 00000000..43a6cd9e Binary files /dev/null and b/web/dungeon/audio/voice/male/key/settle_again.ogg differ diff --git a/web/dungeon/audio/voice/male/key/sys_continue_ask.m4a b/web/dungeon/audio/voice/male/key/sys_continue_ask.m4a new file mode 100644 index 00000000..f4b88563 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/sys_continue_ask.m4a differ diff --git a/web/dungeon/audio/voice/male/key/sys_continue_ask.ogg b/web/dungeon/audio/voice/male/key/sys_continue_ask.ogg new file mode 100644 index 00000000..01abac7f Binary files /dev/null and b/web/dungeon/audio/voice/male/key/sys_continue_ask.ogg differ diff --git a/web/dungeon/audio/voice/male/key/sys_menu.m4a b/web/dungeon/audio/voice/male/key/sys_menu.m4a new file mode 100644 index 00000000..224e40f5 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/sys_menu.m4a differ diff --git a/web/dungeon/audio/voice/male/key/sys_menu.ogg b/web/dungeon/audio/voice/male/key/sys_menu.ogg new file mode 100644 index 00000000..fd10397f Binary files /dev/null and b/web/dungeon/audio/voice/male/key/sys_menu.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_attack.m4a b/web/dungeon/audio/voice/male/key/tut_attack.m4a new file mode 100644 index 00000000..3a05541d Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_attack.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_attack.ogg b/web/dungeon/audio/voice/male/key/tut_attack.ogg new file mode 100644 index 00000000..d2a235f5 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_attack.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_d0.m4a b/web/dungeon/audio/voice/male/key/tut_d0.m4a new file mode 100644 index 00000000..172ac42d Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_d0.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_d0.ogg b/web/dungeon/audio/voice/male/key/tut_d0.ogg new file mode 100644 index 00000000..22721556 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_d0.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_flee_hold.m4a b/web/dungeon/audio/voice/male/key/tut_flee_hold.m4a new file mode 100644 index 00000000..f87b5387 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_flee_hold.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_flee_hold.ogg b/web/dungeon/audio/voice/male/key/tut_flee_hold.ogg new file mode 100644 index 00000000..efec7710 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_flee_hold.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_menu.m4a b/web/dungeon/audio/voice/male/key/tut_menu.m4a new file mode 100644 index 00000000..aa5f6a72 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_menu.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_menu.ogg b/web/dungeon/audio/voice/male/key/tut_menu.ogg new file mode 100644 index 00000000..8a2da79b Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_menu.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_parry.m4a b/web/dungeon/audio/voice/male/key/tut_parry.m4a new file mode 100644 index 00000000..24fe0dad Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_parry.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_parry.ogg b/web/dungeon/audio/voice/male/key/tut_parry.ogg new file mode 100644 index 00000000..c3e86cf1 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_parry.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_replay.m4a b/web/dungeon/audio/voice/male/key/tut_replay.m4a new file mode 100644 index 00000000..181f5023 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_replay.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_replay.ogg b/web/dungeon/audio/voice/male/key/tut_replay.ogg new file mode 100644 index 00000000..a08ba940 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_replay.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_skip.m4a b/web/dungeon/audio/voice/male/key/tut_skip.m4a new file mode 100644 index 00000000..b1660ebb Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_skip.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_skip.ogg b/web/dungeon/audio/voice/male/key/tut_skip.ogg new file mode 100644 index 00000000..0ec92e5d Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_skip.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_stairs_on.m4a b/web/dungeon/audio/voice/male/key/tut_stairs_on.m4a new file mode 100644 index 00000000..da53e22d Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_stairs_on.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_stairs_on.ogg b/web/dungeon/audio/voice/male/key/tut_stairs_on.ogg new file mode 100644 index 00000000..6bb4b187 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_stairs_on.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_status.m4a b/web/dungeon/audio/voice/male/key/tut_status.m4a new file mode 100644 index 00000000..c98867fb Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_status.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_status.ogg b/web/dungeon/audio/voice/male/key/tut_status.ogg new file mode 100644 index 00000000..a380c4a8 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_status.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_turn.m4a b/web/dungeon/audio/voice/male/key/tut_turn.m4a new file mode 100644 index 00000000..e097d72b Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_turn.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_turn.ogg b/web/dungeon/audio/voice/male/key/tut_turn.ogg new file mode 100644 index 00000000..560da695 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_turn.ogg differ diff --git a/web/dungeon/audio/voice/male/key/tut_walk.m4a b/web/dungeon/audio/voice/male/key/tut_walk.m4a new file mode 100644 index 00000000..e2e48d3c Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_walk.m4a differ diff --git a/web/dungeon/audio/voice/male/key/tut_walk.ogg b/web/dungeon/audio/voice/male/key/tut_walk.ogg new file mode 100644 index 00000000..c80b3188 Binary files /dev/null and b/web/dungeon/audio/voice/male/key/tut_walk.ogg differ diff --git a/web/dungeon/test/playtest.mjs b/web/dungeon/test/playtest.mjs index 5499049e..2e73764d 100644 --- a/web/dungeon/test/playtest.mjs +++ b/web/dungeon/test/playtest.mjs @@ -71,7 +71,8 @@ globalThis.clearTimeout = unschedule; globalThis.localStorage = localStorageMock; globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); }; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, configurable: true, }); globalThis.window = { diff --git a/web/engine/audio.js b/web/engine/audio.js index 483a2147..2837e780 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -32,6 +32,12 @@ * - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试 * `audio/voice//intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的 * `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。 + * - 输入模式(settings.input + gesture.inputMode() 运行时跟随):**生效模式为键盘** + * (settings.input==='key',或 auto 且最近使用键盘/纯桌面初始判定)时,voice + * 候选链最前面多一跳 `audio/voice//key/.*`——键盘措辞变体资产 + * (P1 双套资产路线 a,仅覆盖含操作词的句子),缺失时自然回退 + * `voice//` 主资产 → 平铺,触屏措辞资产零改动。缓存键带输入模式, + * 模式切换后从下一句起用另一套(与性别切换同语义)。 * - settings.speed:voice 层语速,**变速不变调**——voice 走 SoundTouch 时间 * 拉伸管线(vendor/soundtouch.js,ScriptProcessorNode),从下一句开始生效。 * - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。 @@ -342,18 +348,25 @@ export class WebAudioEngine { * id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。 * voice 层按 settings.voice 优先尝试 `voice//` 子目录, * 找不到再回退平铺路径(过渡期兼容)。 + * **键盘变体(P1 路线 a)**:生效输入模式为键盘(gesture.inputMode()==='key', + * 即 settings.input==='key' 或 auto 且键盘在跟)时,候选链前置 + * `voice//key/` 一跳——键盘措辞资产只有含操作词的句子,其余 + * id 此跳 404 后自然落回 `voice//` 主资产 → 平铺,游戏无感。 */ _candidates(id) { const name = id.slice(id.lastIndexOf('/') + 1); const hasExt = name.includes('.'); if (id.startsWith('voice/')) { - const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6); + const rest = id.slice(6); + const gender = settings.get().voice; + const genderBase = this.basePath + 'voice/' + gender + '/' + rest; const flatBase = this.basePath + id; - if (hasExt) return [genderBase, flatBase]; - return [ - ...EXT_FALLBACKS.map((ext) => genderBase + ext), - ...EXT_FALLBACKS.map((ext) => flatBase + ext), - ]; + // key 候选只按「生效模式」动态决定(无独立缓存层,见 _load 的缓存键) + const bases = inputMode() === 'key' + ? [this.basePath + 'voice/' + gender + '/key/' + rest, genderBase, flatBase] + : [genderBase, flatBase]; + if (hasExt) return bases; + return bases.flatMap((b) => EXT_FALLBACKS.map((ext) => b + ext)); } const base = this.basePath + id; if (hasExt) return [base]; @@ -365,8 +378,11 @@ export class WebAudioEngine { * 资产缺失/无法解码时 console.error 并返回 null,不抛异常。 */ _load(id) { - // voice 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果 - const key = id.startsWith('voice/') ? `${settings.get().voice}|${id}` : id; + // voice 层缓存键带性别 + 输入模式(key 变体/主资产/平铺是三套不同音频): + // 切换 settings.voice 或输入模式后不会复用旧缓存结果,自然从下一句生效 + const key = id.startsWith('voice/') + ? `${inputMode() === 'key' ? 'key' : 'main'}|${settings.get().voice}|${id}` + : id; if (this._cache.has(key)) return this._cache.get(key); const p = (async () => { if (!this.ctx) return null; diff --git a/web/escape/audio/voice/female/key/again.m4a b/web/escape/audio/voice/female/key/again.m4a new file mode 100644 index 00000000..3688e5cb Binary files /dev/null and b/web/escape/audio/voice/female/key/again.m4a differ diff --git a/web/escape/audio/voice/female/key/again.ogg b/web/escape/audio/voice/female/key/again.ogg new file mode 100644 index 00000000..1c75b125 Binary files /dev/null and b/web/escape/audio/voice/female/key/again.ogg differ diff --git a/web/escape/audio/voice/female/key/g1_move.m4a b/web/escape/audio/voice/female/key/g1_move.m4a new file mode 100644 index 00000000..35b98b5b Binary files /dev/null and b/web/escape/audio/voice/female/key/g1_move.m4a differ diff --git a/web/escape/audio/voice/female/key/g1_move.ogg b/web/escape/audio/voice/female/key/g1_move.ogg new file mode 100644 index 00000000..5d99a425 Binary files /dev/null and b/web/escape/audio/voice/female/key/g1_move.ogg differ diff --git a/web/escape/audio/voice/female/key/g2_wall.m4a b/web/escape/audio/voice/female/key/g2_wall.m4a new file mode 100644 index 00000000..865f9e18 Binary files /dev/null and b/web/escape/audio/voice/female/key/g2_wall.m4a differ diff --git a/web/escape/audio/voice/female/key/g2_wall.ogg b/web/escape/audio/voice/female/key/g2_wall.ogg new file mode 100644 index 00000000..55576315 Binary files /dev/null and b/web/escape/audio/voice/female/key/g2_wall.ogg differ diff --git a/web/escape/audio/voice/female/key/g3_left.m4a b/web/escape/audio/voice/female/key/g3_left.m4a new file mode 100644 index 00000000..e8b9ed8c Binary files /dev/null and b/web/escape/audio/voice/female/key/g3_left.m4a differ diff --git a/web/escape/audio/voice/female/key/g3_left.ogg b/web/escape/audio/voice/female/key/g3_left.ogg new file mode 100644 index 00000000..2ca8f444 Binary files /dev/null and b/web/escape/audio/voice/female/key/g3_left.ogg differ diff --git a/web/escape/audio/voice/female/key/g4_down.m4a b/web/escape/audio/voice/female/key/g4_down.m4a new file mode 100644 index 00000000..8f53b1e6 Binary files /dev/null and b/web/escape/audio/voice/female/key/g4_down.m4a differ diff --git a/web/escape/audio/voice/female/key/g4_down.ogg b/web/escape/audio/voice/female/key/g4_down.ogg new file mode 100644 index 00000000..fc494aad Binary files /dev/null and b/web/escape/audio/voice/female/key/g4_down.ogg differ diff --git a/web/escape/audio/voice/female/key/g5_inspect.m4a b/web/escape/audio/voice/female/key/g5_inspect.m4a new file mode 100644 index 00000000..d4121d48 Binary files /dev/null and b/web/escape/audio/voice/female/key/g5_inspect.m4a differ diff --git a/web/escape/audio/voice/female/key/g5_inspect.ogg b/web/escape/audio/voice/female/key/g5_inspect.ogg new file mode 100644 index 00000000..3a08b1fe Binary files /dev/null and b/web/escape/audio/voice/female/key/g5_inspect.ogg differ diff --git a/web/escape/audio/voice/female/key/g6_doubletap.m4a b/web/escape/audio/voice/female/key/g6_doubletap.m4a new file mode 100644 index 00000000..62a63a82 Binary files /dev/null and b/web/escape/audio/voice/female/key/g6_doubletap.m4a differ diff --git a/web/escape/audio/voice/female/key/g6_doubletap.ogg b/web/escape/audio/voice/female/key/g6_doubletap.ogg new file mode 100644 index 00000000..e27b35ee Binary files /dev/null and b/web/escape/audio/voice/female/key/g6_doubletap.ogg differ diff --git a/web/escape/audio/voice/female/key/g7_goal.m4a b/web/escape/audio/voice/female/key/g7_goal.m4a new file mode 100644 index 00000000..e6ad8c0e Binary files /dev/null and b/web/escape/audio/voice/female/key/g7_goal.m4a differ diff --git a/web/escape/audio/voice/female/key/g7_goal.ogg b/web/escape/audio/voice/female/key/g7_goal.ogg new file mode 100644 index 00000000..1f7aefa6 Binary files /dev/null and b/web/escape/audio/voice/female/key/g7_goal.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_down.m4a b/web/escape/audio/voice/female/key/guide_blunt_down.m4a new file mode 100644 index 00000000..f76c0d04 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_down.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_down.ogg b/web/escape/audio/voice/female/key/guide_blunt_down.ogg new file mode 100644 index 00000000..acffc4f3 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_down.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_left.m4a b/web/escape/audio/voice/female/key/guide_blunt_left.m4a new file mode 100644 index 00000000..259b80d5 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_left.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_left.ogg b/web/escape/audio/voice/female/key/guide_blunt_left.ogg new file mode 100644 index 00000000..4f122ba9 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_left.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_right.m4a b/web/escape/audio/voice/female/key/guide_blunt_right.m4a new file mode 100644 index 00000000..373f86f5 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_right.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_right.ogg b/web/escape/audio/voice/female/key/guide_blunt_right.ogg new file mode 100644 index 00000000..9160be27 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_right.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_up.m4a b/web/escape/audio/voice/female/key/guide_blunt_up.m4a new file mode 100644 index 00000000..f476cbff Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_up.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_blunt_up.ogg b/web/escape/audio/voice/female/key/guide_blunt_up.ogg new file mode 100644 index 00000000..c55b1e15 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_blunt_up.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_down.m4a b/web/escape/audio/voice/female/key/guide_wrong_down.m4a new file mode 100644 index 00000000..0d7c053a Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_down.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_down.ogg b/web/escape/audio/voice/female/key/guide_wrong_down.ogg new file mode 100644 index 00000000..c03eacd9 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_down.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_left.m4a b/web/escape/audio/voice/female/key/guide_wrong_left.m4a new file mode 100644 index 00000000..4de7919e Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_left.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_left.ogg b/web/escape/audio/voice/female/key/guide_wrong_left.ogg new file mode 100644 index 00000000..5825a939 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_left.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_right.m4a b/web/escape/audio/voice/female/key/guide_wrong_right.m4a new file mode 100644 index 00000000..129d7963 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_right.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_right.ogg b/web/escape/audio/voice/female/key/guide_wrong_right.ogg new file mode 100644 index 00000000..7491ef4f Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_right.ogg differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_up.m4a b/web/escape/audio/voice/female/key/guide_wrong_up.m4a new file mode 100644 index 00000000..d592df28 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_up.m4a differ diff --git a/web/escape/audio/voice/female/key/guide_wrong_up.ogg b/web/escape/audio/voice/female/key/guide_wrong_up.ogg new file mode 100644 index 00000000..2608c022 Binary files /dev/null and b/web/escape/audio/voice/female/key/guide_wrong_up.ogg differ diff --git a/web/escape/audio/voice/female/key/help_ops.m4a b/web/escape/audio/voice/female/key/help_ops.m4a new file mode 100644 index 00000000..75ab4964 Binary files /dev/null and b/web/escape/audio/voice/female/key/help_ops.m4a differ diff --git a/web/escape/audio/voice/female/key/help_ops.ogg b/web/escape/audio/voice/female/key/help_ops.ogg new file mode 100644 index 00000000..01916269 Binary files /dev/null and b/web/escape/audio/voice/female/key/help_ops.ogg differ diff --git a/web/escape/audio/voice/female/key/inspect_gate_ready.m4a b/web/escape/audio/voice/female/key/inspect_gate_ready.m4a new file mode 100644 index 00000000..33bc6515 Binary files /dev/null and b/web/escape/audio/voice/female/key/inspect_gate_ready.m4a differ diff --git a/web/escape/audio/voice/female/key/inspect_gate_ready.ogg b/web/escape/audio/voice/female/key/inspect_gate_ready.ogg new file mode 100644 index 00000000..d6882aa5 Binary files /dev/null and b/web/escape/audio/voice/female/key/inspect_gate_ready.ogg differ diff --git a/web/escape/audio/voice/female/key/nav_down.m4a b/web/escape/audio/voice/female/key/nav_down.m4a new file mode 100644 index 00000000..81868de2 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_down.m4a differ diff --git a/web/escape/audio/voice/female/key/nav_down.ogg b/web/escape/audio/voice/female/key/nav_down.ogg new file mode 100644 index 00000000..7ea049fb Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_down.ogg differ diff --git a/web/escape/audio/voice/female/key/nav_left.m4a b/web/escape/audio/voice/female/key/nav_left.m4a new file mode 100644 index 00000000..bbcd4f75 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_left.m4a differ diff --git a/web/escape/audio/voice/female/key/nav_left.ogg b/web/escape/audio/voice/female/key/nav_left.ogg new file mode 100644 index 00000000..7023c7b7 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_left.ogg differ diff --git a/web/escape/audio/voice/female/key/nav_right.m4a b/web/escape/audio/voice/female/key/nav_right.m4a new file mode 100644 index 00000000..9b37600f Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_right.m4a differ diff --git a/web/escape/audio/voice/female/key/nav_right.ogg b/web/escape/audio/voice/female/key/nav_right.ogg new file mode 100644 index 00000000..46bc9762 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_right.ogg differ diff --git a/web/escape/audio/voice/female/key/nav_up.m4a b/web/escape/audio/voice/female/key/nav_up.m4a new file mode 100644 index 00000000..99825b99 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_up.m4a differ diff --git a/web/escape/audio/voice/female/key/nav_up.ogg b/web/escape/audio/voice/female/key/nav_up.ogg new file mode 100644 index 00000000..ae118f62 Binary files /dev/null and b/web/escape/audio/voice/female/key/nav_up.ogg differ diff --git a/web/escape/audio/voice/female/key/reopen_prompt.m4a b/web/escape/audio/voice/female/key/reopen_prompt.m4a new file mode 100644 index 00000000..2f0c415c Binary files /dev/null and b/web/escape/audio/voice/female/key/reopen_prompt.m4a differ diff --git a/web/escape/audio/voice/female/key/reopen_prompt.ogg b/web/escape/audio/voice/female/key/reopen_prompt.ogg new file mode 100644 index 00000000..d4da6112 Binary files /dev/null and b/web/escape/audio/voice/female/key/reopen_prompt.ogg differ diff --git a/web/escape/audio/voice/female/key/skip_hint.m4a b/web/escape/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..1a461a16 Binary files /dev/null and b/web/escape/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/escape/audio/voice/female/key/skip_hint.ogg b/web/escape/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..3b5875bd Binary files /dev/null and b/web/escape/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/escape/audio/voice/female/key/start_room.m4a b/web/escape/audio/voice/female/key/start_room.m4a new file mode 100644 index 00000000..76aca0fd Binary files /dev/null and b/web/escape/audio/voice/female/key/start_room.m4a differ diff --git a/web/escape/audio/voice/female/key/start_room.ogg b/web/escape/audio/voice/female/key/start_room.ogg new file mode 100644 index 00000000..ef0e59b1 Binary files /dev/null and b/web/escape/audio/voice/female/key/start_room.ogg differ diff --git a/web/escape/audio/voice/female/key/stuck_box_2.m4a b/web/escape/audio/voice/female/key/stuck_box_2.m4a new file mode 100644 index 00000000..beeaf1b3 Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_box_2.m4a differ diff --git a/web/escape/audio/voice/female/key/stuck_box_2.ogg b/web/escape/audio/voice/female/key/stuck_box_2.ogg new file mode 100644 index 00000000..a4cb0587 Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_box_2.ogg differ diff --git a/web/escape/audio/voice/female/key/stuck_cabinet_2.m4a b/web/escape/audio/voice/female/key/stuck_cabinet_2.m4a new file mode 100644 index 00000000..dccdb3bf Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_cabinet_2.m4a differ diff --git a/web/escape/audio/voice/female/key/stuck_cabinet_2.ogg b/web/escape/audio/voice/female/key/stuck_cabinet_2.ogg new file mode 100644 index 00000000..0f9252d5 Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_cabinet_2.ogg differ diff --git a/web/escape/audio/voice/female/key/stuck_gate_2.m4a b/web/escape/audio/voice/female/key/stuck_gate_2.m4a new file mode 100644 index 00000000..f8413afd Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_gate_2.m4a differ diff --git a/web/escape/audio/voice/female/key/stuck_gate_2.ogg b/web/escape/audio/voice/female/key/stuck_gate_2.ogg new file mode 100644 index 00000000..ddec2a40 Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_gate_2.ogg differ diff --git a/web/escape/audio/voice/female/key/stuck_key_2.m4a b/web/escape/audio/voice/female/key/stuck_key_2.m4a new file mode 100644 index 00000000..4085b940 Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_key_2.m4a differ diff --git a/web/escape/audio/voice/female/key/stuck_key_2.ogg b/web/escape/audio/voice/female/key/stuck_key_2.ogg new file mode 100644 index 00000000..958acf1c Binary files /dev/null and b/web/escape/audio/voice/female/key/stuck_key_2.ogg differ diff --git a/web/escape/audio/voice/female/key/use_fail.m4a b/web/escape/audio/voice/female/key/use_fail.m4a new file mode 100644 index 00000000..25e87a2c Binary files /dev/null and b/web/escape/audio/voice/female/key/use_fail.m4a differ diff --git a/web/escape/audio/voice/female/key/use_fail.ogg b/web/escape/audio/voice/female/key/use_fail.ogg new file mode 100644 index 00000000..9de3eb7f Binary files /dev/null and b/web/escape/audio/voice/female/key/use_fail.ogg differ diff --git a/web/escape/audio/voice/male/key/again.m4a b/web/escape/audio/voice/male/key/again.m4a new file mode 100644 index 00000000..ab81eb7b Binary files /dev/null and b/web/escape/audio/voice/male/key/again.m4a differ diff --git a/web/escape/audio/voice/male/key/again.ogg b/web/escape/audio/voice/male/key/again.ogg new file mode 100644 index 00000000..60ef67b6 Binary files /dev/null and b/web/escape/audio/voice/male/key/again.ogg differ diff --git a/web/escape/audio/voice/male/key/g1_move.m4a b/web/escape/audio/voice/male/key/g1_move.m4a new file mode 100644 index 00000000..3d9e7a7a Binary files /dev/null and b/web/escape/audio/voice/male/key/g1_move.m4a differ diff --git a/web/escape/audio/voice/male/key/g1_move.ogg b/web/escape/audio/voice/male/key/g1_move.ogg new file mode 100644 index 00000000..4cb96897 Binary files /dev/null and b/web/escape/audio/voice/male/key/g1_move.ogg differ diff --git a/web/escape/audio/voice/male/key/g2_wall.m4a b/web/escape/audio/voice/male/key/g2_wall.m4a new file mode 100644 index 00000000..613d500f Binary files /dev/null and b/web/escape/audio/voice/male/key/g2_wall.m4a differ diff --git a/web/escape/audio/voice/male/key/g2_wall.ogg b/web/escape/audio/voice/male/key/g2_wall.ogg new file mode 100644 index 00000000..8fbcc33d Binary files /dev/null and b/web/escape/audio/voice/male/key/g2_wall.ogg differ diff --git a/web/escape/audio/voice/male/key/g3_left.m4a b/web/escape/audio/voice/male/key/g3_left.m4a new file mode 100644 index 00000000..202163ff Binary files /dev/null and b/web/escape/audio/voice/male/key/g3_left.m4a differ diff --git a/web/escape/audio/voice/male/key/g3_left.ogg b/web/escape/audio/voice/male/key/g3_left.ogg new file mode 100644 index 00000000..64e8d9e3 Binary files /dev/null and b/web/escape/audio/voice/male/key/g3_left.ogg differ diff --git a/web/escape/audio/voice/male/key/g4_down.m4a b/web/escape/audio/voice/male/key/g4_down.m4a new file mode 100644 index 00000000..3f16b7c0 Binary files /dev/null and b/web/escape/audio/voice/male/key/g4_down.m4a differ diff --git a/web/escape/audio/voice/male/key/g4_down.ogg b/web/escape/audio/voice/male/key/g4_down.ogg new file mode 100644 index 00000000..e67ead0f Binary files /dev/null and b/web/escape/audio/voice/male/key/g4_down.ogg differ diff --git a/web/escape/audio/voice/male/key/g5_inspect.m4a b/web/escape/audio/voice/male/key/g5_inspect.m4a new file mode 100644 index 00000000..d9cdba15 Binary files /dev/null and b/web/escape/audio/voice/male/key/g5_inspect.m4a differ diff --git a/web/escape/audio/voice/male/key/g5_inspect.ogg b/web/escape/audio/voice/male/key/g5_inspect.ogg new file mode 100644 index 00000000..9f508d06 Binary files /dev/null and b/web/escape/audio/voice/male/key/g5_inspect.ogg differ diff --git a/web/escape/audio/voice/male/key/g6_doubletap.m4a b/web/escape/audio/voice/male/key/g6_doubletap.m4a new file mode 100644 index 00000000..fa228a6f Binary files /dev/null and b/web/escape/audio/voice/male/key/g6_doubletap.m4a differ diff --git a/web/escape/audio/voice/male/key/g6_doubletap.ogg b/web/escape/audio/voice/male/key/g6_doubletap.ogg new file mode 100644 index 00000000..ba982525 Binary files /dev/null and b/web/escape/audio/voice/male/key/g6_doubletap.ogg differ diff --git a/web/escape/audio/voice/male/key/g7_goal.m4a b/web/escape/audio/voice/male/key/g7_goal.m4a new file mode 100644 index 00000000..0f93779e Binary files /dev/null and b/web/escape/audio/voice/male/key/g7_goal.m4a differ diff --git a/web/escape/audio/voice/male/key/g7_goal.ogg b/web/escape/audio/voice/male/key/g7_goal.ogg new file mode 100644 index 00000000..4947168a Binary files /dev/null and b/web/escape/audio/voice/male/key/g7_goal.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_down.m4a b/web/escape/audio/voice/male/key/guide_blunt_down.m4a new file mode 100644 index 00000000..79160002 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_down.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_down.ogg b/web/escape/audio/voice/male/key/guide_blunt_down.ogg new file mode 100644 index 00000000..a96a53fe Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_down.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_left.m4a b/web/escape/audio/voice/male/key/guide_blunt_left.m4a new file mode 100644 index 00000000..7054b0b3 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_left.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_left.ogg b/web/escape/audio/voice/male/key/guide_blunt_left.ogg new file mode 100644 index 00000000..c070ac59 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_left.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_right.m4a b/web/escape/audio/voice/male/key/guide_blunt_right.m4a new file mode 100644 index 00000000..9ceebd23 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_right.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_right.ogg b/web/escape/audio/voice/male/key/guide_blunt_right.ogg new file mode 100644 index 00000000..d3c083a5 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_right.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_up.m4a b/web/escape/audio/voice/male/key/guide_blunt_up.m4a new file mode 100644 index 00000000..ffbbb8ca Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_up.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_blunt_up.ogg b/web/escape/audio/voice/male/key/guide_blunt_up.ogg new file mode 100644 index 00000000..58fc443f Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_blunt_up.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_down.m4a b/web/escape/audio/voice/male/key/guide_wrong_down.m4a new file mode 100644 index 00000000..a0b21931 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_down.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_down.ogg b/web/escape/audio/voice/male/key/guide_wrong_down.ogg new file mode 100644 index 00000000..37e889a7 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_down.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_left.m4a b/web/escape/audio/voice/male/key/guide_wrong_left.m4a new file mode 100644 index 00000000..c56367eb Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_left.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_left.ogg b/web/escape/audio/voice/male/key/guide_wrong_left.ogg new file mode 100644 index 00000000..7cbc4159 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_left.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_right.m4a b/web/escape/audio/voice/male/key/guide_wrong_right.m4a new file mode 100644 index 00000000..46346c60 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_right.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_right.ogg b/web/escape/audio/voice/male/key/guide_wrong_right.ogg new file mode 100644 index 00000000..5e2850ae Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_right.ogg differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_up.m4a b/web/escape/audio/voice/male/key/guide_wrong_up.m4a new file mode 100644 index 00000000..7e5cd44c Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_up.m4a differ diff --git a/web/escape/audio/voice/male/key/guide_wrong_up.ogg b/web/escape/audio/voice/male/key/guide_wrong_up.ogg new file mode 100644 index 00000000..1d0aed70 Binary files /dev/null and b/web/escape/audio/voice/male/key/guide_wrong_up.ogg differ diff --git a/web/escape/audio/voice/male/key/help_ops.m4a b/web/escape/audio/voice/male/key/help_ops.m4a new file mode 100644 index 00000000..02fbe7ea Binary files /dev/null and b/web/escape/audio/voice/male/key/help_ops.m4a differ diff --git a/web/escape/audio/voice/male/key/help_ops.ogg b/web/escape/audio/voice/male/key/help_ops.ogg new file mode 100644 index 00000000..52149404 Binary files /dev/null and b/web/escape/audio/voice/male/key/help_ops.ogg differ diff --git a/web/escape/audio/voice/male/key/inspect_gate_ready.m4a b/web/escape/audio/voice/male/key/inspect_gate_ready.m4a new file mode 100644 index 00000000..5e1be61c Binary files /dev/null and b/web/escape/audio/voice/male/key/inspect_gate_ready.m4a differ diff --git a/web/escape/audio/voice/male/key/inspect_gate_ready.ogg b/web/escape/audio/voice/male/key/inspect_gate_ready.ogg new file mode 100644 index 00000000..ba004426 Binary files /dev/null and b/web/escape/audio/voice/male/key/inspect_gate_ready.ogg differ diff --git a/web/escape/audio/voice/male/key/nav_down.m4a b/web/escape/audio/voice/male/key/nav_down.m4a new file mode 100644 index 00000000..2f784968 Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_down.m4a differ diff --git a/web/escape/audio/voice/male/key/nav_down.ogg b/web/escape/audio/voice/male/key/nav_down.ogg new file mode 100644 index 00000000..e33e6b9b Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_down.ogg differ diff --git a/web/escape/audio/voice/male/key/nav_left.m4a b/web/escape/audio/voice/male/key/nav_left.m4a new file mode 100644 index 00000000..afe82485 Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_left.m4a differ diff --git a/web/escape/audio/voice/male/key/nav_left.ogg b/web/escape/audio/voice/male/key/nav_left.ogg new file mode 100644 index 00000000..d7985757 Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_left.ogg differ diff --git a/web/escape/audio/voice/male/key/nav_right.m4a b/web/escape/audio/voice/male/key/nav_right.m4a new file mode 100644 index 00000000..721ee3ca Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_right.m4a differ diff --git a/web/escape/audio/voice/male/key/nav_right.ogg b/web/escape/audio/voice/male/key/nav_right.ogg new file mode 100644 index 00000000..348b82aa Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_right.ogg differ diff --git a/web/escape/audio/voice/male/key/nav_up.m4a b/web/escape/audio/voice/male/key/nav_up.m4a new file mode 100644 index 00000000..0facb6ae Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_up.m4a differ diff --git a/web/escape/audio/voice/male/key/nav_up.ogg b/web/escape/audio/voice/male/key/nav_up.ogg new file mode 100644 index 00000000..6f055a07 Binary files /dev/null and b/web/escape/audio/voice/male/key/nav_up.ogg differ diff --git a/web/escape/audio/voice/male/key/reopen_prompt.m4a b/web/escape/audio/voice/male/key/reopen_prompt.m4a new file mode 100644 index 00000000..a24bcecb Binary files /dev/null and b/web/escape/audio/voice/male/key/reopen_prompt.m4a differ diff --git a/web/escape/audio/voice/male/key/reopen_prompt.ogg b/web/escape/audio/voice/male/key/reopen_prompt.ogg new file mode 100644 index 00000000..53209c09 Binary files /dev/null and b/web/escape/audio/voice/male/key/reopen_prompt.ogg differ diff --git a/web/escape/audio/voice/male/key/skip_hint.m4a b/web/escape/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..2d65d7e0 Binary files /dev/null and b/web/escape/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/escape/audio/voice/male/key/skip_hint.ogg b/web/escape/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..29df79b6 Binary files /dev/null and b/web/escape/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/escape/audio/voice/male/key/start_room.m4a b/web/escape/audio/voice/male/key/start_room.m4a new file mode 100644 index 00000000..20fc178a Binary files /dev/null and b/web/escape/audio/voice/male/key/start_room.m4a differ diff --git a/web/escape/audio/voice/male/key/start_room.ogg b/web/escape/audio/voice/male/key/start_room.ogg new file mode 100644 index 00000000..e21c0f58 Binary files /dev/null and b/web/escape/audio/voice/male/key/start_room.ogg differ diff --git a/web/escape/audio/voice/male/key/stuck_box_2.m4a b/web/escape/audio/voice/male/key/stuck_box_2.m4a new file mode 100644 index 00000000..a95388e6 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_box_2.m4a differ diff --git a/web/escape/audio/voice/male/key/stuck_box_2.ogg b/web/escape/audio/voice/male/key/stuck_box_2.ogg new file mode 100644 index 00000000..b97fb1e5 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_box_2.ogg differ diff --git a/web/escape/audio/voice/male/key/stuck_cabinet_2.m4a b/web/escape/audio/voice/male/key/stuck_cabinet_2.m4a new file mode 100644 index 00000000..031ece71 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_cabinet_2.m4a differ diff --git a/web/escape/audio/voice/male/key/stuck_cabinet_2.ogg b/web/escape/audio/voice/male/key/stuck_cabinet_2.ogg new file mode 100644 index 00000000..62488c5f Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_cabinet_2.ogg differ diff --git a/web/escape/audio/voice/male/key/stuck_gate_2.m4a b/web/escape/audio/voice/male/key/stuck_gate_2.m4a new file mode 100644 index 00000000..aaa6b429 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_gate_2.m4a differ diff --git a/web/escape/audio/voice/male/key/stuck_gate_2.ogg b/web/escape/audio/voice/male/key/stuck_gate_2.ogg new file mode 100644 index 00000000..7ec7397d Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_gate_2.ogg differ diff --git a/web/escape/audio/voice/male/key/stuck_key_2.m4a b/web/escape/audio/voice/male/key/stuck_key_2.m4a new file mode 100644 index 00000000..a8446df2 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_key_2.m4a differ diff --git a/web/escape/audio/voice/male/key/stuck_key_2.ogg b/web/escape/audio/voice/male/key/stuck_key_2.ogg new file mode 100644 index 00000000..6fd75c01 Binary files /dev/null and b/web/escape/audio/voice/male/key/stuck_key_2.ogg differ diff --git a/web/escape/audio/voice/male/key/use_fail.m4a b/web/escape/audio/voice/male/key/use_fail.m4a new file mode 100644 index 00000000..8f8f489a Binary files /dev/null and b/web/escape/audio/voice/male/key/use_fail.m4a differ diff --git a/web/escape/audio/voice/male/key/use_fail.ogg b/web/escape/audio/voice/male/key/use_fail.ogg new file mode 100644 index 00000000..6113d0ff Binary files /dev/null and b/web/escape/audio/voice/male/key/use_fail.ogg differ diff --git a/web/escape/test/playtest.mjs b/web/escape/test/playtest.mjs index 61ba22b7..66019252 100644 --- a/web/escape/test/playtest.mjs +++ b/web/escape/test/playtest.mjs @@ -71,7 +71,8 @@ const fetchLog = []; let blockedAssets = []; // 命中即 404(模拟资产缺失) Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (p) => vibrateLog.push(p) }, configurable: true, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (p) => vibrateLog.push(p) }, configurable: true, }); const store = new Map(); diff --git a/web/harness/engine-input.test.mjs b/web/harness/engine-input.test.mjs index 22c42790..059bd900 100644 --- a/web/harness/engine-input.test.mjs +++ b/web/harness/engine-input.test.mjs @@ -14,7 +14,10 @@ * 最近通道(keydown→key / touchstart→touch),初始按能力检测; * K7 immediateTap(rhythm):空格按下即 tap、长按补 tapcancel、双击改判—— * 键盘路径与触摸路径行为逐条一致; - * K8 detach 后键盘监听移除;孤儿 keyup 不产生事件。 + * K8 detach 后键盘监听移除;孤儿 keyup 不产生事件; + * K9 voice 键盘变体路由(P1 路线 a):生效模式=key 时 audio.js 候选链前置 + * voice//key/(回退 gender → 平铺),touch 模式链与 P1 前一致; + * 缓存键按输入模式分桶(key||)。 * * 运行:tools/node/bin/node web/harness/engine-input.test.mjs */ @@ -245,6 +248,51 @@ assert(events.length === 0, 'detach 同时清掉挂起的定时器(无迟到 t keyUp('Space'); assert(events.length === 0, '孤儿空格 keyup(无对应认领)无事件无异常'); +// ---- K9 voice 键盘变体资产路由(P1 路线 a,engine/audio.js _candidates/_load)---- +console.log('\n=== K9 voice 键盘变体路由(key 候选前置 + 回退链) ==='); +{ + const detach9 = attachGestures(root, () => {}); // K8 已 detach:重新挂上才能用 keyDown 驱动 inputMode 跟随 + const { WebAudioEngine } = await import(path.join(WEB, 'engine', 'audio.js')); + const engine = new WebAudioEngine('audio/'); + + settings.set({ input: 'auto' }); + keyDown('ArrowUp'); // 初始即 key(纯桌面),按键再确认一次 + assert(inputMode() === 'key', 'auto + 纯桌面 → 生效模式 key'); + let c = engine._candidates('voice/help'); + assert(c[0] === 'audio/voice/female/key/help.ogg', 'key 模式:候选链首跳 voice//key/.ogg'); + assert(c[4] === 'audio/voice/female/help.ogg', 'key 模式:第二跳回 voice//(主资产)'); + assert(c[8] === 'audio/voice/help.ogg', 'key 模式:第三跳平铺回退(触屏/角色句兜底)'); + assert(c.length === 12, 'key 模式:3 跳 × 4 扩展名 = 12 个候选'); + c = engine._candidates('voice/help.ogg'); + assert(c.length === 3 && c[0].endsWith('/key/help.ogg') && c[1].endsWith('/female/help.ogg'), + 'key 模式:带扩展名 id 同样三跳,key 在最前'); + + settings.set({ input: 'touch' }); + c = engine._candidates('voice/help'); + assert(!c.some((u) => u.includes('/key/')) && c[0] === 'audio/voice/female/help.ogg' && c.length === 8, + 'touch 模式:无 key 跳,候选链与 P1 前完全一致(gender → flat × 4 扩展)'); + + settings.set({ input: 'auto' }); + fireDoc('touchstart'); + c = engine._candidates('voice/help'); + assert(!c.some((u) => u.includes('/key/')), 'auto 运行时跟随:触摸后变体跳消失'); + keyDown('ArrowUp'); + c = engine._candidates('voice/help'); + assert(c[0] === 'audio/voice/female/key/help.ogg', 'auto 运行时跟随:键盘一按变体跳回来(语音随最近通道切措辞)'); + + // 缓存键按生效模式分桶:key/main 两套结果互不串缓存(与性别切换同语义) + engine._cache.clear(); + await engine._load('voice/help'); // ctx 为 null → 直接 null,但仍写入缓存 + const keys = [...engine._cache.keys()]; + assert(keys.length === 1 && keys[0] === 'key|female|voice/help', + '缓存键带输入模式:key 模式下为 key||'); + fireDoc('touchstart'); + await engine._load('voice/help'); + assert([...engine._cache.keys()].some((k) => k === 'main|female|voice/help'), + '切回 touch 后同一 id 落独立 main|| 缓存桶'); + detach9(); +} + console.log(`\n========================================`); console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`); if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); } diff --git a/web/harness/guide.test.mjs b/web/harness/guide.test.mjs index 3e31570a..6bd59a02 100644 --- a/web/harness/guide.test.mjs +++ b/web/harness/guide.test.mjs @@ -109,7 +109,8 @@ globalThis.localStorage = { const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true, }); class MockParam { diff --git a/web/harness/live/control.mjs b/web/harness/live/control.mjs index 890a9dca..1b53e074 100644 --- a/web/harness/live/control.mjs +++ b/web/harness/live/control.mjs @@ -230,7 +230,9 @@ async function buildListenRows() { s.sent = true; // pending 的会在响应里翻回 false voices.push({ seg: s.idx, voiceId: s.voiceId, t0: s.t0, t1: s.t1, - expected: (VOICE_TEXT[S.game] || {})[s.voiceId] || null, + // key 变体会话(settings.input='key')对照 __key__ 表,变体句按键盘措辞验收 + expected: (S.settings?.input === 'key' && (VOICE_TEXT.__key__?.[S.game] || {})[s.voiceId]) + || (VOICE_TEXT[S.game] || {})[s.voiceId] || null, }); } // 间隙探针:相邻 voice 段之间 / 录音起点到第一段,>1.5s 且未探过 diff --git a/web/harness/mock.mjs b/web/harness/mock.mjs index 3582efe3..e2c2715b 100644 --- a/web/harness/mock.mjs +++ b/web/harness/mock.mjs @@ -93,7 +93,10 @@ export function installMock(gameDir) { const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } }, + // maxTouchPoints: 1 —— 转写 harness 是手机 persona:inputMode() 判 touch, + // voice 走触屏措辞主资产(与 voice_text.json 主表文本一致;键盘变体桌面 + // 路由由 harness/engine-input.test.mjs K9 专测) + value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } }, configurable: true, }); diff --git a/web/harness/voice_text.json b/web/harness/voice_text.json index 078382c6..ec6e527d 100644 --- a/web/harness/voice_text.json +++ b/web/harness/voice_text.json @@ -720,5 +720,187 @@ "unlock_echo": "解锁回声强化,侧向听得更远。", "unlock_blade": "解锁武器,开局带着短刃。", "unlock_none": "金币还不够,继续攒。" + }, + "__key__": { + "storyline": { + "help": "操作说明。按空格:继续剧情。按回车:重听刚才的台词。按上方向键:听这段说明和当前进度。按下方向键:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,按左方向键或者按右方向键,做出你的决定。选择无法撤销。", + "menu": "菜单。按左方向键:重新开始整个故事。按右方向键,或者按空格:回到故事。按上方向键:听操作说明。", + "p1": "请选择。按左方向键:先检查背包里的收音机。按右方向键:先摸黑寻找出口。", + "p2": "请选择。按左方向键:钻进旁边的通风管道。按右方向键:躺进路边的尸体堆里装死。", + "p3": "请选择。按左方向键:循着哭声过去看看。按右方向键:不去管它,直接去北区。", + "p4": "请选择。按左方向键:带上小满一起走。按右方向键:留下食物,独自上路。", + "p5": "请选择。按左方向键:走桥下的污水涵洞。按右方向键:爬桥上悬着的钢索。", + "p6": "请选择。按左方向键:交出坐标,保命要紧。按右方向键:拒绝他,想办法周旋。", + "p7": "请选择。按左方向键:追上去,警告避难所。按右方向键:自己逃命,越远越好。", + "p8": "请选择。按左方向键:用公开频道广播,让全城都听见。按右方向键:用加密频段,只发给避难所。", + "n00_intro": "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:按空格,继续剧情;按回车,重听上一句;按上方向键,听你的状态和帮助;按下方向键,呼出菜单。当我说“请选择”的时候,按左方向键,或者按右方向键,做出你的决定。选择一旦做出,就无法回头。现在,按空格,睁开眼睛。", + "e1_dawn": "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。按空格,可以回到故事开始的地方。", + "e2_price": "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。按空格,重新开始。", + "e3_alone": "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。按空格,重新开始。", + "e4_static": "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。", + "skip_hint": "按空格可跳过旁白。", + "gd_continue": "按空格,继续。", + "gd_brief": "按空格继续剧情。请选择的时候,按左右方向键。", + "gd_again": "还在吗?按空格继续。" + }, + "escape": { + "g1_move": "先活动一下。按任意方向键,走一小步。", + "g2_wall": "这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。", + "g3_left": "跟着我指的路走。按左方向键。", + "g4_down": "对。再按下方向键。", + "g5_inspect": "听到水声了吗?按一下空格,仔细检查这个房间。", + "g6_doubletap": "钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。", + "g7_goal": "找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。", + "guide_wrong_up": "不是这个方向。按上方向键。", + "guide_wrong_down": "不是这个方向。按下方向键。", + "guide_wrong_left": "不是这个方向。按左方向键。", + "guide_wrong_right": "不是这个方向。按右方向键。", + "guide_blunt_up": "按一下上方向键。", + "guide_blunt_down": "按一下下方向键。", + "guide_blunt_left": "按一下左方向键。", + "guide_blunt_right": "按一下右方向键。", + "reopen_prompt": "按上方向键,重新听一遍引导。按空格,直接开始。", + "stuck_key_2": "去西南角的储物间,按空格检查,找到钥匙。", + "stuck_cabinet_2": "去东边的铁柜房,按回车,用钥匙打开铁柜。", + "stuck_box_2": "去西北角的电箱房,按回车,把保险丝装进电箱。", + "stuck_gate_2": "去东南角的大门,按回车,打开它,离开这里。", + "nav_up": "按上方向键。", + "nav_down": "按下方向键。", + "nav_left": "按左方向键。", + "nav_right": "按右方向键。", + "start_room": "你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。", + "inspect_gate_ready": "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。", + "use_fail": "这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。", + "again": "按空格,再来一次。按下方向键,打开菜单。", + "help_ops": "方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。", + "skip_hint": "按空格可跳过旁白。" + }, + "simon": { + "t1_up": "按上方向键,是这个声音。", + "t2_down": "按下方向键,是这个声音。", + "t3_left": "按左方向键,是这个声音。", + "t4_right": "按右方向键,是这个声音。", + "t5_tap": "按空格,是这个声音。", + "t6_double": "按回车,是这个声音。", + "t_wrong_up": "不是这个,按上方向键试试。", + "t_wrong_down": "不是这个,按下方向键试试。", + "t_wrong_left": "不是这个,按左方向键试试。", + "t_wrong_right": "不是这个,按右方向键试试。", + "t_wrong_tap": "不是这个,按空格试试。", + "t_wrong_double": "不是这个,按回车试试。", + "t_blunt_up": "按一下上方向键。", + "t_blunt_down": "按一下下方向键。", + "t_blunt_left": "按一下左方向键。", + "t_blunt_right": "按一下右方向键。", + "t_blunt_tap": "轻轻按一下空格。", + "t_blunt_double": "按一下回车。", + "t7_retry": "答错可以消耗机会重听。按上方向键,可以随时重听。", + "replay": "按空格,再来一局。", + "rules_prompt": "按上方向键,重听规则。按下方向键,打开菜单。", + "rules": "上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。", + "skip_hint": "按空格可跳过旁白。", + "gd_relisten": "需要的话,按上方向键可以重新听一遍序列。", + "gd_economy": "记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。", + "gd_occ_up": "这一轮,按上方向键是作答。", + "gd_occ_dt": "这一轮,按回车是作答。", + "gd_occ_both": "这一轮,按上方向键和回车都是作答。" + }, + "rhythm": { + "tut_1": "听,底鼓。在下一小节的同一个位置,按空格。", + "tut_2": "这次是左和右。跟着打,按左方向键,按右方向键。", + "tut_3": "最后一句,三个音,中间是按回车。", + "select_hint": "左右方向键选曲目,按空格开始,按下方向键回大厅。", + "restart": "按空格再来一遍,按右方向键下一首,按下方向键回大厅。", + "menu_main": "菜单。按空格切换选项,按回车确认。", + "skip_hint": "按空格可跳过旁白。", + "gd_miss_early": "抢拍了。等声音落下再按。", + "gd_miss_wrong": "方向不对。跟着音色按方向键。" + }, + "space": { + "drill_left": "听,左边有陨石。按左方向键,把它拨开!", + "drill_right": "这次在右边。按右方向键!", + "drill_up": "上面有东西,声音又尖又高。按上方向键!", + "drill_down": "下面是低沉的轰隆。按下方向键!", + "drill_retry_left": "那是左边来的,按左方向键。", + "drill_retry_right": "那是右边来的,按右方向键。", + "drill_retry_up": "声音又高又尖,是上面,按上方向键。", + "drill_retry_down": "轰隆声在下面,按下方向键。", + "protect_left": "训练护盾接住了。那颗在左边,听到左边的呼啸,按左方向键。", + "protect_right": "训练护盾接住了。那颗在右边,听到右边的呼啸,按右方向键。", + "protect_up": "训练护盾接住了。那颗在上面,听到高高的呼啸,按上方向键。", + "protect_down": "训练护盾接住了。那颗在下面,听到低沉的轰隆,按下方向键。", + "feat_boost": "试试按空格,引擎会短促加速,直接冲过空隙。", + "feat_shield": "按回车展开护盾,能挡下一次撞击。", + "hit_left": "左边撞上的。听到左边的呼啸,按左方向键。", + "hit_right": "右边撞上的。听到右边的呼啸,按右方向键。", + "hit_up": "上面撞上的。听到高高的呼啸,按上方向键。", + "hit_down": "下面撞上的。听到低沉的轰隆,按下方向键。", + "restart_hint": "按空格再飞一次,按下方向键回大厅。", + "second_run_ask": "按上方向键重新听演练,按空格直接出发。", + "menu_main": "菜单。按空格切换选项,按回车确认。", + "help_recap": "左边来按左方向键,右边来按右方向键,上面来按上方向键,下面来按下方向键。按空格加速,按回车护盾。", + "skip_hint": "按空格可跳过旁白。", + "gd_hit_hint": "被撞了。听方位,按对应方向键就能拨开。" + }, + "rpg": { + "sys_continue_ask": "检测到你的旅程记录。按空格继续,按右方向键开始新的旅程。", + "sys_menu": "菜单。左右方向键选择,按空格确认。", + "help_explore": "左右方向键切换选项,按空格确认。按回车重新听场景,按上方向键查看状态,按下方向键打开菜单。", + "help_battle": "战斗中,左右方向键选招式,按空格出手。听到敌人的前摇,按回车格挡。", + "btl_lose_hint": "记住,听清前摇,按回车格挡。", + "tut_b2": "就是现在,按回车格挡!", + "tut_b3": "挡住了!轮到你了。左右方向键选攻击,按空格出剑。", + "shop_open": "商店。左右方向键看看货,按空格购买。", + "skip_hint": "按空格可跳过旁白。", + "tut_battle_2": "左右方向键切换招式,攻击、破空斩、防御、金疮药。按空格,出手。", + "tut_battle_3": "敌人出手前有一声前摇。听到前摇,马上按回车,就能格挡。", + "tut_battle_5": "记住:听清前摇,按回车格挡。开始吧!", + "tut_b0": "战斗里,左右方向键选招式,按空格出手。听到这个声音,就是你的回合。先按一按方向键,试试。", + "tut_b6": "来不及格挡,就选防御。左右方向键,选防御,按空格。", + "gd_parry_miss": "前摇声起的时候按回车,才能格挡。", + "gd_stall": "想好了吗?左右方向键选择,按空格出手。" + }, + "radio": { + "gd_brief": "按空格继续节目。请选择的时候,按左或右方向键。", + "gd_continue": "按空格,继续。", + "gd_again": "还在吗?按空格继续。", + "gd_ring1": "热线在闪。按空格,接听。", + "gd_ring2": "灯还亮着,听众在等。按空格,接听。", + "sys_help": "按空格继续,按回车重新听。按上方向键听状态,按下方向键开菜单。请选择时,左右方向键。", + "sys_menu": "菜单。按空格切换选项,按回车确认。", + "sys_skip": "想跳过这句,按空格就行。", + "sys_continue_ask": "检测到你的夜班记录。按空格继续上次,按右方向键开始新的夜晚。", + "p1": "老人在发抖。按左方向键,陪他哼那首老歌。按右方向键,冷静问他路口的路牌。", + "p2": "雨声更密了。按左方向键,留在原地陪他等巡逻队。按右方向键,转接派出所。", + "p3": "按左方向键,告诉她这趟车值得上。按右方向键,把选择还给她。", + "p4": "按左方向键,唱一段摇篮曲哄她睡。按右方向键,轻轻问她怎么了。", + "p5": "孩子的声音在抖。按左方向键,告诉她这不是她的错。按右方向键,答应她明天都会好。", + "p6": "摔门声你听见了。按左方向键,替孩子给她带句话。按右方向键,劝她回家把话说开。", + "p7": "她的呼吸平了。按左方向键,留在直播里,让全城等这个门铃。按右方向键,换垫乐,私下陪她说完。", + "p8": "方向盘比话筒危险。按左方向键,陪他聊聊闺女。按右方向键,让他立刻进服务区。", + "p9": "留言机红灯在闪,是老主播三年前没播完的那句晚安。按左方向键,替他播出去。按右方向键,照常收播。" + }, + "dungeon": { + "sys_continue_ask": "检测到探窟记录。按空格继续,按右方向键重新开始。", + "tut_d0": "先学会用耳朵看。按空格,发出声波脉冲。", + "tut_walk": "按住空格不放,你就往前走。", + "tut_turn": "按左右方向键,可以转身。", + "tut_replay": "按回车,再发一次脉冲,不要钱。", + "tut_status": "按上方向键,随时听自己的状态。", + "tut_menu": "按下方向键,打开菜单。", + "tut_parry": "听到它的动静,按回车格挡。", + "tut_attack": "现在按空格,挥拳打它。", + "tut_flee_hold": "想逃跑,按住空格别松手。", + "tut_stairs_on": "楼梯就在脚下。按空格,下潜。", + "tut_skip": "已跳过教学。按空格发脉冲,先听听四周。", + "sys_menu": "菜单。按空格切换,按回车确认。", + "help_explore": "按空格发脉冲,按住空格前进,左右方向键转身。按回车再听,上方向键查状态,下方向键开菜单。", + "help_battle": "敌人前摇声响时按回车格挡,按空格挥击。打不过就按住空格逃跑。", + "die_adv_1": "下次,前摇声响时就按回车。", + "die_adv_2": "打不过,就按住空格逃跑。", + "gd_pulse": "按空格发脉冲,听听四周。", + "gd_turn": "轮到你了。按空格挥击。", + "settle_again": "按空格,再探一次。" + } } } \ No newline at end of file diff --git a/web/index.html b/web/index.html index 186c3b50..14058680 100644 --- a/web/index.html +++ b/web/index.html @@ -86,6 +86,16 @@ font-size: 14px; cursor: pointer; } + #pair-code-display { + align-self: center; + min-width: 150px; + padding: 6px 10px; + font-size: 22px; + letter-spacing: 0.3em; + text-align: center; + color: #7ec699; + } + #remove-form .manage-row button { background: #3c4043; } .manage-note { font-size: 12px; color: #5f6368; margin: 4px 0 0; } /* 设置区 */ #settings-row { display: flex; flex-wrap: wrap; gap: 16px; } @@ -167,7 +177,7 @@
- +

PIN 是可选项,只为防家人恶作剧;不设也完整可玩。

@@ -176,11 +186,36 @@
- +
- +
+ +
+ ------ + + +
+

配对码逐位朗读,10 分钟内单次有效;重新生成会使旧码立即作废。

+
+
+ +
+ + +
+

在老设备的「档案管理 → 转移到新设备」处获取配对码;连错 5 次需等待 10 分钟。

+
+
+
+ +
+

清掉本机的档案快照与登录凭证并回到匿名新档案;云端进度保留,可随时用「名字 + PIN」或新配对码找回。

+
+

@@ -291,8 +326,8 @@ import { settings } from './engine/settings.js'; import { BUCKETS, SETTINGS_KEY, - applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth, - nextLocalProfileName, readRegistry, readStorageJSON, restoreSnapshot, + applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth, + nextLocalProfileName, readRegistry, readStorageJSON, removeProfile, restoreSnapshot, setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry, writeStorageJSON, } from './profiles.js'; @@ -311,9 +346,13 @@ // ---------- 服务器 API(不可达时静默降级为纯本地档案) ---------- + /** + * tokenOverride === null → 显式匿名(不带 Authorization,如配对转入、名+PIN 换发登录); + * 缺省 → 当前档案 token。 + */ async function api(path, opts = {}, tokenOverride) { const cur = getCurrentProfile(localStorage); - const token = tokenOverride || (cur && cur.token) || null; + const token = tokenOverride !== undefined ? tokenOverride : ((cur && cur.token) || null); const headers = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = 'Bearer ' + token; const resp = await fetch('/api/' + path, { ...opts, headers }); @@ -343,9 +382,76 @@ const loginPinInput = document.getElementById('login-pin-input'); const loginBtn = document.getElementById('login-btn'); const offlineNote = document.getElementById('offline-note'); + const pairOutForm = document.getElementById('pair-out-form'); + const pairCodeDisplay = document.getElementById('pair-code-display'); + const pairStartBtn = document.getElementById('pair-start-btn'); + const pairReplayBtn = document.getElementById('pair-replay-btn'); + const pairInForm = document.getElementById('pair-in-form'); + const pairCodeInput = document.getElementById('pair-code-input'); + const pairJoinBtn = document.getElementById('pair-join-btn'); + const removeBtn = document.getElementById('remove-btn'); function announce(text) { userMsg.textContent = text || ''; } + // ---------- 可听化(P1 裁定:配置类数字与自定义玩家名不走 TTS 资产,用浏览器 ---------- + // ---------- speechSynthesis;中文 voice 优先;失败静默降级,aria-live 文本兜底) ---------- + + let zhVoice = null; + function pickZhVoice() { + try { + const voices = window.speechSynthesis ? window.speechSynthesis.getVoices() : []; + zhVoice = voices.find((v) => /^zh([-_]|$)/i.test(v.lang) && /cN|Chinese|中文|普通话/i.test(v.name)) + || voices.find((v) => /^zh([-_]|$)/i.test(v.lang)) + || null; + } catch (_) { zhVoice = null; } + } + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + pickZhVoice(); + try { window.speechSynthesis.addEventListener('voiceschanged', pickZhVoice); } catch (_) { /* 老浏览器 */ } + } + + /** 返回是否成功入队(不可用/异常 → false,调用方自行降级)。 */ + function speak(text, rate = 1) { + try { + if (!('speechSynthesis' in window)) return false; + const u = new SpeechSynthesisUtterance(text); + u.lang = 'zh-CN'; + u.rate = rate; + if (zhVoice) u.voice = zhVoice; + window.speechSynthesis.cancel(); // 顶掉上一句(重听配对码场景) + window.speechSynthesis.speak(u); + return true; + } catch (_) { return false; } + } + + /** 配对码逐位朗读:'048920' → 「配对码,零,四,八,九,二,零」。 */ + function speakPairCode(code) { + return speak(`配对码,${digitsToCn(code).split(' ').join(',')}。`, 0.9); + } + + function pairCodeSpeech(code) { return digitsToCn(code).split(' ').join(','); } + + /** 玩家名可听化 P1 版:speechSynthesis 念名(中文 voice 优先);不可用则静默降级为「玩家 N」。 */ + function speakProfileName(profile) { + if (!profile) return; + if (speak(profile.name)) return; + const list = readRegistry(localStorage); + const idx = list.findIndex((p) => p.id === profile.id); + speak('玩家' + cnNum(idx >= 0 ? idx + 1 : 1)); + } + + // ---------- token 过期(180 天滑动到期)引导(01 遗留清单) ---------- + + function isAuthExpired(e) { + return !!e && e.status === 401 && String(e.message || '').includes('未授权'); + } + const AUTH_EXPIRED_HINT = '档案已过期:请展开档案管理,用「名字 + PIN」重新登录、从其他设备转入,或新建玩家。'; + function guideAuthExpired() { + announce(AUTH_EXPIRED_HINT); + profileManage.hidden = false; + loginNameInput.focus(); + } + // ---------- 同步映射表(各档案已知的服务器桶 updatedAt/rev) ---------- function getSyncMap(id) { return readStorageJSON(localStorage, syncKeyOf(id), {}) || {}; } @@ -466,12 +572,14 @@ try { await syncProfile(cur); // 回拉:服务器进度倒灌 + 本地新进度上云 } catch (e) { - announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`); reflectSettings(); renderUserArea(); refreshCards(); + if (isAuthExpired(e)) { guideAuthExpired(); return true; } // token 180 天滑动到期 + announce(`已切换到${cur.name}(部分云端进度拉取失败:${e.message})`); return true; } reflectSettings(); renderUserArea(); refreshCards(); announce(`已切换到${cur.name}`); + speakProfileName(cur); return true; } @@ -499,7 +607,7 @@ }; } - async function createNewProfile() { + async function createNewProfile({ quiet = false } = {}) { closeMenu(); let p; if (PUBLIC_STATIC) { @@ -516,7 +624,11 @@ list.push(p); writeRegistry(localStorage, list); const ok = await switchTo(p.id); - announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`); + if (!quiet) { + announce(ok === false ? '已取消创建' : `已创建玩家档案:${p.name}`); + speakProfileName(p); + } + return p; } // ---------- 首次初始化:0 输入 0 点击静默建档 ---------- @@ -550,9 +662,9 @@ setCurrentProfile(localStorage, first.id); applyAuth(localStorage, first); renderUserArea(); refreshCards(); - if (created) announce(`已创建玩家档案:${first.name}`); + if (created) { announce(`已创建玩家档案:${first.name}`); speakProfileName(first); } // 既有本地进度「认领」进首个档案并上云(游客数据原地升级,不丢——01 §4.4) - try { await syncProfile(first); refreshCards(); } catch (_) { /* 离线:只留本地 */ } + try { await syncProfile(first); refreshCards(); } catch (e) { if (isAuthExpired(e)) guideAuthExpired(); } return; } let cur = getCurrentProfile(localStorage) || readRegistry(localStorage)[0]; @@ -568,7 +680,9 @@ try { await syncProfile(cur); refreshCards(); - } catch (_) { /* 离线:本地主存,游戏照玩 */ } + } catch (e) { + if (isAuthExpired(e)) guideAuthExpired(); // token 180 天滑动到期 → 重新登录或新建 + } } // ---------- 玩家区 UI ---------- @@ -580,7 +694,10 @@ const online = !!(cur && cur.token); pinForm.hidden = !online; loginForm.hidden = !online; + pairOutForm.hidden = !online; + pairInForm.hidden = !online; offlineNote.hidden = online || !cur; + resetPairCodeUI(); // 显示中的配对码属于切换前的档案,一并复位 closeMenu(); } @@ -614,7 +731,7 @@ const newBtn = menuButton('+ 新玩家', '新建玩家档案', createNewProfile); newBtn.setAttribute('role', 'menuitem'); profileMenu.append(newBtn); - const manageBtn = menuButton('档案管理(改名 / 设 PIN / 登录)', '打开档案管理', () => { + const manageBtn = menuButton('档案管理(改名 / 设 PIN / 转移设备 / 登录)', '打开档案管理', () => { closeMenu(); profileManage.hidden = !profileManage.hidden; if (!profileManage.hidden) renameInput.focus(); @@ -681,41 +798,162 @@ announce(`已为${cur.name}设置 PIN,可在其他设备用「${cur.name} + PIN」登录`); }; + /** + * join(配对转入)/ login(名+PIN 换发)成功后,把本机视图换绑到服务器档案: + * 快照/同步表搬 id、注册表原位替换(目标档案若已在本机列表则去重)、当前档案切换, + * 再 syncProfile 让本地较新的桶上云、云端合并结果倒灌。 + */ + async function adoptBoundProfile(cur, r) { + const token = r.token || (cur && cur.token); + const entry = { id: r.profileId, name: r.name, token, claimed: !!r.claimed }; + if (cur && r.profileId !== cur.id) { + moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId)); + moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId)); + } + const list = readRegistry(localStorage).filter((p) => p.id !== r.profileId); + const idx = cur ? list.findIndex((p) => p.id === cur.id) : -1; + if (idx >= 0) list[idx] = entry; else list.push(entry); + writeRegistry(localStorage, list); + setCurrentProfile(localStorage, r.profileId); + applyAuth(localStorage, entry); + try { await syncProfile(entry); } catch (_) { /* 合并结果稍后再拉 */ } + renderUserArea(); refreshCards(); reflectSettings(); + return entry; + } + loginBtn.onclick = async () => { const cur = getCurrentProfile(localStorage); - if (!cur || !cur.token) { announce('离线档案不支持登录(需要家庭服务器)'); return; } + if (!cur) return; const name = loginNameInput.value.trim(); const pin = loginPinInput.value.trim(); if (!NAME_RE.test(name)) { announce('请输入要登录的名字'); return; } if (!PIN_RE.test(pin)) { announce('PIN 需为 4~6 位数字'); return; } - // 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新 - try { - await syncProfile(cur); - } catch (e) { announce(`同步失败:${e.message}`); return; } - let r; - try { - r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token); - } catch (e) { announce(e.message); return; } - if (r.profileId && r.profileId !== cur.id) { - // 服务器已把本机 token 改绑目标档案:搬运快照/同步表,注册表原位替换 - moveStorageKey(snapshotKeyOf(cur.id), snapshotKeyOf(r.profileId)); - moveStorageKey(syncKeyOf(cur.id), syncKeyOf(r.profileId)); - const list = readRegistry(localStorage).map((p) => - p.id === cur.id ? { id: r.profileId, name: r.name, token: cur.token, claimed: true } : p); - writeRegistry(localStorage, list); - setCurrentProfile(localStorage, r.profileId); - applyAuth(localStorage, getCurrentProfile(localStorage)); - } else { - const list = readRegistry(localStorage).map((p) => - p.id === cur.id ? { ...p, claimed: true } : p); - writeRegistry(localStorage, list); + if (PUBLIC_STATIC) { announce('纯静态形态不支持登录(需要家庭服务器)'); return; } + let r = null; + // 无 token(纯本地档案遇服务器可用)或 token 已过期:claim 走不通,直接 login 换发, + // 本地进度由客户端 syncProfile 按桶 LWW 推上去(与配对转入的无 token 分支同语义) + let deadToken = !cur.token; + if (!deadToken) { + // 先把本机当前进度全部 flush 上去,确保服务器端合并时两边的桶都最新 + try { + await syncProfile(cur); + } catch (e) { + if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; } + deadToken = true; + } + } + if (!deadToken) { + try { + r = await api('claim', { method: 'POST', body: JSON.stringify({ name, pin }) }, cur.token); + } catch (e) { + if (!isAuthExpired(e)) { announce(e.message); return; } + deadToken = true; + } + } + if (deadToken) { + try { + r = await api('login', { method: 'POST', body: JSON.stringify({ username: name, pin }) }, null); + } catch (e) { announce(e.message); return; } } - const now = getCurrentProfile(localStorage); - try { await syncProfile(now); } catch (_) { /* 合并结果稍后再拉 */ } loginNameInput.value = ''; loginPinInput.value = ''; - renderUserArea(); refreshCards(); reflectSettings(); - announce(r.merged ? `欢迎回来,${now.name}(两台设备进度已合并)` : `已登录:${now.name}`); + if (!r.merged && r.profileId === cur.id && !deadToken) { + // 原地认领(名字+PIN 设到当前档案):只补 claimed 标记 + const list = readRegistry(localStorage).map((p) => (p.id === cur.id ? { ...p, claimed: true } : p)); + writeRegistry(localStorage, list); + renderUserArea(); + announce(`已登录:${cur.name}`); + return; + } + const entry = await adoptBoundProfile(cur, r); + announce(r.merged ? `欢迎回来,${entry.name}(两台设备进度已合并)` : `已登录:${entry.name}`); + speakProfileName(entry); + }; + + // ---------- 档案管理:配对码(转移到新设备 / 从其他设备转入) + 从本机移除 ---------- + + let pairCode = null; + + function resetPairCodeUI() { + pairCode = null; + pairCodeDisplay.textContent = '------'; + pairCodeDisplay.setAttribute('aria-label', '配对码未生成'); + pairReplayBtn.hidden = true; + } + + pairStartBtn.onclick = async () => { + const cur = getCurrentProfile(localStorage); + if (!cur || !cur.token) { announce('离线档案不能生成配对码(需要家庭服务器)'); return; } + try { + const r = await api('pair/start', { method: 'POST' }, cur.token); + pairCode = r.code; + const until = new Date(r.expiresAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + pairCodeDisplay.textContent = r.code; + pairCodeDisplay.setAttribute('aria-label', `配对码 ${pairCodeSpeech(r.code)},${until} 前有效`); + pairReplayBtn.hidden = false; + announce(`配对码:${pairCodeSpeech(r.code)}。${until} 前单次有效,请在 10 分钟内于新设备输入`); + speakPairCode(r.code); + } catch (e) { announce(`配对码生成失败:${e.message}`); } + }; + + pairReplayBtn.onclick = () => { + if (!pairCode) return; + announce(`配对码:${pairCodeSpeech(pairCode)}`); + speakPairCode(pairCode); + }; + + pairJoinBtn.onclick = async () => { + const code = pairCodeInput.value.trim(); + if (!/^\d{6}$/.test(code)) { announce('配对码需为 6 位数字'); return; } + const cur = getCurrentProfile(localStorage); + if (!cur) return; + // 先 flush 本机进度:join 在服务器端按桶 LWW 合并,两边都最新 + if (cur.token) { + try { + await syncProfile(cur); + } catch (e) { + if (!isAuthExpired(e)) { announce(`同步失败:${e.message}`); return; } + // token 已过期:join 会把它视作无档案设备直接签发老档案的新 token, + // 本地进度随后由 syncProfile 客户端侧 LWW 推上去 + } + } + let r; + try { + r = await api('pair/join', { method: 'POST', body: JSON.stringify({ code }) }, (cur.token || null)); + } catch (e) { + announce(e.status === 404 ? '配对码不存在或已失效,请在老设备重新生成' + : e.status === 429 ? `尝试过多:${e.message}` + : e.status === 409 ? `${e.message}` + : `转入失败:${e.message}`); + return; + } + pairCodeInput.value = ''; + const entry = await adoptBoundProfile(cur, r); + announce(r.merged + ? `欢迎回来,${entry.name}(本机进度已按游戏合并,共 ${r.mergedGames} 项)` + : `欢迎回来,${entry.name}`); + speakProfileName(entry); + }; + + removeBtn.onclick = async () => { + const cur = getCurrentProfile(localStorage); + if (!cur) return; + const online = !!cur.token; + const ok = window.confirm(online + ? `从本机移除「${cur.name}」?云端进度保留,可随时用「名字 + PIN」或新配对码找回。` + : `「${cur.name}」仅存本机(无云端副本),移除后本机进度将丢失。确定移除?`); + if (!ok) return; + if (online) { + try { await syncProfile(cur); } catch (_) { /* 尽力冲云:云端至少保留最后已知副本 */ } + try { await api('logout', { method: 'POST' }, cur.token); } catch (_) { /* 吊销失败:token 自然过期 */ } + } + const name = cur.name; + removeProfile(localStorage, cur.id); // 清快照/同步表/注册表项/CURRENT/AUTH(云端不动) + resetPairCodeUI(); + await createNewProfile({ quiet: true }); // 回匿名新建(switchTo 倒灌前先清空活动 key) + const fresh = getCurrentProfile(localStorage); + announce(`已从本机移除${name}(云端保留),现在是新档案:${fresh ? fresh.name : '(无)'}`); + speakProfileName(fresh); }; // ---------- 设置区(含「操作方式」,settings.input 由引擎侧消费) ---------- diff --git a/web/profiles.js b/web/profiles.js index 6183e016..3dbfebc6 100644 --- a/web/profiles.js +++ b/web/profiles.js @@ -1,10 +1,11 @@ /** - * profiles.js — 无感档案系统的大厅侧纯函数(P0-B)。 + * profiles.js — 无感档案系统的大厅侧纯函数(P0-B + P1 配对码)。 * * 被 web/index.html(UI / 网络编排)与 web/server/test/api-smoke.mjs(node 断言) * 共用:只做 localStorage 形状计算,不碰 DOM / fetch——测试里传一个 Map 版 * storage({getItem,setItem,removeItem})即可。自动名汉字数字与 server.js 的 - * cnNum 保持同一词表(玩家一~九可听,见 research/platform/01-identity.md §4.2)。 + * cnNum 保持同一词表(玩家一~九可听,见 research/platform/01-identity.md §4.2); + * digitsToCn / removeProfile 为 P1 配对码与「从本机移除」的纯函数地基。 * * 档案分桶模型(research/platform/03-data-sync.md §4.4): * soundgame.profiles 本机档案表 [{id, name, token|null, claimed?}] @@ -120,6 +121,35 @@ export function nextLocalProfileName(registry) { return '玩家' + cnNum(registry.length + 1); } +/** + * 配对码/数字串的逐位中文读法(P1 裁定:配置类数字不走 TTS 资产,交给 + * 浏览器 speechSynthesis;0 必须读「零」而非字母 O,故自行映射)。 + * '048920' → '零 四 八 九 二 零'(空格分隔,朗读端再替换为停顿)。 + */ +export function digitsToCn(str) { + const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + return String(str).split('').map((ch) => (ch >= '0' && ch <= '9' ? d[Number(ch)] : ch)).join(' '); +} + +/** + * 「从本机移除(不删云端)」:清本机档案快照与同步表并出注册表; + * 若是当前档案则同时清 CURRENT(调用方负责善后:新建匿名或切换)。 + * 服务器侧数据不动;token 是否吊销由调用方决定(UI 会 best-effort logout)。 + */ +export function removeProfile(storage, id) { + const list = readRegistry(storage).filter((p) => p.id !== id); + writeRegistry(storage, list); + try { + storage.removeItem(snapshotKeyOf(id)); + storage.removeItem(syncKeyOf(id)); + if (storage.getItem(CURRENT_KEY) === id) { + storage.removeItem(CURRENT_KEY); + storage.removeItem(AUTH_KEY); // 是当前档案:游戏侧虚拟视图一并清掉(调用方重建) + } + } catch (_) { /* 隐私模式 */ } + return list; +} + // ---------------- 快照:回灌(切走)/ 倒灌(切入) ---------------- /** 回灌:把当前活动 key 原样存进档案快照。 */ diff --git a/web/radio/audio/voice/female/key/gd_again.m4a b/web/radio/audio/voice/female/key/gd_again.m4a new file mode 100644 index 00000000..dd844e86 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_again.m4a differ diff --git a/web/radio/audio/voice/female/key/gd_again.ogg b/web/radio/audio/voice/female/key/gd_again.ogg new file mode 100644 index 00000000..56dc3910 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_again.ogg differ diff --git a/web/radio/audio/voice/female/key/gd_brief.m4a b/web/radio/audio/voice/female/key/gd_brief.m4a new file mode 100644 index 00000000..773c8eee Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_brief.m4a differ diff --git a/web/radio/audio/voice/female/key/gd_brief.ogg b/web/radio/audio/voice/female/key/gd_brief.ogg new file mode 100644 index 00000000..1135a8d0 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_brief.ogg differ diff --git a/web/radio/audio/voice/female/key/gd_continue.m4a b/web/radio/audio/voice/female/key/gd_continue.m4a new file mode 100644 index 00000000..bb996769 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_continue.m4a differ diff --git a/web/radio/audio/voice/female/key/gd_continue.ogg b/web/radio/audio/voice/female/key/gd_continue.ogg new file mode 100644 index 00000000..7b5fd155 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_continue.ogg differ diff --git a/web/radio/audio/voice/female/key/gd_ring1.m4a b/web/radio/audio/voice/female/key/gd_ring1.m4a new file mode 100644 index 00000000..f997234e Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_ring1.m4a differ diff --git a/web/radio/audio/voice/female/key/gd_ring1.ogg b/web/radio/audio/voice/female/key/gd_ring1.ogg new file mode 100644 index 00000000..a5d79666 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_ring1.ogg differ diff --git a/web/radio/audio/voice/female/key/gd_ring2.m4a b/web/radio/audio/voice/female/key/gd_ring2.m4a new file mode 100644 index 00000000..433a45e5 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_ring2.m4a differ diff --git a/web/radio/audio/voice/female/key/gd_ring2.ogg b/web/radio/audio/voice/female/key/gd_ring2.ogg new file mode 100644 index 00000000..cac30557 Binary files /dev/null and b/web/radio/audio/voice/female/key/gd_ring2.ogg differ diff --git a/web/radio/audio/voice/female/key/p1.m4a b/web/radio/audio/voice/female/key/p1.m4a new file mode 100644 index 00000000..1340229a Binary files /dev/null and b/web/radio/audio/voice/female/key/p1.m4a differ diff --git a/web/radio/audio/voice/female/key/p1.ogg b/web/radio/audio/voice/female/key/p1.ogg new file mode 100644 index 00000000..a6542ea8 Binary files /dev/null and b/web/radio/audio/voice/female/key/p1.ogg differ diff --git a/web/radio/audio/voice/female/key/p2.m4a b/web/radio/audio/voice/female/key/p2.m4a new file mode 100644 index 00000000..f6585435 Binary files /dev/null and b/web/radio/audio/voice/female/key/p2.m4a differ diff --git a/web/radio/audio/voice/female/key/p2.ogg b/web/radio/audio/voice/female/key/p2.ogg new file mode 100644 index 00000000..0c5012a7 Binary files /dev/null and b/web/radio/audio/voice/female/key/p2.ogg differ diff --git a/web/radio/audio/voice/female/key/p3.m4a b/web/radio/audio/voice/female/key/p3.m4a new file mode 100644 index 00000000..ba73777d Binary files /dev/null and b/web/radio/audio/voice/female/key/p3.m4a differ diff --git a/web/radio/audio/voice/female/key/p3.ogg b/web/radio/audio/voice/female/key/p3.ogg new file mode 100644 index 00000000..11d7bef6 Binary files /dev/null and b/web/radio/audio/voice/female/key/p3.ogg differ diff --git a/web/radio/audio/voice/female/key/p4.m4a b/web/radio/audio/voice/female/key/p4.m4a new file mode 100644 index 00000000..241b4b3a Binary files /dev/null and b/web/radio/audio/voice/female/key/p4.m4a differ diff --git a/web/radio/audio/voice/female/key/p4.ogg b/web/radio/audio/voice/female/key/p4.ogg new file mode 100644 index 00000000..4267ea65 Binary files /dev/null and b/web/radio/audio/voice/female/key/p4.ogg differ diff --git a/web/radio/audio/voice/female/key/p5.m4a b/web/radio/audio/voice/female/key/p5.m4a new file mode 100644 index 00000000..83b01565 Binary files /dev/null and b/web/radio/audio/voice/female/key/p5.m4a differ diff --git a/web/radio/audio/voice/female/key/p5.ogg b/web/radio/audio/voice/female/key/p5.ogg new file mode 100644 index 00000000..247f6504 Binary files /dev/null and b/web/radio/audio/voice/female/key/p5.ogg differ diff --git a/web/radio/audio/voice/female/key/p6.m4a b/web/radio/audio/voice/female/key/p6.m4a new file mode 100644 index 00000000..5337e61b Binary files /dev/null and b/web/radio/audio/voice/female/key/p6.m4a differ diff --git a/web/radio/audio/voice/female/key/p6.ogg b/web/radio/audio/voice/female/key/p6.ogg new file mode 100644 index 00000000..d4999e72 Binary files /dev/null and b/web/radio/audio/voice/female/key/p6.ogg differ diff --git a/web/radio/audio/voice/female/key/p7.m4a b/web/radio/audio/voice/female/key/p7.m4a new file mode 100644 index 00000000..10d87e2f Binary files /dev/null and b/web/radio/audio/voice/female/key/p7.m4a differ diff --git a/web/radio/audio/voice/female/key/p7.ogg b/web/radio/audio/voice/female/key/p7.ogg new file mode 100644 index 00000000..d1af4f49 Binary files /dev/null and b/web/radio/audio/voice/female/key/p7.ogg differ diff --git a/web/radio/audio/voice/female/key/p8.m4a b/web/radio/audio/voice/female/key/p8.m4a new file mode 100644 index 00000000..819fe133 Binary files /dev/null and b/web/radio/audio/voice/female/key/p8.m4a differ diff --git a/web/radio/audio/voice/female/key/p8.ogg b/web/radio/audio/voice/female/key/p8.ogg new file mode 100644 index 00000000..11e5950a Binary files /dev/null and b/web/radio/audio/voice/female/key/p8.ogg differ diff --git a/web/radio/audio/voice/female/key/p9.m4a b/web/radio/audio/voice/female/key/p9.m4a new file mode 100644 index 00000000..210c9d22 Binary files /dev/null and b/web/radio/audio/voice/female/key/p9.m4a differ diff --git a/web/radio/audio/voice/female/key/p9.ogg b/web/radio/audio/voice/female/key/p9.ogg new file mode 100644 index 00000000..e967fc6b Binary files /dev/null and b/web/radio/audio/voice/female/key/p9.ogg differ diff --git a/web/radio/audio/voice/female/key/sys_continue_ask.m4a b/web/radio/audio/voice/female/key/sys_continue_ask.m4a new file mode 100644 index 00000000..0a946aa2 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_continue_ask.m4a differ diff --git a/web/radio/audio/voice/female/key/sys_continue_ask.ogg b/web/radio/audio/voice/female/key/sys_continue_ask.ogg new file mode 100644 index 00000000..313e0c08 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_continue_ask.ogg differ diff --git a/web/radio/audio/voice/female/key/sys_help.m4a b/web/radio/audio/voice/female/key/sys_help.m4a new file mode 100644 index 00000000..7a0e82c7 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_help.m4a differ diff --git a/web/radio/audio/voice/female/key/sys_help.ogg b/web/radio/audio/voice/female/key/sys_help.ogg new file mode 100644 index 00000000..66fc37e7 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_help.ogg differ diff --git a/web/radio/audio/voice/female/key/sys_menu.m4a b/web/radio/audio/voice/female/key/sys_menu.m4a new file mode 100644 index 00000000..c33fe758 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_menu.m4a differ diff --git a/web/radio/audio/voice/female/key/sys_menu.ogg b/web/radio/audio/voice/female/key/sys_menu.ogg new file mode 100644 index 00000000..992076e7 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_menu.ogg differ diff --git a/web/radio/audio/voice/female/key/sys_skip.m4a b/web/radio/audio/voice/female/key/sys_skip.m4a new file mode 100644 index 00000000..7d81bede Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_skip.m4a differ diff --git a/web/radio/audio/voice/female/key/sys_skip.ogg b/web/radio/audio/voice/female/key/sys_skip.ogg new file mode 100644 index 00000000..2c248113 Binary files /dev/null and b/web/radio/audio/voice/female/key/sys_skip.ogg differ diff --git a/web/radio/audio/voice/male/key/gd_again.m4a b/web/radio/audio/voice/male/key/gd_again.m4a new file mode 100644 index 00000000..d83ce894 Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_again.m4a differ diff --git a/web/radio/audio/voice/male/key/gd_again.ogg b/web/radio/audio/voice/male/key/gd_again.ogg new file mode 100644 index 00000000..a2b20db5 Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_again.ogg differ diff --git a/web/radio/audio/voice/male/key/gd_brief.m4a b/web/radio/audio/voice/male/key/gd_brief.m4a new file mode 100644 index 00000000..793ee636 Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_brief.m4a differ diff --git a/web/radio/audio/voice/male/key/gd_brief.ogg b/web/radio/audio/voice/male/key/gd_brief.ogg new file mode 100644 index 00000000..a25e3e1a Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_brief.ogg differ diff --git a/web/radio/audio/voice/male/key/gd_continue.m4a b/web/radio/audio/voice/male/key/gd_continue.m4a new file mode 100644 index 00000000..dfc8f7eb Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_continue.m4a differ diff --git a/web/radio/audio/voice/male/key/gd_continue.ogg b/web/radio/audio/voice/male/key/gd_continue.ogg new file mode 100644 index 00000000..ce1b5fcb Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_continue.ogg differ diff --git a/web/radio/audio/voice/male/key/gd_ring1.m4a b/web/radio/audio/voice/male/key/gd_ring1.m4a new file mode 100644 index 00000000..6564db8a Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_ring1.m4a differ diff --git a/web/radio/audio/voice/male/key/gd_ring1.ogg b/web/radio/audio/voice/male/key/gd_ring1.ogg new file mode 100644 index 00000000..42467a5d Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_ring1.ogg differ diff --git a/web/radio/audio/voice/male/key/gd_ring2.m4a b/web/radio/audio/voice/male/key/gd_ring2.m4a new file mode 100644 index 00000000..9ec180cd Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_ring2.m4a differ diff --git a/web/radio/audio/voice/male/key/gd_ring2.ogg b/web/radio/audio/voice/male/key/gd_ring2.ogg new file mode 100644 index 00000000..fb1c4416 Binary files /dev/null and b/web/radio/audio/voice/male/key/gd_ring2.ogg differ diff --git a/web/radio/audio/voice/male/key/p1.m4a b/web/radio/audio/voice/male/key/p1.m4a new file mode 100644 index 00000000..f5668220 Binary files /dev/null and b/web/radio/audio/voice/male/key/p1.m4a differ diff --git a/web/radio/audio/voice/male/key/p1.ogg b/web/radio/audio/voice/male/key/p1.ogg new file mode 100644 index 00000000..30fb100d Binary files /dev/null and b/web/radio/audio/voice/male/key/p1.ogg differ diff --git a/web/radio/audio/voice/male/key/p2.m4a b/web/radio/audio/voice/male/key/p2.m4a new file mode 100644 index 00000000..f61f7eff Binary files /dev/null and b/web/radio/audio/voice/male/key/p2.m4a differ diff --git a/web/radio/audio/voice/male/key/p2.ogg b/web/radio/audio/voice/male/key/p2.ogg new file mode 100644 index 00000000..e6304706 Binary files /dev/null and b/web/radio/audio/voice/male/key/p2.ogg differ diff --git a/web/radio/audio/voice/male/key/p3.m4a b/web/radio/audio/voice/male/key/p3.m4a new file mode 100644 index 00000000..aac4d801 Binary files /dev/null and b/web/radio/audio/voice/male/key/p3.m4a differ diff --git a/web/radio/audio/voice/male/key/p3.ogg b/web/radio/audio/voice/male/key/p3.ogg new file mode 100644 index 00000000..297262c4 Binary files /dev/null and b/web/radio/audio/voice/male/key/p3.ogg differ diff --git a/web/radio/audio/voice/male/key/p4.m4a b/web/radio/audio/voice/male/key/p4.m4a new file mode 100644 index 00000000..7e83bb8a Binary files /dev/null and b/web/radio/audio/voice/male/key/p4.m4a differ diff --git a/web/radio/audio/voice/male/key/p4.ogg b/web/radio/audio/voice/male/key/p4.ogg new file mode 100644 index 00000000..5f8ade25 Binary files /dev/null and b/web/radio/audio/voice/male/key/p4.ogg differ diff --git a/web/radio/audio/voice/male/key/p5.m4a b/web/radio/audio/voice/male/key/p5.m4a new file mode 100644 index 00000000..15b5c177 Binary files /dev/null and b/web/radio/audio/voice/male/key/p5.m4a differ diff --git a/web/radio/audio/voice/male/key/p5.ogg b/web/radio/audio/voice/male/key/p5.ogg new file mode 100644 index 00000000..54570a4a Binary files /dev/null and b/web/radio/audio/voice/male/key/p5.ogg differ diff --git a/web/radio/audio/voice/male/key/p6.m4a b/web/radio/audio/voice/male/key/p6.m4a new file mode 100644 index 00000000..6ce428a2 Binary files /dev/null and b/web/radio/audio/voice/male/key/p6.m4a differ diff --git a/web/radio/audio/voice/male/key/p6.ogg b/web/radio/audio/voice/male/key/p6.ogg new file mode 100644 index 00000000..db715093 Binary files /dev/null and b/web/radio/audio/voice/male/key/p6.ogg differ diff --git a/web/radio/audio/voice/male/key/p7.m4a b/web/radio/audio/voice/male/key/p7.m4a new file mode 100644 index 00000000..d46d060c Binary files /dev/null and b/web/radio/audio/voice/male/key/p7.m4a differ diff --git a/web/radio/audio/voice/male/key/p7.ogg b/web/radio/audio/voice/male/key/p7.ogg new file mode 100644 index 00000000..ac477d76 Binary files /dev/null and b/web/radio/audio/voice/male/key/p7.ogg differ diff --git a/web/radio/audio/voice/male/key/p8.m4a b/web/radio/audio/voice/male/key/p8.m4a new file mode 100644 index 00000000..8e633df5 Binary files /dev/null and b/web/radio/audio/voice/male/key/p8.m4a differ diff --git a/web/radio/audio/voice/male/key/p8.ogg b/web/radio/audio/voice/male/key/p8.ogg new file mode 100644 index 00000000..073330a0 Binary files /dev/null and b/web/radio/audio/voice/male/key/p8.ogg differ diff --git a/web/radio/audio/voice/male/key/p9.m4a b/web/radio/audio/voice/male/key/p9.m4a new file mode 100644 index 00000000..ac6c4672 Binary files /dev/null and b/web/radio/audio/voice/male/key/p9.m4a differ diff --git a/web/radio/audio/voice/male/key/p9.ogg b/web/radio/audio/voice/male/key/p9.ogg new file mode 100644 index 00000000..e2c31531 Binary files /dev/null and b/web/radio/audio/voice/male/key/p9.ogg differ diff --git a/web/radio/audio/voice/male/key/sys_continue_ask.m4a b/web/radio/audio/voice/male/key/sys_continue_ask.m4a new file mode 100644 index 00000000..8c1cb618 Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_continue_ask.m4a differ diff --git a/web/radio/audio/voice/male/key/sys_continue_ask.ogg b/web/radio/audio/voice/male/key/sys_continue_ask.ogg new file mode 100644 index 00000000..c003c54a Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_continue_ask.ogg differ diff --git a/web/radio/audio/voice/male/key/sys_help.m4a b/web/radio/audio/voice/male/key/sys_help.m4a new file mode 100644 index 00000000..8d7111b8 Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_help.m4a differ diff --git a/web/radio/audio/voice/male/key/sys_help.ogg b/web/radio/audio/voice/male/key/sys_help.ogg new file mode 100644 index 00000000..ba961826 Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_help.ogg differ diff --git a/web/radio/audio/voice/male/key/sys_menu.m4a b/web/radio/audio/voice/male/key/sys_menu.m4a new file mode 100644 index 00000000..d6b56519 Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_menu.m4a differ diff --git a/web/radio/audio/voice/male/key/sys_menu.ogg b/web/radio/audio/voice/male/key/sys_menu.ogg new file mode 100644 index 00000000..393a6c0e Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_menu.ogg differ diff --git a/web/radio/audio/voice/male/key/sys_skip.m4a b/web/radio/audio/voice/male/key/sys_skip.m4a new file mode 100644 index 00000000..34733d4b Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_skip.m4a differ diff --git a/web/radio/audio/voice/male/key/sys_skip.ogg b/web/radio/audio/voice/male/key/sys_skip.ogg new file mode 100644 index 00000000..819cc858 Binary files /dev/null and b/web/radio/audio/voice/male/key/sys_skip.ogg differ diff --git a/web/radio/test/playtest.mjs b/web/radio/test/playtest.mjs index 83c42689..75b38af3 100644 --- a/web/radio/test/playtest.mjs +++ b/web/radio/test/playtest.mjs @@ -114,7 +114,8 @@ globalThis.localStorage = { const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } }, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } }, configurable: true, }); diff --git a/web/rhythm/audio/voice/female/key/gd_miss_early.m4a b/web/rhythm/audio/voice/female/key/gd_miss_early.m4a new file mode 100644 index 00000000..30ddb588 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/gd_miss_early.m4a differ diff --git a/web/rhythm/audio/voice/female/key/gd_miss_early.ogg b/web/rhythm/audio/voice/female/key/gd_miss_early.ogg new file mode 100644 index 00000000..bd0ebe2e Binary files /dev/null and b/web/rhythm/audio/voice/female/key/gd_miss_early.ogg differ diff --git a/web/rhythm/audio/voice/female/key/gd_miss_wrong.m4a b/web/rhythm/audio/voice/female/key/gd_miss_wrong.m4a new file mode 100644 index 00000000..0a87a0fc Binary files /dev/null and b/web/rhythm/audio/voice/female/key/gd_miss_wrong.m4a differ diff --git a/web/rhythm/audio/voice/female/key/gd_miss_wrong.ogg b/web/rhythm/audio/voice/female/key/gd_miss_wrong.ogg new file mode 100644 index 00000000..6dde6e52 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/gd_miss_wrong.ogg differ diff --git a/web/rhythm/audio/voice/female/key/menu_main.m4a b/web/rhythm/audio/voice/female/key/menu_main.m4a new file mode 100644 index 00000000..cfde6b17 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/menu_main.m4a differ diff --git a/web/rhythm/audio/voice/female/key/menu_main.ogg b/web/rhythm/audio/voice/female/key/menu_main.ogg new file mode 100644 index 00000000..be1d78a4 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/menu_main.ogg differ diff --git a/web/rhythm/audio/voice/female/key/restart.m4a b/web/rhythm/audio/voice/female/key/restart.m4a new file mode 100644 index 00000000..ebb6822a Binary files /dev/null and b/web/rhythm/audio/voice/female/key/restart.m4a differ diff --git a/web/rhythm/audio/voice/female/key/restart.ogg b/web/rhythm/audio/voice/female/key/restart.ogg new file mode 100644 index 00000000..914a9eca Binary files /dev/null and b/web/rhythm/audio/voice/female/key/restart.ogg differ diff --git a/web/rhythm/audio/voice/female/key/select_hint.m4a b/web/rhythm/audio/voice/female/key/select_hint.m4a new file mode 100644 index 00000000..40a5a323 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/select_hint.m4a differ diff --git a/web/rhythm/audio/voice/female/key/select_hint.ogg b/web/rhythm/audio/voice/female/key/select_hint.ogg new file mode 100644 index 00000000..4ac0d1c4 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/select_hint.ogg differ diff --git a/web/rhythm/audio/voice/female/key/skip_hint.m4a b/web/rhythm/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..c6e4eb6e Binary files /dev/null and b/web/rhythm/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/rhythm/audio/voice/female/key/skip_hint.ogg b/web/rhythm/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..1c887835 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/rhythm/audio/voice/female/key/tut_1.m4a b/web/rhythm/audio/voice/female/key/tut_1.m4a new file mode 100644 index 00000000..3d039fa6 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_1.m4a differ diff --git a/web/rhythm/audio/voice/female/key/tut_1.ogg b/web/rhythm/audio/voice/female/key/tut_1.ogg new file mode 100644 index 00000000..a0ed0b8c Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_1.ogg differ diff --git a/web/rhythm/audio/voice/female/key/tut_2.m4a b/web/rhythm/audio/voice/female/key/tut_2.m4a new file mode 100644 index 00000000..51d052e2 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_2.m4a differ diff --git a/web/rhythm/audio/voice/female/key/tut_2.ogg b/web/rhythm/audio/voice/female/key/tut_2.ogg new file mode 100644 index 00000000..2519a1fc Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_2.ogg differ diff --git a/web/rhythm/audio/voice/female/key/tut_3.m4a b/web/rhythm/audio/voice/female/key/tut_3.m4a new file mode 100644 index 00000000..e7c623de Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_3.m4a differ diff --git a/web/rhythm/audio/voice/female/key/tut_3.ogg b/web/rhythm/audio/voice/female/key/tut_3.ogg new file mode 100644 index 00000000..7f001212 Binary files /dev/null and b/web/rhythm/audio/voice/female/key/tut_3.ogg differ diff --git a/web/rhythm/audio/voice/male/key/gd_miss_early.m4a b/web/rhythm/audio/voice/male/key/gd_miss_early.m4a new file mode 100644 index 00000000..24c74867 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/gd_miss_early.m4a differ diff --git a/web/rhythm/audio/voice/male/key/gd_miss_early.ogg b/web/rhythm/audio/voice/male/key/gd_miss_early.ogg new file mode 100644 index 00000000..b18b1ad3 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/gd_miss_early.ogg differ diff --git a/web/rhythm/audio/voice/male/key/gd_miss_wrong.m4a b/web/rhythm/audio/voice/male/key/gd_miss_wrong.m4a new file mode 100644 index 00000000..f4164b73 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/gd_miss_wrong.m4a differ diff --git a/web/rhythm/audio/voice/male/key/gd_miss_wrong.ogg b/web/rhythm/audio/voice/male/key/gd_miss_wrong.ogg new file mode 100644 index 00000000..5011c95f Binary files /dev/null and b/web/rhythm/audio/voice/male/key/gd_miss_wrong.ogg differ diff --git a/web/rhythm/audio/voice/male/key/menu_main.m4a b/web/rhythm/audio/voice/male/key/menu_main.m4a new file mode 100644 index 00000000..a5207c3d Binary files /dev/null and b/web/rhythm/audio/voice/male/key/menu_main.m4a differ diff --git a/web/rhythm/audio/voice/male/key/menu_main.ogg b/web/rhythm/audio/voice/male/key/menu_main.ogg new file mode 100644 index 00000000..34e89794 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/menu_main.ogg differ diff --git a/web/rhythm/audio/voice/male/key/restart.m4a b/web/rhythm/audio/voice/male/key/restart.m4a new file mode 100644 index 00000000..3805c425 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/restart.m4a differ diff --git a/web/rhythm/audio/voice/male/key/restart.ogg b/web/rhythm/audio/voice/male/key/restart.ogg new file mode 100644 index 00000000..f6fd5f1f Binary files /dev/null and b/web/rhythm/audio/voice/male/key/restart.ogg differ diff --git a/web/rhythm/audio/voice/male/key/select_hint.m4a b/web/rhythm/audio/voice/male/key/select_hint.m4a new file mode 100644 index 00000000..4965423d Binary files /dev/null and b/web/rhythm/audio/voice/male/key/select_hint.m4a differ diff --git a/web/rhythm/audio/voice/male/key/select_hint.ogg b/web/rhythm/audio/voice/male/key/select_hint.ogg new file mode 100644 index 00000000..9825413e Binary files /dev/null and b/web/rhythm/audio/voice/male/key/select_hint.ogg differ diff --git a/web/rhythm/audio/voice/male/key/skip_hint.m4a b/web/rhythm/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..09bafe47 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/rhythm/audio/voice/male/key/skip_hint.ogg b/web/rhythm/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..7b1be369 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/rhythm/audio/voice/male/key/tut_1.m4a b/web/rhythm/audio/voice/male/key/tut_1.m4a new file mode 100644 index 00000000..3387cb2c Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_1.m4a differ diff --git a/web/rhythm/audio/voice/male/key/tut_1.ogg b/web/rhythm/audio/voice/male/key/tut_1.ogg new file mode 100644 index 00000000..54373e4f Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_1.ogg differ diff --git a/web/rhythm/audio/voice/male/key/tut_2.m4a b/web/rhythm/audio/voice/male/key/tut_2.m4a new file mode 100644 index 00000000..e9ca059c Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_2.m4a differ diff --git a/web/rhythm/audio/voice/male/key/tut_2.ogg b/web/rhythm/audio/voice/male/key/tut_2.ogg new file mode 100644 index 00000000..a5b88188 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_2.ogg differ diff --git a/web/rhythm/audio/voice/male/key/tut_3.m4a b/web/rhythm/audio/voice/male/key/tut_3.m4a new file mode 100644 index 00000000..8d8bded4 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_3.m4a differ diff --git a/web/rhythm/audio/voice/male/key/tut_3.ogg b/web/rhythm/audio/voice/male/key/tut_3.ogg new file mode 100644 index 00000000..2c8c0496 Binary files /dev/null and b/web/rhythm/audio/voice/male/key/tut_3.ogg differ diff --git a/web/rhythm/test/playtest.mjs b/web/rhythm/test/playtest.mjs index 9025add6..1c00197f 100644 --- a/web/rhythm/test/playtest.mjs +++ b/web/rhythm/test/playtest.mjs @@ -70,7 +70,9 @@ globalThis.localStorage = { const vibLog = []; // navigator.vibrate 调用记录(震型数组) Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (p) => { vibLog.push(p); return true; } }, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产 + // (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测) + value: { maxTouchPoints: 1, vibrate: (p) => { vibLog.push(p); return true; } }, configurable: true, }); diff --git a/web/rpg/audio/voice/female/key/btl_lose_hint.m4a b/web/rpg/audio/voice/female/key/btl_lose_hint.m4a new file mode 100644 index 00000000..3573b79f Binary files /dev/null and b/web/rpg/audio/voice/female/key/btl_lose_hint.m4a differ diff --git a/web/rpg/audio/voice/female/key/btl_lose_hint.ogg b/web/rpg/audio/voice/female/key/btl_lose_hint.ogg new file mode 100644 index 00000000..56b201e6 Binary files /dev/null and b/web/rpg/audio/voice/female/key/btl_lose_hint.ogg differ diff --git a/web/rpg/audio/voice/female/key/gd_parry_miss.m4a b/web/rpg/audio/voice/female/key/gd_parry_miss.m4a new file mode 100644 index 00000000..b55d770d Binary files /dev/null and b/web/rpg/audio/voice/female/key/gd_parry_miss.m4a differ diff --git a/web/rpg/audio/voice/female/key/gd_parry_miss.ogg b/web/rpg/audio/voice/female/key/gd_parry_miss.ogg new file mode 100644 index 00000000..6671d3b3 Binary files /dev/null and b/web/rpg/audio/voice/female/key/gd_parry_miss.ogg differ diff --git a/web/rpg/audio/voice/female/key/gd_stall.m4a b/web/rpg/audio/voice/female/key/gd_stall.m4a new file mode 100644 index 00000000..e13a0317 Binary files /dev/null and b/web/rpg/audio/voice/female/key/gd_stall.m4a differ diff --git a/web/rpg/audio/voice/female/key/gd_stall.ogg b/web/rpg/audio/voice/female/key/gd_stall.ogg new file mode 100644 index 00000000..ec5ce456 Binary files /dev/null and b/web/rpg/audio/voice/female/key/gd_stall.ogg differ diff --git a/web/rpg/audio/voice/female/key/help_battle.m4a b/web/rpg/audio/voice/female/key/help_battle.m4a new file mode 100644 index 00000000..378bc3a5 Binary files /dev/null and b/web/rpg/audio/voice/female/key/help_battle.m4a differ diff --git a/web/rpg/audio/voice/female/key/help_battle.ogg b/web/rpg/audio/voice/female/key/help_battle.ogg new file mode 100644 index 00000000..3d46e221 Binary files /dev/null and b/web/rpg/audio/voice/female/key/help_battle.ogg differ diff --git a/web/rpg/audio/voice/female/key/help_explore.m4a b/web/rpg/audio/voice/female/key/help_explore.m4a new file mode 100644 index 00000000..34b7b256 Binary files /dev/null and b/web/rpg/audio/voice/female/key/help_explore.m4a differ diff --git a/web/rpg/audio/voice/female/key/help_explore.ogg b/web/rpg/audio/voice/female/key/help_explore.ogg new file mode 100644 index 00000000..a4d159e4 Binary files /dev/null and b/web/rpg/audio/voice/female/key/help_explore.ogg differ diff --git a/web/rpg/audio/voice/female/key/shop_open.m4a b/web/rpg/audio/voice/female/key/shop_open.m4a new file mode 100644 index 00000000..f55eea48 Binary files /dev/null and b/web/rpg/audio/voice/female/key/shop_open.m4a differ diff --git a/web/rpg/audio/voice/female/key/shop_open.ogg b/web/rpg/audio/voice/female/key/shop_open.ogg new file mode 100644 index 00000000..a36bbefd Binary files /dev/null and b/web/rpg/audio/voice/female/key/shop_open.ogg differ diff --git a/web/rpg/audio/voice/female/key/skip_hint.m4a b/web/rpg/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..9cb3e543 Binary files /dev/null and b/web/rpg/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/rpg/audio/voice/female/key/skip_hint.ogg b/web/rpg/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..1dffe4d0 Binary files /dev/null and b/web/rpg/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/rpg/audio/voice/female/key/sys_continue_ask.m4a b/web/rpg/audio/voice/female/key/sys_continue_ask.m4a new file mode 100644 index 00000000..b8a52bf5 Binary files /dev/null and b/web/rpg/audio/voice/female/key/sys_continue_ask.m4a differ diff --git a/web/rpg/audio/voice/female/key/sys_continue_ask.ogg b/web/rpg/audio/voice/female/key/sys_continue_ask.ogg new file mode 100644 index 00000000..b850425f Binary files /dev/null and b/web/rpg/audio/voice/female/key/sys_continue_ask.ogg differ diff --git a/web/rpg/audio/voice/female/key/sys_menu.m4a b/web/rpg/audio/voice/female/key/sys_menu.m4a new file mode 100644 index 00000000..d0bc9d01 Binary files /dev/null and b/web/rpg/audio/voice/female/key/sys_menu.m4a differ diff --git a/web/rpg/audio/voice/female/key/sys_menu.ogg b/web/rpg/audio/voice/female/key/sys_menu.ogg new file mode 100644 index 00000000..44912dba Binary files /dev/null and b/web/rpg/audio/voice/female/key/sys_menu.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_b0.m4a b/web/rpg/audio/voice/female/key/tut_b0.m4a new file mode 100644 index 00000000..4bd8538b Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b0.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_b0.ogg b/web/rpg/audio/voice/female/key/tut_b0.ogg new file mode 100644 index 00000000..8907a9b9 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b0.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_b2.m4a b/web/rpg/audio/voice/female/key/tut_b2.m4a new file mode 100644 index 00000000..d8ec7621 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b2.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_b2.ogg b/web/rpg/audio/voice/female/key/tut_b2.ogg new file mode 100644 index 00000000..d54ffaa9 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b2.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_b3.m4a b/web/rpg/audio/voice/female/key/tut_b3.m4a new file mode 100644 index 00000000..880047b0 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b3.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_b3.ogg b/web/rpg/audio/voice/female/key/tut_b3.ogg new file mode 100644 index 00000000..88066405 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b3.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_b6.m4a b/web/rpg/audio/voice/female/key/tut_b6.m4a new file mode 100644 index 00000000..033f7c5e Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b6.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_b6.ogg b/web/rpg/audio/voice/female/key/tut_b6.ogg new file mode 100644 index 00000000..0ebc4771 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_b6.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_2.m4a b/web/rpg/audio/voice/female/key/tut_battle_2.m4a new file mode 100644 index 00000000..c074575b Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_2.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_2.ogg b/web/rpg/audio/voice/female/key/tut_battle_2.ogg new file mode 100644 index 00000000..750a7a29 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_2.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_3.m4a b/web/rpg/audio/voice/female/key/tut_battle_3.m4a new file mode 100644 index 00000000..240a77af Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_3.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_3.ogg b/web/rpg/audio/voice/female/key/tut_battle_3.ogg new file mode 100644 index 00000000..40fff621 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_3.ogg differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_5.m4a b/web/rpg/audio/voice/female/key/tut_battle_5.m4a new file mode 100644 index 00000000..e38b3471 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_5.m4a differ diff --git a/web/rpg/audio/voice/female/key/tut_battle_5.ogg b/web/rpg/audio/voice/female/key/tut_battle_5.ogg new file mode 100644 index 00000000..3c978276 Binary files /dev/null and b/web/rpg/audio/voice/female/key/tut_battle_5.ogg differ diff --git a/web/rpg/audio/voice/male/key/btl_lose_hint.m4a b/web/rpg/audio/voice/male/key/btl_lose_hint.m4a new file mode 100644 index 00000000..a12a580d Binary files /dev/null and b/web/rpg/audio/voice/male/key/btl_lose_hint.m4a differ diff --git a/web/rpg/audio/voice/male/key/btl_lose_hint.ogg b/web/rpg/audio/voice/male/key/btl_lose_hint.ogg new file mode 100644 index 00000000..9b87c51d Binary files /dev/null and b/web/rpg/audio/voice/male/key/btl_lose_hint.ogg differ diff --git a/web/rpg/audio/voice/male/key/gd_parry_miss.m4a b/web/rpg/audio/voice/male/key/gd_parry_miss.m4a new file mode 100644 index 00000000..1f2e57cb Binary files /dev/null and b/web/rpg/audio/voice/male/key/gd_parry_miss.m4a differ diff --git a/web/rpg/audio/voice/male/key/gd_parry_miss.ogg b/web/rpg/audio/voice/male/key/gd_parry_miss.ogg new file mode 100644 index 00000000..11d55b63 Binary files /dev/null and b/web/rpg/audio/voice/male/key/gd_parry_miss.ogg differ diff --git a/web/rpg/audio/voice/male/key/gd_stall.m4a b/web/rpg/audio/voice/male/key/gd_stall.m4a new file mode 100644 index 00000000..9793e519 Binary files /dev/null and b/web/rpg/audio/voice/male/key/gd_stall.m4a differ diff --git a/web/rpg/audio/voice/male/key/gd_stall.ogg b/web/rpg/audio/voice/male/key/gd_stall.ogg new file mode 100644 index 00000000..d0752980 Binary files /dev/null and b/web/rpg/audio/voice/male/key/gd_stall.ogg differ diff --git a/web/rpg/audio/voice/male/key/help_battle.m4a b/web/rpg/audio/voice/male/key/help_battle.m4a new file mode 100644 index 00000000..e09a03c4 Binary files /dev/null and b/web/rpg/audio/voice/male/key/help_battle.m4a differ diff --git a/web/rpg/audio/voice/male/key/help_battle.ogg b/web/rpg/audio/voice/male/key/help_battle.ogg new file mode 100644 index 00000000..97f85bb7 Binary files /dev/null and b/web/rpg/audio/voice/male/key/help_battle.ogg differ diff --git a/web/rpg/audio/voice/male/key/help_explore.m4a b/web/rpg/audio/voice/male/key/help_explore.m4a new file mode 100644 index 00000000..60c0e8f5 Binary files /dev/null and b/web/rpg/audio/voice/male/key/help_explore.m4a differ diff --git a/web/rpg/audio/voice/male/key/help_explore.ogg b/web/rpg/audio/voice/male/key/help_explore.ogg new file mode 100644 index 00000000..98bf22a8 Binary files /dev/null and b/web/rpg/audio/voice/male/key/help_explore.ogg differ diff --git a/web/rpg/audio/voice/male/key/shop_open.m4a b/web/rpg/audio/voice/male/key/shop_open.m4a new file mode 100644 index 00000000..1782e11d Binary files /dev/null and b/web/rpg/audio/voice/male/key/shop_open.m4a differ diff --git a/web/rpg/audio/voice/male/key/shop_open.ogg b/web/rpg/audio/voice/male/key/shop_open.ogg new file mode 100644 index 00000000..fd2991cc Binary files /dev/null and b/web/rpg/audio/voice/male/key/shop_open.ogg differ diff --git a/web/rpg/audio/voice/male/key/skip_hint.m4a b/web/rpg/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..bb0e1c6f Binary files /dev/null and b/web/rpg/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/rpg/audio/voice/male/key/skip_hint.ogg b/web/rpg/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..f0e457f9 Binary files /dev/null and b/web/rpg/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/rpg/audio/voice/male/key/sys_continue_ask.m4a b/web/rpg/audio/voice/male/key/sys_continue_ask.m4a new file mode 100644 index 00000000..eea2d809 Binary files /dev/null and b/web/rpg/audio/voice/male/key/sys_continue_ask.m4a differ diff --git a/web/rpg/audio/voice/male/key/sys_continue_ask.ogg b/web/rpg/audio/voice/male/key/sys_continue_ask.ogg new file mode 100644 index 00000000..aa43096f Binary files /dev/null and b/web/rpg/audio/voice/male/key/sys_continue_ask.ogg differ diff --git a/web/rpg/audio/voice/male/key/sys_menu.m4a b/web/rpg/audio/voice/male/key/sys_menu.m4a new file mode 100644 index 00000000..42935dd6 Binary files /dev/null and b/web/rpg/audio/voice/male/key/sys_menu.m4a differ diff --git a/web/rpg/audio/voice/male/key/sys_menu.ogg b/web/rpg/audio/voice/male/key/sys_menu.ogg new file mode 100644 index 00000000..49814cc4 Binary files /dev/null and b/web/rpg/audio/voice/male/key/sys_menu.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_b0.m4a b/web/rpg/audio/voice/male/key/tut_b0.m4a new file mode 100644 index 00000000..e0bb6d8f Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b0.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_b0.ogg b/web/rpg/audio/voice/male/key/tut_b0.ogg new file mode 100644 index 00000000..568fb918 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b0.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_b2.m4a b/web/rpg/audio/voice/male/key/tut_b2.m4a new file mode 100644 index 00000000..3fc1ff50 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b2.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_b2.ogg b/web/rpg/audio/voice/male/key/tut_b2.ogg new file mode 100644 index 00000000..59d91624 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b2.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_b3.m4a b/web/rpg/audio/voice/male/key/tut_b3.m4a new file mode 100644 index 00000000..0ef4e427 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b3.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_b3.ogg b/web/rpg/audio/voice/male/key/tut_b3.ogg new file mode 100644 index 00000000..7111835c Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b3.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_b6.m4a b/web/rpg/audio/voice/male/key/tut_b6.m4a new file mode 100644 index 00000000..1bda9b22 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b6.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_b6.ogg b/web/rpg/audio/voice/male/key/tut_b6.ogg new file mode 100644 index 00000000..d624758f Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_b6.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_2.m4a b/web/rpg/audio/voice/male/key/tut_battle_2.m4a new file mode 100644 index 00000000..5ea47f37 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_2.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_2.ogg b/web/rpg/audio/voice/male/key/tut_battle_2.ogg new file mode 100644 index 00000000..6436eef5 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_2.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_3.m4a b/web/rpg/audio/voice/male/key/tut_battle_3.m4a new file mode 100644 index 00000000..e9c32599 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_3.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_3.ogg b/web/rpg/audio/voice/male/key/tut_battle_3.ogg new file mode 100644 index 00000000..9554f079 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_3.ogg differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_5.m4a b/web/rpg/audio/voice/male/key/tut_battle_5.m4a new file mode 100644 index 00000000..912c0856 Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_5.m4a differ diff --git a/web/rpg/audio/voice/male/key/tut_battle_5.ogg b/web/rpg/audio/voice/male/key/tut_battle_5.ogg new file mode 100644 index 00000000..08cd970f Binary files /dev/null and b/web/rpg/audio/voice/male/key/tut_battle_5.ogg differ diff --git a/web/rpg/test/playtest.mjs b/web/rpg/test/playtest.mjs index 0b752937..17419970 100644 --- a/web/rpg/test/playtest.mjs +++ b/web/rpg/test/playtest.mjs @@ -72,7 +72,8 @@ globalThis.clearTimeout = unschedule; globalThis.localStorage = localStorageMock; globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); }; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, configurable: true, }); globalThis.window = { diff --git a/web/server/README.md b/web/server/README.md index b77fb89f..b4272549 100644 --- a/web/server/README.md +++ b/web/server/README.md @@ -7,6 +7,10 @@ P0-B 起采用**无感档案系统**(设计见 `research/platform/01-identity. 认领(命名 + 设 PIN)完全可选,同设备多玩家靠大厅档案切换器隔离,进度按游戏分桶、 服务器时钟裁决的桶级 LWW 同步。8 个游戏目录零改动——分桶/倒灌发生在大厅层。 +P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案管理 → 转移到新设备」 +生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案; +token 180 天滑动过期,到期后大厅引导「重新登录或新建」。 + ## 启动 ```bash @@ -30,6 +34,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js - PIN 以 **scrypt 哈希**(N=16384, r=8, node:crypto 内置)存储,不再明文; - token **180 天滑动过期**、可 logout 吊销、惰性清扫,不再无限增长; - 匿名建档端点有 per-IP 频控(默认 10 个/天,`SG_DEVICE_LIMIT` 覆盖); +- 配对码:**仅存内存不落盘、不进日志**;10 分钟单次有效、同档案同时仅 1 个有效码、 + `pair/start` per-IP 频控(默认 20 个/天,`SG_PAIR_LIMIT` 覆盖)、`pair/join` + **连错 5 次封锁同 IP 10 分钟**(10^6 码空间 × 5 次 → 单窗口猜中概率 ≤ 5×10⁻⁶); - 仍是 HTTP 明文传输、无 TLS、无登录失败退避——威胁模型是「家庭 WiFi 可信 + 防家人恶作剧」。公网开放 API 的前置清单见 `docs/deploy.md`「以后若要开放用户系统」。 @@ -81,8 +88,9 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js ### POST /api/login —— 旧客户端兼容 - 请求:`{ "username": "uuu", "pin": "0858" }` -- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu" }` +- 成功:`200 { "ok": true, "token": "…", "username": "uuu", "profileId": "uuu", "name": "uuu", "claimed": true }` - v1 用户迁移后照常可登录;若存量 PIN 尚为明文,验证成功时**透明升级**为 scrypt 哈希。 + 大厅在 token 180 天过期后的「名字 + PIN 重新登录」路径也走本端点(无需有效 token)。 ### POST /api/register —— 旧客户端兼容(建议改用 device + claim) @@ -93,6 +101,33 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js - 请求头:Bearer;成功:`200 { "ok": true }` +### POST /api/pair/start —— 生成配对码(老设备出示,P1) + +- 请求头:Bearer(匿名 / 已认领档案均可) +- 请求:`{}` +- 成功:`200 { "ok": true, "code": "483920", "expiresAt": "2026-09-09T12:10:00Z" }` + - `code`:6 位数字,**10 分钟有效、单次使用**;同档案同时仅 1 个有效码, + 重新生成会立即使旧码作废(单码作废 + 重生成即调用本端点) + - 码仅存服务器内存:进程重启即全部失效(老设备再点一次「生成配对码」即可) +- 失败:`401` / `429`(同 IP 当日生成数超 `SG_PAIR_LIMIT`,默认 20) + +### POST /api/pair/join —— 配对码转入(新设备,P1) + +- 请求:`{ "code": "483920" }`;请求头 Bearer 可选(新设备当前匿名档案 token)。 + token 无效/过期时按「无档案设备」处理,不报 401。 +- 成功(带 token,与 claim-merge 同语义——**新设备当前档案按桶 LWW 并入码对应的 + 老档案**,方向裁定:本地新进度并入老档案): + `200 { "ok": true, "merged": true, "mergedGames": 2, "profileId": "p_…", "name": "玩家一", "token": "<原 token,已改绑>", "claimed": false }` +- 成功(无 token,全新浏览器):直接为老档案签发新 token(`merged: false`) +- 成功(joiner 已是该档案):幂等返回当前 token(`merged: false`),码照常作废 +- 成功即**作废该码**(单次使用),并发重复 join 靠同步删除保证原子 +- 失败: + - `400`(码格式非法,不计入连错) + - `404`(码不存在 / 已过期 / 已用过——统一文案不泄露状态;**计入连错**) + - `409`(joiner 当前档案已设 PIN:请先「+ 新玩家」建匿名档案再转入;不消费码) + - `429`(同 IP 连错 5 次 → 封锁 10 分钟) +- 日志脱敏:配对码明文不进日志(与 PIN 同待遇) + ### GET /api/progress —— 读进度 - 单桶(旧形状,游戏兼容,如 radio 直接读 `data`): @@ -153,4 +188,6 @@ tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶 ``` 覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/ -合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H4)。 +合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、 +配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、 +配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。 diff --git a/web/server/server.js b/web/server/server.js index cfc14dc5..98c51005 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -17,6 +17,14 @@ * - POST /api/login {username, pin} 旧客户端兼容(v1 用户迁移后仍可登录) * - POST /api/register {username, pin} 旧客户端兼容(等价于直接建已认领档案,建议改用 claim) * - POST /api/logout 吊销当前 token(Bearer) + * - POST /api/pair/start (Bearer,匿名/已认领均可)生成 6 位配对码: + * 10 分钟有效、单次使用、同档案同时仅 1 个有效码 + * (重新生成即作废旧码)、per-IP 频控(SG_PAIR_LIMIT,默认 20/天) + * - POST /api/pair/join {code} 新设备转入:把新设备当前档案按桶 LWW 合并进码对应 + * 的老档案并改绑 token(与 claim-merge 同语义);无 token + * 时直接为老档案签发新 token。join 连错 5 次 per-IP 封锁 + * 10 分钟;成功后码立即作废(单次使用)。配对码仅存内存, + * 不落盘、不进日志。 * - GET /api/progress?game=xxx 读单桶,保持 {game, data} 形状(radio 等游戏兼容) * - GET /api/progress 读全部桶 {games: {...}}(大厅一次拉回,修 03 §1.2) * - PUT /api/progress {game, data, baseUpdatedAt?} @@ -34,7 +42,8 @@ * .json {game: data} → profiles/.json(每桶补 updatedAt/rev) * * 安全水位(家庭局域网):PIN scrypt 哈希(N=16384, r=8)、token 180 天滑动过期 + - * 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)。 + * 登出吊销 + 惰性清扫、/api/device per-IP 频控(默认 10 个/天,env SG_DEVICE_LIMIT)、 + * /api/pair/* per-IP 频控 + join 5 连错封锁 + 配对码日志脱敏。 * HTTP 明文传输不变——请勿暴露公网,详见 web/server/README.md。 */ @@ -56,6 +65,10 @@ const USERS_FILE = path.join(DATA_DIR, 'users.json'); // v1 兼容 const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); // v1 兼容 const DEVICE_LIMIT = Number(process.env.SG_DEVICE_LIMIT) || 10; // per-IP 每日匿名建档上限 +const PAIR_START_LIMIT = Number(process.env.SG_PAIR_LIMIT) || 20; // per-IP 每日配对码签发上限 +const PAIR_TTL_MS = 10 * 60 * 1000; // 配对码有效期:10 分钟 +const PAIR_MAX_FAILS = 5; // join 连错封锁阈值(码尝试错误 5 次 → 封锁,旧设备重新生成) +const PAIR_BLOCK_MS = 10 * 60 * 1000; // join 连错封锁时长 const TOKEN_TTL_MS = 180 * 24 * 3600 * 1000; // 180 天滑动 const TOKEN_TOUCH_MS = 6 * 3600 * 1000; // 滑动续期节流(6h 内不重复落盘) @@ -83,6 +96,7 @@ const MIME = { const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/; const PIN_RE = /^\d{4,6}$/; const TOKEN_RE = /^[0-9a-f]{16,128}$/i; +const PAIR_CODE_RE = /^\d{6}$/; // ---------------- 状态 ---------------- @@ -256,6 +270,101 @@ function findClaimedByName(name) { return null; } +// ---------------- 跨档案合并原语(claim-merge 与 pair/join 共用,03 §3.4) ---------------- + +/** 把 srcPid 的进度按桶 LWW 并入 targetPid,返回更新的桶数。 */ +function mergeGamesInto(targetPid, srcPid) { + let mergedGames = 0; + return withGamesDoc(targetPid, (dst) => + loadGamesDoc(srcPid).then((src) => { + for (const [game, bucket] of Object.entries(src.games)) { + const cur = dst.games[game]; + if (!cur || newerBucket(cur, bucket) !== cur) { + dst.games[game] = bucket; + mergedGames += 1; + } + } + }), + ).then(() => mergedGames); +} + +/** 源档案出索引 + 惰性删除其进度文件(claim-merge / pair-join 收尾共用)。 */ +async function retireProfile(pid) { + delete profileIndex.profiles[pid]; + await persistProfileIndex(); + enqueueWrite(profileGamesFile(pid), async () => { + await fsp.unlink(profileGamesFile(pid)).catch(() => {}); + }); +} + +// ---------------- 配对码(P1:跨设备转移,01 §4.2 旅程 3) ---------------- +// 仅存内存:10 分钟短票据,进程重启即失效(老设备重新生成即可),不落盘不进日志。 + +/** @type {Map} code -> entry */ +const pairings = new Map(); +/** 同档案同时仅 1 个有效码:profileId -> code */ +const profilePairCode = new Map(); +/** join 连错封锁:ip -> {fails, blockedUntil} */ +const pairJoinFails = new Map(); +/** pair/start per-IP 频控:ip -> {day, count} */ +const pairStarts = new Map(); + +function sweepPairings() { + const now = Date.now(); + for (const [code, e] of pairings) { + if (!profileIndex.profiles[e.profileId] || Date.parse(e.expiresAt) < now) { + pairings.delete(code); + if (profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId); + } + } +} + +/** 作废一个码(join 成功消费或重新生成顶替)。 */ +function consumePairing(code) { + const e = pairings.get(code); + pairings.delete(code); + if (e && profilePairCode.get(e.profileId) === code) profilePairCode.delete(e.profileId); +} + +function clientIp(req) { + return String(req.socket.remoteAddress || '?').replace(/^::ffff:/, ''); +} + +function allowPairStart(req) { + const ip = clientIp(req); + const day = new Date().toISOString().slice(0, 10); + const rec = pairStarts.get(ip); + if (!rec || rec.day !== day) { + pairStarts.set(ip, { day, count: 1 }); + return true; + } + if (rec.count >= PAIR_START_LIMIT) return false; + rec.count += 1; + return true; +} + +function pairJoinBlocked(req) { + const rec = pairJoinFails.get(clientIp(req)); + return !!(rec && rec.blockedUntil > Date.now()); +} + +/** join 一次失败(码不存在/已用/过期)计一次;连错 PAIR_MAX_FAILS 次封锁 PAIR_BLOCK_MS。 */ +function registerPairJoinFail(req) { + const ip = clientIp(req); + let rec = pairJoinFails.get(ip); + if (!rec) { + rec = { fails: 0, blockedUntil: 0 }; + pairJoinFails.set(ip, rec); + } + if (rec.blockedUntil > Date.now()) return; // 封锁期内已拒之门外,不再累计 + rec.fails += 1; + if (rec.fails >= PAIR_MAX_FAILS) { + rec.fails = 0; + rec.blockedUntil = Date.now() + PAIR_BLOCK_MS; + console.log(`[server] pair/join 连错 ${PAIR_MAX_FAILS} 次: ip=${ip} 封锁 ${PAIR_BLOCK_MS / 60000} 分钟(码作废需老设备重新生成)`); + } +} + /** 自动名:玩家一、玩家二 … 玩家九十九(预生成语音词表覆盖一~九,见 01 §4.2)。 */ function cnNum(n) { const d = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; @@ -271,7 +380,7 @@ function cnNum(n) { const deviceCreations = new Map(); // ip -> {day, count} function allowDeviceCreate(req) { - const ip = String(req.socket.remoteAddress || '?').replace(/^::ffff:/, ''); + const ip = clientIp(req); const day = new Date().toISOString().slice(0, 10); const rec = deviceCreations.get(ip); if (!rec || rec.day !== day) { @@ -430,32 +539,100 @@ async function handleApi(req, res, url) { target.pinHash = hashPin(pin); // v1 明文透明升级(迁移漏网兜底) } const srcPid = auth.profileId; - let mergedGames = 0; - await withGamesDoc(targetPid, (dst) => { - // 源/目标两文件各自串行;合并按桶 LWW(newerBucket),重复执行幂等 - return loadGamesDoc(srcPid).then((src) => { - for (const [game, bucket] of Object.entries(src.games)) { - const cur = dst.games[game]; - if (!cur || newerBucket(cur, bucket) !== cur) { - dst.games[game] = bucket; - mergedGames += 1; - } - } - }); - }); + const mergedGames = await mergeGamesInto(targetPid, srcPid); tokens[auth.token].profileId = targetPid; // token 改绑目标档案 await persistTokens(); - delete profileIndex.profiles[srcPid]; // 匿名源档案出索引(其 games 文件成孤儿,惰性清理见下) - await persistProfileIndex(); - enqueueWrite(profileGamesFile(srcPid), async () => { - await fsp.unlink(profileGamesFile(srcPid)).catch(() => {}); - }); + await retireProfile(srcPid); // 匿名源档案出索引(进度已并入,文件惰性清理) console.log(`[server] claim 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新)`); return sendJson(res, 200, { ok: true, merged: true, mergedGames, profileId: targetPid, name: target.name, claimed: true, }); } + // ---- 配对码:老设备出示(Bearer,匿名/已认领均可) ---- + if (route === 'POST /api/pair/start') { + const auth = authProfile(req); + if (!auth) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' }); + sweepPairings(); + if (!allowPairStart(req)) { + return sendJson(res, 429, { error: `本机今日配对码生成数已达上限(${PAIR_START_LIMIT})` }); + } + // 同档案同时仅 1 个有效码:重新生成即作废旧码 + const old = profilePairCode.get(auth.profileId); + if (old) consumePairing(old); + let code; + for (;;) { + code = String(crypto.randomInt(0, 1000000)).padStart(6, '0'); + if (!pairings.has(code)) break; + } + pairings.set(code, { + profileId: auth.profileId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString(), + }); + profilePairCode.set(auth.profileId, code); + // 日志不落配对码明文(与 PIN 同待遇:短时凭据不进日志) + console.log(`[server] 配对码签发: pid=${auth.profileId}(码 ****,10 分钟单次有效)`); + return sendJson(res, 200, { ok: true, code, expiresAt: new Date(Date.now() + PAIR_TTL_MS).toISOString() }); + } + + // ---- 配对码:新设备转入(可带当前匿名档案 token,服务器端按桶 LWW 合并进老档案) ---- + if (route === 'POST /api/pair/join') { + const body = await parseBody(req); + const { code } = body; + if (typeof code !== 'string' || !PAIR_CODE_RE.test(code)) { + return sendJson(res, 400, { error: '配对码需为 6 位数字' }); + } + sweepPairings(); + if (pairJoinBlocked(req)) { + return sendJson(res, 429, { error: `配对码尝试错误次数过多,请 ${PAIR_BLOCK_MS / 60000} 分钟后再试(老设备可重新生成新码)` }); + } + const entry = pairings.get(code); + if (!entry || !profileIndex.profiles[entry.profileId] || Date.parse(entry.expiresAt) < Date.now()) { + consumePairing(code); // 若是过期残留则顺带清掉 + registerPairJoinFail(req); + return sendJson(res, 404, { error: '配对码不存在或已失效(10 分钟单次有效),请在老设备重新生成' }); + } + const targetPid = entry.profileId; + const target = profileIndex.profiles[targetPid]; + + // joiner 身份可选:Bearer / body.token;无效或缺失一律视作无档案新设备(直发新 token) + const auth = authProfile(req) + || (typeof body.token === 'string' && TOKEN_RE.test(body.token) ? authByToken(body.token.toLowerCase()) : null); + + // joiner 已是该档案(同设备重复转入):幂等返回,消费码 + if (auth && auth.profileId === targetPid) { + consumePairing(code); + return sendJson(res, 200, { + ok: true, merged: false, mergedGames: 0, profileId: targetPid, name: target.name, + token: auth.token, claimed: !!target.pinHash, + }); + } + // 已设 PIN 的档案不参与合并(与 claim-merge 同防线):先建匿名档案再转入 + if (auth && auth.profile.pinHash) { + return sendJson(res, 409, { error: '当前档案已设 PIN;请先用「+ 新玩家」建匿名档案再转入' }); + } + + consumePairing(code); // 同步作废 = 单次使用的原子保证(后续 await 期间并发 join 即失效) + let token; + let mergedGames = 0; + if (auth) { + const srcPid = auth.profileId; + mergedGames = await mergeGamesInto(targetPid, srcPid); + tokens[auth.token].profileId = targetPid; // token 改绑老档案 + await persistTokens(); + await retireProfile(srcPid); + console.log(`[server] pair 合并: ${srcPid} → ${targetPid}(${mergedGames} 桶更新,码 **** 已作废)`); + } else { + token = issueToken(targetPid); + console.log(`[server] pair 无档案转入: 新设备 → ${targetPid}(码 **** 已作废)`); + } + return sendJson(res, 200, { + ok: true, merged: !!auth, mergedGames, profileId: targetPid, name: target.name, + token: token || auth.token, claimed: !!target.pinHash, + }); + } + // ---- 旧客户端兼容:登录(v1 用户名+PIN,映射到档案) ---- if (route === 'POST /api/login') { const { username, pin } = await parseBody(req); @@ -469,7 +646,9 @@ async function handleApi(req, res, url) { await persistProfileIndex(); } const token = issueToken(pid); - return sendJson(res, 200, { ok: true, token, username: profile.name, profileId: pid, name: profile.name }); + return sendJson(res, 200, { + ok: true, token, username: profile.name, profileId: pid, name: profile.name, claimed: true, + }); } // ---- 旧客户端兼容:注册(直接建已认领档案;新客户端请用 /api/device + /api/claim) ---- diff --git a/web/server/test/api-smoke.mjs b/web/server/test/api-smoke.mjs index 9ba9050f..36689642 100644 --- a/web/server/test/api-smoke.mjs +++ b/web/server/test/api-smoke.mjs @@ -11,6 +11,9 @@ * profiles/.json(PIN 明文即刻 scrypt 哈希、旧 token 继续有效) * G. per-IP 匿名建档频控(429)与 /server/ 静态目录封禁(数据不外泄) * H. 大厅 localStorage 分桶逻辑单测(web/profiles.js 纯函数,Map 版 storage) + * I. 配对码跨设备(P1):start(6 位/10min/同档案 1 码/频控)→ join 按桶 LWW 合并 + * + token 改绑 → 单次使用作废 + join 5 连错封锁 + 已设 PIN joiner 拒绝 + 日志不落码 + * J. token 180 天滑动过期:到期 401(大厅「档案已过期」引导的服务器侧路径) * * 运行:tools/node/bin/node web/server/test/api-smoke.mjs(或任意 node ≥18) * 全部通过退出码 0;任一失败抛异常退出非 0。测试数据全部写进临时目录。 @@ -26,8 +29,8 @@ import { fileURLToPath } from 'node:url'; import { BUCKETS, SETTINGS_KEY, - applyAuth, decideBucket, getCurrentProfile, migrateLegacyAuth, - nextLocalProfileName, readRegistry, restoreSnapshot, setCurrentProfile, + applyAuth, cnNum, decideBucket, digitsToCn, getCurrentProfile, migrateLegacyAuth, + nextLocalProfileName, readRegistry, removeProfile, restoreSnapshot, setCurrentProfile, snapshotKeyOf, stashSnapshot, syncKeyOf, writeRegistry, } from '../../profiles.js'; @@ -165,6 +168,47 @@ test('H4. 受管桶清单覆盖 8 个可同步游戏 + _settings', () => { for (const b of BUCKETS) assert.ok(b.key.startsWith('soundgame.'), '桶 key 应保持游戏原始 key 名(游戏零改动)'); }); +test('H5. 配对码逐位中文读法(0 读「零」非字母 O;speechSynthesis 前置纯函数)', () => { + assert.equal(digitsToCn('048920'), '零 四 八 九 二 零'); + assert.equal(digitsToCn('000000'), '零 零 零 零 零 零'); + assert.equal(digitsToCn('123456'), '一 二 三 四 五 六'); + assert.equal(digitsToCn(''), ''); + assert.equal(digitsToCn('9'), '九'); + // 自动名词表与 server.js cnNum 同源(玩家一~九可听) + assert.equal(cnNum(1), '一'); + assert.equal(cnNum(10), '十'); + assert.equal(cnNum(21), '二十一'); +}); + +test('H6. 从本机移除:清快照/同步表/注册表项;当前档案连带 CURRENT/AUTH,非当前不动 AUTH', () => { + const storage = mockStorage(); + writeRegistry(storage, [ + { id: 'p_aaa', name: '玩家一', token: 't1' }, + { id: 'p_bbb', name: '玩家二', token: 't2' }, + ]); + setCurrentProfile(storage, 'p_bbb'); + storage.setItem(snapshotKeyOf('p_aaa'), JSON.stringify({ 'soundgame.progress.rpg': '{}' })); + storage.setItem(syncKeyOf('p_aaa'), JSON.stringify({ rpg: { updatedAt: 'x', rev: 1 } })); + applyAuth(storage, { id: 'p_bbb', name: '玩家二', token: 't2' }); + + // 移除非当前档案:不清 CURRENT/AUTH + let list = removeProfile(storage, 'p_aaa'); + assert.equal(list.length, 1); + assert.equal(storage.getItem(snapshotKeyOf('p_aaa')), null); + assert.equal(storage.getItem(syncKeyOf('p_aaa')), null); + assert.equal(storage.getItem('soundgame.profile.current'), 'p_bbb'); + assert.ok(storage.getItem('soundgame.auth'), '非当前档案移除不应动 AUTH'); + + // 移除当前档案:连带清 CURRENT/AUTH(调用方负责新建/切换) + storage.setItem(snapshotKeyOf('p_bbb'), '{}'); + list = removeProfile(storage, 'p_bbb'); + assert.deepEqual(list, []); + assert.equal(storage.getItem('soundgame.profile.current'), null); + assert.equal(storage.getItem('soundgame.auth'), null); + assert.equal(storage.getItem(snapshotKeyOf('p_bbb')), null); + assert.equal(getCurrentProfile(storage), null); +}); + function mockStorage() { const m = new Map(); return { @@ -423,3 +467,208 @@ test('G2. per-IP 匿名建档频控', async () => { await srv.stop(); } }); + +// ---------------- I. 配对码跨设备(P1) ---------------- + +test('I1. 配对码全流程:start(6 位/10min/同档案重生成顶替)→ join 按桶 LWW 合并 → 单次作废', async () => { + const srv = await startServer({}); + try { + // 老设备:建档 + rpg 进度 + let r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const oldDev = { profileId: r.json.profileId, token: r.json.token, name: r.json.name }; + r = await call(srv.base, 'PUT', '/api/progress', { + token: oldDev.token, + body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T10:00:00Z', summary: '第二章·Lv7', lv: 7 } }, + }); + assert.equal(r.status, 200); + + // start:6 位数字、10 分钟有效期、需鉴权 + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + assert.equal(r.status, 200); + assert.ok(r.json.ok); + assert.match(r.json.code, /^\d{6}$/); + const firstCode = r.json.code; + const ttl = Date.parse(r.json.expiresAt) - Date.now(); + assert.ok(ttl > 9 * 60 * 1000 && ttl <= 10 * 60 * 1000, `expiresAt 应为 ~10 分钟(实际 ${ttl}ms)`); + r = await call(srv.base, 'POST', '/api/pair/start'); + assert.equal(r.status, 401, 'pair/start 必须鉴权'); + + // 同档案重新生成:旧码立即作废(同档案同时仅 1 个有效码) + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + const code2 = r.json.code; + let code = code2; + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const stranger = { token: r.json.token }; + // 被顶替的旧码 → 404(计 1 次错误尝试,未到封锁阈值不影响后续) + r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: firstCode } }); + assert.equal(r.status, 404, '被重新生成顶替的旧码必须立即作废'); + // 非 6 位数字 → 400(格式校验在先,不计入连错计数) + r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code: '000001-not-real' } }); + assert.equal(r.status, 400, '非 6 位数字 → 400'); + + // 新设备:匿名档案 + dungeon 进度 + 更新的 rpg 进度 + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const newDev = { profileId: r.json.profileId, token: r.json.token }; + r = await call(srv.base, 'PUT', '/api/progress', { + token: newDev.token, + body: { game: 'dungeon', data: { schema: 1, summary: '第 3 层', floor: 3 } }, + }); + assert.equal(r.status, 200); + r = await call(srv.base, 'PUT', '/api/progress', { + token: newDev.token, + body: { game: 'rpg', data: { schema: 1, updatedAt: '2026-09-09T20:00:00Z', summary: '第三章·Lv12', lv: 12 } }, + }); + assert.equal(r.status, 200); + + // join:新设备档案按桶 LWW 并入老档案,token 改绑(与 claim-merge 同语义) + r = await call(srv.base, 'POST', '/api/pair/join', { token: newDev.token, body: { code } }); + assert.equal(r.status, 200); + assert.equal(r.json.ok, true); + assert.equal(r.json.merged, true); + assert.equal(r.json.profileId, oldDev.profileId, '新设备 token 应改绑到码对应的老档案'); + assert.equal(r.json.name, oldDev.name); + assert.equal(r.json.token, newDev.token, 'join 复用新设备 token(改绑,不发新)'); + assert.ok(r.json.mergedGames >= 2, `至少 rpg+dungeon 两桶更新(实际 ${r.json.mergedGames})`); + + // 合并结果:LWW 裁决(rpg 取新设备的第 12 级;dungeon 是新设备独有桶) + r = await call(srv.base, 'GET', '/api/progress', { token: newDev.token }); + assert.equal(r.json.games.rpg.data.lv, 12, 'LWW:新设备 rpg 更新,应胜出'); + assert.equal(r.json.games.dungeon.data.floor, 3); + // 老设备 token 不受影响,且看到同一份合并后的档案 + r = await call(srv.base, 'GET', '/api/progress?game=dungeon', { token: oldDev.token }); + assert.equal(r.json.data.floor, 3); + + // 单次使用:同一码再用 → 失效(404) + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const third = { token: r.json.token }; + r = await call(srv.base, 'POST', '/api/pair/join', { token: third.token, body: { code } }); + assert.equal(r.status, 404, 'join 成功后旧码必须立即失效'); + + // 无 token 新设备(全新浏览器)join:直接为老档案签发新 token + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + code = r.json.code; + r = await call(srv.base, 'POST', '/api/pair/join', { body: { code } }); + assert.equal(r.status, 200); + assert.equal(r.json.merged, false); + assert.equal(r.json.profileId, oldDev.profileId); + assert.match(r.json.token, /^[0-9a-f]{48}$/, '无档案设备应拿到老档案的新 token'); + r = await call(srv.base, 'GET', '/api/progress?game=rpg', { token: r.json.token }); + assert.equal(r.json.data.lv, 12, '无档案 join 也能拉到合并后的进度'); + + // joiner 已设 PIN(已认领档案)→ 409,防误合并 + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const claimed = { token: r.json.token }; + r = await call(srv.base, 'POST', '/api/claim', { token: claimed.token, body: { pin: '1111' } }); + assert.equal(r.status, 200); + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + code = r.json.code; + r = await call(srv.base, 'POST', '/api/pair/join', { token: claimed.token, body: { code } }); + assert.equal(r.status, 409, '已设 PIN 的档案不允许作为 join 源被合并'); + + // joiner 已是该档案(同设备重复转入)→ 幂等成功,消费码 + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + code = r.json.code; + r = await call(srv.base, 'POST', '/api/pair/join', { token: oldDev.token, body: { code } }); + assert.equal(r.status, 200); + assert.equal(r.json.merged, false); + assert.equal(r.json.token, oldDev.token); + r = await call(srv.base, 'POST', '/api/pair/join', { token: stranger.token, body: { code } }); + assert.equal(r.status, 404, '幂等转入同样消费码(单次)'); + + // 日志不落配对码明文(与 PIN 同待遇):本会话所有生成过的码逐一比对 + r = await call(srv.base, 'POST', '/api/pair/start', { token: oldDev.token }); + const logged = srv.logs.join(''); + for (const c of [code, code2, r.json.code]) { + assert.ok(!logged.includes(c), `服务器日志不得包含配对码明文 ${c}`); + } + } finally { + await srv.stop(); + } +}); + +test('I2. 配对码防护:join 5 连错封锁 10 分钟、start per-IP 频控、过期码失败计数', async () => { + const srv = await startServer({ env: { SG_PAIR_LIMIT: '3' } }); + try { + let r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const dev = { token: r.json.token }; + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const guesser = { token: r.json.token }; + + // 5 次错误码 → 全部 404,第 6 次起 429(封锁) + for (let i = 1; i <= 5; i++) { + r = await call(srv.base, 'POST', '/api/pair/join', { token: guesser.token, body: { code: '00000' + i } }); + assert.equal(r.status, 404, `第 ${i} 次错误码应 404`); + } + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const legit = { token: r.json.token }; + r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token }); + const code = r.json.code; + r = await call(srv.base, 'POST', '/api/pair/join', { token: legit.token, body: { code } }); + assert.equal(r.status, 429, '连错 5 次后同 IP 一切 join 被封锁(含正确码)'); + assert.ok(r.json.error.includes('10 分钟'), '封锁提示应引导等待与重新生成'); + + // start per-IP 频控:SG_PAIR_LIMIT=3 → 第 4 次 429 + r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token }); + assert.equal(r.status, 200); // 2 + r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token }); + assert.equal(r.status, 200); // 3 + r = await call(srv.base, 'POST', '/api/pair/start', { token: dev.token }); + assert.equal(r.status, 429, 'pair/start 应受 per-IP 频控'); + } finally { + await srv.stop(); + } +}); + +// ---------------- J. token 180 天滑动过期(大厅「档案已过期」引导的服务器侧路径) ---------------- + +test('J1. 过期 token → 401;pair/join 对过期 token 视作无档案设备(引导可自愈)', async () => { + const dataDir = tmpDataDir(); + // 手工落一份「token 已过期」的数据:1 个档案 + 1 条 179 天前过期的 token + const expiredToken = 'ab'.repeat(24); + fs.mkdirSync(path.join(dataDir, 'profiles'), { recursive: true }); + fs.writeFileSync(path.join(dataDir, 'profiles.json'), JSON.stringify({ + seq: 1, + profiles: { p_dead: { name: '玩家一', pinHash: null, createdAt: '2025-01-01T00:00:00Z' } }, + }, null, 2)); + fs.writeFileSync(path.join(dataDir, 'tokens.json'), JSON.stringify({ + [expiredToken]: { + profileId: 'p_dead', createdAt: '2025-01-01T00:00:00Z', + lastSeen: '2025-01-01T00:00:00Z', expiresAt: '2025-06-01T00:00:00Z', // 早已过期 + }, + }, null, 2)); + + const srv = await startServer({ dataDir }); + try { + // 全部鉴权端点对过期 token 一律 401(大厅据此弹「档案已过期,重新登录或新建」) + let r = await call(srv.base, 'GET', '/api/profile', { token: expiredToken }); + assert.equal(r.status, 401); + assert.ok(r.json.error.includes('未授权'), '错误文案供大厅 isAuthExpired 判定'); + r = await call(srv.base, 'GET', '/api/progress', { token: expiredToken }); + assert.equal(r.status, 401); + r = await call(srv.base, 'PUT', '/api/progress', { token: expiredToken, body: { game: 'rpg', data: {} } }); + assert.equal(r.status, 401); + r = await call(srv.base, 'POST', '/api/pair/start', { token: expiredToken }); + assert.equal(r.status, 401); + // 过期 token 已被惰性清扫出 tokens.json + const tokensDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tokens.json'), 'utf8')); + assert.ok(!tokensDisk[expiredToken], '过期 token 应被惰性清扫'); + + // 恢复路径 A(大厅「新玩家」):匿名建档照常可用 + r = await call(srv.base, 'POST', '/api/device', { body: {} }); + const helper = { token: r.json.token, profileId: r.json.profileId }; + assert.notEqual(helper.profileId, 'p_dead'); + r = await call(srv.base, 'GET', '/api/profile', { token: helper.token }); + assert.equal(r.status, 200); + + // 恢复路径 B(配对转入):pair/join 带过期 token 不 401,视作无档案设备直发新 token + // (服务器端对 join 的宽容 = 大厅引导文案「从其他设备转入」可以无感自愈) + r = await call(srv.base, 'POST', '/api/pair/start', { token: helper.token }); + const code = r.json.code; + r = await call(srv.base, 'POST', '/api/pair/join', { token: expiredToken, body: { code } }); + assert.equal(r.status, 200, 'join 对过期 token 应按无档案处理而非 401'); + assert.match(r.json.token, /^[0-9a-f]{48}$/); + assert.equal(r.json.profileId, helper.profileId); + } finally { + await srv.stop(); + } +}); diff --git a/web/simon/audio/voice/female/key/gd_economy.m4a b/web/simon/audio/voice/female/key/gd_economy.m4a new file mode 100644 index 00000000..b695660c Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_economy.m4a differ diff --git a/web/simon/audio/voice/female/key/gd_economy.ogg b/web/simon/audio/voice/female/key/gd_economy.ogg new file mode 100644 index 00000000..09344e65 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_economy.ogg differ diff --git a/web/simon/audio/voice/female/key/gd_occ_both.m4a b/web/simon/audio/voice/female/key/gd_occ_both.m4a new file mode 100644 index 00000000..3c15d6b9 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_both.m4a differ diff --git a/web/simon/audio/voice/female/key/gd_occ_both.ogg b/web/simon/audio/voice/female/key/gd_occ_both.ogg new file mode 100644 index 00000000..b74109b7 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_both.ogg differ diff --git a/web/simon/audio/voice/female/key/gd_occ_dt.m4a b/web/simon/audio/voice/female/key/gd_occ_dt.m4a new file mode 100644 index 00000000..b7bcf7cf Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_dt.m4a differ diff --git a/web/simon/audio/voice/female/key/gd_occ_dt.ogg b/web/simon/audio/voice/female/key/gd_occ_dt.ogg new file mode 100644 index 00000000..39b31ca3 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_dt.ogg differ diff --git a/web/simon/audio/voice/female/key/gd_occ_up.m4a b/web/simon/audio/voice/female/key/gd_occ_up.m4a new file mode 100644 index 00000000..fc9b791f Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_up.m4a differ diff --git a/web/simon/audio/voice/female/key/gd_occ_up.ogg b/web/simon/audio/voice/female/key/gd_occ_up.ogg new file mode 100644 index 00000000..3b13c828 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_occ_up.ogg differ diff --git a/web/simon/audio/voice/female/key/gd_relisten.m4a b/web/simon/audio/voice/female/key/gd_relisten.m4a new file mode 100644 index 00000000..2f11a93c Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_relisten.m4a differ diff --git a/web/simon/audio/voice/female/key/gd_relisten.ogg b/web/simon/audio/voice/female/key/gd_relisten.ogg new file mode 100644 index 00000000..fcad0567 Binary files /dev/null and b/web/simon/audio/voice/female/key/gd_relisten.ogg differ diff --git a/web/simon/audio/voice/female/key/replay.m4a b/web/simon/audio/voice/female/key/replay.m4a new file mode 100644 index 00000000..992c8790 Binary files /dev/null and b/web/simon/audio/voice/female/key/replay.m4a differ diff --git a/web/simon/audio/voice/female/key/replay.ogg b/web/simon/audio/voice/female/key/replay.ogg new file mode 100644 index 00000000..25a4d3c9 Binary files /dev/null and b/web/simon/audio/voice/female/key/replay.ogg differ diff --git a/web/simon/audio/voice/female/key/rules.m4a b/web/simon/audio/voice/female/key/rules.m4a new file mode 100644 index 00000000..da082193 Binary files /dev/null and b/web/simon/audio/voice/female/key/rules.m4a differ diff --git a/web/simon/audio/voice/female/key/rules.ogg b/web/simon/audio/voice/female/key/rules.ogg new file mode 100644 index 00000000..61620161 Binary files /dev/null and b/web/simon/audio/voice/female/key/rules.ogg differ diff --git a/web/simon/audio/voice/female/key/rules_prompt.m4a b/web/simon/audio/voice/female/key/rules_prompt.m4a new file mode 100644 index 00000000..73d8e844 Binary files /dev/null and b/web/simon/audio/voice/female/key/rules_prompt.m4a differ diff --git a/web/simon/audio/voice/female/key/rules_prompt.ogg b/web/simon/audio/voice/female/key/rules_prompt.ogg new file mode 100644 index 00000000..248a2df7 Binary files /dev/null and b/web/simon/audio/voice/female/key/rules_prompt.ogg differ diff --git a/web/simon/audio/voice/female/key/skip_hint.m4a b/web/simon/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..bc207e76 Binary files /dev/null and b/web/simon/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/simon/audio/voice/female/key/skip_hint.ogg b/web/simon/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..9eac2ed8 Binary files /dev/null and b/web/simon/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/simon/audio/voice/female/key/t1_up.m4a b/web/simon/audio/voice/female/key/t1_up.m4a new file mode 100644 index 00000000..85d29a51 Binary files /dev/null and b/web/simon/audio/voice/female/key/t1_up.m4a differ diff --git a/web/simon/audio/voice/female/key/t1_up.ogg b/web/simon/audio/voice/female/key/t1_up.ogg new file mode 100644 index 00000000..a70ff32a Binary files /dev/null and b/web/simon/audio/voice/female/key/t1_up.ogg differ diff --git a/web/simon/audio/voice/female/key/t2_down.m4a b/web/simon/audio/voice/female/key/t2_down.m4a new file mode 100644 index 00000000..970964db Binary files /dev/null and b/web/simon/audio/voice/female/key/t2_down.m4a differ diff --git a/web/simon/audio/voice/female/key/t2_down.ogg b/web/simon/audio/voice/female/key/t2_down.ogg new file mode 100644 index 00000000..f4ef2c87 Binary files /dev/null and b/web/simon/audio/voice/female/key/t2_down.ogg differ diff --git a/web/simon/audio/voice/female/key/t3_left.m4a b/web/simon/audio/voice/female/key/t3_left.m4a new file mode 100644 index 00000000..94b668c6 Binary files /dev/null and b/web/simon/audio/voice/female/key/t3_left.m4a differ diff --git a/web/simon/audio/voice/female/key/t3_left.ogg b/web/simon/audio/voice/female/key/t3_left.ogg new file mode 100644 index 00000000..e93eb3b6 Binary files /dev/null and b/web/simon/audio/voice/female/key/t3_left.ogg differ diff --git a/web/simon/audio/voice/female/key/t4_right.m4a b/web/simon/audio/voice/female/key/t4_right.m4a new file mode 100644 index 00000000..acbe7aca Binary files /dev/null and b/web/simon/audio/voice/female/key/t4_right.m4a differ diff --git a/web/simon/audio/voice/female/key/t4_right.ogg b/web/simon/audio/voice/female/key/t4_right.ogg new file mode 100644 index 00000000..7e05f126 Binary files /dev/null and b/web/simon/audio/voice/female/key/t4_right.ogg differ diff --git a/web/simon/audio/voice/female/key/t5_tap.m4a b/web/simon/audio/voice/female/key/t5_tap.m4a new file mode 100644 index 00000000..03de839d Binary files /dev/null and b/web/simon/audio/voice/female/key/t5_tap.m4a differ diff --git a/web/simon/audio/voice/female/key/t5_tap.ogg b/web/simon/audio/voice/female/key/t5_tap.ogg new file mode 100644 index 00000000..578a3e25 Binary files /dev/null and b/web/simon/audio/voice/female/key/t5_tap.ogg differ diff --git a/web/simon/audio/voice/female/key/t6_double.m4a b/web/simon/audio/voice/female/key/t6_double.m4a new file mode 100644 index 00000000..b6a2fb3c Binary files /dev/null and b/web/simon/audio/voice/female/key/t6_double.m4a differ diff --git a/web/simon/audio/voice/female/key/t6_double.ogg b/web/simon/audio/voice/female/key/t6_double.ogg new file mode 100644 index 00000000..db956f3e Binary files /dev/null and b/web/simon/audio/voice/female/key/t6_double.ogg differ diff --git a/web/simon/audio/voice/female/key/t7_retry.m4a b/web/simon/audio/voice/female/key/t7_retry.m4a new file mode 100644 index 00000000..a46a903a Binary files /dev/null and b/web/simon/audio/voice/female/key/t7_retry.m4a differ diff --git a/web/simon/audio/voice/female/key/t7_retry.ogg b/web/simon/audio/voice/female/key/t7_retry.ogg new file mode 100644 index 00000000..55309ad2 Binary files /dev/null and b/web/simon/audio/voice/female/key/t7_retry.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_double.m4a b/web/simon/audio/voice/female/key/t_blunt_double.m4a new file mode 100644 index 00000000..d63fba78 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_double.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_double.ogg b/web/simon/audio/voice/female/key/t_blunt_double.ogg new file mode 100644 index 00000000..1a5c1400 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_double.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_down.m4a b/web/simon/audio/voice/female/key/t_blunt_down.m4a new file mode 100644 index 00000000..d863d1e4 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_down.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_down.ogg b/web/simon/audio/voice/female/key/t_blunt_down.ogg new file mode 100644 index 00000000..6bbc0e0e Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_down.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_left.m4a b/web/simon/audio/voice/female/key/t_blunt_left.m4a new file mode 100644 index 00000000..cae6f99f Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_left.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_left.ogg b/web/simon/audio/voice/female/key/t_blunt_left.ogg new file mode 100644 index 00000000..4b11371e Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_left.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_right.m4a b/web/simon/audio/voice/female/key/t_blunt_right.m4a new file mode 100644 index 00000000..f8c72e29 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_right.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_right.ogg b/web/simon/audio/voice/female/key/t_blunt_right.ogg new file mode 100644 index 00000000..ceecadde Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_right.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_tap.m4a b/web/simon/audio/voice/female/key/t_blunt_tap.m4a new file mode 100644 index 00000000..053744e8 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_tap.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_tap.ogg b/web/simon/audio/voice/female/key/t_blunt_tap.ogg new file mode 100644 index 00000000..20681a6b Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_tap.ogg differ diff --git a/web/simon/audio/voice/female/key/t_blunt_up.m4a b/web/simon/audio/voice/female/key/t_blunt_up.m4a new file mode 100644 index 00000000..9f5f9e78 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_up.m4a differ diff --git a/web/simon/audio/voice/female/key/t_blunt_up.ogg b/web/simon/audio/voice/female/key/t_blunt_up.ogg new file mode 100644 index 00000000..d2ab2fb0 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_blunt_up.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_double.m4a b/web/simon/audio/voice/female/key/t_wrong_double.m4a new file mode 100644 index 00000000..9fe090a1 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_double.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_double.ogg b/web/simon/audio/voice/female/key/t_wrong_double.ogg new file mode 100644 index 00000000..f959f482 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_double.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_down.m4a b/web/simon/audio/voice/female/key/t_wrong_down.m4a new file mode 100644 index 00000000..06990ad6 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_down.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_down.ogg b/web/simon/audio/voice/female/key/t_wrong_down.ogg new file mode 100644 index 00000000..5145c010 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_down.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_left.m4a b/web/simon/audio/voice/female/key/t_wrong_left.m4a new file mode 100644 index 00000000..c61c095d Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_left.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_left.ogg b/web/simon/audio/voice/female/key/t_wrong_left.ogg new file mode 100644 index 00000000..c984d220 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_left.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_right.m4a b/web/simon/audio/voice/female/key/t_wrong_right.m4a new file mode 100644 index 00000000..0c82f9f4 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_right.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_right.ogg b/web/simon/audio/voice/female/key/t_wrong_right.ogg new file mode 100644 index 00000000..a6507508 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_right.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_tap.m4a b/web/simon/audio/voice/female/key/t_wrong_tap.m4a new file mode 100644 index 00000000..a1d031dc Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_tap.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_tap.ogg b/web/simon/audio/voice/female/key/t_wrong_tap.ogg new file mode 100644 index 00000000..8b60fbd0 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_tap.ogg differ diff --git a/web/simon/audio/voice/female/key/t_wrong_up.m4a b/web/simon/audio/voice/female/key/t_wrong_up.m4a new file mode 100644 index 00000000..2afcd724 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_up.m4a differ diff --git a/web/simon/audio/voice/female/key/t_wrong_up.ogg b/web/simon/audio/voice/female/key/t_wrong_up.ogg new file mode 100644 index 00000000..2d0863b0 Binary files /dev/null and b/web/simon/audio/voice/female/key/t_wrong_up.ogg differ diff --git a/web/simon/audio/voice/male/key/gd_economy.m4a b/web/simon/audio/voice/male/key/gd_economy.m4a new file mode 100644 index 00000000..39d6c7e3 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_economy.m4a differ diff --git a/web/simon/audio/voice/male/key/gd_economy.ogg b/web/simon/audio/voice/male/key/gd_economy.ogg new file mode 100644 index 00000000..c769d3e5 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_economy.ogg differ diff --git a/web/simon/audio/voice/male/key/gd_occ_both.m4a b/web/simon/audio/voice/male/key/gd_occ_both.m4a new file mode 100644 index 00000000..c303fa9e Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_both.m4a differ diff --git a/web/simon/audio/voice/male/key/gd_occ_both.ogg b/web/simon/audio/voice/male/key/gd_occ_both.ogg new file mode 100644 index 00000000..76474a02 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_both.ogg differ diff --git a/web/simon/audio/voice/male/key/gd_occ_dt.m4a b/web/simon/audio/voice/male/key/gd_occ_dt.m4a new file mode 100644 index 00000000..05ccda10 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_dt.m4a differ diff --git a/web/simon/audio/voice/male/key/gd_occ_dt.ogg b/web/simon/audio/voice/male/key/gd_occ_dt.ogg new file mode 100644 index 00000000..d4e91316 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_dt.ogg differ diff --git a/web/simon/audio/voice/male/key/gd_occ_up.m4a b/web/simon/audio/voice/male/key/gd_occ_up.m4a new file mode 100644 index 00000000..1845fa17 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_up.m4a differ diff --git a/web/simon/audio/voice/male/key/gd_occ_up.ogg b/web/simon/audio/voice/male/key/gd_occ_up.ogg new file mode 100644 index 00000000..ef4d61b5 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_occ_up.ogg differ diff --git a/web/simon/audio/voice/male/key/gd_relisten.m4a b/web/simon/audio/voice/male/key/gd_relisten.m4a new file mode 100644 index 00000000..9d34b88a Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_relisten.m4a differ diff --git a/web/simon/audio/voice/male/key/gd_relisten.ogg b/web/simon/audio/voice/male/key/gd_relisten.ogg new file mode 100644 index 00000000..1476fa39 Binary files /dev/null and b/web/simon/audio/voice/male/key/gd_relisten.ogg differ diff --git a/web/simon/audio/voice/male/key/replay.m4a b/web/simon/audio/voice/male/key/replay.m4a new file mode 100644 index 00000000..6af927b7 Binary files /dev/null and b/web/simon/audio/voice/male/key/replay.m4a differ diff --git a/web/simon/audio/voice/male/key/replay.ogg b/web/simon/audio/voice/male/key/replay.ogg new file mode 100644 index 00000000..8d442ebf Binary files /dev/null and b/web/simon/audio/voice/male/key/replay.ogg differ diff --git a/web/simon/audio/voice/male/key/rules.m4a b/web/simon/audio/voice/male/key/rules.m4a new file mode 100644 index 00000000..38b258d6 Binary files /dev/null and b/web/simon/audio/voice/male/key/rules.m4a differ diff --git a/web/simon/audio/voice/male/key/rules.ogg b/web/simon/audio/voice/male/key/rules.ogg new file mode 100644 index 00000000..d7d8bb6c Binary files /dev/null and b/web/simon/audio/voice/male/key/rules.ogg differ diff --git a/web/simon/audio/voice/male/key/rules_prompt.m4a b/web/simon/audio/voice/male/key/rules_prompt.m4a new file mode 100644 index 00000000..d78781f7 Binary files /dev/null and b/web/simon/audio/voice/male/key/rules_prompt.m4a differ diff --git a/web/simon/audio/voice/male/key/rules_prompt.ogg b/web/simon/audio/voice/male/key/rules_prompt.ogg new file mode 100644 index 00000000..d90b1a55 Binary files /dev/null and b/web/simon/audio/voice/male/key/rules_prompt.ogg differ diff --git a/web/simon/audio/voice/male/key/skip_hint.m4a b/web/simon/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..e0438f5d Binary files /dev/null and b/web/simon/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/simon/audio/voice/male/key/skip_hint.ogg b/web/simon/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..d8f50b16 Binary files /dev/null and b/web/simon/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/simon/audio/voice/male/key/t1_up.m4a b/web/simon/audio/voice/male/key/t1_up.m4a new file mode 100644 index 00000000..2466b93e Binary files /dev/null and b/web/simon/audio/voice/male/key/t1_up.m4a differ diff --git a/web/simon/audio/voice/male/key/t1_up.ogg b/web/simon/audio/voice/male/key/t1_up.ogg new file mode 100644 index 00000000..e7c6872e Binary files /dev/null and b/web/simon/audio/voice/male/key/t1_up.ogg differ diff --git a/web/simon/audio/voice/male/key/t2_down.m4a b/web/simon/audio/voice/male/key/t2_down.m4a new file mode 100644 index 00000000..f9dcb42a Binary files /dev/null and b/web/simon/audio/voice/male/key/t2_down.m4a differ diff --git a/web/simon/audio/voice/male/key/t2_down.ogg b/web/simon/audio/voice/male/key/t2_down.ogg new file mode 100644 index 00000000..0cb7a694 Binary files /dev/null and b/web/simon/audio/voice/male/key/t2_down.ogg differ diff --git a/web/simon/audio/voice/male/key/t3_left.m4a b/web/simon/audio/voice/male/key/t3_left.m4a new file mode 100644 index 00000000..c2bc59ea Binary files /dev/null and b/web/simon/audio/voice/male/key/t3_left.m4a differ diff --git a/web/simon/audio/voice/male/key/t3_left.ogg b/web/simon/audio/voice/male/key/t3_left.ogg new file mode 100644 index 00000000..c4cb7dac Binary files /dev/null and b/web/simon/audio/voice/male/key/t3_left.ogg differ diff --git a/web/simon/audio/voice/male/key/t4_right.m4a b/web/simon/audio/voice/male/key/t4_right.m4a new file mode 100644 index 00000000..0fd9e05f Binary files /dev/null and b/web/simon/audio/voice/male/key/t4_right.m4a differ diff --git a/web/simon/audio/voice/male/key/t4_right.ogg b/web/simon/audio/voice/male/key/t4_right.ogg new file mode 100644 index 00000000..b90486c5 Binary files /dev/null and b/web/simon/audio/voice/male/key/t4_right.ogg differ diff --git a/web/simon/audio/voice/male/key/t5_tap.m4a b/web/simon/audio/voice/male/key/t5_tap.m4a new file mode 100644 index 00000000..45afc778 Binary files /dev/null and b/web/simon/audio/voice/male/key/t5_tap.m4a differ diff --git a/web/simon/audio/voice/male/key/t5_tap.ogg b/web/simon/audio/voice/male/key/t5_tap.ogg new file mode 100644 index 00000000..2faa6f61 Binary files /dev/null and b/web/simon/audio/voice/male/key/t5_tap.ogg differ diff --git a/web/simon/audio/voice/male/key/t6_double.m4a b/web/simon/audio/voice/male/key/t6_double.m4a new file mode 100644 index 00000000..d570860b Binary files /dev/null and b/web/simon/audio/voice/male/key/t6_double.m4a differ diff --git a/web/simon/audio/voice/male/key/t6_double.ogg b/web/simon/audio/voice/male/key/t6_double.ogg new file mode 100644 index 00000000..ceffdccd Binary files /dev/null and b/web/simon/audio/voice/male/key/t6_double.ogg differ diff --git a/web/simon/audio/voice/male/key/t7_retry.m4a b/web/simon/audio/voice/male/key/t7_retry.m4a new file mode 100644 index 00000000..f61fcc15 Binary files /dev/null and b/web/simon/audio/voice/male/key/t7_retry.m4a differ diff --git a/web/simon/audio/voice/male/key/t7_retry.ogg b/web/simon/audio/voice/male/key/t7_retry.ogg new file mode 100644 index 00000000..412004a8 Binary files /dev/null and b/web/simon/audio/voice/male/key/t7_retry.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_double.m4a b/web/simon/audio/voice/male/key/t_blunt_double.m4a new file mode 100644 index 00000000..e0a69e75 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_double.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_double.ogg b/web/simon/audio/voice/male/key/t_blunt_double.ogg new file mode 100644 index 00000000..06a02dcd Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_double.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_down.m4a b/web/simon/audio/voice/male/key/t_blunt_down.m4a new file mode 100644 index 00000000..12134b95 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_down.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_down.ogg b/web/simon/audio/voice/male/key/t_blunt_down.ogg new file mode 100644 index 00000000..f82be10a Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_down.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_left.m4a b/web/simon/audio/voice/male/key/t_blunt_left.m4a new file mode 100644 index 00000000..fffbef26 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_left.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_left.ogg b/web/simon/audio/voice/male/key/t_blunt_left.ogg new file mode 100644 index 00000000..b8ad8153 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_left.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_right.m4a b/web/simon/audio/voice/male/key/t_blunt_right.m4a new file mode 100644 index 00000000..790bfbc7 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_right.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_right.ogg b/web/simon/audio/voice/male/key/t_blunt_right.ogg new file mode 100644 index 00000000..181cd43c Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_right.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_tap.m4a b/web/simon/audio/voice/male/key/t_blunt_tap.m4a new file mode 100644 index 00000000..4bb551b8 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_tap.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_tap.ogg b/web/simon/audio/voice/male/key/t_blunt_tap.ogg new file mode 100644 index 00000000..0d35e961 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_tap.ogg differ diff --git a/web/simon/audio/voice/male/key/t_blunt_up.m4a b/web/simon/audio/voice/male/key/t_blunt_up.m4a new file mode 100644 index 00000000..7a00d35e Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_up.m4a differ diff --git a/web/simon/audio/voice/male/key/t_blunt_up.ogg b/web/simon/audio/voice/male/key/t_blunt_up.ogg new file mode 100644 index 00000000..e9331d44 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_blunt_up.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_double.m4a b/web/simon/audio/voice/male/key/t_wrong_double.m4a new file mode 100644 index 00000000..f2020ad5 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_double.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_double.ogg b/web/simon/audio/voice/male/key/t_wrong_double.ogg new file mode 100644 index 00000000..811a9426 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_double.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_down.m4a b/web/simon/audio/voice/male/key/t_wrong_down.m4a new file mode 100644 index 00000000..c2549372 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_down.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_down.ogg b/web/simon/audio/voice/male/key/t_wrong_down.ogg new file mode 100644 index 00000000..13af18a2 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_down.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_left.m4a b/web/simon/audio/voice/male/key/t_wrong_left.m4a new file mode 100644 index 00000000..a2bbb07c Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_left.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_left.ogg b/web/simon/audio/voice/male/key/t_wrong_left.ogg new file mode 100644 index 00000000..da8f669a Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_left.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_right.m4a b/web/simon/audio/voice/male/key/t_wrong_right.m4a new file mode 100644 index 00000000..1f748b23 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_right.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_right.ogg b/web/simon/audio/voice/male/key/t_wrong_right.ogg new file mode 100644 index 00000000..b707ab63 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_right.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_tap.m4a b/web/simon/audio/voice/male/key/t_wrong_tap.m4a new file mode 100644 index 00000000..edb91fb7 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_tap.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_tap.ogg b/web/simon/audio/voice/male/key/t_wrong_tap.ogg new file mode 100644 index 00000000..0d322abd Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_tap.ogg differ diff --git a/web/simon/audio/voice/male/key/t_wrong_up.m4a b/web/simon/audio/voice/male/key/t_wrong_up.m4a new file mode 100644 index 00000000..66470697 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_up.m4a differ diff --git a/web/simon/audio/voice/male/key/t_wrong_up.ogg b/web/simon/audio/voice/male/key/t_wrong_up.ogg new file mode 100644 index 00000000..105befd7 Binary files /dev/null and b/web/simon/audio/voice/male/key/t_wrong_up.ogg differ diff --git a/web/simon/test/playtest.mjs b/web/simon/test/playtest.mjs index f5c67d8a..30295d58 100644 --- a/web/simon/test/playtest.mjs +++ b/web/simon/test/playtest.mjs @@ -175,7 +175,9 @@ for (const [k, v] of Object.entries({ createElement: () => fakeEl(), getElementById: () => null, }, - navigator: { vibrate: (p) => vibLog.push(p) }, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产 + // (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测) + navigator: { maxTouchPoints: 1, vibrate: (p) => vibLog.push(p) }, localStorage: localStorageMock, fetch: fetchMock, performance: { now: () => nowMs }, diff --git a/web/space/audio/voice/female/key/drill_down.m4a b/web/space/audio/voice/female/key/drill_down.m4a new file mode 100644 index 00000000..5a8be4f7 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_down.m4a differ diff --git a/web/space/audio/voice/female/key/drill_down.ogg b/web/space/audio/voice/female/key/drill_down.ogg new file mode 100644 index 00000000..2c0de03c Binary files /dev/null and b/web/space/audio/voice/female/key/drill_down.ogg differ diff --git a/web/space/audio/voice/female/key/drill_left.m4a b/web/space/audio/voice/female/key/drill_left.m4a new file mode 100644 index 00000000..819b64f7 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_left.m4a differ diff --git a/web/space/audio/voice/female/key/drill_left.ogg b/web/space/audio/voice/female/key/drill_left.ogg new file mode 100644 index 00000000..66b25290 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_left.ogg differ diff --git a/web/space/audio/voice/female/key/drill_retry_down.m4a b/web/space/audio/voice/female/key/drill_retry_down.m4a new file mode 100644 index 00000000..ef0393dd Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_down.m4a differ diff --git a/web/space/audio/voice/female/key/drill_retry_down.ogg b/web/space/audio/voice/female/key/drill_retry_down.ogg new file mode 100644 index 00000000..7838a460 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_down.ogg differ diff --git a/web/space/audio/voice/female/key/drill_retry_left.m4a b/web/space/audio/voice/female/key/drill_retry_left.m4a new file mode 100644 index 00000000..8062f0d9 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_left.m4a differ diff --git a/web/space/audio/voice/female/key/drill_retry_left.ogg b/web/space/audio/voice/female/key/drill_retry_left.ogg new file mode 100644 index 00000000..0fce15ef Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_left.ogg differ diff --git a/web/space/audio/voice/female/key/drill_retry_right.m4a b/web/space/audio/voice/female/key/drill_retry_right.m4a new file mode 100644 index 00000000..c987b162 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_right.m4a differ diff --git a/web/space/audio/voice/female/key/drill_retry_right.ogg b/web/space/audio/voice/female/key/drill_retry_right.ogg new file mode 100644 index 00000000..619de22a Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_right.ogg differ diff --git a/web/space/audio/voice/female/key/drill_retry_up.m4a b/web/space/audio/voice/female/key/drill_retry_up.m4a new file mode 100644 index 00000000..6136ca1c Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_up.m4a differ diff --git a/web/space/audio/voice/female/key/drill_retry_up.ogg b/web/space/audio/voice/female/key/drill_retry_up.ogg new file mode 100644 index 00000000..6a9e5355 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_retry_up.ogg differ diff --git a/web/space/audio/voice/female/key/drill_right.m4a b/web/space/audio/voice/female/key/drill_right.m4a new file mode 100644 index 00000000..32713529 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_right.m4a differ diff --git a/web/space/audio/voice/female/key/drill_right.ogg b/web/space/audio/voice/female/key/drill_right.ogg new file mode 100644 index 00000000..59c89da4 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_right.ogg differ diff --git a/web/space/audio/voice/female/key/drill_up.m4a b/web/space/audio/voice/female/key/drill_up.m4a new file mode 100644 index 00000000..4d847d65 Binary files /dev/null and b/web/space/audio/voice/female/key/drill_up.m4a differ diff --git a/web/space/audio/voice/female/key/drill_up.ogg b/web/space/audio/voice/female/key/drill_up.ogg new file mode 100644 index 00000000..817fae1b Binary files /dev/null and b/web/space/audio/voice/female/key/drill_up.ogg differ diff --git a/web/space/audio/voice/female/key/feat_boost.m4a b/web/space/audio/voice/female/key/feat_boost.m4a new file mode 100644 index 00000000..61d0c8ac Binary files /dev/null and b/web/space/audio/voice/female/key/feat_boost.m4a differ diff --git a/web/space/audio/voice/female/key/feat_boost.ogg b/web/space/audio/voice/female/key/feat_boost.ogg new file mode 100644 index 00000000..a4791348 Binary files /dev/null and b/web/space/audio/voice/female/key/feat_boost.ogg differ diff --git a/web/space/audio/voice/female/key/feat_shield.m4a b/web/space/audio/voice/female/key/feat_shield.m4a new file mode 100644 index 00000000..42cb09a6 Binary files /dev/null and b/web/space/audio/voice/female/key/feat_shield.m4a differ diff --git a/web/space/audio/voice/female/key/feat_shield.ogg b/web/space/audio/voice/female/key/feat_shield.ogg new file mode 100644 index 00000000..c54f3de8 Binary files /dev/null and b/web/space/audio/voice/female/key/feat_shield.ogg differ diff --git a/web/space/audio/voice/female/key/gd_hit_hint.m4a b/web/space/audio/voice/female/key/gd_hit_hint.m4a new file mode 100644 index 00000000..4eff29e0 Binary files /dev/null and b/web/space/audio/voice/female/key/gd_hit_hint.m4a differ diff --git a/web/space/audio/voice/female/key/gd_hit_hint.ogg b/web/space/audio/voice/female/key/gd_hit_hint.ogg new file mode 100644 index 00000000..d0903f9b Binary files /dev/null and b/web/space/audio/voice/female/key/gd_hit_hint.ogg differ diff --git a/web/space/audio/voice/female/key/help_recap.m4a b/web/space/audio/voice/female/key/help_recap.m4a new file mode 100644 index 00000000..1f9350ed Binary files /dev/null and b/web/space/audio/voice/female/key/help_recap.m4a differ diff --git a/web/space/audio/voice/female/key/help_recap.ogg b/web/space/audio/voice/female/key/help_recap.ogg new file mode 100644 index 00000000..2cd6fc2b Binary files /dev/null and b/web/space/audio/voice/female/key/help_recap.ogg differ diff --git a/web/space/audio/voice/female/key/hit_down.m4a b/web/space/audio/voice/female/key/hit_down.m4a new file mode 100644 index 00000000..77f36a9c Binary files /dev/null and b/web/space/audio/voice/female/key/hit_down.m4a differ diff --git a/web/space/audio/voice/female/key/hit_down.ogg b/web/space/audio/voice/female/key/hit_down.ogg new file mode 100644 index 00000000..e860158a Binary files /dev/null and b/web/space/audio/voice/female/key/hit_down.ogg differ diff --git a/web/space/audio/voice/female/key/hit_left.m4a b/web/space/audio/voice/female/key/hit_left.m4a new file mode 100644 index 00000000..9e982271 Binary files /dev/null and b/web/space/audio/voice/female/key/hit_left.m4a differ diff --git a/web/space/audio/voice/female/key/hit_left.ogg b/web/space/audio/voice/female/key/hit_left.ogg new file mode 100644 index 00000000..72d225e5 Binary files /dev/null and b/web/space/audio/voice/female/key/hit_left.ogg differ diff --git a/web/space/audio/voice/female/key/hit_right.m4a b/web/space/audio/voice/female/key/hit_right.m4a new file mode 100644 index 00000000..f7ca20eb Binary files /dev/null and b/web/space/audio/voice/female/key/hit_right.m4a differ diff --git a/web/space/audio/voice/female/key/hit_right.ogg b/web/space/audio/voice/female/key/hit_right.ogg new file mode 100644 index 00000000..c4926b2d Binary files /dev/null and b/web/space/audio/voice/female/key/hit_right.ogg differ diff --git a/web/space/audio/voice/female/key/hit_up.m4a b/web/space/audio/voice/female/key/hit_up.m4a new file mode 100644 index 00000000..0fbfe392 Binary files /dev/null and b/web/space/audio/voice/female/key/hit_up.m4a differ diff --git a/web/space/audio/voice/female/key/hit_up.ogg b/web/space/audio/voice/female/key/hit_up.ogg new file mode 100644 index 00000000..8a749fd2 Binary files /dev/null and b/web/space/audio/voice/female/key/hit_up.ogg differ diff --git a/web/space/audio/voice/female/key/menu_main.m4a b/web/space/audio/voice/female/key/menu_main.m4a new file mode 100644 index 00000000..f3759d46 Binary files /dev/null and b/web/space/audio/voice/female/key/menu_main.m4a differ diff --git a/web/space/audio/voice/female/key/menu_main.ogg b/web/space/audio/voice/female/key/menu_main.ogg new file mode 100644 index 00000000..2308b435 Binary files /dev/null and b/web/space/audio/voice/female/key/menu_main.ogg differ diff --git a/web/space/audio/voice/female/key/protect_down.m4a b/web/space/audio/voice/female/key/protect_down.m4a new file mode 100644 index 00000000..66a7bd19 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_down.m4a differ diff --git a/web/space/audio/voice/female/key/protect_down.ogg b/web/space/audio/voice/female/key/protect_down.ogg new file mode 100644 index 00000000..044b2cd5 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_down.ogg differ diff --git a/web/space/audio/voice/female/key/protect_left.m4a b/web/space/audio/voice/female/key/protect_left.m4a new file mode 100644 index 00000000..fd5a50e6 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_left.m4a differ diff --git a/web/space/audio/voice/female/key/protect_left.ogg b/web/space/audio/voice/female/key/protect_left.ogg new file mode 100644 index 00000000..1149a8a3 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_left.ogg differ diff --git a/web/space/audio/voice/female/key/protect_right.m4a b/web/space/audio/voice/female/key/protect_right.m4a new file mode 100644 index 00000000..8e37e182 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_right.m4a differ diff --git a/web/space/audio/voice/female/key/protect_right.ogg b/web/space/audio/voice/female/key/protect_right.ogg new file mode 100644 index 00000000..b269accd Binary files /dev/null and b/web/space/audio/voice/female/key/protect_right.ogg differ diff --git a/web/space/audio/voice/female/key/protect_up.m4a b/web/space/audio/voice/female/key/protect_up.m4a new file mode 100644 index 00000000..36071ba3 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_up.m4a differ diff --git a/web/space/audio/voice/female/key/protect_up.ogg b/web/space/audio/voice/female/key/protect_up.ogg new file mode 100644 index 00000000..e7a13ee3 Binary files /dev/null and b/web/space/audio/voice/female/key/protect_up.ogg differ diff --git a/web/space/audio/voice/female/key/restart_hint.m4a b/web/space/audio/voice/female/key/restart_hint.m4a new file mode 100644 index 00000000..2dd188c4 Binary files /dev/null and b/web/space/audio/voice/female/key/restart_hint.m4a differ diff --git a/web/space/audio/voice/female/key/restart_hint.ogg b/web/space/audio/voice/female/key/restart_hint.ogg new file mode 100644 index 00000000..178fb921 Binary files /dev/null and b/web/space/audio/voice/female/key/restart_hint.ogg differ diff --git a/web/space/audio/voice/female/key/second_run_ask.m4a b/web/space/audio/voice/female/key/second_run_ask.m4a new file mode 100644 index 00000000..83e3c6e1 Binary files /dev/null and b/web/space/audio/voice/female/key/second_run_ask.m4a differ diff --git a/web/space/audio/voice/female/key/second_run_ask.ogg b/web/space/audio/voice/female/key/second_run_ask.ogg new file mode 100644 index 00000000..5bd4249f Binary files /dev/null and b/web/space/audio/voice/female/key/second_run_ask.ogg differ diff --git a/web/space/audio/voice/female/key/skip_hint.m4a b/web/space/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..4db70e30 Binary files /dev/null and b/web/space/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/space/audio/voice/female/key/skip_hint.ogg b/web/space/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..330ec13e Binary files /dev/null and b/web/space/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/space/audio/voice/male/key/drill_down.m4a b/web/space/audio/voice/male/key/drill_down.m4a new file mode 100644 index 00000000..39ccd03e Binary files /dev/null and b/web/space/audio/voice/male/key/drill_down.m4a differ diff --git a/web/space/audio/voice/male/key/drill_down.ogg b/web/space/audio/voice/male/key/drill_down.ogg new file mode 100644 index 00000000..146e5e19 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_down.ogg differ diff --git a/web/space/audio/voice/male/key/drill_left.m4a b/web/space/audio/voice/male/key/drill_left.m4a new file mode 100644 index 00000000..970537f0 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_left.m4a differ diff --git a/web/space/audio/voice/male/key/drill_left.ogg b/web/space/audio/voice/male/key/drill_left.ogg new file mode 100644 index 00000000..32938a6e Binary files /dev/null and b/web/space/audio/voice/male/key/drill_left.ogg differ diff --git a/web/space/audio/voice/male/key/drill_retry_down.m4a b/web/space/audio/voice/male/key/drill_retry_down.m4a new file mode 100644 index 00000000..b2e9c48a Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_down.m4a differ diff --git a/web/space/audio/voice/male/key/drill_retry_down.ogg b/web/space/audio/voice/male/key/drill_retry_down.ogg new file mode 100644 index 00000000..e6b973f7 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_down.ogg differ diff --git a/web/space/audio/voice/male/key/drill_retry_left.m4a b/web/space/audio/voice/male/key/drill_retry_left.m4a new file mode 100644 index 00000000..c534761b Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_left.m4a differ diff --git a/web/space/audio/voice/male/key/drill_retry_left.ogg b/web/space/audio/voice/male/key/drill_retry_left.ogg new file mode 100644 index 00000000..e95618ba Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_left.ogg differ diff --git a/web/space/audio/voice/male/key/drill_retry_right.m4a b/web/space/audio/voice/male/key/drill_retry_right.m4a new file mode 100644 index 00000000..6988d2b1 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_right.m4a differ diff --git a/web/space/audio/voice/male/key/drill_retry_right.ogg b/web/space/audio/voice/male/key/drill_retry_right.ogg new file mode 100644 index 00000000..1e4624f3 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_right.ogg differ diff --git a/web/space/audio/voice/male/key/drill_retry_up.m4a b/web/space/audio/voice/male/key/drill_retry_up.m4a new file mode 100644 index 00000000..62a2f503 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_up.m4a differ diff --git a/web/space/audio/voice/male/key/drill_retry_up.ogg b/web/space/audio/voice/male/key/drill_retry_up.ogg new file mode 100644 index 00000000..623d29a4 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_retry_up.ogg differ diff --git a/web/space/audio/voice/male/key/drill_right.m4a b/web/space/audio/voice/male/key/drill_right.m4a new file mode 100644 index 00000000..dd0b836d Binary files /dev/null and b/web/space/audio/voice/male/key/drill_right.m4a differ diff --git a/web/space/audio/voice/male/key/drill_right.ogg b/web/space/audio/voice/male/key/drill_right.ogg new file mode 100644 index 00000000..d9c2377f Binary files /dev/null and b/web/space/audio/voice/male/key/drill_right.ogg differ diff --git a/web/space/audio/voice/male/key/drill_up.m4a b/web/space/audio/voice/male/key/drill_up.m4a new file mode 100644 index 00000000..7d790538 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_up.m4a differ diff --git a/web/space/audio/voice/male/key/drill_up.ogg b/web/space/audio/voice/male/key/drill_up.ogg new file mode 100644 index 00000000..ef2b0926 Binary files /dev/null and b/web/space/audio/voice/male/key/drill_up.ogg differ diff --git a/web/space/audio/voice/male/key/feat_boost.m4a b/web/space/audio/voice/male/key/feat_boost.m4a new file mode 100644 index 00000000..9b865643 Binary files /dev/null and b/web/space/audio/voice/male/key/feat_boost.m4a differ diff --git a/web/space/audio/voice/male/key/feat_boost.ogg b/web/space/audio/voice/male/key/feat_boost.ogg new file mode 100644 index 00000000..1171bcb6 Binary files /dev/null and b/web/space/audio/voice/male/key/feat_boost.ogg differ diff --git a/web/space/audio/voice/male/key/feat_shield.m4a b/web/space/audio/voice/male/key/feat_shield.m4a new file mode 100644 index 00000000..2ef5245b Binary files /dev/null and b/web/space/audio/voice/male/key/feat_shield.m4a differ diff --git a/web/space/audio/voice/male/key/feat_shield.ogg b/web/space/audio/voice/male/key/feat_shield.ogg new file mode 100644 index 00000000..f2141a1d Binary files /dev/null and b/web/space/audio/voice/male/key/feat_shield.ogg differ diff --git a/web/space/audio/voice/male/key/gd_hit_hint.m4a b/web/space/audio/voice/male/key/gd_hit_hint.m4a new file mode 100644 index 00000000..7e47642e Binary files /dev/null and b/web/space/audio/voice/male/key/gd_hit_hint.m4a differ diff --git a/web/space/audio/voice/male/key/gd_hit_hint.ogg b/web/space/audio/voice/male/key/gd_hit_hint.ogg new file mode 100644 index 00000000..af569531 Binary files /dev/null and b/web/space/audio/voice/male/key/gd_hit_hint.ogg differ diff --git a/web/space/audio/voice/male/key/help_recap.m4a b/web/space/audio/voice/male/key/help_recap.m4a new file mode 100644 index 00000000..45e482fa Binary files /dev/null and b/web/space/audio/voice/male/key/help_recap.m4a differ diff --git a/web/space/audio/voice/male/key/help_recap.ogg b/web/space/audio/voice/male/key/help_recap.ogg new file mode 100644 index 00000000..26721369 Binary files /dev/null and b/web/space/audio/voice/male/key/help_recap.ogg differ diff --git a/web/space/audio/voice/male/key/hit_down.m4a b/web/space/audio/voice/male/key/hit_down.m4a new file mode 100644 index 00000000..19f10864 Binary files /dev/null and b/web/space/audio/voice/male/key/hit_down.m4a differ diff --git a/web/space/audio/voice/male/key/hit_down.ogg b/web/space/audio/voice/male/key/hit_down.ogg new file mode 100644 index 00000000..75545e60 Binary files /dev/null and b/web/space/audio/voice/male/key/hit_down.ogg differ diff --git a/web/space/audio/voice/male/key/hit_left.m4a b/web/space/audio/voice/male/key/hit_left.m4a new file mode 100644 index 00000000..8b45b6cb Binary files /dev/null and b/web/space/audio/voice/male/key/hit_left.m4a differ diff --git a/web/space/audio/voice/male/key/hit_left.ogg b/web/space/audio/voice/male/key/hit_left.ogg new file mode 100644 index 00000000..64a3e0d4 Binary files /dev/null and b/web/space/audio/voice/male/key/hit_left.ogg differ diff --git a/web/space/audio/voice/male/key/hit_right.m4a b/web/space/audio/voice/male/key/hit_right.m4a new file mode 100644 index 00000000..14d1e13e Binary files /dev/null and b/web/space/audio/voice/male/key/hit_right.m4a differ diff --git a/web/space/audio/voice/male/key/hit_right.ogg b/web/space/audio/voice/male/key/hit_right.ogg new file mode 100644 index 00000000..33e8741e Binary files /dev/null and b/web/space/audio/voice/male/key/hit_right.ogg differ diff --git a/web/space/audio/voice/male/key/hit_up.m4a b/web/space/audio/voice/male/key/hit_up.m4a new file mode 100644 index 00000000..134aeaed Binary files /dev/null and b/web/space/audio/voice/male/key/hit_up.m4a differ diff --git a/web/space/audio/voice/male/key/hit_up.ogg b/web/space/audio/voice/male/key/hit_up.ogg new file mode 100644 index 00000000..99391269 Binary files /dev/null and b/web/space/audio/voice/male/key/hit_up.ogg differ diff --git a/web/space/audio/voice/male/key/menu_main.m4a b/web/space/audio/voice/male/key/menu_main.m4a new file mode 100644 index 00000000..0869e396 Binary files /dev/null and b/web/space/audio/voice/male/key/menu_main.m4a differ diff --git a/web/space/audio/voice/male/key/menu_main.ogg b/web/space/audio/voice/male/key/menu_main.ogg new file mode 100644 index 00000000..d6ae5af5 Binary files /dev/null and b/web/space/audio/voice/male/key/menu_main.ogg differ diff --git a/web/space/audio/voice/male/key/protect_down.m4a b/web/space/audio/voice/male/key/protect_down.m4a new file mode 100644 index 00000000..4dd49ad1 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_down.m4a differ diff --git a/web/space/audio/voice/male/key/protect_down.ogg b/web/space/audio/voice/male/key/protect_down.ogg new file mode 100644 index 00000000..47b84161 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_down.ogg differ diff --git a/web/space/audio/voice/male/key/protect_left.m4a b/web/space/audio/voice/male/key/protect_left.m4a new file mode 100644 index 00000000..4a285b50 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_left.m4a differ diff --git a/web/space/audio/voice/male/key/protect_left.ogg b/web/space/audio/voice/male/key/protect_left.ogg new file mode 100644 index 00000000..59ca2c16 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_left.ogg differ diff --git a/web/space/audio/voice/male/key/protect_right.m4a b/web/space/audio/voice/male/key/protect_right.m4a new file mode 100644 index 00000000..6214ca69 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_right.m4a differ diff --git a/web/space/audio/voice/male/key/protect_right.ogg b/web/space/audio/voice/male/key/protect_right.ogg new file mode 100644 index 00000000..8f842f45 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_right.ogg differ diff --git a/web/space/audio/voice/male/key/protect_up.m4a b/web/space/audio/voice/male/key/protect_up.m4a new file mode 100644 index 00000000..f5021c43 Binary files /dev/null and b/web/space/audio/voice/male/key/protect_up.m4a differ diff --git a/web/space/audio/voice/male/key/protect_up.ogg b/web/space/audio/voice/male/key/protect_up.ogg new file mode 100644 index 00000000..1611fadd Binary files /dev/null and b/web/space/audio/voice/male/key/protect_up.ogg differ diff --git a/web/space/audio/voice/male/key/restart_hint.m4a b/web/space/audio/voice/male/key/restart_hint.m4a new file mode 100644 index 00000000..b3789d8d Binary files /dev/null and b/web/space/audio/voice/male/key/restart_hint.m4a differ diff --git a/web/space/audio/voice/male/key/restart_hint.ogg b/web/space/audio/voice/male/key/restart_hint.ogg new file mode 100644 index 00000000..d1a4e4a9 Binary files /dev/null and b/web/space/audio/voice/male/key/restart_hint.ogg differ diff --git a/web/space/audio/voice/male/key/second_run_ask.m4a b/web/space/audio/voice/male/key/second_run_ask.m4a new file mode 100644 index 00000000..9b598e34 Binary files /dev/null and b/web/space/audio/voice/male/key/second_run_ask.m4a differ diff --git a/web/space/audio/voice/male/key/second_run_ask.ogg b/web/space/audio/voice/male/key/second_run_ask.ogg new file mode 100644 index 00000000..93d84fd7 Binary files /dev/null and b/web/space/audio/voice/male/key/second_run_ask.ogg differ diff --git a/web/space/audio/voice/male/key/skip_hint.m4a b/web/space/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..099355cb Binary files /dev/null and b/web/space/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/space/audio/voice/male/key/skip_hint.ogg b/web/space/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..07545e07 Binary files /dev/null and b/web/space/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/space/test/playtest.mjs b/web/space/test/playtest.mjs index 3e7576bf..9b70a3f5 100644 --- a/web/space/test/playtest.mjs +++ b/web/space/test/playtest.mjs @@ -59,7 +59,8 @@ Object.defineProperty(globalThis, 'performance', { value: { now: () => clock.now }, configurable: true, }); Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (p) => { vibrations.push(p); return true; } }, configurable: true, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) + value: { maxTouchPoints: 1, vibrate: (p) => { vibrations.push(p); return true; } }, configurable: true, }); const vibrations = []; diff --git a/web/storyline/audio/voice/female/key/e1_dawn.m4a b/web/storyline/audio/voice/female/key/e1_dawn.m4a new file mode 100644 index 00000000..bcdabeae Binary files /dev/null and b/web/storyline/audio/voice/female/key/e1_dawn.m4a differ diff --git a/web/storyline/audio/voice/female/key/e1_dawn.ogg b/web/storyline/audio/voice/female/key/e1_dawn.ogg new file mode 100644 index 00000000..0c7414ec Binary files /dev/null and b/web/storyline/audio/voice/female/key/e1_dawn.ogg differ diff --git a/web/storyline/audio/voice/female/key/e2_price.m4a b/web/storyline/audio/voice/female/key/e2_price.m4a new file mode 100644 index 00000000..2a3a3b6a Binary files /dev/null and b/web/storyline/audio/voice/female/key/e2_price.m4a differ diff --git a/web/storyline/audio/voice/female/key/e2_price.ogg b/web/storyline/audio/voice/female/key/e2_price.ogg new file mode 100644 index 00000000..751ce84d Binary files /dev/null and b/web/storyline/audio/voice/female/key/e2_price.ogg differ diff --git a/web/storyline/audio/voice/female/key/e3_alone.m4a b/web/storyline/audio/voice/female/key/e3_alone.m4a new file mode 100644 index 00000000..0d283656 Binary files /dev/null and b/web/storyline/audio/voice/female/key/e3_alone.m4a differ diff --git a/web/storyline/audio/voice/female/key/e3_alone.ogg b/web/storyline/audio/voice/female/key/e3_alone.ogg new file mode 100644 index 00000000..60a747b2 Binary files /dev/null and b/web/storyline/audio/voice/female/key/e3_alone.ogg differ diff --git a/web/storyline/audio/voice/female/key/e4_static.m4a b/web/storyline/audio/voice/female/key/e4_static.m4a new file mode 100644 index 00000000..3a00cd83 Binary files /dev/null and b/web/storyline/audio/voice/female/key/e4_static.m4a differ diff --git a/web/storyline/audio/voice/female/key/e4_static.ogg b/web/storyline/audio/voice/female/key/e4_static.ogg new file mode 100644 index 00000000..84393578 Binary files /dev/null and b/web/storyline/audio/voice/female/key/e4_static.ogg differ diff --git a/web/storyline/audio/voice/female/key/gd_again.m4a b/web/storyline/audio/voice/female/key/gd_again.m4a new file mode 100644 index 00000000..851cbe5f Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_again.m4a differ diff --git a/web/storyline/audio/voice/female/key/gd_again.ogg b/web/storyline/audio/voice/female/key/gd_again.ogg new file mode 100644 index 00000000..1f0c683d Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_again.ogg differ diff --git a/web/storyline/audio/voice/female/key/gd_brief.m4a b/web/storyline/audio/voice/female/key/gd_brief.m4a new file mode 100644 index 00000000..852104c2 Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_brief.m4a differ diff --git a/web/storyline/audio/voice/female/key/gd_brief.ogg b/web/storyline/audio/voice/female/key/gd_brief.ogg new file mode 100644 index 00000000..086c1cfc Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_brief.ogg differ diff --git a/web/storyline/audio/voice/female/key/gd_continue.m4a b/web/storyline/audio/voice/female/key/gd_continue.m4a new file mode 100644 index 00000000..a1b2a6a4 Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_continue.m4a differ diff --git a/web/storyline/audio/voice/female/key/gd_continue.ogg b/web/storyline/audio/voice/female/key/gd_continue.ogg new file mode 100644 index 00000000..b0055059 Binary files /dev/null and b/web/storyline/audio/voice/female/key/gd_continue.ogg differ diff --git a/web/storyline/audio/voice/female/key/help.m4a b/web/storyline/audio/voice/female/key/help.m4a new file mode 100644 index 00000000..f455f43d Binary files /dev/null and b/web/storyline/audio/voice/female/key/help.m4a differ diff --git a/web/storyline/audio/voice/female/key/help.ogg b/web/storyline/audio/voice/female/key/help.ogg new file mode 100644 index 00000000..1cba975d Binary files /dev/null and b/web/storyline/audio/voice/female/key/help.ogg differ diff --git a/web/storyline/audio/voice/female/key/menu.m4a b/web/storyline/audio/voice/female/key/menu.m4a new file mode 100644 index 00000000..815af1af Binary files /dev/null and b/web/storyline/audio/voice/female/key/menu.m4a differ diff --git a/web/storyline/audio/voice/female/key/menu.ogg b/web/storyline/audio/voice/female/key/menu.ogg new file mode 100644 index 00000000..7a246645 Binary files /dev/null and b/web/storyline/audio/voice/female/key/menu.ogg differ diff --git a/web/storyline/audio/voice/female/key/n00_intro.m4a b/web/storyline/audio/voice/female/key/n00_intro.m4a new file mode 100644 index 00000000..2eaa4169 Binary files /dev/null and b/web/storyline/audio/voice/female/key/n00_intro.m4a differ diff --git a/web/storyline/audio/voice/female/key/n00_intro.ogg b/web/storyline/audio/voice/female/key/n00_intro.ogg new file mode 100644 index 00000000..8ec3b8e9 Binary files /dev/null and b/web/storyline/audio/voice/female/key/n00_intro.ogg differ diff --git a/web/storyline/audio/voice/female/key/p1.m4a b/web/storyline/audio/voice/female/key/p1.m4a new file mode 100644 index 00000000..75dcec0e Binary files /dev/null and b/web/storyline/audio/voice/female/key/p1.m4a differ diff --git a/web/storyline/audio/voice/female/key/p1.ogg b/web/storyline/audio/voice/female/key/p1.ogg new file mode 100644 index 00000000..f4dfd8fb Binary files /dev/null and b/web/storyline/audio/voice/female/key/p1.ogg differ diff --git a/web/storyline/audio/voice/female/key/p2.m4a b/web/storyline/audio/voice/female/key/p2.m4a new file mode 100644 index 00000000..7fb61271 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p2.m4a differ diff --git a/web/storyline/audio/voice/female/key/p2.ogg b/web/storyline/audio/voice/female/key/p2.ogg new file mode 100644 index 00000000..4b798f10 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p2.ogg differ diff --git a/web/storyline/audio/voice/female/key/p3.m4a b/web/storyline/audio/voice/female/key/p3.m4a new file mode 100644 index 00000000..fde76015 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p3.m4a differ diff --git a/web/storyline/audio/voice/female/key/p3.ogg b/web/storyline/audio/voice/female/key/p3.ogg new file mode 100644 index 00000000..3e6a5883 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p3.ogg differ diff --git a/web/storyline/audio/voice/female/key/p4.m4a b/web/storyline/audio/voice/female/key/p4.m4a new file mode 100644 index 00000000..276e94a4 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p4.m4a differ diff --git a/web/storyline/audio/voice/female/key/p4.ogg b/web/storyline/audio/voice/female/key/p4.ogg new file mode 100644 index 00000000..52ca28b2 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p4.ogg differ diff --git a/web/storyline/audio/voice/female/key/p5.m4a b/web/storyline/audio/voice/female/key/p5.m4a new file mode 100644 index 00000000..796eb1e9 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p5.m4a differ diff --git a/web/storyline/audio/voice/female/key/p5.ogg b/web/storyline/audio/voice/female/key/p5.ogg new file mode 100644 index 00000000..0cd92931 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p5.ogg differ diff --git a/web/storyline/audio/voice/female/key/p6.m4a b/web/storyline/audio/voice/female/key/p6.m4a new file mode 100644 index 00000000..a29d59b7 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p6.m4a differ diff --git a/web/storyline/audio/voice/female/key/p6.ogg b/web/storyline/audio/voice/female/key/p6.ogg new file mode 100644 index 00000000..e360038e Binary files /dev/null and b/web/storyline/audio/voice/female/key/p6.ogg differ diff --git a/web/storyline/audio/voice/female/key/p7.m4a b/web/storyline/audio/voice/female/key/p7.m4a new file mode 100644 index 00000000..4d581796 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p7.m4a differ diff --git a/web/storyline/audio/voice/female/key/p7.ogg b/web/storyline/audio/voice/female/key/p7.ogg new file mode 100644 index 00000000..659b9f5c Binary files /dev/null and b/web/storyline/audio/voice/female/key/p7.ogg differ diff --git a/web/storyline/audio/voice/female/key/p8.m4a b/web/storyline/audio/voice/female/key/p8.m4a new file mode 100644 index 00000000..5f5e77af Binary files /dev/null and b/web/storyline/audio/voice/female/key/p8.m4a differ diff --git a/web/storyline/audio/voice/female/key/p8.ogg b/web/storyline/audio/voice/female/key/p8.ogg new file mode 100644 index 00000000..6022a415 Binary files /dev/null and b/web/storyline/audio/voice/female/key/p8.ogg differ diff --git a/web/storyline/audio/voice/female/key/skip_hint.m4a b/web/storyline/audio/voice/female/key/skip_hint.m4a new file mode 100644 index 00000000..ce69a194 Binary files /dev/null and b/web/storyline/audio/voice/female/key/skip_hint.m4a differ diff --git a/web/storyline/audio/voice/female/key/skip_hint.ogg b/web/storyline/audio/voice/female/key/skip_hint.ogg new file mode 100644 index 00000000..a0261642 Binary files /dev/null and b/web/storyline/audio/voice/female/key/skip_hint.ogg differ diff --git a/web/storyline/audio/voice/male/key/e1_dawn.m4a b/web/storyline/audio/voice/male/key/e1_dawn.m4a new file mode 100644 index 00000000..6f731031 Binary files /dev/null and b/web/storyline/audio/voice/male/key/e1_dawn.m4a differ diff --git a/web/storyline/audio/voice/male/key/e1_dawn.ogg b/web/storyline/audio/voice/male/key/e1_dawn.ogg new file mode 100644 index 00000000..4ef8180d Binary files /dev/null and b/web/storyline/audio/voice/male/key/e1_dawn.ogg differ diff --git a/web/storyline/audio/voice/male/key/e2_price.m4a b/web/storyline/audio/voice/male/key/e2_price.m4a new file mode 100644 index 00000000..0f345406 Binary files /dev/null and b/web/storyline/audio/voice/male/key/e2_price.m4a differ diff --git a/web/storyline/audio/voice/male/key/e2_price.ogg b/web/storyline/audio/voice/male/key/e2_price.ogg new file mode 100644 index 00000000..ead959fb Binary files /dev/null and b/web/storyline/audio/voice/male/key/e2_price.ogg differ diff --git a/web/storyline/audio/voice/male/key/e3_alone.m4a b/web/storyline/audio/voice/male/key/e3_alone.m4a new file mode 100644 index 00000000..e041dfb0 Binary files /dev/null and b/web/storyline/audio/voice/male/key/e3_alone.m4a differ diff --git a/web/storyline/audio/voice/male/key/e3_alone.ogg b/web/storyline/audio/voice/male/key/e3_alone.ogg new file mode 100644 index 00000000..8c380e40 Binary files /dev/null and b/web/storyline/audio/voice/male/key/e3_alone.ogg differ diff --git a/web/storyline/audio/voice/male/key/e4_static.m4a b/web/storyline/audio/voice/male/key/e4_static.m4a new file mode 100644 index 00000000..6956386c Binary files /dev/null and b/web/storyline/audio/voice/male/key/e4_static.m4a differ diff --git a/web/storyline/audio/voice/male/key/e4_static.ogg b/web/storyline/audio/voice/male/key/e4_static.ogg new file mode 100644 index 00000000..52264175 Binary files /dev/null and b/web/storyline/audio/voice/male/key/e4_static.ogg differ diff --git a/web/storyline/audio/voice/male/key/gd_again.m4a b/web/storyline/audio/voice/male/key/gd_again.m4a new file mode 100644 index 00000000..11d7e349 Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_again.m4a differ diff --git a/web/storyline/audio/voice/male/key/gd_again.ogg b/web/storyline/audio/voice/male/key/gd_again.ogg new file mode 100644 index 00000000..ba475d93 Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_again.ogg differ diff --git a/web/storyline/audio/voice/male/key/gd_brief.m4a b/web/storyline/audio/voice/male/key/gd_brief.m4a new file mode 100644 index 00000000..b57c5a8a Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_brief.m4a differ diff --git a/web/storyline/audio/voice/male/key/gd_brief.ogg b/web/storyline/audio/voice/male/key/gd_brief.ogg new file mode 100644 index 00000000..cdc19a86 Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_brief.ogg differ diff --git a/web/storyline/audio/voice/male/key/gd_continue.m4a b/web/storyline/audio/voice/male/key/gd_continue.m4a new file mode 100644 index 00000000..d0c7f895 Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_continue.m4a differ diff --git a/web/storyline/audio/voice/male/key/gd_continue.ogg b/web/storyline/audio/voice/male/key/gd_continue.ogg new file mode 100644 index 00000000..da88fbe7 Binary files /dev/null and b/web/storyline/audio/voice/male/key/gd_continue.ogg differ diff --git a/web/storyline/audio/voice/male/key/help.m4a b/web/storyline/audio/voice/male/key/help.m4a new file mode 100644 index 00000000..48a57344 Binary files /dev/null and b/web/storyline/audio/voice/male/key/help.m4a differ diff --git a/web/storyline/audio/voice/male/key/help.ogg b/web/storyline/audio/voice/male/key/help.ogg new file mode 100644 index 00000000..7b15cc9b Binary files /dev/null and b/web/storyline/audio/voice/male/key/help.ogg differ diff --git a/web/storyline/audio/voice/male/key/menu.m4a b/web/storyline/audio/voice/male/key/menu.m4a new file mode 100644 index 00000000..38206246 Binary files /dev/null and b/web/storyline/audio/voice/male/key/menu.m4a differ diff --git a/web/storyline/audio/voice/male/key/menu.ogg b/web/storyline/audio/voice/male/key/menu.ogg new file mode 100644 index 00000000..a7011a3a Binary files /dev/null and b/web/storyline/audio/voice/male/key/menu.ogg differ diff --git a/web/storyline/audio/voice/male/key/n00_intro.m4a b/web/storyline/audio/voice/male/key/n00_intro.m4a new file mode 100644 index 00000000..4304c2db Binary files /dev/null and b/web/storyline/audio/voice/male/key/n00_intro.m4a differ diff --git a/web/storyline/audio/voice/male/key/n00_intro.ogg b/web/storyline/audio/voice/male/key/n00_intro.ogg new file mode 100644 index 00000000..3dca06ed Binary files /dev/null and b/web/storyline/audio/voice/male/key/n00_intro.ogg differ diff --git a/web/storyline/audio/voice/male/key/p1.m4a b/web/storyline/audio/voice/male/key/p1.m4a new file mode 100644 index 00000000..76875cc7 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p1.m4a differ diff --git a/web/storyline/audio/voice/male/key/p1.ogg b/web/storyline/audio/voice/male/key/p1.ogg new file mode 100644 index 00000000..1844a46d Binary files /dev/null and b/web/storyline/audio/voice/male/key/p1.ogg differ diff --git a/web/storyline/audio/voice/male/key/p2.m4a b/web/storyline/audio/voice/male/key/p2.m4a new file mode 100644 index 00000000..538c0218 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p2.m4a differ diff --git a/web/storyline/audio/voice/male/key/p2.ogg b/web/storyline/audio/voice/male/key/p2.ogg new file mode 100644 index 00000000..0b0116a6 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p2.ogg differ diff --git a/web/storyline/audio/voice/male/key/p3.m4a b/web/storyline/audio/voice/male/key/p3.m4a new file mode 100644 index 00000000..7cd3b02e Binary files /dev/null and b/web/storyline/audio/voice/male/key/p3.m4a differ diff --git a/web/storyline/audio/voice/male/key/p3.ogg b/web/storyline/audio/voice/male/key/p3.ogg new file mode 100644 index 00000000..aa9b9c1a Binary files /dev/null and b/web/storyline/audio/voice/male/key/p3.ogg differ diff --git a/web/storyline/audio/voice/male/key/p4.m4a b/web/storyline/audio/voice/male/key/p4.m4a new file mode 100644 index 00000000..2b67ce65 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p4.m4a differ diff --git a/web/storyline/audio/voice/male/key/p4.ogg b/web/storyline/audio/voice/male/key/p4.ogg new file mode 100644 index 00000000..6650a8c1 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p4.ogg differ diff --git a/web/storyline/audio/voice/male/key/p5.m4a b/web/storyline/audio/voice/male/key/p5.m4a new file mode 100644 index 00000000..96e92f5a Binary files /dev/null and b/web/storyline/audio/voice/male/key/p5.m4a differ diff --git a/web/storyline/audio/voice/male/key/p5.ogg b/web/storyline/audio/voice/male/key/p5.ogg new file mode 100644 index 00000000..715f9107 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p5.ogg differ diff --git a/web/storyline/audio/voice/male/key/p6.m4a b/web/storyline/audio/voice/male/key/p6.m4a new file mode 100644 index 00000000..ad077c04 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p6.m4a differ diff --git a/web/storyline/audio/voice/male/key/p6.ogg b/web/storyline/audio/voice/male/key/p6.ogg new file mode 100644 index 00000000..47cdd04a Binary files /dev/null and b/web/storyline/audio/voice/male/key/p6.ogg differ diff --git a/web/storyline/audio/voice/male/key/p7.m4a b/web/storyline/audio/voice/male/key/p7.m4a new file mode 100644 index 00000000..50c1009e Binary files /dev/null and b/web/storyline/audio/voice/male/key/p7.m4a differ diff --git a/web/storyline/audio/voice/male/key/p7.ogg b/web/storyline/audio/voice/male/key/p7.ogg new file mode 100644 index 00000000..aee2a716 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p7.ogg differ diff --git a/web/storyline/audio/voice/male/key/p8.m4a b/web/storyline/audio/voice/male/key/p8.m4a new file mode 100644 index 00000000..463d2e98 Binary files /dev/null and b/web/storyline/audio/voice/male/key/p8.m4a differ diff --git a/web/storyline/audio/voice/male/key/p8.ogg b/web/storyline/audio/voice/male/key/p8.ogg new file mode 100644 index 00000000..d659ac1c Binary files /dev/null and b/web/storyline/audio/voice/male/key/p8.ogg differ diff --git a/web/storyline/audio/voice/male/key/skip_hint.m4a b/web/storyline/audio/voice/male/key/skip_hint.m4a new file mode 100644 index 00000000..4deb8b11 Binary files /dev/null and b/web/storyline/audio/voice/male/key/skip_hint.m4a differ diff --git a/web/storyline/audio/voice/male/key/skip_hint.ogg b/web/storyline/audio/voice/male/key/skip_hint.ogg new file mode 100644 index 00000000..c7976971 Binary files /dev/null and b/web/storyline/audio/voice/male/key/skip_hint.ogg differ diff --git a/web/storyline/test/playtest.mjs b/web/storyline/test/playtest.mjs index f3864c68..c8a1db06 100644 --- a/web/storyline/test/playtest.mjs +++ b/web/storyline/test/playtest.mjs @@ -101,7 +101,9 @@ globalThis.localStorage = { const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { - value: { vibrate: (...a) => { vibrateLog.push(a); return true; } }, configurable: true, + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产 + // (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测) + value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true, }); class MockParam { @@ -407,7 +409,8 @@ realLog('== live round2 N1:长语音看门狗续约不掐(§16)=='); const { audio, playLog, game } = makeRig(); await audio.unlock(); // 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待) - audio._cache.set('female|voice/n00_intro', Promise.resolve({ + // 缓存键格式(P1 起): ||(见 engine/audio.js _load) + audio._cache.set('main|female|voice/n00_intro', Promise.resolve({ duration: 40.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16), }));