feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。 引擎层(web/engine/,其余 12 款游戏零改动): - floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、 等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck) - receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4) - confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5) - hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9) - novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3) - pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce) 游戏层: - storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除; 抉择点三档提示+主动重听;断点存档复述(§4.12) - escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定); 撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩 - simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账 (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执 测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0, 首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过 (38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# 26-apply · 《暗室》重构设计单(26 号文档落地)
|
||||
|
||||
> 按 26 号文档附录 C 填写。交互规则权威来源:[26-audio-interaction-playbook.md](26-audio-interaction-playbook.md)。
|
||||
|
||||
## 1. 一句话核心动作
|
||||
|
||||
**朝四个方向滑动在房间里移动,单击检查和使用,按住屏幕问路。**
|
||||
|
||||
## 2. 玩家可感知的状态(3 个)与声音身份
|
||||
|
||||
| 状态 | 听感 | 来源 |
|
||||
|---|---|---|
|
||||
| 探索(轮到你) | 当前房间的环境音常响(水滴/风/电流) | §4.1 ACTION |
|
||||
| 描述在播(游戏在说) | 房间描述语音,BGM 压低跨链 | §4.1 SPEAKING |
|
||||
| 撞墙/无效 | 闷响 + invalid 回执(低「咚」) | §4.2 |
|
||||
|
||||
## 3. 新手手势表(四向滑动是玩法 → §4.3 裁定:帮助换到长按)
|
||||
|
||||
| 手势 | 含义 | 说明 |
|
||||
|---|---|---|
|
||||
| 四向滑动 | 移动(核心玩法,不按阶梯锁) | swipeCore |
|
||||
| 单击 | 检查 + 使用(合并;旧「双击=用物品」废除) | 站对房间即用 |
|
||||
| 长按 | 我在哪、我该干嘛(目标 + 方位一句话) | longpressCore |
|
||||
| 双击 | (阶段3解锁后)重听最近描述 | 未解锁按单击处理 |
|
||||
|
||||
## 4. 状态 × 手势矩阵(新手期)
|
||||
|
||||
| 状态 \ 手势 | 单击 | 四向滑动 | 长按 | 双击(未解锁) |
|
||||
|---|---|---|---|---|
|
||||
| 引导答题 | 按步骤答(纠错句)/ invalid | 按步骤答(脚步/闷响) | 帮助 | 按单击处理 |
|
||||
| 探索(ACTION) | 检查+使用(环境音/描述即反馈) | 移动=脚步声(silent-ok);撞墙=闷响+invalid+归因 | 帮助(目标+方位) | 按单击处理 |
|
||||
| 描述在播 | 跳过这句 + ok | 打断并移动 | 打断,帮助 | 按单击处理 |
|
||||
| 结算 | 重新开始 + ok | invalid + inv_settle | 重听结算 | 按单击处理 |
|
||||
| 菜单 | 换一项 + 播报 | 右=确认 / 左=上一项 / 其余 invalid | 重听菜单说明 | 按单击处理 |
|
||||
| 迷茫 L2 期间 | 照常 | 只放行接管方向,其余 invalid(不移动) | 照常 | 照常 |
|
||||
|
||||
## 5. 等待点配置表(引导期)与探索期接管
|
||||
|
||||
引导等待点(6s/15s 两档,short=「滑一下。」/「向左滑。」):
|
||||
|
||||
| 步骤 | 期待 | 指令句 |
|
||||
|---|---|---|
|
||||
| 0 | 任意滑动 | g0_new「黑暗。朝任意方向,滑一下。」 |
|
||||
| 1 | 撞墙×2(教无效反馈) | g0b(+ wall 归因) |
|
||||
| 2 | 左滑 | g3_new「现在,向左滑。」 |
|
||||
| 3 | 下滑 | g4_down「对。再向下滑。」 |
|
||||
| 4 | 单击 | g5_inspect → g5_key + g7_goal_new |
|
||||
|
||||
探索期(无固定等待点,静默喂迷茫信号 40s 起每 20s)三级接管:
|
||||
|
||||
| 级 | 做什么 | 台词 |
|
||||
|---|---|---|
|
||||
| 1 | 说目标方位(30s 台账去重,说过就提示音) | nav_*「向右滑。」 |
|
||||
| 2 | 只放行第一导航方向 | sys_only_*「现在,只要向左滑。」 |
|
||||
| 3 | 听我做一次:游戏自己朝目标走一步 | sys_demo + 脚步声 + sys_now_you |
|
||||
|
||||
## 6. 首 3 分钟逐秒脚本
|
||||
|
||||
| 时间 | 游戏说 / 播 | 期待玩家做 | 没做 | 做错 |
|
||||
|---|---|---|---|---|
|
||||
| 0~3s | 环境水滴声 →「黑暗。朝任意方向,滑一下。」 | 任意滑动 | 6s:「滑一下。」 | 点按:纠错句 |
|
||||
| 3~12s | 脚步声 →「对。你被困在地下设施里。再滑两下,试试墙。」 | 滑动撞墙 | 同上 | 方向错也有闷响(试错无代价) |
|
||||
| 12~25s | 闷响 + 「那边是墙,换个方向。」+ 方位句 ×2 →「现在,向左滑。」 | 左滑 | 「向左滑。」 | 其他方向:纠错 |
|
||||
| 25~40s | 脚步 →「对。再向下滑。」→ 储物间环境音 | 下滑 | 同上 | 纠错 |
|
||||
| 40~60s | 「听到水声了吗?单击一下,仔细检查这个房间。」→ 拾取音 →「你摸到了一把钥匙!」 | 单击 | 「单击。」 | 滑动:纠错 |
|
||||
| 60~90s | 「找到能用钥匙打开的东西。迷路了,按住屏幕。」→ 自由探索 | 探索 | 40s 起接管 | 撞墙归因 |
|
||||
| 90~180s | 首到房间描述、谜题链推进(钥匙→铁柜→保险丝→电箱→大门) | 探索 | 接管三级 | 归因 + 就近重试 |
|
||||
|
||||
节奏检查 ✓:3s 内动手;首成功=第一次滑动(脚步声);每 30s 有可听进展(新房间环境音/描述)。
|
||||
|
||||
## 7. 失败表
|
||||
|
||||
| 失败类型 | 判定 | 归因句 | 回到哪里 | 新手期代价 |
|
||||
|---|---|---|---|---|
|
||||
| 方向错(撞墙) | 越界 | wall + wall_dir_*(每方向整局一次,之后只闷响+回执) | 原地 | 无 |
|
||||
| 用错地方 | 站位不对 | (合并进检查语义:描述告诉你这里有什么) | 原地 | 无 |
|
||||
| 迷路 | 静默/乱点信号 | 接管 L1→L2→L3 | 原地 | 无 |
|
||||
|
||||
## 8. 台词表(类别标注)
|
||||
|
||||
- `critical`:g0_new(开场指令 <3s);结算承诺段 again(+解锁教学)
|
||||
- `content`:g0b/g3_new/g4_down/g5_*、room_*、empty_*、found_key、unlock_cabinet、fix_box、escape、time_band、replay、start_room、inspect_*、gd_*_dir、gd_next_fuse
|
||||
- `hint`:lp_*(长按帮助)、nav_*、sys_only_*、sys_continue_ask、g_hint_swipe、menu_help
|
||||
- `feedback`:wall、wall_dir_*、g_wrong_*、guide_wrong_*、guide_blunt_swipe、inv_*、sys_demo、sys_now_you
|
||||
- 完整清单见 `tools/gen_voice_dual.py` ESCAPE_LINES(74 句 × 双性别)。
|
||||
|
||||
## 9. 断点复述句
|
||||
|
||||
- 保存:每步移动/进展写 `soundgame.escape.save`(坐标 + 谜题状态 + 用时)。
|
||||
- 回来:sys_continue_ask;单击继续(恢复坐标 + lp 帮助句作复述),右滑重新开始。
|
||||
|
||||
## 10. 测试记录
|
||||
|
||||
- 无头 playtest:`web/escape/test/playtest.mjs`(32 断言,乱点机器人:无声操作 0 /
|
||||
被动原句重复 0 / 第一次成功 2.1s;撞墙归因按原则 7 只解释一次)。
|
||||
- 真人冷启动:待测。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 26-apply · 《音序回响》重构设计单(26 号文档落地)
|
||||
|
||||
> 按 26 号文档附录 C 填写。交互规则权威来源:[26-audio-interaction-playbook.md](26-audio-interaction-playbook.md)。
|
||||
|
||||
## 1. 一句话核心动作
|
||||
|
||||
**听一串音色,原样复现。**
|
||||
|
||||
## 2. 玩家可感知的状态(3 个)与声音身份
|
||||
|
||||
| 状态 | 听感 | 来源 |
|
||||
|---|---|---|
|
||||
| 听序列(游戏在说) | 音色序列 + 轮次播报;手势 = busy 回执(「嗯」) | §4.1 BUSY |
|
||||
| 轮到你(答题) | input_open 标记音 → 风铃 + 等待声;答题回声即时 | §4.1 YOUR_TURN |
|
||||
| 结算 | gameover 音 + 结算链 + menu pad | — |
|
||||
|
||||
## 3. 新手手势表与音色阶梯
|
||||
|
||||
全部六个手势在本作都是**音色**(答题输入,§4.3「滑动是玩法」裁定);
|
||||
帮助(状态查询)在长按。音色按玩家实力渐进开放(做中学,一次只学一样):
|
||||
|
||||
| 音色 | 解锁条件(历史最佳轮数) |
|
||||
|---|---|
|
||||
| 单击 / 左滑 / 右滑 | 新手即有(F3:选项 ≤3) |
|
||||
| 上滑 | 清过第 1 轮 |
|
||||
| 下滑 | 清过第 3 轮(同时=菜单手势) |
|
||||
| 双击 | 清过第 6 轮(同时=重听手势) |
|
||||
|
||||
新音色第一次进序列时现场教一句(「向上滑,是这个声音。」)马上用。
|
||||
老玩家(best>0)跳过教学,做对过的事不教第二遍(原则 11)。
|
||||
|
||||
## 4. 状态 × 手势矩阵(新手期)
|
||||
|
||||
| 状态 \ 手势 | 单击 | 滑动×4 | 长按 | 双击(未解锁) |
|
||||
|---|---|---|---|---|
|
||||
| 教学(指令在播,critical) | busy 回执(等指令说完) | busy 回执 | 帮助 | busy |
|
||||
| 教学答题 | 答题/纠错 | 答题/纠错(回响即反馈) | 状态句 | 按单击处理 |
|
||||
| 播报/序列播放(BUSY) | busy 回执 | busy 回执 | busy 回执 | busy 回执 |
|
||||
| 答题(YOUR_TURN) | 作答(音色回声) | 作答 | 状态句(轮次段位,不闭窗) | 未入池=invalid;入池=第 6 音色 |
|
||||
| 结算 | 重新开始(言行一致) | invalid + 解释 | 重听结算 | (阶段3)重听结算 |
|
||||
| 菜单 | 换一项 | 右=确认/左=上一项 | 重听菜单说明 | 按单击处理 |
|
||||
|
||||
## 5. 等待点配置表
|
||||
|
||||
| id | 期待 | 提示 full | 提示 short | 静默阈值 | L2 | L3 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| tut0 | tap | (指令句刚说过) | 「点一下。」 | 6s/15s | — | 提示音 |
|
||||
| tut_<g> | 当前教的音色 | 教学句 | 「再试一次。」 | 6s/15s | — | 提示音 |
|
||||
| input | 全部已解锁音色 | 「轮到你了。」 | (提示音) | 10s/25s | 免费重听整串 | 放慢 + 重听 |
|
||||
| settle | tap | 「单击,再来一局。」 | 「再试一次。」 | 20s/45s | — | 提示音 |
|
||||
| menu | tap/left/right | menu_help | menu_help | 20s/45s | — | 提示音 |
|
||||
|
||||
## 6. 首 3 分钟逐秒脚本
|
||||
|
||||
| 时间 | 游戏说 / 播 | 期待玩家做 | 没做 | 做错 |
|
||||
|---|---|---|---|---|
|
||||
| 0~3s | 「音序回响。点一下屏幕。」(critical) | 单击 | 6s:「点一下。」 | 滑动:纠错 |
|
||||
| 3~10s | 单击音色回响 → 「向左滑,是这个声音。」+ 音色 | 左滑 | 「再试一次。」 | 纠错句(30s 内不重播) |
|
||||
| 10~30s | 「对了。」→ 右滑音、单击音各教一个,马上练 | 左/右/单击 | 同上 | 同上 |
|
||||
| 30~40s | 「记住顺序,原样复现。答错,我们就再听一遍。」→「第一轮,听好了。」→ 2 音序列 | 听 | — | 播放期手势=busy |
|
||||
| 40~50s | input_open + 风铃 + 等待声 | 复现 2 音 | 10s:「轮到你了。」25s:提示音 | 见失败表 |
|
||||
| 50~90s | 每轮 +1 音;答对必有表扬句(≤4 字) | 复现 | 三档 + 接管 | 归因 + 免费重听 |
|
||||
| 90~180s | 第 4 轮完成 → 解锁菜单教学句;序列渐长 | 复现 | 接管 L2 免费重听 / L3 放慢演示 | 第 3 次失败放慢 |
|
||||
|
||||
节奏检查 ✓:3s 内动手;首成功=第一击音色回响(≤10s);每轮都有进展回报。
|
||||
|
||||
## 7. 失败表(替代旧「机会账」经济)
|
||||
|
||||
| 失败次数 | 处理 | 台词 |
|
||||
|---|---|---|
|
||||
| 1~2 | 2 秒内归因(「错了。是向左滑。」)→ 免费重听整串 | wr_* + relisten |
|
||||
| 3 | 「我放慢一些。」→ 间隔 ×1.6 + 序列减 1 音 | slow_down |
|
||||
| 4 | 结算(成绩段位 + 破纪录 + 承诺句) | gameover + score_band + replay |
|
||||
|
||||
新手期失败零资源消耗(原则 15);归因真话(错的音色名如实说)。
|
||||
|
||||
## 8. 台词表(类别标注)
|
||||
|
||||
- `critical`:t0_new、t7_new、教学指令链(t1~t6 各 <3s)、结算承诺段(replay + 解锁教学)
|
||||
- `content`:round_01..20、score_band1..6、gameover、new_record、t_ok、correct、gd_clear、gd_record
|
||||
- `hint`:your_turn、t_retry、st_b1..4、sys_demo、menu_help
|
||||
- `feedback`:wr_*(归因)、relisten、slow_down、t_wrong_*、inv_busy、inv_generic
|
||||
- 完整清单见 `tools/gen_voice_dual.py` SIMON_LINES(74 句 × 双性别)。
|
||||
|
||||
## 9. 断点复述句
|
||||
|
||||
对局内不设存档(单局 2~5 分钟,微循环自洽);历史最佳跨局持久
|
||||
(`soundgame.simon.best`),决定音色池与新音教学。
|
||||
|
||||
## 10. 测试记录
|
||||
|
||||
- 无头 playtest:`web/simon/test/playtest.mjs`(21 断言,乱点机器人:无声操作 0
|
||||
(播放期也有 busy 回执)/ 被动原句重复 0 / 第一次成功 10.2s)。
|
||||
- 真人冷启动:待测。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 26-apply · 《声命线》重构设计单(26 号文档落地)
|
||||
|
||||
> 按 26 号文档附录 C 填写。本文是 storyline R3 的施工依据与验收基准;
|
||||
> 交互规则的权威来源是 [26-audio-interaction-playbook.md](26-audio-interaction-playbook.md)。
|
||||
|
||||
## 1. 一句话核心动作
|
||||
|
||||
**听一段故事,单击继续;听到「请选择」时左右滑做决定。**
|
||||
|
||||
## 2. 玩家可感知的状态(3 个)与声音身份
|
||||
|
||||
| 状态 | 听感 | 来源 |
|
||||
|---|---|---|
|
||||
| 听讲(游戏在说) | 旁白语音,BGM 压低且跨整条链 | §4.1 SPEAKING |
|
||||
| 轮到你(继续/抉择) | 风铃交接音 → 轻的「嘟—嘟」等待声 | §4.1 YOUR_TURN |
|
||||
| (菜单/结算属元状态,由 bgm/menu 与结算句标识) | | |
|
||||
|
||||
## 3. 新手手势表与解锁阶梯
|
||||
|
||||
| 手势 | 含义 | 解锁 |
|
||||
|---|---|---|
|
||||
| 单击 | 继续 / 跳过这句 | 阶段 0 |
|
||||
| 左滑 / 右滑 | 抉择 | 阶段 0 |
|
||||
| 上滑 | 我在哪(一句话状态) | 阶段 1(第一次抉择后,教学句挂链尾) |
|
||||
| 下滑 | 菜单 | 阶段 2(第一次到达结局后) |
|
||||
| 双击 | 重听(等待点完整句 / 重播节点) | 阶段 3(累计 3 局);未解锁按单击处理 |
|
||||
|
||||
## 4. 状态 × 手势矩阵(新手期)
|
||||
|
||||
| 状态 \ 手势 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | 连点 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 游戏在说 | 跳过这句 + ok | 跳去选项句(不盲选)/ 非抉择点 invalid+解释 | 打断,答「我在哪」 | invalid + 首次解释 | 按单击处理 | 400ms 内 busy |
|
||||
| 等你继续 | 继续 + ok | invalid(inv_nosel) | 我在哪 | invalid | (阶段3=重听) | busy |
|
||||
| 等你选择 | 再听选项(「再说一遍:」+ 完整句,hold) | 抉择 + ok | 「正在抉择。左右滑。」 | invalid | (阶段3=重听) | 计入迷茫 |
|
||||
| 结算 | 重新开始 + ok | invalid(sys_hint_again_full) | 重播状态句 | 菜单(阶段2) | 按单击处理 | 只处理第一下 |
|
||||
| 菜单 | 换一项 + 播报 | 右=确认 / 左=上一项 | 重听菜单说明 | invalid | 按单击处理 | busy |
|
||||
|
||||
验收:无空格;无「没有声音」的格子;连点无破坏性效果。
|
||||
|
||||
## 5. 等待点配置表
|
||||
|
||||
| id | 期待 | 提示 full | 提示 short | 静默阈值 | L2 只留 | L3 演示 | 失败归因 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| intro | tap | (指令句刚说过)| 「点一下。」 | 6s / 15s | tap | 「听我做一次。」→ 替玩家走一步 | — |
|
||||
| continue | tap | 「单击,继续。」 | 「单击。」 | 18s / 40s | tap | 同上 | — |
|
||||
| choice | left/right | pN(选项句) | 「左右滑,选。」 | 25s / 50s | 左右滑 | 「左右滑,都可以。现在你来。」 | — |
|
||||
| settle | tap | 「单击,重新开始。」 | 「单击。」 | 20s / 45s | tap | 提示音 | — |
|
||||
| reopen | tap/right | sys_continue_ask | 「单击。」 | 12s / 30s | — | 提示音 | — |
|
||||
| menu | tap/left/right | menu_help | menu_help | 20s / 45s | — | 提示音 | — |
|
||||
|
||||
三档选择 + 30s 台账跨等待点去重(说过就更短)在 `engine/hints.js` 统一实现。
|
||||
|
||||
## 6. 首 3 分钟逐秒脚本
|
||||
|
||||
| 时间 | 游戏说 / 播 | 期待玩家做 | 没做 | 做错 |
|
||||
|---|---|---|---|---|
|
||||
| 0~3s | 心跳声 →「点一下屏幕。」(critical) | 单击 | 6s:「点一下。」15s:提示音 | 滑动:invalid + inv_up_locked 等 |
|
||||
| 3~12s | ok 回执 → 旁白 n00_a →「听到这个声音,就是轮到你了。」→ 风铃 + 等待声 | 单击 | 18s:「单击,继续。」40s:「单击。」 | 双击:按单击处理 |
|
||||
| 12~30s | n00_b + 「单击可跳过旁白」→ n01 醒来 | 单击(可跳句) | 三档提示 | 滑动:inv_nosel |
|
||||
| 30~60s | n02 → p1 选项句 → 交出抉择 | 左/右滑 | 25s 复述 p1;50s「左右滑,选。」 | 滑早了:跳去选项句,不盲选 |
|
||||
| ≈60s | 第一次抉择完成 → 解锁上滑:「想知道自己在哪,就向上滑。」 | 上滑 | 「向上滑。」 | 单击=再听选项 |
|
||||
| 60~180s | n03~n07 剧情推进,p2 抉择 | 单击/滑动 | 三档提示 + 迷茫接管 | 归因真话 |
|
||||
| 乱滑/无效×3 | 迷茫接管 L1「左右滑,选。」→ L2 只留期待手势 → L3 演示 | | | |
|
||||
|
||||
节奏检查:3s 内第一次操作 ✓;10s 内第一次成功(ok 回执在第一下单击)✓;每 30s 有进展(节点推进 + sfx)✓;无纯规则说明句 ✓。
|
||||
|
||||
## 7. 失败表
|
||||
|
||||
| 失败类型 | 判定 | 归因句 | 回到哪里 | 新手期代价 |
|
||||
|---|---|---|---|---|
|
||||
| 无效滑动(非抉择点) | 手势与状态不符 | inv_nosel「现在不用选。单击继续。」 | 原地 | 无 |
|
||||
| 静默 | 阈值到点 | 三档提示 | 原地 | 无 |
|
||||
| 迷茫 | 信号累计 ≥2 | 接管 L1 短句 / L2 缩小 / L3 演示 | 原地 | 无 |
|
||||
|
||||
(叙事游戏无资源失败;旧「双击=整节点重播」为反模式 5,已废除。)
|
||||
|
||||
## 8. 台词表(类别标注)
|
||||
|
||||
- `critical`:sys_first_tap(唯一一次开场指令,<3s)
|
||||
- `content`:n00_a/b、n01~n25、p1~p8、e1~e4、skip_hint、sys_turn_teach、sys_unlock_*、menu_item*
|
||||
- `hint`:sys_hint_continue_full/short、sys_hint_choice_short、sys_only_choice、sys_demo_*、status_s1..3、status_choice、sys_continue_ask
|
||||
- `feedback`:inv_nosel、inv_generic、inv_up_locked、inv_down_locked、sys_hint_again_full
|
||||
- 主动重听:sys_say_again「再说一遍:」(前缀,全项目一句)
|
||||
- 完整清单见 `tools/gen_voice_dual.py` STORYLINE_LINES(61 句 × 双性别;角色音沿用平铺资产)。
|
||||
|
||||
## 9. 断点复述句
|
||||
|
||||
- 保存:每个节点进入时写 `soundgame.storyline.save`(nodeId + 剧情标记)。
|
||||
- 回来:sys_continue_ask「检测到上次的进度。单击,继续上次。右滑,从头开始。」
|
||||
- 复述:status_sN(位置 → 目标 → 一个动作,≤20 字)。
|
||||
|
||||
## 10. 测试记录
|
||||
|
||||
- 无头 playtest:`web/storyline/test/playtest.mjs`(37 断言,含 §6.2 乱点机器人:
|
||||
无声操作 0 / 被动原句重复 0 / 打断失败 0 / 第一次操作 2.1s / 第一次成功 2.1s)。
|
||||
- 真人冷启动:待测(§6.3,3~5 人)。
|
||||
@@ -10,6 +10,7 @@
|
||||
|---|---|---|
|
||||
| [00-global-ux.md](00-global-ux.md) | 全局交互规范(终审修订版):设置系统、haptics 震动语言(10 震型)、旁白整句化、手势约定与模态菜单、用户系统 API | ⑥⑦ + 全局需求 |
|
||||
| [26-audio-interaction-playbook.md](26-audio-interaction-playbook.md) | 纯音频交互第一性原理与落地手册:对话模型、16 原则、发言权管理器/输入回执/新手手势/三档提示/迷茫检测/语音优先级、三张设计单模板、P0-P3 改造顺序、乱点机器人与真人冷启动验证 | 真人试玩「搞不清状态、点两下就重复、不知道怎么玩」 |
|
||||
| [26-apply-storyline.md](26-apply-storyline.md) / [26-apply-escape.md](26-apply-escape.md) / [26-apply-simon.md](26-apply-simon.md) | 26 号手册首批落地(R3):三款游戏的设计单(附录 C:状态×手势矩阵/等待点/逐秒脚本/失败表/台词类别)+ 引擎新模块(floor/receipts/confusion/hints/novice/pipeline) | 同上 |
|
||||
| [01-escape-redesign.md](01-escape-redesign.md) | 《暗室》重设计:引导式开场、三级卡关提示、线索音可重听 | ⑤ |
|
||||
| [02-simon-redesign.md](02-simon-redesign.md) | 《音序回响》重设计:去 BGM、旁白整句化、交互式教学、复活机制(含双击语义裁定) | ⑥⑦ |
|
||||
| [03-rhythm-rebuild.md](03-rhythm-rebuild.md) + [03-rhythm-voice-ids.md](03-rhythm-voice-ids.md) | 《节拍行者》重构施工稿:跟随领奏玩法、三首完整谱面、判定契约、31 句台词表 | ⑧ |
|
||||
|
||||
+193
-223
@@ -89,30 +89,55 @@ _SL_EPIC = _pair("低沉醇厚的中年男声旁白,悲壮,尾音下沉",
|
||||
"女声旁白,悲壮,尾音下沉")
|
||||
|
||||
STORYLINE_LINES = [
|
||||
# ---- 系统语音 ----
|
||||
("help", _SL_NARR, "操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。"),
|
||||
("status1", _SL_NARR, "当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。"),
|
||||
("status2", _SL_NARR, "当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。"),
|
||||
("status3", _SL_NARR, "当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。"),
|
||||
("menu", _SL_NARR, "菜单。向左滑:重新开始整个故事。向右滑,或者单击:回到故事。向上滑:听操作说明。"),
|
||||
# ---- 选择提示 ----
|
||||
("p1", _SL_NARR, "请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。"),
|
||||
("p2", _SL_NARR, "请选择。向左滑:钻进旁边的通风管道。向右滑:躺进路边的尸体堆里装死。"),
|
||||
("p3", _SL_NARR, "请选择。向左滑:循着哭声过去看看。向右滑:不去管它,直接去北区。"),
|
||||
("p4", _SL_NARR, "请选择。向左滑:带上小满一起走。向右滑:留下食物,独自上路。"),
|
||||
("p5", _SL_NARR, "请选择。向左滑:走桥下的污水涵洞。向右滑:爬桥上悬着的钢索。"),
|
||||
("p6", _SL_NARR, "请选择。向左滑:交出坐标,保命要紧。向右滑:拒绝他,想办法周旋。"),
|
||||
("p7", _SL_NARR, "请选择。向左滑:追上去,警告避难所。向右滑:自己逃命,越远越好。"),
|
||||
("p8", _SL_NARR, "请选择。向左滑:用公开频道广播,让全城都听见。向右滑:用加密频段,只发给避难所。"),
|
||||
# ---- 第一幕 ----
|
||||
("n00_intro", _SL_NARR, "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。"),
|
||||
# ---- 26 号重构:系统/交互层(发言权·三档提示·阶梯·复述)----
|
||||
("sys_first_tap", _SL_NARR, "点一下屏幕。"),
|
||||
("sys_first_tap_short", _SL_NARR, "点一下。"),
|
||||
("n00_a", _SL_NARR, "大停电后的第三十天。没有电,没有网络,只有收音机里的沙沙声。"),
|
||||
("n00_b", _SL_NARR, "你是林晚,前急救员。单击屏幕,睁开眼睛。"),
|
||||
("sys_turn_teach", _SL_NARR, "听到这个声音,就是轮到你了。"),
|
||||
("skip_hint", _SL_NARR, "单击可跳过旁白。"),
|
||||
("sys_hint_continue_full", _SL_NARR, "单击,继续。"),
|
||||
("sys_hint_continue_short", _SL_NARR, "单击。"),
|
||||
("sys_hint_choice_short", _SL_NARR, "左右滑,选。"),
|
||||
("sys_hint_again_full", _SL_NARR, "单击,重新开始。"),
|
||||
("sys_say_again", _SL_NARR, "再说一遍:"),
|
||||
("status_s1", _SL_NARR, "第一幕,地铁站。找林晨。单击继续。"),
|
||||
("status_s2", _SL_NARR, "第二幕,断桥。过江。单击继续。"),
|
||||
("status_s3", _SL_NARR, "第三幕,广播塔。发警报。单击继续。"),
|
||||
("status_choice", _SL_NARR, "正在抉择。左右滑。"),
|
||||
("sys_continue_ask", _SL_NARR, "检测到上次的进度。单击,继续上次。右滑,从头开始。"),
|
||||
("inv_nosel", _SL_NARR, "现在不用选。单击继续。"),
|
||||
("inv_generic", _SL_NARR, "这个操作用不上。单击继续。"),
|
||||
("inv_up_locked", _SL_NARR, "上滑还没解锁。先单击继续。"),
|
||||
("inv_down_locked", _SL_NARR, "菜单,玩完一局解锁。单击继续。"),
|
||||
("sys_only_choice", _SL_NARR, "现在,只要左右滑。"),
|
||||
("sys_demo_continue", _SL_NARR, "听我做一次。"),
|
||||
("sys_demo_choice", _SL_NARR, "左右滑,都可以。现在你来。"),
|
||||
("sys_unlock_up", _SL_NARR, "想知道自己在哪,就向上滑。"),
|
||||
("sys_unlock_down", _SL_NARR, "向下滑,可以打开菜单。"),
|
||||
("sys_unlock_dt", _SL_NARR, "没听清,就双击,再听一遍。"),
|
||||
("menu_open", _SL_NARR, "菜单。"),
|
||||
("menu_item1", _SL_NARR, "重新开始。"),
|
||||
("menu_item2", _SL_NARR, "回大厅。"),
|
||||
("menu_item3", _SL_NARR, "返回故事。"),
|
||||
("menu_help", _SL_NARR, "单击换一项,右滑确认。"),
|
||||
# ---- 选择提示(动作 + 结果,指令句 ≤12 字)----
|
||||
("p1", _SL_NARR, "请选择。左滑,检查收音机。右滑,摸黑找出口。"),
|
||||
("p2", _SL_NARR, "请选择。左滑,钻进通风管道。右滑,躺下装死。"),
|
||||
("p3", _SL_NARR, "请选择。左滑,循哭声过去。右滑,直接去北区。"),
|
||||
("p4", _SL_NARR, "请选择。左滑,带上小满。右滑,独自上路。"),
|
||||
("p5", _SL_NARR, "请选择。左滑,走桥下涵洞。右滑,爬桥上钢索。"),
|
||||
("p6", _SL_NARR, "请选择。左滑,交出坐标。右滑,拒绝周旋。"),
|
||||
("p7", _SL_NARR, "请选择。左滑,追上去警告。右滑,自己逃命。"),
|
||||
("p8", _SL_NARR, "请选择。左滑,公开频道。右滑,加密频段。"),
|
||||
# ---- 第一幕(旁白,不变)----
|
||||
("n01_wake", _SL_NARR, "后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。"),
|
||||
("n04_exit", _SL_NARR, "你扶着潮湿的墙,一步一步往外摸。第五十步,脚尖踢到一个硬物——是一支手电,还剩半格电。光柱劈开黑暗的那一瞬,你看见墙上用红漆刷着一行大字:别去北区。"),
|
||||
("n05_signal", _SL_NARR, "就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:"),
|
||||
("n06_whistle", _SL_NARR, "你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。"),
|
||||
("n08_vent", _SL_NARR, "你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。"),
|
||||
("n09_dead", _SL_NARR, "你屏住呼吸,把自己埋进路边冰冷的尸体堆里。手电、罐头、半瓶水,被他们一样一样摸走。有人踢了你一脚,你纹丝不动。就在他们离开时,你听见一句不该听见的话——“北区那个陷阱,今晚就收网。”"),
|
||||
# ---- 第二幕 ----
|
||||
# ---- 第二幕(旁白,不变)----
|
||||
("n10_rain", _SL_NARR, "酸雨开始下了。街面上升起细密的嘶嘶声,像无数条小蛇在游。雨幕那头,隐约传来一阵哭声——细细的,是个孩子。"),
|
||||
("n11_child", _SL_NARR, "哭声来自一间塌了一半的便利店。一个小男孩抱着膝盖坐在货架底下,身边躺着一个再也不会醒来的女人。他抬起头,脸上全是灰,小声说:“我叫小满。妈妈说,数到一千,她就会醒。我已经数到……数到好多个一千了。”"),
|
||||
("n13_leave", _SL_NARR, "你把仅剩的一罐罐头放在他身边,转身走进雨里。身后,孩子重新开始数数:一……二……三……你告诉自己,别回头。千万别回头。"),
|
||||
@@ -122,23 +147,15 @@ STORYLINE_LINES = [
|
||||
("n16b_culvert_c", _SL_NARR, "你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。"),
|
||||
("n17_cable", _SL_NARR, "钢索比想象中更滑。酸雨把它泡得发黏,你每挪一步,整根钢索就晃一下。江风从断桥的缺口灌进来,像有无数只手在水底下拽你的脚。你不往下看。你只知道,前面就是北岸。"),
|
||||
("n17b_cable_c", _SL_NARR, "钢索比想象中更滑。小满趴在你背上,一声不吭,两条小胳膊勒得你生疼。走到一半,钢索猛地一沉,你听见身后钢丝崩断的脆响。“闭眼。”你说。然后你用尽全身力气,向前,向前,直到膝盖磕上北岸的石头。"),
|
||||
# ---- 第三幕 ----
|
||||
# ---- 第三幕(旁白,不变)----
|
||||
("n20_convoy", _SL_NARR, "猎狗的车队朝北区开走了,探照灯在雨幕里划出一道道口子。你蹲在原地,胃里一阵一阵地泛酸。晨晨在北区。而刚才,是你,亲手把狼,引到了她的门口。"),
|
||||
("n22_fight", _SL_NARR, "你往后退了半步,忽然笑了:“坐标在我脑子里。杀了我,你什么都拿不到。”猎狗愣了半秒。就是这半秒,你抓起一把沙土扬在他脸上,转身就跑。子弹擦着肩膀打进墙里,火辣辣地疼。但你在跑,你在跑——活着的感觉,从来没有这么清楚过。"),
|
||||
("n23_tower", _SL_NARR, "广播塔就立在头顶,红色的信号灯在雨里一闪一闪,像一颗还在跳动的心脏。塔底的中控台居然还能用。只要爬上去,你就能接入城市广播网,把警报发给北区的每一个人。问题是——用哪个频道。"),
|
||||
# ---- 结局 ----
|
||||
# ---- 结局(不变)----
|
||||
("e1_dawn", _SL_WARM, "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。单击屏幕,可以回到故事开始的地方。"),
|
||||
("e2_price", _SL_EPIC, "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。"),
|
||||
("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"),
|
||||
("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"),
|
||||
("skip_hint", _SL_NARR, "单击可跳过旁白。"),
|
||||
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
|
||||
("gd_continue", _SL_NARR, "轻触屏幕,继续。"),
|
||||
("gd_invalid", _SL_NARR, "这个操作在这里用不上。"),
|
||||
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
|
||||
("gd_brief", _SL_NARR, "单击继续剧情。请选择的时候,左右滑动。"),
|
||||
("gd_again", _SL_NARR, "还在吗?单击继续。"),
|
||||
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- escape
|
||||
@@ -147,62 +164,63 @@ _ESC = _pair("低沉平稳的男声,冷静、克制,带一点神秘感",
|
||||
"沉稳平静的女声,冷静、克制,带一点神秘感")
|
||||
|
||||
ESCAPE_LINES = [
|
||||
# ---- 开场引导 G0-G7 ----
|
||||
("g0_intro", _ESC, "黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。"),
|
||||
("g1_move", _ESC, "先活动一下。朝任意方向滑一下,走一小步。"),
|
||||
("g1_done", _ESC, "很好,你迈出了第一步。"),
|
||||
("g2_wall", _ESC, "这个房间不大,四面都是墙。再滑两下,试试碰碰墙。"),
|
||||
("g3_left", _ESC, "跟着我指的路走。向左滑。"),
|
||||
# ---- 26 号重构:引导(首 3 分钟逐秒脚本,见 26-apply-escape.md)----
|
||||
("g0_new", _ESC, "黑暗。朝任意方向,滑一下。"),
|
||||
("g0b", _ESC, "对。你被困在地下设施里。再滑两下,试试墙。"),
|
||||
("g3_new", _ESC, "现在,向左滑。"),
|
||||
("g4_down", _ESC, "对。再向下滑。"),
|
||||
("g5_inspect", _ESC, "听到水声了吗?单击一下,仔细检查这个房间。"),
|
||||
("g5_key", _ESC, "你摸到了一把钥匙!"),
|
||||
("g6_doubletap", _ESC, "钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。"),
|
||||
("g6_done", _ESC, "对,就是这样。接下来,靠你自己了。"),
|
||||
("g7_goal", _ESC, "找到能用这把钥匙打开的东西。需要帮助时,在原地双击。"),
|
||||
# ---- 引导纠错与跳过 ----
|
||||
("guide_wrong_up", _ESC, "不是这个方向。向上滑。"),
|
||||
("guide_wrong_down", _ESC, "不是这个方向。向下滑。"),
|
||||
("g7_goal_new", _ESC, "找到能用钥匙打开的东西。迷路了,按住屏幕。"),
|
||||
("g_wrong_gesture", _ESC, "不是这个操作。照我说的做。"),
|
||||
("g_wrong_tap", _ESC, "这一步不点。滑动。"),
|
||||
("g_hint_swipe", _ESC, "滑一下。"),
|
||||
("guide_wrong_left", _ESC, "不是这个方向。向左滑。"),
|
||||
("guide_wrong_right", _ESC, "不是这个方向。向右滑。"),
|
||||
("guide_wrong_gesture", _ESC, "不是这个操作。照我说的做。"),
|
||||
("guide_blunt_up", _ESC, "手指贴着屏幕,向上推一下。"),
|
||||
("guide_blunt_down", _ESC, "手指贴着屏幕,向下推一下。"),
|
||||
("guide_blunt_left", _ESC, "手指贴着屏幕,向左推一下。"),
|
||||
("guide_blunt_right", _ESC, "手指贴着屏幕,向右推一下。"),
|
||||
("guide_offpath", _ESC, "先跟我走。"),
|
||||
("guide_skip", _ESC, "跳过引导,直接开始自由探索。"),
|
||||
("reopen_prompt", _ESC, "上滑,重新听一遍引导。单击,直接开始。"),
|
||||
# ---- 卡关三级提示 + 导航 ----
|
||||
("stuck_ask", _ESC, "需要帮助吗?"),
|
||||
("stuck_key_1", _ESC, "铁锈味最重的地方,值得仔细看看。"),
|
||||
("stuck_key_2", _ESC, "去西南角的储物间,单击检查,找到钥匙。"),
|
||||
("stuck_key_3", _ESC, "跟我说的方向走,去西南角的储物间。"),
|
||||
("stuck_cabinet_1", _ESC, "钥匙就在你身上。它能打开某一把锁。"),
|
||||
("stuck_cabinet_2", _ESC, "去东边的铁柜房,双击,用钥匙打开铁柜。"),
|
||||
("stuck_cabinet_3", _ESC, "跟我说的方向走,去铁柜所在的房间。"),
|
||||
("stuck_box_1", _ESC, "保险丝属于电箱。有电流声的地方,就有电箱。"),
|
||||
("stuck_box_2", _ESC, "去西北角的电箱房,双击,把保险丝装进电箱。"),
|
||||
("stuck_box_3", _ESC, "跟我说的方向走,去电箱所在的房间。"),
|
||||
("stuck_gate_1", _ESC, "电已经通了。该去找那扇铁门了。"),
|
||||
("stuck_gate_2", _ESC, "去东南角的大门,双击,打开它,离开这里。"),
|
||||
("stuck_gate_3", _ESC, "跟我说的方向走,去大门所在的房间。"),
|
||||
("guide_wrong_down", _ESC, "不是这个方向。向下滑。"),
|
||||
("guide_blunt_swipe", _ESC, "手指贴着屏幕,朝我说的方向推一下。"),
|
||||
# ---- 撞墙与导航(失败表:方向错)----
|
||||
("wall", _ESC, "那边是墙,换个方向。"),
|
||||
("wall_dir_l", _ESC, "左边是墙。"),
|
||||
("wall_dir_r", _ESC, "右边是墙。"),
|
||||
("wall_dir_u", _ESC, "上边是墙。"),
|
||||
("wall_dir_d", _ESC, "下边是墙。"),
|
||||
("nav_up", _ESC, "向上滑。"),
|
||||
("nav_down", _ESC, "向下滑。"),
|
||||
("nav_left", _ESC, "向左滑。"),
|
||||
("nav_right", _ESC, "向右滑。"),
|
||||
# ---- 房间描述 ----
|
||||
# ---- 长按「我在哪」(目标 + 方位,一句话)----
|
||||
("lp_key", _ESC, "目标,找钥匙。"),
|
||||
("lp_cabinet", _ESC, "目标,开铁柜。"),
|
||||
("lp_box", _ESC, "目标,修电箱。"),
|
||||
("lp_gate", _ESC, "目标,开大门。"),
|
||||
("lp_here", _ESC, "就在这儿。单击。"),
|
||||
("lp_tap", _ESC, "单击。"),
|
||||
# ---- 迷茫接管(三级)----
|
||||
("sys_only_up", _ESC, "现在,只要向上滑。"),
|
||||
("sys_only_down", _ESC, "现在,只要向下滑。"),
|
||||
("sys_only_left", _ESC, "现在,只要向左滑。"),
|
||||
("sys_only_right", _ESC, "现在,只要向右滑。"),
|
||||
("sys_demo", _ESC, "听我做一次。"),
|
||||
("sys_now_you", _ESC, "现在,你来。"),
|
||||
# ---- 系统层 ----
|
||||
("sys_continue_ask", _ESC, "检测到你的进度。单击,继续上次。右滑,重新开始。"),
|
||||
("sys_say_again", _ESC, "再说一遍:"),
|
||||
("inv_generic", _ESC, "这里用不上。滑动移动,单击检查。"),
|
||||
("inv_settle", _ESC, "单击,重新开始。"),
|
||||
("menu_open", _ESC, "菜单。"),
|
||||
("menu_item1", _ESC, "重新开始。"),
|
||||
("menu_item2", _ESC, "回大厅。"),
|
||||
("menu_item3", _ESC, "返回游戏。"),
|
||||
("menu_help", _ESC, "单击换一项,右滑确认。"),
|
||||
# ---- 房间与谜题链 ----
|
||||
("start_room", _ESC, "你站在设施中央的房间里。滑动,移动。单击,检查。"),
|
||||
("room_storage", _ESC, "一间储物间。货架上堆满杂物,角落里传来水滴声。"),
|
||||
("room_cabinet", _ESC, "这个房间里,立着一个铁柜。"),
|
||||
("room_power", _ESC, "这个房间的墙上,有一只电箱。"),
|
||||
("room_gate", _ESC, "面前,是一扇沉重的铁门。"),
|
||||
# ---- 用时播报 4 段整句 ----
|
||||
("time_band1", _ESC, "你逃出来了,用时不到两分钟。了不起!"),
|
||||
("time_band2", _ESC, "你逃出来了,用时不到五分钟。"),
|
||||
("time_band3", _ESC, "你逃出来了,用时不到十分钟。"),
|
||||
("time_band4", _ESC, "你逃出来了,用时超过十分钟。"),
|
||||
# ---- 保留句(文本沿用,按新 instruct 重录) ----
|
||||
("wall", _ESC, "那边是墙,换个方向。"),
|
||||
("start_room", _ESC, "你站在设施中央的房间里。滑动移动,单击检查,双击使用物品。"),
|
||||
("empty_1", _ESC, "房间里空空荡荡,只有你自己的回声。"),
|
||||
("empty_2", _ESC, "你伸出手,摸到的只有冰冷粗糙的墙壁。"),
|
||||
("empty_3", _ESC, "除了远处的水滴声,这里什么都没有。"),
|
||||
("found_key", _ESC, "你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。"),
|
||||
("inspect_cabinet_locked", _ESC, "这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。"),
|
||||
("unlock_cabinet", _ESC, "钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。"),
|
||||
@@ -210,48 +228,23 @@ ESCAPE_LINES = [
|
||||
("box_fixed", _ESC, "电箱运转正常,电流的嗡鸣声平稳而持续。"),
|
||||
("fix_box", _ESC, "你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!"),
|
||||
("inspect_gate", _ESC, "面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。"),
|
||||
("inspect_gate_ready", _ESC, "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。"),
|
||||
("escape", _ESC, "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。"),
|
||||
("use_fail", _ESC, "这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。"),
|
||||
("inv_empty", _ESC, "你摸了摸身上,什么也没有。"),
|
||||
("inv_key", _ESC, "你身上带着一把钥匙。"),
|
||||
("inv_fuse", _ESC, "你身上带着一枚保险丝。"),
|
||||
("inv_both", _ESC, "你身上带着一把钥匙和一枚保险丝。"),
|
||||
("replay", _ESC, "黑暗再次合拢。你回到了中央的房间。这一次,你会更快。"),
|
||||
("again", _ESC, "单击屏幕,再来一次。下滑,打开菜单。"),
|
||||
("empty_1", _ESC, "房间里空空荡荡,只有你自己的回声。"),
|
||||
("empty_2", _ESC, "你伸出手,摸到的只有冰冷粗糙的墙壁。"),
|
||||
("empty_3", _ESC, "除了远处的水滴声,这里什么都没有。"),
|
||||
# ---- 菜单与帮助 ----
|
||||
("help_ops", _ESC, "滑动,在房间之间移动。单击,检查房间,或者重听房间的声音。双击,使用物品,或者查看背包。"),
|
||||
("menu_open", _ESC, "菜单。"),
|
||||
("menu_restart", _ESC, "重新开始。"),
|
||||
("menu_home", _ESC, "回大厅。"),
|
||||
("menu_back", _ESC, "返回游戏。"),
|
||||
("skip_hint", _ESC, "单击可跳过旁白。"),
|
||||
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
|
||||
("inspect_gate_ready", _ESC, "电子锁的指示灯亮了,发出微弱的绿光。单击,打开它,离开这里。"),
|
||||
("gd_next_fuse", _ESC, "保险丝到手。接下来,去找电箱。"),
|
||||
# ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)----
|
||||
# H1:目标句后按房间图接播目标房间方位句(移动坐标系:up/down/left/right 滑动方向即方位)。
|
||||
("gd_room_l", _ESC, "左面的房间。"),
|
||||
("gd_room_r", _ESC, "右面的房间。"),
|
||||
("gd_room_u", _ESC, "上面的房间。"),
|
||||
("gd_room_d", _ESC, "下面的房间。"),
|
||||
# ---- escape-state round2(docs/design/25-escape-map-flow.md §4.1 B1/B2/B3,台词与草案逐字一致)----
|
||||
# B1(F1):撞墙方位句(陈述式非教学腔),wall 播完后接播,同向 10s 限频。
|
||||
("wall_dir_l", _ESC, "左边是墙。"),
|
||||
("wall_dir_r", _ESC, "右边是墙。"),
|
||||
("wall_dir_u", _ESC, "上边是墙。"),
|
||||
("wall_dir_d", _ESC, "下边是墙。"),
|
||||
# B2(F2):目标名+方位一体句,替换三处 gd_room_* 接播(原句保留作异常路径兜底)。
|
||||
("gd_cabinet_dir", _ESC, "铁柜,在右面的房间。"),
|
||||
("gd_box_dir", _ESC, "电箱,在左面的房间。"),
|
||||
("gd_gate_dir", _ESC, "大门,在右面的房间。"),
|
||||
# B3(F3):无进展 150s 活跃计时器的进度重述(stage=cabinet/box/gate,key 阶段不走本通道)。
|
||||
("gd_prog_1", _ESC, "钥匙已经到手。找到能用它的锁。"),
|
||||
("gd_prog_2", _ESC, "铁柜已经打开。电还没有通。"),
|
||||
("gd_prog_3", _ESC, "电已经通了。去找那扇大门。"),
|
||||
|
||||
# ---- 结算 ----
|
||||
("escape", _ESC, "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。"),
|
||||
("time_band1", _ESC, "你逃出来了,用时不到两分钟。了不起!"),
|
||||
("time_band2", _ESC, "你逃出来了,用时不到五分钟。"),
|
||||
("time_band3", _ESC, "你逃出来了,用时不到十分钟。"),
|
||||
("time_band4", _ESC, "你逃出来了,用时超过十分钟。"),
|
||||
("again", _ESC, "单击屏幕,再来一次。"),
|
||||
("replay", _ESC, "黑暗再次合拢。你回到了中央的房间。这一次,你会更快。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- simon
|
||||
@@ -260,9 +253,8 @@ _SIMON = _pair("平静、清晰,像一个耐心的游戏主持人")
|
||||
_SIMON_ROUNDS = ["第一", "第二", "第三", "第四", "第五", "第六", "第七", "第八", "第九", "第十",
|
||||
"第十一", "第十二", "第十三", "第十四", "第十五", "第十六", "第十七", "第十八", "第十九", "第二十"]
|
||||
SIMON_LINES = [
|
||||
# ---- 交互式教学 T0-T7 ----
|
||||
("t0_welcome", _SIMON, "欢迎来到音序回响。先认识六种声音。"),
|
||||
("t0_goal", _SIMON, "我会演奏一串声音,请你记住顺序,原样复现。"),
|
||||
# ---- 26 号重构:教学(3 秒内动手;新手只教三个音色)----
|
||||
("t0_new", _SIMON, "音序回响。点一下屏幕。"),
|
||||
("t1_up", _SIMON, "向上滑,是这个声音。"),
|
||||
("t2_down", _SIMON, "向下滑,是这个声音。"),
|
||||
("t3_left", _SIMON, "向左滑,是这个声音。"),
|
||||
@@ -270,6 +262,7 @@ SIMON_LINES = [
|
||||
("t5_tap", _SIMON, "单击,是这个声音。"),
|
||||
("t6_double", _SIMON, "双击,是这个声音。"),
|
||||
("t_try", _SIMON, "你试试。"),
|
||||
("t_retry", _SIMON, "再试一次。"),
|
||||
("t_ok", _SIMON, "对了。"),
|
||||
("t_wrong_up", _SIMON, "不是这个,向上滑试试。"),
|
||||
("t_wrong_down", _SIMON, "不是这个,向下滑试试。"),
|
||||
@@ -277,16 +270,8 @@ SIMON_LINES = [
|
||||
("t_wrong_right", _SIMON, "不是这个,向右滑试试。"),
|
||||
("t_wrong_tap", _SIMON, "不是这个,单击试试。"),
|
||||
("t_wrong_double", _SIMON, "不是这个,双击试试。"),
|
||||
("t_blunt_up", _SIMON, "手指贴着屏幕,向上推一下。"),
|
||||
("t_blunt_down", _SIMON, "手指贴着屏幕,向下推一下。"),
|
||||
("t_blunt_left", _SIMON, "手指贴着屏幕,向左推一下。"),
|
||||
("t_blunt_right", _SIMON, "手指贴着屏幕,向右推一下。"),
|
||||
("t_blunt_tap", _SIMON, "用指尖,轻轻点一下屏幕。"),
|
||||
("t_blunt_double", _SIMON, "用指尖,快速点两下屏幕。"),
|
||||
("t7_ready", _SIMON, "六种声音都记住了。后面的序列,会越来越长。"),
|
||||
("t7_retry", _SIMON, "答错可以消耗机会重听。向上滑,可以随时重听。"),
|
||||
("t_skip", _SIMON, "跳过教学,直接开始。"),
|
||||
# ---- 轮次播报(整句,round_01..round_20 + 兜底) ----
|
||||
("t7_new", _SIMON, "记住顺序,原样复现。答错,我们就再听一遍。"),
|
||||
# ---- 轮次播报(整句)----
|
||||
*[(f"round_{i + 1:02d}", _SIMON, f"{w}轮,听好了。") for i, w in enumerate(_SIMON_ROUNDS)],
|
||||
("round_more", _SIMON, "新的一轮,听好了。"),
|
||||
# ---- 成绩播报 6 段整句 ----
|
||||
@@ -296,60 +281,42 @@ SIMON_LINES = [
|
||||
("score_band4", _SIMON, "你坚持了将近十轮。"),
|
||||
("score_band5", _SIMON, "你坚持了十轮以上,很厉害。"),
|
||||
("score_band6", _SIMON, "你坚持了十五轮以上,大师级!"),
|
||||
# ---- 失败表(归因 2 秒内 + 免费重听 + 放慢)----
|
||||
("wr_up", _SIMON, "错了。是向上滑。"),
|
||||
("wr_down", _SIMON, "错了。是向下滑。"),
|
||||
("wr_left", _SIMON, "错了。是向左滑。"),
|
||||
("wr_right", _SIMON, "错了。是向右滑。"),
|
||||
("wr_tap", _SIMON, "错了。是单击。"),
|
||||
("wr_dt", _SIMON, "错了。是双击。"),
|
||||
("relisten", _SIMON, "再听一遍。"),
|
||||
("slow_down", _SIMON, "我放慢一些。"),
|
||||
# ---- 流程句 ----
|
||||
("your_turn", _SIMON, "轮到你了。"),
|
||||
("chances_out", _SIMON, "重听机会用完了,小心。"),
|
||||
("retry_use", _SIMON, "错了。用一次机会,再听一遍。"),
|
||||
("relisten", _SIMON, "再听一遍。"),
|
||||
("replay", _SIMON, "单击屏幕,再来一局。"),
|
||||
("rules_prompt", _SIMON, "上滑,重听规则。下滑,打开菜单。"),
|
||||
("rules", _SIMON, "上行音,向上滑。下行音,向下滑。左边的音,向左滑。右边的音,向右滑。一声轻响,单击。两声轻响,双击。"),
|
||||
("gameover", _SIMON, "游戏结束。"),
|
||||
("replay", _SIMON, "单击,再来一局。"),
|
||||
("new_record", _SIMON, "新纪录!"),
|
||||
# ---- 状态查询(长按,一句话)----
|
||||
("st_b1", _SIMON, "前几轮。"),
|
||||
("st_b2", _SIMON, "第五轮上下。"),
|
||||
("st_b3", _SIMON, "第十轮上下。"),
|
||||
("st_b4", _SIMON, "十五轮以上。"),
|
||||
# ---- 反馈短句 ----
|
||||
("correct", _SIMON, "答对了!"),
|
||||
("wrong", _SIMON, "错了。"),
|
||||
("chance_plus", _SIMON, "机会加一。"),
|
||||
("new_record", _SIMON, "新纪录!"),
|
||||
("gd_clear", _SIMON, "这轮全对。"),
|
||||
("gd_record", _SIMON, "破纪录了。"),
|
||||
# ---- 系统层 ----
|
||||
("sys_say_again", _SIMON, "再说一遍:"),
|
||||
("sys_demo", _SIMON, "听我做一次。"),
|
||||
("inv_busy", _SIMON, "正在播放。稍等。"),
|
||||
("inv_generic", _SIMON, "这个操作用不上。"),
|
||||
("sys_unlock_down", _SIMON, "向下滑,可以打开菜单。"),
|
||||
("sys_unlock_dt", _SIMON, "没听清,就双击,再听一遍。"),
|
||||
# ---- 菜单 ----
|
||||
("menu_open", _SIMON, "菜单。"),
|
||||
("menu_restart", _SIMON, "重新开始。"),
|
||||
("menu_home", _SIMON, "回大厅。"),
|
||||
("menu_back", _SIMON, "返回游戏。"),
|
||||
("skip_hint", _SIMON, "单击可跳过旁白。"),
|
||||
# ---- 向导层(docs/design/14-guide-layer.md,schemes §1.5 定稿全文)----
|
||||
("gd_relisten", _SIMON, "需要的话,向上滑可以重新听一遍序列。"),
|
||||
# ---- 向导层 round2(docs/design/14-guide-layer.md「round2 v1.1」,synthesis §3 定稿全文)----
|
||||
# round3 G5 重录(14 号文档「round3 v1.2」):双击改回纯音色、重听改绑上滑,
|
||||
# 机会经济句按裁定书新文案一字不改重录;教学链 t7_retry 同步手势口径。
|
||||
("gd_economy", _SIMON, "记不住就向上滑,重新听一遍。免费机会有两次,用完要扣一次机会。"),
|
||||
# ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)----
|
||||
("gd_clear", _SIMON, "漂亮,过了。"),
|
||||
("gd_record", _SIMON, "破纪录了。"),
|
||||
# ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)----
|
||||
# H2:轮次开场占用预告(序列播放后、轮到你提示前;重听键与答案音共用词表的根治形态)。
|
||||
("gd_occ_up", _SIMON, "这一轮,向上滑是作答。"),
|
||||
("gd_occ_dt", _SIMON, "这一轮,双击是作答。"),
|
||||
("gd_occ_both", _SIMON, "这一轮,向上滑和双击都是作答。"),
|
||||
# H3:使用时如实报余量(替换主动重听路径的旧「用完了」绝对句)。
|
||||
("gd_chances_left1", _SIMON, "免费重听还剩一次。"),
|
||||
("gd_chances_left0", _SIMON, "免费重听用完了。之后重听会花一次机会,别怕,慢慢来。"),
|
||||
# ---- 状态查询(research/simon-polish round1,S1/S3/S4;input 阶段长按=问状态,
|
||||
# 13 句全部整句预生成、数字分档,禁运行时拼接;不加 guide 前缀——三档 guide 全可用,
|
||||
# 属玩家主动查询而非向导插播)----
|
||||
("status_len_short", _SIMON, "这一轮很短。"),
|
||||
("status_len_mid", _SIMON, "这一轮不长不短。"),
|
||||
("status_len_long", _SIMON, "这一轮挺长。"),
|
||||
("status_len_xlong", _SIMON, "这一轮很长,稳住。"),
|
||||
("status_prog_start", _SIMON, "刚开头,一个还没对。"),
|
||||
("status_prog_early", _SIMON, "刚答了个开头。"),
|
||||
("status_prog_half", _SIMON, "答了一半左右。"),
|
||||
("status_prog_late", _SIMON, "就剩最后几个了。"),
|
||||
("status_free2", _SIMON, "免费重听还有两次。"),
|
||||
("status_free1", _SIMON, "免费重听还有一次。"),
|
||||
("status_chance2", _SIMON, "免费用完,机会还剩两次。"),
|
||||
("status_chance1", _SIMON, "免费用完,机会还剩一次。"),
|
||||
("status_out", _SIMON, "免费和机会都用完了,答错就结束。"),
|
||||
|
||||
("menu_item1", _SIMON, "重新开始。"),
|
||||
("menu_item2", _SIMON, "回大厅。"),
|
||||
("menu_item3", _SIMON, "返回结算。"),
|
||||
("menu_help", _SIMON, "单击换一项,右滑确认。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- rhythm
|
||||
@@ -2167,89 +2134,92 @@ NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC, "abyss": ABYSS_NPC, "detective": DET
|
||||
# 双表一致铁律:本表与 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", "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。按空格,重新开始。"),
|
||||
("sys_first_tap", "按空格。"),
|
||||
("sys_first_tap_short", "按一下空格。"),
|
||||
("n00_b", "你是林晚,前急救员。按空格,睁开眼睛。"),
|
||||
("sys_hint_continue_full", "按空格,继续。"),
|
||||
("sys_hint_continue_short", "按空格。"),
|
||||
("sys_hint_choice_short", "左右方向键,选。"),
|
||||
("sys_hint_again_full", "按空格,重新开始。"),
|
||||
("status_s1", "第一幕,地铁站。找林晨。按空格继续。"),
|
||||
("status_s2", "第二幕,断桥。过江。按空格继续。"),
|
||||
("status_s3", "第三幕,广播塔。发警报。按空格继续。"),
|
||||
("status_choice", "正在抉择。左右方向键。"),
|
||||
("sys_continue_ask", "检测到上次的进度。按空格,继续上次。右方向键,从头开始。"),
|
||||
("inv_nosel", "现在不用选。按空格继续。"),
|
||||
("inv_generic", "这个操作用不上。按空格继续。"),
|
||||
("inv_up_locked", "上方向键还没解锁。先按空格继续。"),
|
||||
("inv_down_locked", "菜单,玩完一局解锁。按空格继续。"),
|
||||
("sys_only_choice", "现在,只要左右方向键。"),
|
||||
("sys_unlock_up", "想知道自己在哪,就按上方向键。"),
|
||||
("sys_unlock_down", "按下方向键,可以打开菜单。"),
|
||||
("sys_unlock_dt", "没听清,就按回车,再听一遍。"),
|
||||
("menu_help", "空格换一项,右方向键确认。"),
|
||||
("p1", "请选择。左方向键,检查收音机。右方向键,摸黑找出口。"),
|
||||
("p2", "请选择。左方向键,钻进通风管道。右方向键,躺下装死。"),
|
||||
("p3", "请选择。左方向键,循哭声过去。右方向键,直接去北区。"),
|
||||
("p4", "请选择。左方向键,带上小满。右方向键,独自上路。"),
|
||||
("p5", "请选择。左方向键,走桥下涵洞。右方向键,爬桥上钢索。"),
|
||||
("p6", "请选择。左方向键,交出坐标。右方向键,拒绝周旋。"),
|
||||
("p7", "请选择。左方向键,追上去警告。右方向键,自己逃命。"),
|
||||
("p8", "请选择。左方向键,公开频道。右方向键,加密频段。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_continue", "按空格,继续。"),
|
||||
("gd_brief", "按空格继续剧情。请选择的时候,按左右方向键。"),
|
||||
("gd_again", "还在吗?按空格继续。"),
|
||||
],
|
||||
"escape": [
|
||||
("g1_move", "先活动一下。按任意方向键,走一小步。"),
|
||||
("g2_wall", "这个房间不大,四面都是墙。再按两下方向键,试试碰碰墙。"),
|
||||
("g3_left", "跟着我指的路走。按左方向键。"),
|
||||
("g0_new", "黑暗。按任意方向键。"),
|
||||
("g0b", "对。你被困在地下设施里。再按两下方向键,试试墙。"),
|
||||
("g3_new", "现在,按左方向键。"),
|
||||
("g4_down", "对。再按下方向键。"),
|
||||
("g5_inspect", "听到水声了吗?按一下空格,仔细检查这个房间。"),
|
||||
("g6_doubletap", "钥匙能开锁。按回车,是把物品用出去的用法。现在,按回车试试。"),
|
||||
("g7_goal", "找到能用这把钥匙打开的东西。需要帮助时,在原地按回车。"),
|
||||
("guide_wrong_up", "不是这个方向。按上方向键。"),
|
||||
("guide_wrong_down", "不是这个方向。按下方向键。"),
|
||||
("g7_goal_new", "找到能用钥匙打开的东西。迷路了,按住空格。"),
|
||||
("g_wrong_tap", "这一步不按空格。用方向键。"),
|
||||
("g_hint_swipe", "按一下方向键。"),
|
||||
("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", "去东南角的大门,按回车,打开它,离开这里。"),
|
||||
("guide_wrong_down", "不是这个方向。按下方向键。"),
|
||||
("guide_blunt_swipe", "按我说的方向键,按一下。"),
|
||||
("nav_up", "按上方向键。"),
|
||||
("nav_down", "按下方向键。"),
|
||||
("nav_left", "按左方向键。"),
|
||||
("nav_right", "按右方向键。"),
|
||||
("start_room", "你站在设施中央的房间里。方向键移动,按空格检查,按回车使用物品。"),
|
||||
("inspect_gate_ready", "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。按回车,打开它,离开这里。"),
|
||||
("use_fail", "这里没有用得上东西的地方。换个房间试试,或者先按空格检查一下周围。"),
|
||||
("again", "按空格,再来一次。按下方向键,打开菜单。"),
|
||||
("help_ops", "方向键,在房间之间移动。按空格,检查房间,或者重听房间的声音。按回车,使用物品,或者查看背包。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("lp_tap", "按空格。"),
|
||||
("lp_here", "就在这儿。按空格。"),
|
||||
("sys_only_up", "现在,只要按上方向键。"),
|
||||
("sys_only_down", "现在,只要按下方向键。"),
|
||||
("sys_only_left", "现在,只要按左方向键。"),
|
||||
("sys_only_right", "现在,只要按右方向键。"),
|
||||
("sys_continue_ask", "检测到你的进度。按空格,继续上次。右方向键,重新开始。"),
|
||||
("inv_settle", "按空格,重新开始。"),
|
||||
("menu_help", "空格换一项,右方向键确认。"),
|
||||
("start_room", "你站在设施中央的房间里。方向键,移动。空格,检查。"),
|
||||
("inspect_gate_ready", "电子锁的指示灯亮了,发出微弱的绿光。按空格,打开它,离开这里。"),
|
||||
("again", "按空格,再来一次。"),
|
||||
],
|
||||
"simon": [
|
||||
("t0_new", "音序回响。按一下空格。"),
|
||||
("t1_up", "按上方向键,是这个声音。"),
|
||||
("t2_down", "按下方向键,是这个声音。"),
|
||||
("t3_left", "按左方向键,是这个声音。"),
|
||||
("t4_right", "按右方向键,是这个声音。"),
|
||||
("t5_tap", "按空格,是这个声音。"),
|
||||
("t6_double", "按回车,是这个声音。"),
|
||||
("t_retry", "再按一次。"),
|
||||
("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", "答错可以消耗机会重听。按上方向键,可以随时重听。"),
|
||||
("wr_up", "错了。是按上方向键。"),
|
||||
("wr_down", "错了。是按下方向键。"),
|
||||
("wr_left", "错了。是按左方向键。"),
|
||||
("wr_right", "错了。是按右方向键。"),
|
||||
("wr_tap", "错了。是按空格。"),
|
||||
("wr_dt", "错了。是按回车。"),
|
||||
("replay", "按空格,再来一局。"),
|
||||
("rules_prompt", "按上方向键,重听规则。按下方向键,打开菜单。"),
|
||||
("rules", "上行音,按上方向键。下行音,按下方向键。左边的音,按左方向键。右边的音,按右方向键。一声轻响,按空格。两声轻响,按回车。"),
|
||||
("skip_hint", "按空格可跳过旁白。"),
|
||||
("gd_relisten", "需要的话,按上方向键可以重新听一遍序列。"),
|
||||
("gd_economy", "记不住就按上方向键,重新听一遍。免费机会有两次,用完要扣一次机会。"),
|
||||
("gd_occ_up", "这一轮,按上方向键是作答。"),
|
||||
("gd_occ_dt", "这一轮,按回车是作答。"),
|
||||
("gd_occ_both", "这一轮,按上方向键和回车都是作答。"),
|
||||
("sys_unlock_down", "按下方向键,可以打开菜单。"),
|
||||
("sys_unlock_dt", "没听清,就按回车,再听一遍。"),
|
||||
("menu_help", "空格换一项,右方向键确认。"),
|
||||
],
|
||||
# rhythm:8 对(24-rhythm-voice-ids.md 键盘变体表,措辞含数字键/方向键/空格/回车)。
|
||||
"rhythm": [
|
||||
("tut_map", "先认识你的鼓。屏幕横着分三列,左边、中间、右边;竖着分两行,上面清亮,下面低沉。按数字一键到六键,听六面鼓。"),
|
||||
("tut_probe", "按出三个不同的键,我们就开始。"),
|
||||
|
||||
@@ -300,6 +300,18 @@ export class WebAudioEngine {
|
||||
this._unduckBgm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音链级压低持有(26 号文档 §4.1「音乐压低要跨整条链」):游戏层用
|
||||
* onComplete 一句句接起来的链,句间引擎队列是空的、会恢复音乐音量,
|
||||
* 玩家就听到一次「好像轮到我了」。Floor 在整条链期间持有一份压低,
|
||||
* 链结束/被打断时释放。可重入(计数)。
|
||||
* @param {boolean} on true=持有一份压低;false=释放一份
|
||||
*/
|
||||
holdDuck(on) {
|
||||
this._duckHolds = Math.max(0, (this._duckHolds || 0) + (on ? 1 : -1));
|
||||
if (on) this._duckBgm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其
|
||||
* onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异:
|
||||
@@ -443,6 +455,7 @@ export class WebAudioEngine {
|
||||
}
|
||||
|
||||
_unduckBgm() {
|
||||
if (this._duckHolds > 0) return; // Floor 持有链级压低:句间不恢复(26 号 §4.1)
|
||||
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* confusion.js — 迷茫检测(26 号文档 §4.5,P2 第 9 项)。
|
||||
*
|
||||
* 迷茫不只看「不动」,更要从「乱点」里识别。信号(10s 滑动窗口):
|
||||
* | 信号 | 判定 | 权重 |
|
||||
* |----------|----------------------------|------|
|
||||
* | 连续无效 | 10 秒内 ≥3 次 invalid | 2 |
|
||||
* | 反复重听 | 10 秒内主动重听 ≥2 次 | 1 |
|
||||
* | 乱滑 | 2 秒内 ≥3 个不同滑动方向 | 1 |
|
||||
* | 同步失败 | 同一等待点失败 ≥2 次 | 2 |
|
||||
* | 静默 | 动作类 6s / 剧情类 18s 无操作 | 1 |
|
||||
*
|
||||
* 权重累计 ≥2 触发接管,15s 冷却期内不再触发。三级接管由当前等待点实现
|
||||
* (onConfused(level)):1 提示(_short 句)→ 2 缩小(只保留一个正确手势)→
|
||||
* 3 演示(游戏自己做一遍再交回)。同一等待点每触发一次升一级;第 3 级用过后
|
||||
* 该等待点停止接管,只保留等待声。
|
||||
*/
|
||||
const WINDOW_MS = 10000;
|
||||
const COOLDOWN_MS = 15000;
|
||||
const SCORE_TRIGGER = 2;
|
||||
const MAX_LEVEL = 3;
|
||||
|
||||
export class Confusion {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {(level: number) => void} opts.onConfused 三级接管回调(由当前等待点实现)
|
||||
*/
|
||||
constructor({ onConfused } = {}) {
|
||||
this.events = []; // { t, kind, dir? }
|
||||
this.cooldownUntil = 0;
|
||||
this.onConfused = onConfused || null;
|
||||
this.level = 0; // 当前等待点已接管到的级别
|
||||
this.silenceMs = 18000; // 静默阈值:动作类 6s / 剧情类 18s(游戏设定)
|
||||
}
|
||||
|
||||
/** 玩家输入(任意手势;dir=手势名,乱滑检测用)。 */
|
||||
record(g) { this._push({ kind: 'input', dir: g }); }
|
||||
/** 无效操作结果(pipeline 在游戏返回 invalid 时喂入)。 */
|
||||
recordInvalid() { this._push({ kind: 'invalid' }); }
|
||||
/** 玩家主动重听。 */
|
||||
recordReplay() { this._push({ kind: 'replay' }); }
|
||||
/** 同一步骤失败(游戏判定:答错/走错,喂入)。 */
|
||||
recordFail() { this._push({ kind: 'fail' }); }
|
||||
/** 静默(guide.watchdog 到点回调喂入;权重 1)。 */
|
||||
recordSilence() { this._push({ kind: 'silence' }); }
|
||||
|
||||
/** 换等待点:清事件、级别归零。 */
|
||||
resetForWaitPoint() {
|
||||
this.events = [];
|
||||
this.level = 0;
|
||||
}
|
||||
|
||||
/** 当前级别(等待点第 3 级用过后不再升)。 */
|
||||
get exhausted() { return this.level >= MAX_LEVEL; }
|
||||
|
||||
_push(e) {
|
||||
const now = performance.now();
|
||||
this.events.push({ t: now, ...e });
|
||||
this.events = this.events.filter((x) => now - x.t < WINDOW_MS);
|
||||
if (now < this.cooldownUntil) return;
|
||||
if (this._score(now) >= SCORE_TRIGGER && this.level < MAX_LEVEL) {
|
||||
this.level++;
|
||||
this.cooldownUntil = now + COOLDOWN_MS;
|
||||
if (this.onConfused) this.onConfused(this.level);
|
||||
}
|
||||
}
|
||||
|
||||
_score(now) {
|
||||
const n = (k) => this.events.filter((x) => x.kind === k).length;
|
||||
const recentDirs = new Set(this.events
|
||||
.filter((x) => x.kind === 'input' && now - x.t < 2000 && x.dir && x.dir !== 'tap')
|
||||
.map((x) => x.dir));
|
||||
let s = 0;
|
||||
if (n('invalid') >= 3) s += 2;
|
||||
if (n('replay') >= 2) s += 1;
|
||||
if (recentDirs.size >= 3) s += 1;
|
||||
if (n('fail') >= 2) s += 2;
|
||||
if (n('silence') >= 1 && this._onlySilenceRecently(now)) s += 1;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 静默只在「窗口内除静默外几乎没有输入」时计分(有输入就不算静默)。 */
|
||||
_onlySilenceRecently(now) {
|
||||
const inputs = this.events.filter((x) => x.kind === 'input' && now - x.t < WINDOW_MS);
|
||||
return inputs.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default Confusion;
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* floor.js — 发言权管理器(26 号文档 §4.1,P1 第 5 项)。
|
||||
*
|
||||
* 全局只维护一个「现在谁在说话」。四种状态:
|
||||
* | 状态 | 含义 | 听感 | 玩家操作时 |
|
||||
* |-----------|--------------------------|----------------------------------|--------------------------------|
|
||||
* | SPEAKING | 游戏在说一段话(整条链) | 音乐压低;句间不恢复、不起等待声 | tap=跳过这句;滑动=打断并执行;critical=busy 回执 |
|
||||
* | YOUR_TURN | 在等玩家操作 | 交接提示音 → 持续等待声 | 等待声 80ms 内淡出,交给游戏 |
|
||||
* | BUSY | 演出动画音效 / 处理中 | 只有内容音效 | busy 回执,不执行 |
|
||||
* | ACTION | 实时玩法(节奏/答题) | 玩法本身的声音就是状态 | 直接交给游戏,不打断任何东西 |
|
||||
*
|
||||
* 硬规则(§4.1 转换规则 + §4.6 语音优先级):
|
||||
* - 离开 SPEAKING 只能去 YOUR_TURN(yieldTurn)或 BUSY(继续演出),不存在安静态。
|
||||
* - 进入 YOUR_TURN 必须经过 yieldTurn(waitPoint):交接提示音 → 400ms 后起等待声 →
|
||||
* 挂提示计时 + 迷茫检测(waitPoint.arm())。
|
||||
* - 语音链音乐压低跨整条链(audio.holdDuck)。
|
||||
* - 类别:critical(不可打断,须 <3s)/ content(可打断)/ hint / feedback
|
||||
* (只在自己空闲时播,播不出去就丢——绝不排到长链后面迟到)。
|
||||
* - 单句资产缺失看门狗:引擎对缺失资产不回调 onComplete,Floor 轮询
|
||||
* isVoicePlaying(),确认未在播才推进链(对齐 storyline 旧 VOICE_WATCHDOG_MS 语义,
|
||||
* 真在播的长句续约不掐)。
|
||||
*
|
||||
* 等待声(运行时合成,零资产):三角波 220Hz+330Hz 两声「嘟—嘟」,每声 60ms、
|
||||
* 间隔 180ms、每 1.4s 重复;增益 0.03。等待超 20s 间隔拉长到 2.5s(耐心感)。
|
||||
* 交接提示音与 guide.js 的 turn 同规格(1318.5Hz 风铃 + 高频泛音)——它是状态信号
|
||||
* 不是帮助内容,guide=off 也照播(原则 1/2)。
|
||||
*/
|
||||
|
||||
/** 等待声参数(§4.1「等待声怎么做」起步值)。 */
|
||||
const BED = {
|
||||
f1: 220, f2: 330, // 两声「嘟—嘟」
|
||||
toneMs: 60, gapMs: 180, // 每声 60ms,两声间隔 180ms
|
||||
loopMs: 1400, slowMs: 2500,// 默认每 1.4s 重复;20s 后 2.5s
|
||||
gain: 0.03, // ≈ guide 提示音增益 0.12 的四分之一
|
||||
slowAfterMs: 20000,
|
||||
fadeOutMs: 80, // 玩家一操作,等待声 80ms 内淡出
|
||||
};
|
||||
|
||||
/** 交接提示音(turn 风铃同规格)。 */
|
||||
const TURN_SPEC = [
|
||||
{ f: 1318.5, d: 0.28, wave: 'sine', g: 0.12 },
|
||||
{ f: 2637.0, d: 0.12, wave: 'sine', g: 0.03 },
|
||||
];
|
||||
|
||||
/** 单句资产缺失看门狗轮询间隔。 */
|
||||
const SENTINEL_POLL_MS = 500;
|
||||
/** play() 发起后允许的网络+解码宽限(本地资产 <100ms;超时未见开播=缺失)。 */
|
||||
const SENTINEL_GRACE_MS = 3000;
|
||||
|
||||
export class Floor {
|
||||
/**
|
||||
* @param {import('./audio.js').WebAudioEngine} audio
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.turnEarcon=true] yieldTurn 时播交接提示音(测试可关)
|
||||
* @param {boolean} [opts.bed=true] 等待声(虚拟时钟测试关掉,避免周期定时器不排水)
|
||||
*/
|
||||
constructor(audio, opts = {}) {
|
||||
this.audio = audio;
|
||||
this.state = 'BUSY';
|
||||
this.turnEarcon = opts.turnEarcon !== false;
|
||||
this.bedEnabled = opts.bed !== false;
|
||||
|
||||
this._chain = null; // { ids, i, critical, kind, onDone, token }
|
||||
this._token = 0;
|
||||
this._bed = null; // { timer, nodes, startedAt }
|
||||
this._waitPoint = null; // 当前等待点(yieldTurn 挂上,输入/完成时摘下)
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 对外 API
|
||||
|
||||
/**
|
||||
* 播一段话(整条语音链)。已在播的链被顶掉(打断即作废,不触发其 onDone)。
|
||||
* @param {string[]} ids voice id 列表('voice/xxx')
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.critical] 关键句:玩家操作给 busy 回执,不打断(须 <3s)
|
||||
* @param {'content'|'hint'|'feedback'} [opts.kind='content'] 类别(§4.6):
|
||||
* hint/feedback 只在空闲时播,SPEAKING 中直接丢弃(不排队迟到)
|
||||
* @param {() => void} [opts.onDone] 整条链自然播完(不含被打断)后回调;
|
||||
* 由 onDone 决定 yieldTurn 还是继续演出
|
||||
*/
|
||||
speak(ids, opts = {}) {
|
||||
const critical = !!opts.critical;
|
||||
const kind = opts.kind || 'content';
|
||||
if (kind === 'hint' || kind === 'feedback') {
|
||||
// 过期即弃(§4.6):正在说话时不播、不排队
|
||||
if (this.state === 'SPEAKING') return false;
|
||||
}
|
||||
if (!ids || !ids.length) {
|
||||
if (opts.onDone) opts.onDone();
|
||||
return true;
|
||||
}
|
||||
this._stopBed();
|
||||
this._killChain();
|
||||
this.state = 'SPEAKING';
|
||||
const token = ++this._token;
|
||||
this._chain = { ids, i: 0, critical, kind, onDone: opts.onDone, token };
|
||||
this.audio.holdDuck(true); // 压低跨整条链
|
||||
this._next(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交出话轮:交接提示音 → 400ms 后起等待声 → 挂等待点(提示计时+迷茫检测)。
|
||||
* @param {import('./hints.js').WaitPoint|null} [waitPoint]
|
||||
*/
|
||||
yieldTurn(waitPoint = null) {
|
||||
this._stopBed();
|
||||
if (this._waitPoint && this._waitPoint !== waitPoint) this._waitPoint.complete();
|
||||
this._waitPoint = waitPoint;
|
||||
this.state = 'YOUR_TURN';
|
||||
if (this.turnEarcon) this._playTurnEarcon();
|
||||
const token = ++this._token; // 防竞争:400ms 内又 speak 则不起等待声
|
||||
setTimeout(() => {
|
||||
if (token !== this._token || this.state !== 'YOUR_TURN') return;
|
||||
this._startBed();
|
||||
}, 400);
|
||||
if (waitPoint) waitPoint.arm();
|
||||
}
|
||||
|
||||
/** 进入实时玩法(节奏/答题/探索):玩法声音即状态,输入直接给游戏。 */
|
||||
enterAction() {
|
||||
this._stopBed();
|
||||
if (this._waitPoint) { this._waitPoint.complete(); this._waitPoint = null; } // 清掉残留提示计时
|
||||
this.state = 'ACTION';
|
||||
}
|
||||
|
||||
/** 离开实时玩法回 BUSY(游戏演出/播报前调用)。 */
|
||||
leaveAction() {
|
||||
if (this.state === 'ACTION') this.state = 'BUSY';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示句播完后回到等待态(等待点内部用):不重播交接提示音、不重挂等待点,
|
||||
* 只恢复 YOUR_TURN + 等待声——档位计数在等待点里保留(原则 9)。
|
||||
*/
|
||||
resumeTurn() {
|
||||
if (this.state === 'SPEAKING') return;
|
||||
this.state = 'YOUR_TURN';
|
||||
this._startBed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入先经过这里(pipeline 调用)。返回:
|
||||
* 'consumed' = 发言权层已处理(tap 跳句),游戏不必再处理;
|
||||
* 'busy' = 忽略这次操作,给 busy 回执;
|
||||
* null = 交给游戏处理。
|
||||
*
|
||||
* 打断语义(§4.1 裁定,写进手势表,所有游戏一致):
|
||||
* - 单击:只打断 = 跳过当前这句(链继续走下一句);
|
||||
* - 滑动/上滑:打断并执行(滑动是有明确意图的操作)。
|
||||
*/
|
||||
handleInput(g) {
|
||||
if (this.state === 'SPEAKING') {
|
||||
if (this._chain && this._chain.critical) return 'busy';
|
||||
if (g === 'tap') {
|
||||
this.audio.skipVoice(); // 视为这句自然播完:onComplete → 链继续
|
||||
return 'consumed';
|
||||
}
|
||||
this._killChain(); // 打断并执行:作废旧链,这次操作照常交给游戏
|
||||
this.state = 'BUSY'; // 处理中;游戏决定接下来 yieldTurn / 继续演出 / 回 ACTION
|
||||
return null;
|
||||
}
|
||||
if (this.state === 'BUSY') return 'busy';
|
||||
if (this.state === 'YOUR_TURN') {
|
||||
this._stopBed();
|
||||
if (this._waitPoint) this._waitPoint.rearm(); // 计时复位;档位计数保留(原则 9)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待点结算。done=true(有效操作完成这个点):摘下等待点(含迷茫计数清零);
|
||||
* done=false(无效操作/未完成):等待点保留(玩家还在这个点),只重排静默计时
|
||||
* ——档位计数保留(原则 9)。
|
||||
*/
|
||||
settleWaitPoint(done = true) {
|
||||
const wp = this._waitPoint;
|
||||
if (!wp) return;
|
||||
if (done) {
|
||||
this._waitPoint = null;
|
||||
wp.complete();
|
||||
return;
|
||||
}
|
||||
wp.rearm();
|
||||
// 打断解释句后(BUSY)也要回到等待态,等待声重新响起
|
||||
if (this.state !== 'SPEAKING' && this.state !== 'ACTION') this.resumeTurn();
|
||||
}
|
||||
|
||||
/** 当前等待点(供游戏查询限制手势等)。 */
|
||||
get waitPoint() { return this._waitPoint; }
|
||||
|
||||
/** 当前链是否在播(含句间)。 */
|
||||
get speaking() { return this.state === 'SPEAKING'; }
|
||||
|
||||
/** 当前正在播的句 id(状态播报用)。 */
|
||||
currentId() {
|
||||
return this._chain ? this._chain.ids[this._chain.i] : null;
|
||||
}
|
||||
|
||||
/** 交接提示音(公开:三档提示的第 3 档只播它,§4.4)。 */
|
||||
chime() { this._playTurnEarcon(); }
|
||||
|
||||
/** 彻底打断:杀链 + 停等待声 + 回 BUSY(重开/进菜单等场景,游戏层调用)。 */
|
||||
interrupt() {
|
||||
this._killChain();
|
||||
this._stopBed();
|
||||
if (this._waitPoint) { this._waitPoint.complete(); this._waitPoint = null; }
|
||||
this.state = 'BUSY';
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 内部实现
|
||||
|
||||
_killChain() {
|
||||
const c = this._chain;
|
||||
this._chain = null;
|
||||
if (!c) return;
|
||||
this._token++; // 作废后续推进
|
||||
this.audio.holdDuck(false); // 释放链级压低
|
||||
this.audio.stopVoice(); // 打断即作废:不触发 onComplete
|
||||
}
|
||||
|
||||
_next(token) {
|
||||
const c = this._chain;
|
||||
if (!c || c.token !== token) return; // 链已被打断/顶掉,作废
|
||||
if (c.i >= c.ids.length) { // 整条链播完
|
||||
this._chain = null;
|
||||
this.audio.holdDuck(false);
|
||||
this.state = 'BUSY'; // 由 onDone 决定 yieldTurn 还是继续演出
|
||||
if (c.onDone) c.onDone();
|
||||
return;
|
||||
}
|
||||
const id = c.ids[c.i];
|
||||
let sawPlaying = false;
|
||||
const poll = () => {
|
||||
if (!this._chain || this._chain.token !== token) return;
|
||||
if (this.audio.isVoicePlaying()) { sawPlaying = true; t = setTimeout(poll, SENTINEL_POLL_MS); return; }
|
||||
if (!sawPlaying && performance.now() - t0 < SENTINEL_GRACE_MS) { t = setTimeout(poll, SENTINEL_POLL_MS); return; }
|
||||
// 资产缺失(引擎静默跳过、永不回调):告警续链不卡死;真在播的长句不会到这
|
||||
console.warn('[Floor] 语音资产缺失,跳过续链:', id);
|
||||
this._advance(token);
|
||||
};
|
||||
const t0 = performance.now();
|
||||
let t = setTimeout(poll, SENTINEL_POLL_MS);
|
||||
this.audio.play(id, {
|
||||
onComplete: () => {
|
||||
clearTimeout(t);
|
||||
if (!this._chain || this._chain.token !== token) return;
|
||||
this._advance(token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_advance(token) {
|
||||
const c = this._chain;
|
||||
if (!c || c.token !== token) return;
|
||||
c.i++;
|
||||
this._next(token);
|
||||
}
|
||||
|
||||
/** 交接提示音:风铃质感(guide turn 同规格),guide 设置无关。 */
|
||||
_playTurnEarcon() {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!ctx || !this.audio._master || !this.audio.unlocked) return;
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
for (const n of TURN_SPEC) {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = n.wave;
|
||||
osc.frequency.value = n.f;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(n.g, t0 + 0.008);
|
||||
g.gain.linearRampToValueAtTime(n.g * 0.55, t0 + n.d * 0.4);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + n.d);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + n.d + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待声:两声「嘟—嘟」循环。 */
|
||||
_startBed() {
|
||||
if (!this.bedEnabled || this._bed) return;
|
||||
const startedAt = performance.now();
|
||||
const bed = { timer: null, nodes: null, startedAt };
|
||||
this._bed = bed;
|
||||
const cycle = () => {
|
||||
if (this._bed !== bed) return;
|
||||
bed.nodes = this._beep();
|
||||
const waited = performance.now() - startedAt;
|
||||
const interval = waited > BED.slowAfterMs ? BED.slowMs : BED.loopMs;
|
||||
bed.timer = setTimeout(cycle, interval);
|
||||
};
|
||||
cycle();
|
||||
}
|
||||
|
||||
_beep() {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!ctx || !this.audio._master || !this.audio.unlocked) return null;
|
||||
const mk = (delay, f) => {
|
||||
const t0 = ctx.currentTime + 0.01 + delay;
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = f;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(BED.gain, t0 + 0.01);
|
||||
g.gain.setValueAtTime(BED.gain, t0 + BED.toneMs / 1000 - 0.012);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + BED.toneMs / 1000);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + BED.toneMs / 1000 + 0.02);
|
||||
return g;
|
||||
};
|
||||
return [mk(0, BED.f1), mk((BED.toneMs + BED.gapMs) / 1000, BED.f2)];
|
||||
}
|
||||
|
||||
_stopBed() {
|
||||
const bed = this._bed;
|
||||
this._bed = null;
|
||||
if (!bed) return;
|
||||
if (bed.timer !== null) clearTimeout(bed.timer);
|
||||
if (bed.nodes) {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
for (const g of bed.nodes) {
|
||||
if (!ctx) continue;
|
||||
try {
|
||||
const t = ctx.currentTime;
|
||||
g.gain.cancelScheduledValues(t);
|
||||
g.gain.setValueAtTime(g.gain.value, t);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t + BED.fadeOutMs / 1000);
|
||||
} catch (_) { /* 已停 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Floor;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* hints.js — 等待点与三档提示(26 号文档 §4.4 / 附录 A,P1 第 6 项)+ 无效操作
|
||||
* 解释一次(原则 7)。
|
||||
*
|
||||
* 等待点 = 需要玩家操作的时刻。配置(附录 A 模板):
|
||||
* { id, expected: ['left','right'], // 期待的手势(迷茫第 2 级只保留它们)
|
||||
* full: 'voice/xxx_full', // 完整提示
|
||||
* short: 'voice/xxx_short', // 短句(≤6 字,只保留动作)
|
||||
* silenceMs: [6000, 16000, 30000], // 静默分级阈值(同时喂迷茫检测的静默信号)
|
||||
* restrictLine: 'voice/xxx_only', // 迷茫第 2 级说明句(缺省用 short)
|
||||
* onDemo: () => {} } // 迷茫第 3 级演示(游戏自己完成这一步再交回)
|
||||
*
|
||||
* 三档选择(§4.4):
|
||||
* 第 n 次被动提示:0 → full;1 → short;≥2 → 只给交接提示音(earcon)。
|
||||
* 游戏主动给的提示永远不原样重复(原则 9)。
|
||||
* 玩家主动要求重听(requestReplay):永远给完整句,前缀「再说一遍:」
|
||||
* (voice/sys_say_again,全项目一句、每游戏各存一份资产)。
|
||||
*
|
||||
* 计数作用范围:按「这一个等待点」算,离开这个点(complete / 重新 arm)就清零;
|
||||
* 玩家做了有效操作但没完成目标(rearm),计数不清零,下次提示接着变短。
|
||||
*/
|
||||
|
||||
/** 提示台账:id → 最近一次播出时刻(模块级,跨等待点共享)。 */
|
||||
const LEDGER_WINDOW_MS = 30000;
|
||||
const hintLedger = new Map();
|
||||
function ledgerSay(id) { hintLedger.set(id, performance.now()); }
|
||||
function ledgerSaidRecently(id) {
|
||||
const t = hintLedger.get(id);
|
||||
return t !== undefined && performance.now() - t < LEDGER_WINDOW_MS;
|
||||
}
|
||||
/** 台账(导出):游戏层接管提示等自发句子复用同一去重规则(原则 9)。 */
|
||||
export { ledgerSay as markHintSaid, ledgerSaidRecently as hintSaidRecently };
|
||||
|
||||
export class WaitPoint {
|
||||
/**
|
||||
* @param {object} conf 附录 A 配置
|
||||
* @param {object} ctx { floor, confusion }
|
||||
*/
|
||||
constructor(conf, ctx) {
|
||||
this.conf = conf;
|
||||
this.floor = ctx.floor;
|
||||
this.confusion = ctx.confusion;
|
||||
this.hintCount = 0; // 本等待点已给的被动提示次数(决定档位)
|
||||
this._timers = [];
|
||||
this._armedAt = 0;
|
||||
this._restricted = false;
|
||||
}
|
||||
|
||||
/** 挂上(floor.yieldTurn 调用):清迷茫计数 + 按阈值排静默提示(阈值绝对)。 */
|
||||
arm() {
|
||||
this._clearTimers();
|
||||
this.hintCount = 0;
|
||||
this._armedAt = performance.now();
|
||||
if (this.confusion) this.confusion.resetForWaitPoint();
|
||||
this._scheduleTiers();
|
||||
}
|
||||
|
||||
/** 玩家动了但没完成这个点:静默计时复位,档位计数保留。 */
|
||||
rearm() {
|
||||
this._clearTimers();
|
||||
this._scheduleTiers();
|
||||
}
|
||||
|
||||
_scheduleTiers() {
|
||||
const th = this.conf.silenceMs || [18000, 40000];
|
||||
for (let k = 0; k < th.length; k++) {
|
||||
this._timers.push(setTimeout(() => this._fireSilence(k), th[k]));
|
||||
}
|
||||
this._armTailSilence(th.length ? th[th.length - 1] : 0);
|
||||
}
|
||||
|
||||
/** 这个等待点完成:清计时、清迷茫计数。 */
|
||||
complete() {
|
||||
this._clearTimers();
|
||||
this.hintCount = 0;
|
||||
this._restricted = false;
|
||||
if (this.confusion) this.confusion.resetForWaitPoint();
|
||||
}
|
||||
|
||||
/** 玩家主动重听:完整句 + 前缀,不重置档位计数(原则 9)。 */
|
||||
requestReplay() {
|
||||
if (this.confusion) this.confusion.recordReplay();
|
||||
const full = this.conf.full;
|
||||
if (!full) return;
|
||||
ledgerSay(full); // 刚主动听过,30s 内被动提示自动降档
|
||||
this.floor.speak(['voice/sys_say_again', full], {
|
||||
kind: 'hint',
|
||||
onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); },
|
||||
});
|
||||
}
|
||||
|
||||
/** 迷茫第 2 级:只保留 expected 手势,其他手势只给 invalid 回执。 */
|
||||
get restricted() { return this._restricted; }
|
||||
|
||||
/** 手势是否被本等待点放行(未受限时永远 true)。 */
|
||||
allows(g) {
|
||||
if (!this._restricted) return true;
|
||||
return (this.conf.expected || []).includes(g);
|
||||
}
|
||||
|
||||
/** 静默到点:喂迷茫静默信号 + 给第 hintCount 档提示。 */
|
||||
_fireSilence() {
|
||||
if (this.confusion) this.confusion.recordSilence();
|
||||
this._givePassiveHint();
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值用尽后的长间隔静默汇报(只喂迷茫信号,不再升提示档)。
|
||||
* 最多 4 次(≈2 分钟):等待声仍在标记状态(原则 1),接管三级也用尽后
|
||||
* 不再无限催促;有界也让虚拟时钟能排干定时器。
|
||||
*/
|
||||
_armTailSilence(afterMs) {
|
||||
let left = 4;
|
||||
const tick = () => {
|
||||
this._timers = this._timers.filter((t) => t !== id);
|
||||
if (this.confusion) this.confusion.recordSilence();
|
||||
if (--left <= 0) return;
|
||||
id = setTimeout(tick, 30000);
|
||||
this._timers.push(id);
|
||||
};
|
||||
let id = setTimeout(tick, afterMs + 30000);
|
||||
this._timers.push(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 第 hintCount 次被动提示:full → short → earcon(full 缺省时第 0 档就是短句)。
|
||||
* 台账跨等待点:30s 内别的等待点刚说过同一句 → 直接降档(被动原句重复 = 0)。
|
||||
*/
|
||||
_givePassiveHint() {
|
||||
this.hintCount++;
|
||||
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
|
||||
const use = (id) => { ledgerSay(id); this.floor.speak([id], resume); };
|
||||
if (this.conf.full && !ledgerSaidRecently(this.conf.full)) {
|
||||
use(this.conf.full);
|
||||
} else if (this.conf.short && !ledgerSaidRecently(this.conf.short)) {
|
||||
use(this.conf.short);
|
||||
} else {
|
||||
this.floor.chime(); // 只给交接提示音
|
||||
}
|
||||
}
|
||||
|
||||
/** 三级接管(confusion.onConfused 接到这里)。 */
|
||||
takeover(level) {
|
||||
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
|
||||
if (level === 1) {
|
||||
// 只说下一步的一个动作,用 _short 句(30s 内说过就只给提示音)
|
||||
const s = this.conf.short || this.conf.full;
|
||||
if (s && !ledgerSaidRecently(s)) { ledgerSay(s); this.floor.speak([s], resume); }
|
||||
else this.floor.chime();
|
||||
} else if (level === 2) {
|
||||
this._restricted = true;
|
||||
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
|
||||
if (line) this.floor.speak([line], resume);
|
||||
} else if (level === 3 && this.conf.onDemo) {
|
||||
this._restricted = false;
|
||||
this.conf.onDemo();
|
||||
}
|
||||
}
|
||||
|
||||
_clearTimers() {
|
||||
for (const t of this._timers) clearTimeout(t);
|
||||
this._timers.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效操作解释一次(原则 7):第一次无效操作说一句原因和正确做法;
|
||||
* 之后只给回执声。解释句按「状态 × 手势」为键,每键只播一次(同局)。
|
||||
*/
|
||||
export class InvalidHints {
|
||||
constructor() {
|
||||
this._said = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key 状态×手势 键(游戏提供,如 'story:swipe')
|
||||
* @param {string|null} voiceId 解释句 id;null = 该格没有解释句,只给回执
|
||||
* @returns {boolean} 是否真的播了
|
||||
*/
|
||||
explainOnce(key, voiceId) {
|
||||
if (!voiceId) return false;
|
||||
if (this._said.has(key)) return false;
|
||||
if (ledgerSaidRecently(voiceId)) { this._said.add(key); return false; } // 同句音频 30s 内不重播
|
||||
this._said.add(key);
|
||||
ledgerSay(voiceId);
|
||||
// 解释句走 idle-only 反馈通道(§4.6):正在说话时丢弃;播完回到等待态
|
||||
// (无效操作不推进游戏,话轮仍属玩家)
|
||||
this._resolveFloor?.speak([voiceId], {
|
||||
kind: 'feedback',
|
||||
onDone: () => { if (this._resolveFloor && this._resolveFloor.waitPoint) this._resolveFloor.resumeTurn(); },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 注入 floor(延迟绑定:构造时游戏可能还没建 floor)。 */
|
||||
bind(floor) { this._resolveFloor = floor; return this; }
|
||||
|
||||
reset() { this._said.clear(); }
|
||||
}
|
||||
|
||||
export { WaitPoint as default };
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* novice.js — 新手手势模式与解锁阶梯(26 号文档 §4.3,P0 第 1 项 + P3 第 11 项)。
|
||||
*
|
||||
* 新手手势表(全部游戏共用,原则 6):
|
||||
* | 手势 | 唯一含义 | 新手期(阶段 0) |
|
||||
* |------------|------------------------|---------------------------------|
|
||||
* | tap | 继续 / 确定 / 听一下 | 开放 |
|
||||
* | left/right | 选择 / 移动 | 开放 |
|
||||
* | up | 我在哪、我该干嘛 | 阶段 1 解锁(完成第一个小目标) |
|
||||
* | down | 菜单 | 阶段 2 解锁(完成第一局) |
|
||||
* | doubletap | 重听 | 阶段 3 解锁(累计 3 局);未解锁按单击处理 |
|
||||
* | longpress | 帮助(四向玩法类游戏) | 仅「滑动是玩法」的游戏开放(§4.3 裁定) |
|
||||
*
|
||||
* 解锁进度跨局持久(localStorage),全局共享(手势表全项目一致,学会一次就算会)。
|
||||
* 游戏在对应时机调 onGoal() / onGameDone(),返回新解锁的手势(null=无新解锁),
|
||||
* 随后按原则 11 教学:先说一句(teachLine),马上用一次。
|
||||
*/
|
||||
const STORAGE_KEY = 'soundgame.gestures';
|
||||
|
||||
/** 解锁教学句 id(每游戏目录各存一份同文案资产,§4.3 阶梯表)。 */
|
||||
const TEACH_IDS = {
|
||||
up: 'sys_unlock_up', // 「想知道自己在哪,就向上滑。」
|
||||
down: 'sys_unlock_down', // 「向下滑,可以打开菜单。」
|
||||
doubletap: 'sys_unlock_dt', // 「没听清,就双击,再听一遍。」
|
||||
};
|
||||
|
||||
const UNLOCK_AT = { 1: 'up', 2: 'down', 3: 'doubletap' };
|
||||
|
||||
export class GestureLadder {
|
||||
constructor(key = STORAGE_KEY) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.key);
|
||||
if (!raw) return { stage: 0, games: 0 };
|
||||
const o = JSON.parse(raw);
|
||||
return {
|
||||
stage: Math.max(0, Math.min(3, Number(o.stage) || 0)),
|
||||
games: Math.max(0, Number(o.games) || 0),
|
||||
};
|
||||
} catch (_) {
|
||||
return { stage: 0, games: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
_save(o) {
|
||||
try { localStorage.setItem(this.key, JSON.stringify(o)); } catch (_) { /* 隐私模式 */ }
|
||||
}
|
||||
|
||||
/** 当前阶段 0..3。 */
|
||||
stage() { return this._load().stage; }
|
||||
|
||||
/** 手势是否已解锁(tap/left/right 永远开放;longpress 由游戏按玩法裁定)。 */
|
||||
has(g) {
|
||||
if (g === 'tap' || g === 'left' || g === 'right') return true;
|
||||
if (g === 'up') return this.stage() >= 1;
|
||||
if (g === 'down') return this.stage() >= 2;
|
||||
if (g === 'doubletap') return this.stage() >= 3;
|
||||
return false; // longpress 等:走游戏自己的核心玩法裁定
|
||||
}
|
||||
|
||||
/** 完成第一个小目标 → 阶段 1。返回新解锁手势或 null。 */
|
||||
onGoal() { return this._unlock(1); }
|
||||
|
||||
/** 完成一局 → games+1;第 1 局解锁阶段 2,第 3 局解锁阶段 3。 */
|
||||
onGameDone() {
|
||||
const o = this._load();
|
||||
o.games += 1;
|
||||
this._save(o);
|
||||
let got = null;
|
||||
if (o.games >= 1) got = this._unlock(2) || got;
|
||||
if (o.games >= 3) got = this._unlock(3) || got;
|
||||
return got;
|
||||
}
|
||||
|
||||
_unlock(stage) {
|
||||
const o = this._load();
|
||||
if (o.stage >= stage) return null;
|
||||
o.stage = stage;
|
||||
this._save(o);
|
||||
return UNLOCK_AT[stage];
|
||||
}
|
||||
|
||||
/** 老玩家在大厅一键全部解锁。 */
|
||||
unlockAll() {
|
||||
const o = this._load();
|
||||
o.stage = 3;
|
||||
this._save(o);
|
||||
}
|
||||
|
||||
/** 解锁教学句 voice id。 */
|
||||
teachLine(g) { return TEACH_IDS[g] || null; }
|
||||
}
|
||||
|
||||
export default GestureLadder;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* pipeline.js — 输入管道(26 号文档 §4.2 接线方式 + §4.3 新手手势模式 + 防连点)。
|
||||
*
|
||||
* 每款改造游戏的 attachGestures 回调都换成这个管道(P0 第 1/2/3/4 项一次落地):
|
||||
*
|
||||
* const pipe = makeInputPipeline({ floor, confusion, receipts, invalidHints,
|
||||
* game, ladder });
|
||||
* attachGestures(root, pipe, { doubleTapWindow: 0 }); // 关掉双击 + 去掉 300ms 延迟
|
||||
*
|
||||
* 管道顺序:
|
||||
* 1. 新手手势门(§4.3):未解锁 doubletap 按一次 tap 处理(键盘回车发的
|
||||
* doubletap 同样折算);未解锁 down / 非玩法 longpress → invalid 回执 +
|
||||
* 首次附一句解释(原则 7)。
|
||||
* 2. confusion.record(g)(§4.5 乱点信号)。
|
||||
* 3. floor.handleInput(g)(§4.1):SPEAKING 中 tap=跳句(consumed)、滑动=打断并
|
||||
* 执行、critical=busy;BUSY=busy 回执;YOUR_TURN 停等待声。
|
||||
* 4. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap 只给
|
||||
* busy 回执(点两下不连跳两句)。ACTION 态(答题)不设防。
|
||||
* 5. 等待点第 2 级限制(§4.5 缩小):只放行 expected 手势,其余 invalid 回执。
|
||||
* 6. game.onGesture(g) 返回值收口:
|
||||
* 'ok' 接受(完成当前等待点)
|
||||
* 'hold' 接受但等待点继续等(如抉择点单击=再听选项)
|
||||
* 'silent-ok' 接受且已有明显内容音(省 ok 回执,§4.2)
|
||||
* 'invalid' 拒绝(invalid 回执 + explainOnce + 迷茫信号;等待点计时复位)
|
||||
*/
|
||||
import { GestureLadder } from './novice.js';
|
||||
|
||||
/** 防连点窗口:tap 执行「继续/跳过」后 400ms 内的 tap 只给 busy。 */
|
||||
const TAP_GUARD_MS = 400;
|
||||
|
||||
/** 管道要喂给迷茫检测的手势(收尾/撤销类噪声不计)。 */
|
||||
const REAL_GESTURES = new Set(['tap', 'doubletap', 'up', 'down', 'left', 'right', 'longpress']);
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('./floor.js').Floor} floor
|
||||
* @param {import('./confusion.js').Confusion} confusion
|
||||
* @param {import('./receipts.js').Receipts} receipts
|
||||
* @param {import('./hints.js').InvalidHints} invalidHints
|
||||
* @param {{ onGesture: (g: string, info?: object) => string|void,
|
||||
* stateName?: () => string,
|
||||
* invalidHint?: (state: string, g: string) => string|null }} game
|
||||
* @param {import('./novice.js').GestureLadder} [deps.ladder] 手势解锁阶梯(缺省=全新手)
|
||||
* @param {object} [deps.opts]
|
||||
* @param {boolean} [deps.opts.longpressCore=false] 本游戏长按是核心玩法(四向滑动类
|
||||
* 游戏的帮助键,§4.3 裁定);false 时 longpress → invalid
|
||||
* @param {boolean} [deps.opts.swipeCore=false] 四向滑动是本游戏核心玩法(§4.3 裁定):
|
||||
* up/down 不按阶梯锁(它们是移动,不是帮助/菜单);阶梯只管 doubletap
|
||||
*/
|
||||
export function makeInputPipeline({ floor, confusion, receipts, invalidHints, game, ladder, opts = {} }) {
|
||||
const lad = ladder || new GestureLadder();
|
||||
const longpressCore = (opts && opts.longpressCore) === true;
|
||||
const swipeCore = (opts && opts.swipeCore) === true;
|
||||
let lastTapActedAt = -Infinity;
|
||||
|
||||
return (g, info) => {
|
||||
if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声,不吃回执
|
||||
|
||||
// ---- 1. 新手手势门(§4.3)----
|
||||
if (g === 'doubletap' && !lad.has('doubletap')) g = 'tap'; // 双击按一次单击处理
|
||||
if (!swipeCore && g === 'up' && !lad.has('up')) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:up', game.invalidHint ? game.invalidHint('locked', 'up') : null);
|
||||
return;
|
||||
}
|
||||
if (!swipeCore && g === 'down' && !lad.has('down')) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:down', game.invalidHint ? game.invalidHint('locked', 'down') : null);
|
||||
return;
|
||||
}
|
||||
if (g === 'longpress' && !longpressCore) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:longpress', game.invalidHint ? game.invalidHint('locked', 'longpress') : null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 2. 迷茫检测(§4.5)----
|
||||
if (REAL_GESTURES.has(g)) confusion.record(g);
|
||||
|
||||
// ---- 3. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap
|
||||
// 只给 busy 回执(无论它将被解释成跳句还是继续——都不执行)----
|
||||
if (g === 'tap' && floor.state !== 'ACTION' && performance.now() - lastTapActedAt < TAP_GUARD_MS) {
|
||||
receipts.play('busy');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 4. 发言权(§4.1)----
|
||||
const preState = floor.state;
|
||||
const pre = floor.handleInput(g);
|
||||
if (pre === 'busy') { receipts.play('busy'); return; }
|
||||
if (pre === 'consumed') {
|
||||
receipts.play('ok'); // tap 跳句 = 一次「继续」,进防连点窗口
|
||||
lastTapActedAt = performance.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 5. 迷茫第 2 级:等待点只放行 expected(§4.5 缩小)----
|
||||
const wp = floor.waitPoint;
|
||||
if (wp && wp.restricted && !wp.allows(g)) {
|
||||
receipts.play('invalid');
|
||||
confusion.recordInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 6. 游戏处理 + 回执收口 ----
|
||||
const r = game.onGesture(g, info) || 'ok';
|
||||
if (r === 'invalid') {
|
||||
receipts.play('invalid');
|
||||
const state = game.stateName ? game.stateName() : '';
|
||||
invalidHints.explainOnce(`${state}:${g}`, game.invalidHint ? game.invalidHint(state, g) : null);
|
||||
confusion.recordInvalid();
|
||||
// 等待点仍在(无效操作没完成它):静默计时复位、回到等待态(含打断后的 BUSY)
|
||||
if (floor.waitPoint && !floor.speaking) floor.settleWaitPoint(false);
|
||||
return;
|
||||
}
|
||||
if (g === 'tap' && r !== 'hold' && preState !== 'ACTION') {
|
||||
lastTapActedAt = performance.now(); // tap「继续」进防连点窗口
|
||||
}
|
||||
if (r !== 'silent-ok') receipts.play('ok');
|
||||
if (floor.state === 'YOUR_TURN') floor.settleWaitPoint(r !== 'hold');
|
||||
};
|
||||
}
|
||||
|
||||
export default makeInputPipeline;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* receipts.js — 输入回执三件套(26 号文档 §4.2,P0 第 2 项)。
|
||||
*
|
||||
* 每次玩家操作 100ms 内出声,告知「这下算没算」。三种回执(合成参数为文档起步值):
|
||||
* | kind | 用在哪里 | 听感 |
|
||||
* |----------|------------------------|----------------------|
|
||||
* | ok | 操作被接受并生效 | 清脆的「嗒」 |
|
||||
* | invalid | 收到了,但现在用不上 | 低沉柔和的「咚」 |
|
||||
* | busy | 关键句在说 / 正在处理 | 很轻的「嗯」 |
|
||||
*
|
||||
* 规则(文档 §4.2 / 原则 4):
|
||||
* - 回执由引擎统一播,游戏只告诉管道这次操作的结果是哪一种(见 pipeline.js)。
|
||||
* - 游戏已为这次操作播了明显内容音(脚步、开门)时可以省 ok,invalid/busy 永远要播。
|
||||
* - 音色避开 guide.js 的 turn(1318Hz 风铃)/ nudge(880Hz 木鱼);系统音一律合成音
|
||||
* (原则 10),接 master 总线吃限幅、不被 BGM duck 压低(§4.11 优先级 1)。
|
||||
* - 任何设备都出声,不依赖震动;震动由 gesture/haptics 照常叠加。
|
||||
*/
|
||||
|
||||
/** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */
|
||||
const SPECS = {
|
||||
// 三角波 1600Hz,35ms,起音 2ms,快速衰减,增益 0.08 —— 清脆的「嗒」
|
||||
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.08 },
|
||||
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms,增益 0.10 —— 低沉柔和的「咚」
|
||||
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.10 },
|
||||
// 正弦波 520Hz,40ms,增益 0.04 —— 很轻的「嗯」
|
||||
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.04 },
|
||||
};
|
||||
|
||||
export class Receipts {
|
||||
/** @param {import('./audio.js').WebAudioEngine} audio */
|
||||
constructor(audio) {
|
||||
this.audio = audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播一种回执。未解锁 / 无 ctx(playtest mock)时静默跳过。
|
||||
* @param {'ok'|'invalid'|'busy'} kind
|
||||
*/
|
||||
play(kind) {
|
||||
const spec = SPECS[kind];
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!spec || !ctx || !this.audio._master || !this.audio.unlocked) return;
|
||||
const t0 = ctx.currentTime + 0.005;
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = spec.wave;
|
||||
osc.frequency.setValueAtTime(spec.from, t0);
|
||||
if (spec.to !== spec.from) osc.frequency.linearRampToValueAtTime(spec.to, t0 + spec.dur);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(spec.gain, t0 + spec.attack);
|
||||
// 快速衰减:attack 后线性落到接近 0(听感接近指数衰减)
|
||||
g.gain.linearRampToValueAtTime(spec.gain * 0.35, t0 + spec.attack + (spec.dur - spec.attack) * 0.45);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + spec.dur);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + spec.dur + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
export default Receipts;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user