diff --git a/docs/design/14-guide-layer.md b/docs/design/14-guide-layer.md index b48b1af3..e26b3e28 100644 --- a/docs/design/14-guide-layer.md +++ b/docs/design/14-guide-layer.md @@ -184,3 +184,65 @@ 其后再走两步;simon 看门狗提示上滑重听 + 期望恰为 up 时上滑作答 + 双击音 双击作答,全程无双击歧义时刻;space 每次撞击前 500ms 警报与「左!/右!/上!/ 下!」同窗到达。 + +--- + +# 14 · 向导交互层设计裁定(round4 v1.3 增补) + +> 依据:research/04-playtests/round3/synthesis.md(H1~H5 + §3 新语音表,唯一事实源)。 +> 本文只做**追加**,上文 v1 / round2 / round3 裁定不回改;实现载体同前 +> (`web/engine/guide.js` + 各游戏 game.js 接线),全部改动已落地。 + +## 11. round4 裁定落地摘要(H1~H5) + +| # | 裁定 | 落地 | +|---|---|---| +| H1 | escape 目标带方位 | 目标句接线点盘点:**found_key**(储物间拾钥匙→cabinet (2,1))、**gd_next_fuse**(开柜得保险丝→box (0,0))、**fix_box**(修电箱通电→gate (2,2))三处,句尾接播 `gd_room_l/r/u/d`(游戏反馈层全档位,gd_next_fuse 先例)。方位按房间图对当前坐标实时计算(x 0西..2东 / y 0北..2南;方向语义 = escape 移动坐标系,up/down/left/right 滑动方向即方位);斜对角只播一句、轴序与 `_navVoices` 一致(先东西后南北);目标在同一房间不播。**引导期 g7_goal 不接**:引导本身逐句带路(g3_left/g4_down),方位句冗余,且引导链被 escape playtest 场景 A/D 的 `finishVoices(3)→PLAY` 断言冻结(断言零改动红线)。 | +| H2 | simon 占用手势预告 | `_playSequence` 序列播完 400ms 的「轮到你了」回调里按本轮序列插占用预告链:含 up→`gd_occ_up`、含 doubletap→`gd_occ_dt`、两者皆有→`gd_occ_both`(都不含则不播、直达 input);链尾接第 1 轮 your_turn → `_toInput`(`_speakGuarded`,每句 2.5s 缺失兜底)。游戏反馈层全档位,插在轮次链中(**预告先于「轮到你」**)。被占用轮次(序列含 up)input 看门狗 `say` 动态置空——只发 nudge earcon 不播重听建议句(实现从简,say 按 arm 时刻序列选择);仅 doubletap 占用不影响建议(建议只关联上滑重听键)。 | +| H3 | simon 重听账目真实 | **诊断结论:教学/演练/看门狗均无隐性扣减**(教学期不触 chances;watchdog 提示不消耗机会——guide.test 既有断言)。缺口在**初始发放**:`_beginGame()` chances=1,而教学句 gd_economy 承诺「免费机会有两次」——第二次免费要打完第 4 轮才发放(每 4 轮 +1、上限 2),P1 在第 1 轮用掉唯一一次即听到绝对化「用完了」,账面对不上。**修法=使用时如实报余量**:`_relisten()` 消耗后余 1 播 `gd_chances_left1`、余 0 播 `gd_chances_left0`(替换该路径旧「用完了」句,全档位)——「用完了」从此只在真实余量为 0 时出现。答错自动消耗(retry_use + chances_out)与余 0 再求重听的拒绝(chances_out)两处保留:前者是 t7_retry 明说过的「答错可以消耗机会重听」的机会账、后者播报时点余量确为 0;且两处被 simon playtest 断言锁定(零改动红线)。初始发放数值 1 不动(playtest「初始 1 次重听机会」断言冻结;「不动玩法数值」红线仅豁免 H3 的账目真实化,不做经济重发)。 | +| H4 | simon input 看门狗首级提前 | `GUIDE_INPUT_WATCHDOG_AT` [10000,25000]→**[5000,25000]**:仅 input 首级提前(P4「10 秒干等」),升级级 25s 与两级次数不变;该常量只用于 input 看门狗,非 input 阶段不动。 | +| H5 | rhythm 归因真话 | `_tutEvaluate` 教学首次失败归因改四档:先判**应答窗口无任何尝试**(任一 cue 在窗内无尝试——出窗的自由演奏不算尝试)→ `gd_miss_none`「这拍没接」(none 优先级最高);真出手再分 wrong/early/late(沿用 `_tutAttempts` 判据)。纠正链(retry_*)不变;正片连 2 Miss 仍 `gd_miss`(`_applyMiss` 不动)。 | + +## 12. round4 新增语音资产台词表(10 句 ×2 性别 = 20 文件) + +全文与 synthesis §3 逐字一致(speaker/instruct 沿用各游戏旁白 pair 常量): + +| 游戏 | id | 全文 | 触发 | +|---|---|---|---| +| escape | `gd_room_l` / `gd_room_r` / `gd_room_u` / `gd_room_d` | 左面的房间。/ 右面的房间。/ 上面的房间。/ 下面的房间。 | 目标句(found_key/gd_next_fuse/fix_box)播完后接播(H1,全档位) | +| simon | `gd_occ_up` | 这一轮,向上滑是作答。 | 序列含 up(H2,全档位) | +| simon | `gd_occ_dt` | 这一轮,双击是作答。 | 序列含 doubletap(H2,全档位) | +| simon | `gd_occ_both` | 这一轮,向上滑和双击都是作答。 | 两者皆含(H2,全档位) | +| simon | `gd_chances_left1` | 免费重听还剩一次。 | 免费重听用掉后余 1(H3,全档位) | +| simon | `gd_chances_left0` | 免费重听用完了。之后重听会花一次机会,别怕,慢慢来。 | 用掉后余 0(H3,全档位) | +| rhythm | `gd_miss_none` | 这拍没接。我停下的时候,就轮到你了。 | 教学应答窗口无尝试(H5,full 档,guide.error 通道) | + +资产落位与管线同前:`tools/gen_voice_dual.py --game --only ` GPU 逐条生成 → +`--convert` 落盘 → `tools/check_all_assets.py` 终检。 + +## 13. round4 验证记录 + +- 6 playtest(guide=off)**断言零改动全绿**:storyline 54 / escape 103 / simon 108 / + rhythm 81 / space 113 / rpg 139(与 round3 基线一致)。 +- `web/harness/guide.test.mjs` **120 条断言全绿**(round3 基线 93 条中仅 ② 块两条 + input 看门狗时刻断言随 H4 10s→5s 更新,并固定第 1 轮序列以隔离占用预告的动态 + say;其余既有断言零改动):新增 H 组 27 条——H1 方向映射按图校验(l/r/u/d + + 同房间 null)+ 三处目标句接线在真实解谜链上验证;H2 三种占用预告 + 预告先于 + 「轮到你」+ up 占用轮看门狗无建议句 + 仅 dt 占用不影响建议;H3 余量句与真实 + 余量一致(2→1 播 left1、1→0 播 left0、余 0 拒绝仍 chances_out);H4 首级 5s + (升级级不变);H5 无尝试 gd_miss_none 且不再误报 late/wrong。 +- `tools/check_all_assets.py` 全绿(双格式配对、无 0 字节、全可解码、双性别集合 + 一致);`tools/extract_voice_text.py` 已刷新 `web/harness/voice_text.json` + (447 句)。`gen_voice_dual.py --check` 新增 10 句无时长离群;rpg/tut_b0 等 + 4 句「缺 staging」为 R5 既有已知项,非本轮引入。 +- 转写抽查(research/04-playtests/transcripts/,6 份全部重跑、零资产缺失): + simon——R1 占用预告 t+60.1 先于「轮到你了」t+62.2,5s 首级(t+69.3)与 25s + 升级(t+89.3)两级均无重听建议、期望恰为 up 时上滑作答;R5/R7 建议句 5s 准时 + (t+125.4 / t+179.7),两次免费重听分别跟播「免费重听还剩一次」(t+127.5)与 + 「免费重听用完了。之后重听会花一次机会」(t+181.8),收工态余量 0 与播报一致; + escape—— + gd_next_fuse(t+64.2)后 2.2s 接「左面的房间。」(t+66.4)、fix_box(t+85.0)后 + 2.1s 接「右面的房间。」(t+87.1),均在目标句后 3s 内,剧本照方位句多跨四个 + 房间摸到大门;rhythm——教学整拍不出手归因「这拍没接。我停下的时候,就轮到你了」 + (t+23.2)而非「慢了半拍」,正片首句全漏仍 gd_miss(t+71.6,正片规则不变); + storyline/space/rpg 行为零改动。 diff --git a/research/04-playtests/transcripts/escape.md b/research/04-playtests/transcripts/escape.md index 43689af7..5300fc13 100644 --- a/research/04-playtests/transcripts/escape.md +++ b/research/04-playtests/transcripts/escape.md @@ -109,15 +109,39 @@ t+ 62.1s ♪ 音效 sfx/door_creak t+ 62.1s ▶ 语音:「钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。」 t+ 62.1s 〰 震动:success(成功长震) t+ 64.2s ▶ 向导语音:「保险丝到手。接下来,去找电箱。」 -t+ 66.4s 【状态】柜子开了,听到「保险丝到手。接下来,去找电箱」——目标又有了 探索中,我在 (2,1)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone,语速1) -t+ 66.4s ♪ 音效 sfx/step -t+ 66.4s ♪ 音效 sfx/amb_drip +t+ 66.4s ▶ 向导语音:「左面的房间。」 +t+ 68.5s 【状态】柜子开了。「保险丝到手。接下来,去找电箱。」后面还跟了一句「左面的房间。」——这回知道该往哪边摸了 探索中,我在 (2,1)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone,语速1) +t+ 68.5s ♪ 音效 sfx/step +t+ 68.5s ♪ 音效 sfx/amb_drip -#### 【玩家动作】照新目标往西摸 +#### 【玩家动作】照方位句往左(西)摸 -t+ 70.0s ♪ 音效 sfx/step -t+ 70.0s ♪ 音效 sfx/amb_hum +t+ 75.0s ♪ 音效 sfx/step +t+ 75.0s ♪ 音效 sfx/amb_hum +t+ 80.0s ♪ 音效 sfx/step +t+ 80.0s ♪ 音效 sfx/amb_power -#### 【玩家动作】再走一步,西边隐隐有电流声——电箱在那边 +#### 【玩家动作】电流声越来越清晰——电箱房到了 -t+ 75.0s 【状态】解到第二间、照下一目标句又摸了两步,收工 探索中,我在 (0,1)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone,语速1) +t+ 85.0s ♪ 音效 sfx/spark +t+ 85.0s ♪ 音效 sfx/power_on +t+ 85.0s ▶ 语音:「你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!」 +t+ 85.0s 〰 震动:success(成功长震) +t+ 87.1s ▶ 向导语音:「右面的房间。」 +t+ 95.0s 【状态】电箱修好了。目标句后面照样跟着方位——「右面的房间。」大门在东边 探索中,我在 (0,0)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone,语速1) +t+ 95.0s ♪ 音效 sfx/step +t+ 95.0s ♪ 音效 sfx/amb_wind + +#### 【玩家动作】照方位句往右(东)摸 + +t+ 100.0s ♪ 音效 sfx/step +t+ 100.0s ♪ 音效 sfx/amb_hum +t+ 105.0s ♪ 音效 sfx/step +t+ 105.0s ♪ 音效 sfx/amb_cabinet +t+ 110.0s ♪ 音效 sfx/step +t+ 110.0s ♪ 音效 sfx/amb_gate_live +t+ 110.0s ▶ 语音:「面前,是一扇沉重的铁门。」 + +#### 【玩家动作】脚步声停下来——面前这间就是大门 + +t+ 112.1s 【状态】解到通电、照目标句的方位跨了四个房间摸到大门,收工 探索中,我在 (2,2)。四向滑挪步,单击摸一遍房间,双击用物品(BGM=bgm/drone,语速1) diff --git a/research/04-playtests/transcripts/rhythm.md b/research/04-playtests/transcripts/rhythm.md index b646ff66..61627703 100644 --- a/research/04-playtests/transcripts/rhythm.md +++ b/research/04-playtests/transcripts/rhythm.md @@ -16,9 +16,9 @@ t+ 14.0s 〰 震动:beat(节拍点) t+ 15.3s 〰 震动:beat(节拍点) t+ 19.0s ▶ 语音:「再听一遍,跟着来。」 t+ 19.0s 〰 震动:error(做错了:两连短震) -t+ 19.0s 【状态】第一遍没敢出手,先听它归因 教学:听领奏,下一小节照样打(left→right)(语音播放中,语速1) +t+ 19.0s 【状态】第一遍整拍没出手——听到的是「这拍没接。我停下的时候,就轮到你了」,不是「慢了半拍」 教学:听领奏,下一小节照样打(left→right)(语音播放中,语速1) t+ 21.1s ▶ 语音:「再听一遍,跟着来。」 -t+ 23.2s ▶ 向导语音:「慢了半拍。跟着节拍走。」 +t+ 23.2s ▶ 向导语音:「这拍没接。我停下的时候,就轮到你了。」 t+ 23.2s 【状态】又重新领奏了一遍,还是没敢出手 教学:听领奏,下一小节照样打(left→right)(语音播放中,语速1) t+ 24.7s 〰 震动:beat(节拍点) t+ 26.0s 〰 震动:beat(节拍点) diff --git a/research/04-playtests/transcripts/simon.md b/research/04-playtests/transcripts/simon.md index 3c0ecfca..22068306 100644 --- a/research/04-playtests/transcripts/simon.md +++ b/research/04-playtests/transcripts/simon.md @@ -52,72 +52,224 @@ t+ 49.8s ▶ 语音:「六种声音都记住了。后面的序列,会越 t+ 51.9s ▶ 语音:「答错可以消耗机会重听。向上滑,可以随时重听。」 t+ 54.0s ▶ 向导语音:「记不住就向上滑,重新听一遍。免费机会有两次,用完要扣一次机会。」 t+ 56.2s ▶ 语音:「第一轮,听好了。」 -t+ 58.3s ♪ 音效 sfx/tone_tap +t+ 56.2s 【状态】教学讲完了:免费重听两次、用完扣机会,想重听就向上滑 第 1 轮报数呢,听好了(语音播放中,语速1) +t+ 58.3s ♪ 音效 sfx/tone_up t+ 58.3s 〰 震动:beat(节拍点) -t+ 58.3s 【状态】教学讲完了:免费重听两次、用完扣机会,想重听就向上滑 第 1 轮序列演奏中(2 个音),记住顺序,先别动手(无 BGM,语速1) -t+ 59.0s ♪ 音效 sfx/tone_doubletap +t+ 59.0s ♪ 音效 sfx/tone_tap t+ 59.0s 〰 震动:beat(节拍点) t+ 60.1s ♪ 向导音:turn(轮到你) -t+ 60.1s ▶ 语音:「轮到你了。」 +t+ 60.1s ▶ 向导语音:「这一轮,向上滑是作答。」 t+ 60.1s 〰 震动:confirm(轻点确认) -t+ 62.2s 【状态】第 1 轮有点懵,先僵一会儿 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 62.2s ……… 静默 10s(想不起来序列了) ……… -t+ 72.2s ♪ 向导音:nudge(看门狗轻提醒) -t+ 72.2s ▶ 向导语音:「需要的话,向上滑可以重新听一遍序列。」 -t+ 72.2s 【状态】听到提示:需要的话,向上滑可以重新听一遍序列 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(语音播放中,语速1) +t+ 62.2s ▶ 语音:「轮到你了。」 +t+ 64.3s 【状态】序列一播完就听到「这一轮,向上滑是作答」——重听键这轮被占了 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(这轮上滑是作答,占用了)(要重听就向上滑)(无 BGM,语速1) +t+ 64.3s ……… 静默 5s(第一轮先不动手,看看它催不催) ……… +t+ 69.3s ♪ 向导音:nudge(看门狗轻提醒) +t+ 69.3s ♪ 向导音:watchdog(看门狗升级) +t+ 89.3s ♪ 向导音:nudge(看门狗轻提醒) +t+ 89.3s ♪ 向导音:watchdog(看门狗升级) +t+ 89.3s ♪ 向导音:turn(轮到你) +t+ 89.3s 〰 震动:confirm(轻点确认) -#### 【玩家动作】照提示向上滑,用掉一次免费重听 +#### 【玩家动作】5 秒一声轻提醒、25 秒又一声加确认震——两级提醒都没建议「向上滑重听」,它知道这轮上滑是作答 -t+ 72.2s ▶ 语音:「再听一遍。」 -t+ 72.2s 〰 震动:pickup(获得) -t+ 74.3s ▶ 语音:「重听机会用完了,小心。」 -t+ 76.4s ♪ 音效 sfx/tone_tap -t+ 76.4s 〰 震动:beat(节拍点) -t+ 77.1s ♪ 音效 sfx/tone_doubletap -t+ 77.1s 〰 震动:beat(节拍点) -t+ 78.2s ♪ 向导音:turn(轮到你) -t+ 78.2s ▶ 语音:「轮到你了。」 -t+ 78.2s 〰 震动:confirm(轻点确认) -t+ 80.4s 【状态】轮到我复现了 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 80.4s ♪ 音效 sfx/tone_tap -t+ 80.4s 【状态】轮到我复现了 第 1 轮轮到你了:已对上 1/2,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 80.4s ♪ 音效 sfx/tone_doubletap -t+ 80.4s ▶ 向导语音:「漂亮,过了。」 -t+ 80.4s 〰 震动:success(成功长震) -t+ 82.8s ▶ 语音:「第二轮,听好了。」 -t+ 84.9s ♪ 音效 sfx/tone_tap -t+ 84.9s 〰 震动:beat(节拍点) -t+ 85.6s ♪ 音效 sfx/tone_doubletap -t+ 85.6s 〰 震动:beat(节拍点) -t+ 86.2s ♪ 音效 sfx/tone_up -t+ 86.2s 〰 震动:beat(节拍点) -t+ 87.3s ♪ 向导音:turn(轮到你) -t+ 87.3s 〰 震动:confirm(轻点确认) -t+ 87.3s 【状态】这个是上行音——上滑作答(期望恰为 up 时上滑=作答) 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 87.3s ♪ 音效 sfx/tone_up -t+ 87.3s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 1/3,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 87.3s ♪ 音效 sfx/tone_doubletap -t+ 87.3s 【状态】轮到我复现了 第 2 轮轮到你了:已对上 2/3,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 87.3s ♪ 音效 sfx/tone_up -t+ 87.3s 〰 震动:success(成功长震) -t+ 87.6s ▶ 语音:「第三轮,听好了。」 -t+ 89.7s ♪ 音效 sfx/tone_up -t+ 89.7s 〰 震动:beat(节拍点) -t+ 90.4s ♪ 音效 sfx/tone_doubletap -t+ 90.4s 〰 震动:beat(节拍点) -t+ 91.0s ♪ 音效 sfx/tone_up -t+ 91.7s ♪ 音效 sfx/tone_right -t+ 91.0s 〰 震动:beat(节拍点) -t+ 91.7s 〰 震动:beat(节拍点) -t+ 92.7s ♪ 向导音:turn(轮到你) -t+ 92.7s 〰 震动:confirm(轻点确认) -t+ 92.7s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 0/4,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 92.7s ♪ 音效 sfx/tone_up -t+ 92.7s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 1/4,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 92.7s ♪ 音效 sfx/tone_doubletap -t+ 92.7s 【状态】轮到我复现了 第 3 轮轮到你了:已对上 2/4,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 92.7s ♪ 音效 sfx/tone_up -t+ 92.7s 【状态】这个是双击音——双击作答(round3:input 双击永远=作答) 第 3 轮轮到你了:已对上 3/4,照原顺序做手势(双击就是双击那个音,要重听就向上滑)(无 BGM,语速1) -t+ 92.7s ♪ 音效 sfx/tone_doubletap -t+ 92.7s 〰 震动:success(成功长震) -t+ 92.7s 【状态】收工:完整打完 3 轮,在轮与轮的边界停手(免费重听剩 0 次) 刚答完一轮,播报中(无 BGM,语速1) +t+ 89.3s 【状态】先来一发上滑——期望恰为 up 时上滑就是作答 第 1 轮轮到你了:已对上 0/2,照原顺序做手势(这轮上滑是作答,占用了)(要重听就向上滑)(无 BGM,语速1) +t+ 89.3s ♪ 音效 sfx/tone_up +t+ 89.3s ♪ 音效 sfx/tone_tap +t+ 89.3s ▶ 向导语音:「漂亮,过了。」 +t+ 89.3s 〰 震动:success(成功长震) +t+ 91.7s ▶ 语音:「第二轮,听好了。」 +t+ 93.9s ♪ 音效 sfx/tone_down +t+ 93.9s 〰 震动:beat(节拍点) +t+ 94.5s ♪ 音效 sfx/tone_doubletap +t+ 94.5s 〰 震动:beat(节拍点) +t+ 95.2s ♪ 音效 sfx/tone_left +t+ 95.2s 〰 震动:beat(节拍点) +t+ 96.3s ♪ 向导音:turn(轮到你) +t+ 96.3s ▶ 向导语音:「这一轮,双击是作答。」 +t+ 96.3s 〰 震动:confirm(轻点确认) +t+ 98.4s 【状态】这轮预告的是「双击是作答」 第 2 轮轮到你了:已对上 0/3,照原顺序做手势(这轮双击是作答,占用了)(要重听就向上滑)(无 BGM,语速1) +t+ 98.4s ♪ 音效 sfx/tone_down +t+ 98.4s ♪ 音效 sfx/tone_doubletap +t+ 98.4s ♪ 音效 sfx/tone_left +t+ 98.4s 〰 震动:success(成功长震) +t+ 98.7s ▶ 语音:「第三轮,听好了。」 +t+ 100.8s ♪ 音效 sfx/tone_down +t+ 100.8s 〰 震动:beat(节拍点) +t+ 101.5s ♪ 音效 sfx/tone_right +t+ 101.5s 〰 震动:beat(节拍点) +t+ 102.1s ♪ 音效 sfx/tone_tap +t+ 102.8s ♪ 音效 sfx/tone_left +t+ 102.1s 〰 震动:beat(节拍点) +t+ 102.8s 〰 震动:beat(节拍点) +t+ 103.8s ♪ 向导音:turn(轮到你) +t+ 103.8s 〰 震动:confirm(轻点确认) +t+ 103.8s 【状态】这轮开场没有占用预告——上滑还能当重听键用 第 3 轮轮到你了:已对上 0/4,照原顺序做手势(要重听就向上滑)(无 BGM,语速1) +t+ 103.8s ♪ 音效 sfx/tone_down +t+ 103.8s ♪ 音效 sfx/tone_right +t+ 103.8s ♪ 音效 sfx/tone_tap +t+ 103.8s ♪ 音效 sfx/tone_left +t+ 103.8s 〰 震动:success(成功长震) +t+ 104.1s ▶ 语音:「第四轮,听好了。」 +t+ 106.2s ♪ 音效 sfx/tone_down +t+ 106.9s ♪ 音效 sfx/tone_right +t+ 106.2s 〰 震动:beat(节拍点) +t+ 106.9s 〰 震动:beat(节拍点) +t+ 107.5s ♪ 音效 sfx/tone_tap +t+ 107.5s 〰 震动:beat(节拍点) +t+ 108.1s ♪ 音效 sfx/tone_left +t+ 108.7s ♪ 音效 sfx/tone_down +t+ 108.1s 〰 震动:beat(节拍点) +t+ 108.7s 〰 震动:beat(节拍点) +t+ 109.7s ♪ 向导音:turn(轮到你) +t+ 109.7s 〰 震动:confirm(轻点确认) +t+ 109.7s 【状态】机会加一之后,免费重听真的有两次了 第 4 轮轮到你了:已对上 0/5,照原顺序做手势(要重听就向上滑)(无 BGM,语速1) +t+ 109.7s ♪ 音效 sfx/tone_down +t+ 109.7s ♪ 音效 sfx/tone_right +t+ 109.7s ♪ 音效 sfx/tone_tap +t+ 109.7s ♪ 音效 sfx/tone_left +t+ 109.7s ♪ 音效 sfx/tone_down +t+ 109.7s ▶ 语音:「答对了!」 +t+ 109.7s 〰 震动:success(成功长震) +t+ 109.7s 〰 震动:pickup(获得) +t+ 111.9s ▶ 语音:「机会加一。」 +t+ 114.3s ▶ 语音:「第五轮,听好了。」 +t+ 116.4s ♪ 音效 sfx/tone_down +t+ 116.4s 〰 震动:beat(节拍点) +t+ 117.0s ♪ 音效 sfx/tone_right +t+ 117.6s ♪ 音效 sfx/tone_tap +t+ 117.0s 〰 震动:beat(节拍点) +t+ 117.6s 〰 震动:beat(节拍点) +t+ 118.2s ♪ 音效 sfx/tone_left +t+ 118.8s ♪ 音效 sfx/tone_down +t+ 118.2s 〰 震动:beat(节拍点) +t+ 118.8s 〰 震动:beat(节拍点) +t+ 119.4s ♪ 音效 sfx/tone_right +t+ 119.4s 〰 震动:beat(节拍点) +t+ 120.4s ♪ 向导音:turn(轮到你) +t+ 120.4s 〰 震动:confirm(轻点确认) +t+ 120.4s 【状态】第 5 轮序列有点长,先不动手 第 5 轮轮到你了:已对上 0/6,照原顺序做手势(要重听就向上滑)(无 BGM,语速1) +t+ 120.4s ……… 静默 5s(想不起来序列了) ……… +t+ 125.4s ♪ 向导音:nudge(看门狗轻提醒) +t+ 125.4s ▶ 向导语音:「需要的话,向上滑可以重新听一遍序列。」 +t+ 125.4s 【状态】5 秒就来了提示:需要的话,向上滑可以重新听一遍序列 第 5 轮轮到你了:已对上 0/6,照原顺序做手势(要重听就向上滑)(语音播放中,语速1) + +#### 【玩家动作】照提示向上滑——第 1 次免费重听用掉,跟着听到「免费重听还剩一次」 + +t+ 125.4s ▶ 语音:「再听一遍。」 +t+ 125.4s 〰 震动:pickup(获得) +t+ 127.5s ▶ 向导语音:「免费重听还剩一次。」 +t+ 129.6s ♪ 音效 sfx/tone_down +t+ 129.6s 〰 震动:beat(节拍点) +t+ 130.2s ♪ 音效 sfx/tone_right +t+ 130.8s ♪ 音效 sfx/tone_tap +t+ 130.2s 〰 震动:beat(节拍点) +t+ 130.8s 〰 震动:beat(节拍点) +t+ 131.4s ♪ 音效 sfx/tone_left +t+ 131.4s 〰 震动:beat(节拍点) +t+ 132.0s ♪ 音效 sfx/tone_down +t+ 132.6s ♪ 音效 sfx/tone_right +t+ 132.0s 〰 震动:beat(节拍点) +t+ 132.6s 〰 震动:beat(节拍点) +t+ 133.6s ♪ 向导音:turn(轮到你) +t+ 133.6s 〰 震动:confirm(轻点确认) +t+ 133.6s ♪ 音效 sfx/tone_down +t+ 133.6s ♪ 音效 sfx/tone_right +t+ 133.6s ♪ 音效 sfx/tone_tap +t+ 133.6s ♪ 音效 sfx/tone_left +t+ 133.6s ♪ 音效 sfx/tone_down +t+ 133.6s ♪ 音效 sfx/tone_right +t+ 133.6s 〰 震动:success(成功长震) +t+ 133.9s ▶ 语音:「第六轮,听好了。」 +t+ 136.0s ♪ 音效 sfx/tone_up +t+ 136.6s ♪ 音效 sfx/tone_doubletap +t+ 136.0s 〰 震动:beat(节拍点) +t+ 136.6s 〰 震动:beat(节拍点) +t+ 137.2s ♪ 音效 sfx/tone_tap +t+ 137.7s ♪ 音效 sfx/tone_left +t+ 137.2s 〰 震动:beat(节拍点) +t+ 137.7s 〰 震动:beat(节拍点) +t+ 138.3s ♪ 音效 sfx/tone_down +t+ 138.9s ♪ 音效 sfx/tone_right +t+ 138.3s 〰 震动:beat(节拍点) +t+ 138.9s 〰 震动:beat(节拍点) +t+ 139.4s ♪ 音效 sfx/tone_tap +t+ 139.4s 〰 震动:beat(节拍点) +t+ 140.4s ♪ 向导音:turn(轮到你) +t+ 140.4s ▶ 向导语音:「这一轮,向上滑和双击都是作答。」 +t+ 140.4s 〰 震动:confirm(轻点确认) +t+ 142.5s 【状态】这轮说「向上滑和双击都是作答」——两个键都被占用了 第 6 轮轮到你了:已对上 0/7,照原顺序做手势(这轮上滑和双击是作答,占用了)(要重听就向上滑)(无 BGM,语速1) +t+ 142.5s ……… 静默 5s(轮到我了,先心里过一遍序列) ……… +t+ 147.5s ♪ 向导音:nudge(看门狗轻提醒) +t+ 147.5s ♪ 向导音:watchdog(看门狗升级) +t+ 167.5s ♪ 向导音:nudge(看门狗轻提醒) +t+ 167.5s ♪ 向导音:watchdog(看门狗升级) +t+ 167.5s ♪ 向导音:turn(轮到你) +t+ 167.5s 〰 震动:confirm(轻点确认) + +#### 【玩家动作】又是两级提醒都没有重听建议——占用轮里那句建议自动闭嘴 + +t+ 167.5s ♪ 音效 sfx/tone_up +t+ 167.5s ♪ 音效 sfx/tone_doubletap +t+ 167.5s ♪ 音效 sfx/tone_tap +t+ 167.5s ♪ 音效 sfx/tone_left +t+ 167.5s ♪ 音效 sfx/tone_down +t+ 167.5s ♪ 音效 sfx/tone_right +t+ 167.5s ♪ 音效 sfx/tone_tap +t+ 167.5s 〰 震动:success(成功长震) +t+ 167.8s ▶ 语音:「第七轮,听好了。」 +t+ 169.9s ♪ 音效 sfx/tone_down +t+ 169.9s 〰 震动:beat(节拍点) +t+ 170.5s ♪ 音效 sfx/tone_right +t+ 170.5s 〰 震动:beat(节拍点) +t+ 171.0s ♪ 音效 sfx/tone_tap +t+ 171.6s ♪ 音效 sfx/tone_left +t+ 171.0s 〰 震动:beat(节拍点) +t+ 171.6s 〰 震动:beat(节拍点) +t+ 172.1s ♪ 音效 sfx/tone_down +t+ 172.7s ♪ 音效 sfx/tone_right +t+ 172.1s 〰 震动:beat(节拍点) +t+ 172.7s 〰 震动:beat(节拍点) +t+ 173.2s ♪ 音效 sfx/tone_tap +t+ 173.8s ♪ 音效 sfx/tone_left +t+ 173.2s 〰 震动:beat(节拍点) +t+ 173.8s 〰 震动:beat(节拍点) +t+ 174.7s ♪ 向导音:turn(轮到你) +t+ 174.7s 〰 震动:confirm(轻点确认) +t+ 174.7s ……… 静默 5s(长序列,又想不起来了) ……… +t+ 179.7s ♪ 向导音:nudge(看门狗轻提醒) +t+ 179.7s ▶ 向导语音:「需要的话,向上滑可以重新听一遍序列。」 +t+ 179.7s 【状态】又是那句提示:向上滑可以重新听一遍序列 第 7 轮轮到你了:已对上 0/8,照原顺序做手势(要重听就向上滑)(语音播放中,语速1) + +#### 【玩家动作】向上滑——第 2 次免费重听用掉,这回听到「免费重听用完了。之后重听会花一次机会」 + +t+ 179.7s ▶ 语音:「再听一遍。」 +t+ 179.7s 〰 震动:pickup(获得) +t+ 181.8s ▶ 向导语音:「免费重听用完了。之后重听会花一次机会,别怕,慢慢来。」 +t+ 183.9s ♪ 音效 sfx/tone_down +t+ 183.9s 〰 震动:beat(节拍点) +t+ 184.5s ♪ 音效 sfx/tone_right +t+ 184.5s 〰 震动:beat(节拍点) +t+ 185.0s ♪ 音效 sfx/tone_tap +t+ 185.6s ♪ 音效 sfx/tone_left +t+ 185.0s 〰 震动:beat(节拍点) +t+ 185.6s 〰 震动:beat(节拍点) +t+ 186.1s ♪ 音效 sfx/tone_down +t+ 186.7s ♪ 音效 sfx/tone_right +t+ 186.1s 〰 震动:beat(节拍点) +t+ 186.7s 〰 震动:beat(节拍点) +t+ 187.2s ♪ 音效 sfx/tone_tap +t+ 187.7s ♪ 音效 sfx/tone_left +t+ 187.2s 〰 震动:beat(节拍点) +t+ 187.7s 〰 震动:beat(节拍点) +t+ 188.7s ♪ 向导音:turn(轮到你) +t+ 188.7s 〰 震动:confirm(轻点确认) +t+ 188.7s ♪ 音效 sfx/tone_down +t+ 188.7s ♪ 音效 sfx/tone_right +t+ 188.7s ♪ 音效 sfx/tone_tap +t+ 188.7s ♪ 音效 sfx/tone_left +t+ 188.7s ♪ 音效 sfx/tone_down +t+ 188.7s ♪ 音效 sfx/tone_right +t+ 188.7s ♪ 音效 sfx/tone_tap +t+ 188.7s ♪ 音效 sfx/tone_left +t+ 188.7s 〰 震动:success(成功长震) +t+ 188.7s 【状态】收工:打完 7 轮在轮边界停手(免费重听剩 0 次,账目和播的对上了) 刚答完一轮,播报中(无 BGM,语速1) diff --git a/research/04-playtests/transcripts/space.md b/research/04-playtests/transcripts/space.md index 0e400397..324529d8 100644 --- a/research/04-playtests/transcripts/space.md +++ b/research/04-playtests/transcripts/space.md @@ -58,37 +58,37 @@ t+ 32.8s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 33.3s ♪ 音效 sfx/dodge -t+ 33.3s ♪ 音效 sfx/whoosh_down +t+ 33.3s ♪ 音效 sfx/whoosh_left t+ 36.3s ♪ 音效 sfx/alarm t+ 36.8s ♪ 音效 sfx/alarm -t+ 36.8s ▶ 语音:「下!」 +t+ 36.8s ▶ 语音:「左!」 t+ 36.3s 〰 震动:danger(危险逼近) t+ 36.8s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 37.3s ♪ 音效 sfx/dodge -t+ 37.3s ♪ 音效 sfx/whoosh_left +t+ 37.3s ♪ 音效 sfx/whoosh_up t+ 40.3s ♪ 音效 sfx/alarm t+ 40.8s ♪ 音效 sfx/alarm -t+ 40.8s ▶ 语音:「左!」 +t+ 40.8s ▶ 语音:「上!」 t+ 40.3s 〰 震动:danger(危险逼近) t+ 40.8s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 41.3s ♪ 音效 sfx/dodge -t+ 41.3s ♪ 音效 sfx/whoosh_right +t+ 41.3s ♪ 音效 sfx/whoosh_left t+ 44.3s ♪ 音效 sfx/alarm t+ 44.8s ♪ 音效 sfx/alarm -t+ 44.8s ▶ 语音:「右!」 +t+ 44.8s ▶ 语音:「左!」 t+ 44.3s 〰 震动:danger(危险逼近) t+ 44.8s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 45.3s ♪ 音效 sfx/dodge -t+ 45.3s ♪ 音效 sfx/whoosh_left +t+ 45.3s ♪ 音效 sfx/whoosh_down t+ 47.8s ▶ 语音:「试试单击,引擎会短促加速,直接冲过空隙。」 t+ 47.8s 【状态】游戏停下来教新功能 暂停了刷怪教新功能:单击试试加速(语音播放中,语速1) @@ -96,30 +96,30 @@ t+ 47.8s 【状态】游戏停下来教新功能 暂停了刷怪教新功能 t+ 49.9s ♪ 音效 sfx/boost t+ 49.9s ▶ 语音:「感觉到了吗?加速的时候,陨石撞不上你。」 -t+ 52.4s ♪ 音效 sfx/whoosh_up +t+ 52.4s ♪ 音效 sfx/whoosh_right t+ 55.4s ♪ 音效 sfx/alarm t+ 55.9s ♪ 音效 sfx/alarm -t+ 55.9s ▶ 语音:「上!」 +t+ 55.9s ▶ 语音:「右!」 t+ 55.4s 〰 震动:danger(危险逼近) t+ 55.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 56.4s ♪ 音效 sfx/dodge -t+ 56.4s ♪ 音效 sfx/whoosh_right +t+ 56.4s ♪ 音效 sfx/whoosh_up t+ 59.4s ♪ 音效 sfx/alarm t+ 59.9s ♪ 音效 sfx/alarm -t+ 59.9s ▶ 语音:「右!」 +t+ 59.9s ▶ 语音:「上!」 t+ 59.4s 〰 震动:danger(危险逼近) t+ 59.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 60.4s ♪ 音效 sfx/dodge -t+ 60.4s ♪ 音效 sfx/whoosh_down +t+ 60.4s ♪ 音效 sfx/whoosh_right t+ 63.4s ♪ 音效 sfx/alarm t+ 63.9s ♪ 音效 sfx/alarm -t+ 63.9s ▶ 语音:「下!」 +t+ 63.9s ▶ 语音:「右!」 t+ 63.4s 〰 震动:danger(危险逼近) t+ 63.9s 〰 震动:danger(危险逼近) @@ -136,10 +136,10 @@ t+ 67.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 68.4s ♪ 音效 sfx/dodge -t+ 68.4s ♪ 音效 sfx/whoosh_up +t+ 68.4s ♪ 音效 sfx/whoosh_left t+ 71.4s ♪ 音效 sfx/alarm t+ 71.9s ♪ 音效 sfx/alarm -t+ 71.9s ▶ 语音:「上!」 +t+ 71.9s ▶ 语音:「左!」 t+ 71.4s 〰 震动:danger(危险逼近) t+ 71.9s 〰 震动:danger(危险逼近) @@ -154,10 +154,10 @@ t+ 72.8s 【状态】游戏停下来教新功能 暂停了刷怪教新功能 t+ 74.9s ♪ 音效 sfx/shield t+ 74.9s ▶ 语音:「护盾展开后会自动充能,好了我会告诉你。」 -t+ 77.4s ♪ 音效 sfx/whoosh_right +t+ 77.4s ♪ 音效 sfx/whoosh_down t+ 80.4s ♪ 音效 sfx/alarm t+ 80.9s ♪ 音效 sfx/alarm -t+ 80.9s ▶ 语音:「右!」 +t+ 80.9s ▶ 语音:「下!」 t+ 80.4s 〰 震动:danger(危险逼近) t+ 80.9s 〰 震动:danger(危险逼近) @@ -165,72 +165,72 @@ t+ 80.9s 〰 震动:danger(危险逼近) t+ 81.4s ♪ 音效 sfx/dodge t+ 81.4s ▶ 语音:「十颗了,稳。」 -t+ 81.4s ♪ 音效 sfx/whoosh_right +t+ 81.4s ♪ 音效 sfx/whoosh_down t+ 81.4s 〰 震动:confirm(轻点确认) t+ 84.4s ♪ 音效 sfx/alarm t+ 84.9s ♪ 音效 sfx/alarm -t+ 84.9s ▶ 语音:「右!」 +t+ 84.9s ▶ 语音:「下!」 t+ 84.4s 〰 震动:danger(危险逼近) t+ 84.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 85.4s ♪ 音效 sfx/dodge -t+ 85.4s ♪ 音效 sfx/whoosh_right +t+ 85.4s ♪ 音效 sfx/whoosh_down t+ 87.8s ▶ 语音:「训练护盾关闭。接下来,靠你自己了。」 t+ 87.8s 〰 震动:warning(警告三连震) t+ 88.4s ♪ 音效 sfx/alarm t+ 88.9s ♪ 音效 sfx/alarm -t+ 88.9s ▶ 语音:「右!」 +t+ 88.9s ▶ 语音:「下!」 t+ 88.4s 〰 震动:danger(危险逼近) t+ 88.9s 〰 震动:danger(危险逼近) t+ 88.9s 【状态】保护期结束了,这颗我故意没动 对局中:1 颗陨石在逼近,听方位同向滑开(语音播放中,语速1) t+ 89.4s ♪ 音效 sfx/hit -t+ 89.4s ▶ 语音:「右边撞上的。听到右边的呼啸,向右滑。」 +t+ 89.4s ▶ 语音:「下面撞上的。听到低沉的轰隆,向下滑。」 t+ 89.4s 〰 震动:hit(受击重震) #### 【玩家动作】被撞了。先听到方位归因,又听到「同向滑动就能拨开」 -t+ 89.4s ♪ 音效 sfx/whoosh_up +t+ 89.4s ♪ 音效 sfx/whoosh_left t+ 91.2s ♪ 音效 sfx/alarm t+ 91.6s ▶ 向导语音:「被撞了。听方位,同向滑动就能拨开。」 t+ 91.7s ♪ 音效 sfx/alarm -t+ 91.7s ▶ 语音:「上!」 +t+ 91.7s ▶ 语音:「左!」 t+ 91.2s 〰 震动:danger(危险逼近) t+ 91.7s 〰 震动:danger(危险逼近) t+ 91.7s 【状态】又有一颗,也故意不躲——想听听第二次被撞会说什么 对局中:1 颗陨石在逼近,听方位同向滑开(语音播放中,语速1) t+ 92.2s ♪ 音效 sfx/hit -t+ 92.2s ▶ 语音:「上面撞上的。听到高高的呼啸,向上滑。」 +t+ 92.2s ▶ 语音:「左边撞上的。听到左边的呼啸,向左滑。」 t+ 92.2s 〰 震动:hit(受击重震) #### 【玩家动作】第二次被撞:先听方位归因,又听到「这是最后一次机会,撑住」——这就是全部家底了 -t+ 92.6s ♪ 音效 sfx/whoosh_left +t+ 92.6s ♪ 音效 sfx/whoosh_right t+ 94.3s ▶ 语音:「船体只剩最后一次了,小心!」 t+ 94.4s ♪ 音效 sfx/alarm t+ 94.9s ♪ 音效 sfx/alarm -t+ 94.9s ▶ 语音:「左!」 +t+ 94.9s ▶ 语音:「右!」 t+ 94.4s 〰 震动:danger(危险逼近) t+ 94.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 95.4s ♪ 音效 sfx/dodge -t+ 95.7s ♪ 音效 sfx/whoosh_right +t+ 95.7s ♪ 音效 sfx/whoosh_down t+ 97.5s ♪ 音效 sfx/alarm t+ 97.5s 〰 震动:danger(危险逼近) t+ 98.0s ♪ 音效 sfx/alarm -t+ 98.0s ▶ 语音:「右!」 +t+ 98.0s ▶ 语音:「下!」 t+ 98.0s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 98.5s ♪ 音效 sfx/dodge -t+ 98.8s ♪ 音效 sfx/whoosh_left +t+ 98.8s ♪ 音效 sfx/whoosh_down t+ 100.5s ♪ 音效 sfx/alarm t+ 100.5s 〰 震动:danger(危险逼近) t+ 101.0s ♪ 音效 sfx/alarm -t+ 101.0s ▶ 语音:「左!」 +t+ 101.0s ▶ 语音:「下!」 t+ 101.0s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 @@ -258,10 +258,10 @@ t+ 106.9s 〰 震动:danger(危险逼近) #### 【玩家动作】听到第二段警报和方向呼喊,同向滑开 t+ 107.4s ♪ 音效 sfx/dodge -t+ 107.7s ♪ 音效 sfx/whoosh_right +t+ 107.7s ♪ 音效 sfx/whoosh_left t+ 109.3s ♪ 音效 sfx/alarm t+ 109.8s ♪ 音效 sfx/alarm -t+ 109.8s ▶ 语音:「右!」 +t+ 109.8s ▶ 语音:「左!」 t+ 109.3s 〰 震动:danger(危险逼近) t+ 109.8s 〰 震动:danger(危险逼近) t+ 109.8s 【状态】保护期结束后撑了 22 秒、挨了两下还活着;每回撞击前半秒都能听到方向呼喊,先到这 对局中:1 颗陨石在逼近,听方位同向滑开(语音播放中,语速1) diff --git a/research/05-summary.md b/research/05-summary.md index 63b35795..9576fb4f 100644 --- a/research/05-summary.md +++ b/research/05-summary.md @@ -44,6 +44,16 @@ 未结题原因(跨人设/跨轮复现的状态混淆 P0):rhythm「轮到你」排程迟到 bug(两人设)、escape 拿保险丝后目标断链(round1 P1 提出、round2 P2 复现)、simon 双击双义判据不可感知(P1)、rhythm 教学无降难路径(P4)。→ round3 裁定 G1~G8(round2/synthesis.md)。 +### round3(G1~G8 修订后) + +愿意继续玩:**P1/P2/P4 全 6 游戏通过;P3 5/6(escape)**。rhythm 从 3/4 → 4/4(「放慢陪练」被 P4 实测认可);space 5/5(P3:「威胁自报家门是全场最佳设计」)。 + +P1 终评:「六款工程素养高于 audiogames.net 平均线、无一款把我当白痴」。剩余 P0 全部收敛到三类**信息真实性问题**:simon 手势两义(P1/P2/P4 三人设收敛 → 根治=轮次占用预告而非换键)、simon 重听账目不实(教学说两次用一次报完)、rhythm 无出手被误报「慢了半拍」(证明 round2 偏离裁定错误——归因真话律由此入方法论 v1.2)。→ round4 裁定 H1~H5(round3/synthesis.md)。 + +### round4(H1~H5 修订后) + +(待 round4 终评后填写:门槛判定与结题结论) + ## 4. 交付物与影响 - 方法论(可复用):research/02-methodology/methodology.md diff --git a/tools/gen_voice_dual.py b/tools/gen_voice_dual.py index ae18bf07..c1680ba9 100644 --- a/tools/gen_voice_dual.py +++ b/tools/gen_voice_dual.py @@ -209,6 +209,12 @@ ESCAPE_LINES = [ ("skip_hint", _ESC, "单击可跳过旁白。"), # ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)---- ("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, "下面的房间。"), ] @@ -283,6 +289,14 @@ SIMON_LINES = [ # ---- 向导层 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, "免费重听用完了。之后重听会花一次机会,别怕,慢慢来。"), ] @@ -339,6 +353,9 @@ RHYTHM_LINES = [ # ---- 向导层 round3(docs/design/14-guide-layer.md「round3 v1.2」,synthesis §3 定稿全文)---- ("gd_slow", _RHY, "我放慢一些,跟着试试。"), ("gd_count", _RHY, "四拍之后,继续。"), + # ---- 向导层 round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis §3 定稿全文)---- + # H5:归因真话律——应答窗口无任何尝试播此句(早/晚/错分档只留给真出手的情况)。 + ("gd_miss_none", _RHY, "这拍没接。我停下的时候,就轮到你了。"), ] diff --git a/web/escape/audio/voice/female/gd_room_d.m4a b/web/escape/audio/voice/female/gd_room_d.m4a new file mode 100644 index 00000000..11a38d00 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_d.m4a differ diff --git a/web/escape/audio/voice/female/gd_room_d.ogg b/web/escape/audio/voice/female/gd_room_d.ogg new file mode 100644 index 00000000..899355a8 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_d.ogg differ diff --git a/web/escape/audio/voice/female/gd_room_l.m4a b/web/escape/audio/voice/female/gd_room_l.m4a new file mode 100644 index 00000000..237b3470 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_l.m4a differ diff --git a/web/escape/audio/voice/female/gd_room_l.ogg b/web/escape/audio/voice/female/gd_room_l.ogg new file mode 100644 index 00000000..77fdc087 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_l.ogg differ diff --git a/web/escape/audio/voice/female/gd_room_r.m4a b/web/escape/audio/voice/female/gd_room_r.m4a new file mode 100644 index 00000000..3cc63c7f Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_r.m4a differ diff --git a/web/escape/audio/voice/female/gd_room_r.ogg b/web/escape/audio/voice/female/gd_room_r.ogg new file mode 100644 index 00000000..d2ef4489 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_r.ogg differ diff --git a/web/escape/audio/voice/female/gd_room_u.m4a b/web/escape/audio/voice/female/gd_room_u.m4a new file mode 100644 index 00000000..15d93996 Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_u.m4a differ diff --git a/web/escape/audio/voice/female/gd_room_u.ogg b/web/escape/audio/voice/female/gd_room_u.ogg new file mode 100644 index 00000000..35a0e6fc Binary files /dev/null and b/web/escape/audio/voice/female/gd_room_u.ogg differ diff --git a/web/escape/audio/voice/male/gd_room_d.m4a b/web/escape/audio/voice/male/gd_room_d.m4a new file mode 100644 index 00000000..50b9306f Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_d.m4a differ diff --git a/web/escape/audio/voice/male/gd_room_d.ogg b/web/escape/audio/voice/male/gd_room_d.ogg new file mode 100644 index 00000000..4d30543b Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_d.ogg differ diff --git a/web/escape/audio/voice/male/gd_room_l.m4a b/web/escape/audio/voice/male/gd_room_l.m4a new file mode 100644 index 00000000..b03cf4d8 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_l.m4a differ diff --git a/web/escape/audio/voice/male/gd_room_l.ogg b/web/escape/audio/voice/male/gd_room_l.ogg new file mode 100644 index 00000000..c28e30c2 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_l.ogg differ diff --git a/web/escape/audio/voice/male/gd_room_r.m4a b/web/escape/audio/voice/male/gd_room_r.m4a new file mode 100644 index 00000000..f123e8f8 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_r.m4a differ diff --git a/web/escape/audio/voice/male/gd_room_r.ogg b/web/escape/audio/voice/male/gd_room_r.ogg new file mode 100644 index 00000000..2334df89 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_r.ogg differ diff --git a/web/escape/audio/voice/male/gd_room_u.m4a b/web/escape/audio/voice/male/gd_room_u.m4a new file mode 100644 index 00000000..7244dbb2 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_u.m4a differ diff --git a/web/escape/audio/voice/male/gd_room_u.ogg b/web/escape/audio/voice/male/gd_room_u.ogg new file mode 100644 index 00000000..9b7385b1 Binary files /dev/null and b/web/escape/audio/voice/male/gd_room_u.ogg differ diff --git a/web/escape/game.js b/web/escape/game.js index bfa34826..44f64b89 100644 --- a/web/escape/game.js +++ b/web/escape/game.js @@ -21,6 +21,12 @@ * 60s 无进展 L1 暗示(stuck_ask + stuck_*_1)、120s L2 明示(stuck_*_2)、 * 180s L3 带路(stuck_*_3 + 按当前坐标算路链播 nav_*,此后每 60s 重复)。 * + * round4 H1(docs/design/14-guide-layer.md「round4 v1.3」):阶段目标句 + * (found_key / gd_next_fuse / fix_box)播完后,按房间图计算目标房间相对当前 + * 房间的真实方位,接播 gd_room_l/r/u/d 之一(游戏反馈层全档位;目标在同一 + * 房间不播;方向 = escape 移动坐标系,up/down/left/right 滑动方向即方位)。 + * 引导期 g7_goal 不接(引导本身逐句带路,且引导链被 playtest 断言冻结)。 + * * 语音 id 权威清单:docs/audio/round2-voice-ids.md(已删除的 n0–n9 数字拼接链、 * hint_ 四级提示、intro/tutorial 旧开场不再引用)。震动分工:滑动/点按的识别反馈由 gesture.js 内置,本层只发语义震动 * (error/success/warning/pickup)。 @@ -406,7 +412,14 @@ export class EscapeGame extends Game { this._progressEvent(); this.audio.play('sfx/pickup'); vibrate('pickup'); - describe('voice/found_key', false); + // H1(round4):钥匙到手句后接播铁柜房方位(下一目标 = cabinet (2,1))。 + this._timer(() => { + if (s !== this._session) return; + this._playVoice('voice/found_key', { + skippable: false, // 关键获得不可跳 + onComplete: () => this._playGoalDir(STAGES.cabinet.target), + }); + }, 800); // 先让环境音进耳朵再播描述 } else { describe('voice/empty_2'); } @@ -441,9 +454,12 @@ export class EscapeGame extends Game { // G4(round3):拾取反馈链尾接下一目标句「保险丝到手。接下来,去找电箱。」 // (游戏反馈层全档位,gd_economy 先例——走游戏语音链不走向导通道)——堵上 // P2-round2 P0「保险丝到手后目标悬空:没有新指令、没有下文」。 + // H1(round4):目标句播完接播目标房间方位句(P3 唯一拒因:目标无方位)。 this._playVoice('voice/unlock_cabinet', { skippable: false, // 关键获得不可跳 - onComplete: () => this._playVoice('voice/gd_next_fuse'), + onComplete: () => this._playVoice('voice/gd_next_fuse', { + onComplete: () => this._playGoalDir(STAGES.box.target), + }), }); } else if (x === 0 && y === 0 && this.hasFuse && !this.powerOn) { // 保险丝修电箱 -> 恢复供电(保险丝消耗) @@ -453,7 +469,11 @@ export class EscapeGame extends Game { this.audio.play('sfx/spark'); this.audio.play('sfx/power_on', { volume: 0.8 }); vibrate('success'); - this._playVoice('voice/fix_box', { skippable: false }); // 关键获得不可跳 + // H1(round4):通电目标句后接播大门方位(下一目标 = gate (2,2))。 + this._playVoice('voice/fix_box', { + skippable: false, // 关键获得不可跳 + onComplete: () => this._playGoalDir(STAGES.gate.target), + }); } else if (x === 2 && y === 2 && this.powerOn) { // 通电后开大门 -> 逃脱 this._escape(); @@ -483,6 +503,26 @@ export class EscapeGame extends Game { return 'gate'; } + /** + * H1(round4):目标房间相对当前房间的方位句(gd_room_l/r/u/d 之一)。 + * 方向语义用本游戏移动坐标系:up/down/left/right 滑动方向即方位 + * (y 0北..2南、x 0西..2东,向上滑 dy=-1)。斜对角只播一句,轴序与 + * _navVoices 一致(先东西后南北);目标在同一房间不播(返回 null)。 + */ + _roomDirVoice([tx, ty]) { + const dx = tx - this.x; + const dy = ty - this.y; + if (dx === 0 && dy === 0) return null; + if (dx !== 0) return dx < 0 ? 'voice/gd_room_l' : 'voice/gd_room_r'; + return dy < 0 ? 'voice/gd_room_u' : 'voice/gd_room_d'; + } + + /** H1:目标句播完后接播目标房间方位句(游戏反馈层全档位,gd_next_fuse 先例)。 */ + _playGoalDir(target) { + const dir = this._roomDirVoice(target); + if (dir) this._playVoice(dir); + } + // ---------- 卡关检测与三级提示 ---------- _startStuckTimer() { diff --git a/web/harness/guide.test.mjs b/web/harness/guide.test.mjs index f18d3a2a..069661e3 100644 --- a/web/harness/guide.test.mjs +++ b/web/harness/guide.test.mjs @@ -25,6 +25,7 @@ const GAME_DIRS = { SimonGame: path.join(ROOT, 'web', 'simon'), RhythmGame: path.join(ROOT, 'web', 'rhythm'), SpaceGame: path.join(ROOT, 'web', 'space'), + EscapeGame: path.join(ROOT, 'web', 'escape'), }; let currentGameDir = GAME_DIRS.StorylineGame; @@ -169,6 +170,7 @@ const { StorylineGame } = await import('../storyline/game.js'); const { SimonGame } = await import('../simon/game.js'); const { RhythmGame } = await import('../rhythm/game.js'); const { SpaceGame } = await import('../space/game.js'); +const { EscapeGame } = await import('../escape/game.js'); // guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰) settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 }); @@ -275,6 +277,12 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu await advance(() => game.teachAwait && game.phase === 'tutorial'); await gesture(game, g); } + // round4 H2/H4 前提锚定:announce 时点固定第 1 轮序列为无 up/doubletap 的两音 + //(guide.test 的 Math.random 是真随机——不固定则 gd_relisten 断言会随占用预告 + // 动态置空而偶发跳过;固定后本块行为与 round3 基线一致) + await advance(() => game.phase === 'announce'); + game.sequence[0] = 'down'; + game.sequence[1] = 'tap'; await advance(() => game.phase === 'input'); assert(game.round === 1, '教学走完进入第 1 轮 input'); @@ -283,12 +291,12 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu assert(vibrated([20]), 'turn cue 带 confirm 轻震'); assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音'); - // ①b input 看门狗:10s nudge+gd_relisten + // ①b input 看门狗:round4 H4 首级 10s→5s,nudge+gd_relisten const tInput = now; await advance(() => events.some((e) => e.type === 'nudge')); - assert(now - tInput === 10000, `input 10s 后 nudge 触发(实际 ${now - tInput}ms)`); + assert(now - tInput === 5000, `H4:input 首级 5s 后 nudge 触发(实际 ${now - tInput}ms)`); assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'), - 'input 10s 无输入发 gd_relisten 事件'); + 'input 5s 无输入发 gd_relisten 事件'); assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放'); assert(game.chances === 1, '看门狗提示不消耗重听机会'); @@ -599,6 +607,185 @@ realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超 resetTimers(); } +// ================================================================ ⑩ H1~H5(round4) + +realLog('== ⑩a H2/H3/H4 simon:占用预告三句、被占用轮无重听建议、5s 看门狗、余量句 =='); +async function driveSimonToAnnounce(rig) { + const { audio, game } = rig; + await audio.unlock(); + game.start(); + await advance(() => game.phase === 'tutorial' && game.teachAwait); + for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) { + await advance(() => game.teachAwait && game.phase === 'tutorial'); + await gesture(game, g); + } + await advance(() => game.phase === 'announce'); + return game; +} +/** 测试钩子(13 号文档允许直改实例字段):固定当前轮序列;未指定位用无 up/dt 填充。 */ +function fixSequence(game, recipe) { + const fill = ['down', 'right', 'tap', 'left']; + for (let i = 0; i < game.sequence.length; i++) { + game.sequence[i] = i < recipe.length ? recipe[i] : fill[i % fill.length]; + } +} +{ + const rig = makeRig(SimonGame); + const { playLog, game, events } = rig; + await driveSimonToAnnounce(rig); + + // R1(含 up):序列播放完成后、「轮到你」提示前播 gd_occ_up + fixSequence(game, ['up', 'tap']); + await advance(() => game.phase === 'input'); + assert(voicePlays(playLog, 'voice/gd_occ_up') === 1, 'H2:含 up 轮次播 gd_occ_up(序列播放完成后)'); + const iOcc = playLog.findIndex((e) => e.id === 'voice/gd_occ_up'); + const iTurn = playLog.findIndex((e) => e.id === 'voice/your_turn'); + assert(iOcc > -1 && iTurn > iOcc, 'H2:占用预告在「轮到你」提示之前(插在轮次链中)'); + + // 被占用轮 input 看门狗:H4 首级 5s 只发 nudge,不播重听建议句(say 动态置空) + const tInput = now; + const rl0 = voicePlays(playLog, 'voice/gd_relisten'); + const nb0 = events.filter((e) => e.type === 'nudge').length; + await advance(() => events.filter((e) => e.type === 'nudge').length > nb0); + assert(now - tInput === 5000, `H4:input 看门狗首级 5s(实际 ${now - tInput}ms)`); + assert(voicePlays(playLog, 'voice/gd_relisten') === rl0, + 'H2:up 被占用轮看门狗只发 nudge earcon,不播重听建议句'); + + // 期望恰为 up 时上滑=作答(00 §5.3 豁免同款)→ 清第 1 轮 + for (const g of game.sequence) await gesture(game, g); + await advance(() => game.phase === 'announce' && game.round === 2); + + // R2(仅含 doubletap):gd_occ_dt;重听建议不受影响(建议只关联 up 占用) + fixSequence(game, ['down', 'doubletap', 'left']); + await advance(() => game.phase === 'input'); + assert(voicePlays(playLog, 'voice/gd_occ_dt') === 1, 'H2:含 doubletap 轮次播 gd_occ_dt'); + const t2 = now; + const rl1 = voicePlays(playLog, 'voice/gd_relisten'); + const nb1 = events.filter((e) => e.type === 'nudge').length; + await advance(() => events.filter((e) => e.type === 'nudge').length > nb1); + assert(now - t2 === 5000, 'H4:看门狗首级恒 5s(升级级 25s 与级数不变)'); + assert(voicePlays(playLog, 'voice/gd_relisten') === rl1 + 1, + 'H2:仅 doubletap 占用不影响上滑重听建议'); + for (const g of game.sequence) await gesture(game, g); + await advance(() => game.phase === 'announce' && game.round === 3); + + // R3(up+doubletap 双占用):gd_occ_both;看门狗同样静默只 nudge + fixSequence(game, ['up', 'doubletap', 'left', 'right']); + await advance(() => game.phase === 'input'); + assert(voicePlays(playLog, 'voice/gd_occ_both') === 1, 'H2:up+doubletap 双占用播 gd_occ_both'); + const rl2 = voicePlays(playLog, 'voice/gd_relisten'); + const nb2 = events.filter((e) => e.type === 'nudge').length; + await advance(() => events.filter((e) => e.type === 'nudge').length > nb2); + assert(voicePlays(playLog, 'voice/gd_relisten') === rl2, 'H2:双占用轮看门狗同样不播重听建议'); + for (const g of game.sequence) await gesture(game, g); + await advance(() => game.phase === 'announce' && game.round === 4); + + // H3:余量句与真实余量一致——第 1 次免费重听用掉(2→1)播 left1,第 2 次(1→0)播 left0 + fixSequence(game, []); // 无占用填充轮 + await advance(() => game.phase === 'input'); + game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2) + const rel0 = voicePlays(playLog, 'voice/relisten'); + const l1_0 = voicePlays(playLog, 'voice/gd_chances_left1'); + await gesture(game, 'up'); // 期望非 up → 主动重听①:2→1 + await advance(() => voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1); // 余量句在 relisten 链尾 + assert(game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1, + 'H3:第 1 次免费重听用掉播 gd_chances_left1(余量句与真实余量一致)'); + assert(voicePlays(playLog, 'voice/relisten') === rel0 + 1, 'H3:重听照常播 relisten 并重播序列'); + await advance(() => game.phase === 'input'); + assert(game.inputIdx === 0, 'H3:重听后输入进度清零'); + const l0_0 = voicePlays(playLog, 'voice/gd_chances_left0'); + await gesture(game, 'up'); // 重听②:1→0 + await advance(() => voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1); + assert(game.chances === 0 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1, + 'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在真实余量为 0 时出现)'); + await advance(() => game.phase === 'input'); + const co0 = voicePlays(playLog, 'voice/chances_out'); + await gesture(game, 'up'); // 余 0 再求重听:拒绝路径(机会账)行为不变 + assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1, + 'H3:余 0 再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)'); + while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]); + game.stop(); + resetTimers(); +} + +realLog('== ⑩b H5 rhythm:教学应答窗口无任何尝试 → gd_miss_none(none 优先级最高)=='); +{ + const rig = makeRig(RhythmGame); + const { audio, playLog, game } = rig; + await audio.unlock(); + game.start(); + await advance(() => audio.isVoicePlaying()); // welcome 开播 + await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学 T1 + await advance(() => game.phase === 'tut' && game._cues.length > 0); + // T1 整句不出手(窗口关闭时该音未判定)→ 首次失败归因 gd_miss_none + await advance(() => voicePlays(playLog, 'voice/gd_miss_none') === 1); + assert(game._tutFails === 1, '前提:T1 首次失败(整句不出手)'); + assert(voicePlays(playLog, 'voice/gd_miss_none') === 1, 'H5:无尝试播 gd_miss_none(这拍没接)'); + assert(voicePlays(playLog, 'voice/gd_miss_late') === 0 && voicePlays(playLog, 'voice/gd_miss_wrong') === 0, + 'H5:没出手不再被归因为「慢了半拍」(归因真话律:宁可粗不可假)'); + game.stop(); + resetTimers(); +} + +realLog('== ⑩c H1 escape:目标句后按房间图播 gd_room_*(同房间不播,移动坐标系)=='); +{ + // 方向映射按图校验(直连单元断言) + const rig0 = makeRig(EscapeGame); + const { game: g0 } = rig0; + await rig0.audio.unlock(); + g0.x = 1; g0.y = 1; + assert(g0._roomDirVoice([0, 1]) === 'voice/gd_room_l', 'H1:目标在西 → gd_room_l'); + assert(g0._roomDirVoice([2, 1]) === 'voice/gd_room_r', 'H1:目标在东 → gd_room_r'); + assert(g0._roomDirVoice([1, 0]) === 'voice/gd_room_u', 'H1:目标在北(上面的房间)→ gd_room_u'); + assert(g0._roomDirVoice([1, 2]) === 'voice/gd_room_d', 'H1:目标在南(下面的房间)→ gd_room_d'); + assert(g0._roomDirVoice([1, 1]) === null, 'H1:目标在同一房间不播'); + g0.stop(); + resetTimers(); + + // 三处目标句接线点:found_key → cabinet(2,1);gd_next_fuse → box(0,0);fix_box → gate(2,2) + const rig = makeRig(EscapeGame); + const { audio, playLog, game } = rig; + storageData.set('soundgame.escape.guideDone', '1'); // 二周目直达 REOPEN→PLAY + await audio.unlock(); + game.start(); + await advance(() => game.phase === 'REOPEN'); + await advance(() => !audio.isVoicePlaying()); // reopen_prompt 播完 + await gesture(game, 'tap'); // 直接开始 → PLAY (1,1) + await advance(() => game.phase === 'PLAY' && !audio.isVoicePlaying()); + assert(game.x === 1 && game.y === 1 && !game.hasKey, '前提:二周目 PLAY 起点、身上无钥匙'); + + // ① found_key:储物间拾钥匙 → 铁柜房在东(右面的房间) + await gesture(game, 'left'); // (0,1) + await gesture(game, 'down'); // (0,2) 储物间(首到播 room_storage) + await advance(() => !audio.isVoicePlaying()); // 房间描述播完再单击(否则单击=跳句) + await gesture(game, 'tap'); // 检查 → found_key(800ms 后)→ 方位句 + await advance(() => playLog.some((e) => e.id === 'voice/gd_room_r')); + const ifk = playLog.findIndex((e) => e.id === 'voice/found_key'); + assert(ifk > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_r') > ifk, + 'H1:found_key 后接播 gd_room_r(目标铁柜房按图在东)'); + + // ② gd_next_fuse:开柜得保险丝 → 电箱房在西(左面的房间) + await gesture(game, 'right'); await gesture(game, 'right'); // (1,2) (2,2) + await gesture(game, 'up'); // (2,1) 铁柜房 + await gesture(game, 'doubletap'); // 开柜 + await advance(() => playLog.some((e) => e.id === 'voice/gd_room_l')); + const inf = playLog.findIndex((e) => e.id === 'voice/gd_next_fuse'); + assert(inf > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_l') > inf, + 'H1:gd_next_fuse 后接播 gd_room_l(目标电箱房按图在西)'); + + // ③ fix_box:修电箱通电 → 大门在东(右面的房间) + await gesture(game, 'left'); await gesture(game, 'left'); // (1,1) (0,1) + await gesture(game, 'up'); // (0,0) 电箱房 + await gesture(game, 'doubletap'); // 修电箱 + await advance(() => playLog.filter((e) => e.id === 'voice/gd_room_r').length >= 2); + const ifx = playLog.findIndex((e) => e.id === 'voice/fix_box'); + assert(ifx > -1 && playLog.map((e) => e.id).lastIndexOf('voice/gd_room_r') > ifx, + 'H1:fix_box 后接播 gd_room_r(目标大门按图在东)'); + assert(game.powerOn, '前提:电箱已通电(接线点均在真实解谜链上)'); + game.stop(); + resetTimers(); +} + // ---------------------------------------------------------------- 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); diff --git a/web/harness/sessions/escape.mjs b/web/harness/sessions/escape.mjs index f4b4b213..9a747b25 100644 --- a/web/harness/sessions/escape.mjs +++ b/web/harness/sessions/escape.mjs @@ -1,7 +1,8 @@ /** * 《暗室》新手会话剧本:老玩家二周目不算——这里清空过进度走首局引导(G0-G7), * 每步听清再做;进 PLAY 后先连撞两次墙(触发向导提前一级的卡关提示), - * 然后找到储物间拿钥匙、去铁柜房开柜(解到第二间),收工。 + * 然后找到铁柜房开柜拿保险丝、照 round4 H1 的目标方位句跨四个房间修电箱、 + * 再摸到大门收工(目标句后 3s 内都能听到方位句)。 */ export function describe(game) { switch (game.phase) { @@ -66,11 +67,23 @@ export async function session(ctx, mock) { await move('right', '往东走,风声换了个方向'); await move('right'); await move('down', '到了铁柜房,柜子锁着'); - await ctx.gesture('doubletap'); // 用钥匙 → unlock_cabinet + gd_next_fuse(round3 G4) - await ctx.waitVirtual(4200); - ctx.status('柜子开了,听到「保险丝到手。接下来,去找电箱」——目标又有了'); - // round3 G4 剧本:拿到保险丝后再走两步(朝西边的电箱房方向),目标链不断 - await move('left', '照新目标往西摸'); - await move('left', '再走一步,西边隐隐有电流声——电箱在那边'); - ctx.status('解到第二间、照下一目标句又摸了两步,收工'); + // 用钥匙 → unlock_cabinet + gd_next_fuse + gd_room_l(round4 H1:目标句带方位, + // 从铁柜房 (2,1) 看电箱房 (0,0) 在西 = 左面的房间) + await ctx.gesture('doubletap'); + await ctx.waitVirtual(5600); + ctx.status('柜子开了。「保险丝到手。接下来,去找电箱。」后面还跟了一句「左面的房间。」——这回知道该往哪边摸了'); + // round4 H1 剧本:照方位句跨房间去电箱房——(2,1) → 左×2 = (0,1) → 上 = (0,0) + await move('left', '照方位句往左(西)摸'); + await move('left'); + await move('up', '电流声越来越清晰——电箱房到了'); + // 修电箱 → fix_box + gd_room_r(round4 H1:从 (0,0) 看大门 (2,2) 在东 = 右面的房间) + await ctx.gesture('doubletap'); + await ctx.waitVirtual(5600); + ctx.status('电箱修好了。目标句后面照样跟着方位——「右面的房间。」大门在东边'); + // 照方位句多跨两个房间摸向大门:(0,0) → 右×2 = (2,0) → 下×2 = (2,2) + await move('right', '照方位句往右(东)摸'); + await move('right'); + await move('down'); + await move('down', '脚步声停下来——面前这间就是大门'); + ctx.status('解到通电、照目标句的方位跨了四个房间摸到大门,收工'); } diff --git a/web/harness/sessions/rhythm.mjs b/web/harness/sessions/rhythm.mjs index d48d1ae1..b903882b 100644 --- a/web/harness/sessions/rhythm.mjs +++ b/web/harness/sessions/rhythm.mjs @@ -1,10 +1,11 @@ /** - * 《节拍行者》新手会话剧本:首局完整走教学 T1-T3(第 2 句故意连错两遍, + * 《节拍行者》新手会话剧本:首局完整走教学 T1-T3(第 2 句先整拍不出手—— + * round4 H5 归因真话律:听到的是「这拍没接」而不是「慢了半拍」;再连错一遍 * 触发 round3 G2 的「降速 25% 重演 + gd_slow」,跟着慢速领奏做对后恢复正常) * → 曲目一(90 BPM);曲中下滑呼出菜单逛一圈再恢复(round3 G3:恢复先听 * gd_count「四拍之后,继续」,再跟 4 拍底鼓 count-in 进场;首个应答窗口前 - * 整整 1 小节有 gd_answer+turn 交接标记);第一乐句故意整句漏掉(连 2 Miss - * 触发向导「慢了半拍」提示),之后跟着领奏把一曲打完到结算。 + * 整整 1 小节有 gd_answer+turn 交接标记);正片第一乐句故意整句漏掉(连 2 Miss + * 触发向导 gd_miss「慢了半拍」——正片规则不变),之后跟着领奏把一曲打完到结算。 */ export function describe(game) { switch (game.phase) { @@ -37,11 +38,12 @@ export async function session(ctx, mock) { await ctx.waitCond(() => game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0); ctx.status(`教学第 ${step + 1}/3 句`); if (step === 1) { - // round3 G2 剧本:这一句故意连错两遍(整句不出手),听它放慢一些再带着打。 + // round4 H5 剧本:这一句先整拍不出手——归因必须是「这拍没接」(gd_miss_none), + // 不是「慢了半拍」;再连错一遍(整句不出手)听它放慢一些再带着打(G2)。 // 注意 _tutEvaluate 判定即把 _phaseEnd 置 Infinity 防重入——等待一律挂 // 「持久里程碑」条件(_tutFails/_tutSlowUsed),别挂阶段末时刻。 await ctx.waitCond(() => game._tutFails === 1); // 失败①:没出手 - ctx.status('第一遍没敢出手,先听它归因'); + ctx.status('第一遍整拍没出手——听到的是「这拍没接。我停下的时候,就轮到你了」,不是「慢了半拍」'); await ctx.waitCond(() => game._phaseEnd < 1e8 && t() < game._phaseEnd - 1); // 纠正链播完、重新领奏 ctx.status('又重新领奏了一遍,还是没敢出手'); await ctx.waitCond(() => game._tutSlowUsed === true); // 失败② → 触发降速重演 diff --git a/web/harness/sessions/simon.mjs b/web/harness/sessions/simon.mjs index 9fc6d46b..c92e48f1 100644 --- a/web/harness/sessions/simon.mjs +++ b/web/harness/sessions/simon.mjs @@ -1,10 +1,12 @@ /** - * 《音序回响》新手会话剧本:教学六音一步一步学(中间故意做错一次听纠错)、 - * 收尾听完机会经济句(round3 G5 重录口径:记不住就向上滑重新听);第 1 轮 - * 直接答对(途中有双击音就演示「双击=作答」);第 2 轮开局忘序列僵住 10 秒, - * 听到向导「向上滑可以重新听」提示后照做——用掉一次免费重听(round3 G5: - * input 双击永远=作答,重听改绑上滑);第 3 轮凭记忆答对,在**整轮边界**收工 - * (round2 剧本修正:不在轮中退出)。 + * 《音序回响》新手会话剧本(round4):教学六音一步一步学(中间故意做错一次听纠错)、 + * 收尾听完机会经济句;对局段制造 round4 H 组断言点—— + * - 含 up 的轮次(R1):序列播完先听占用预告 gd_occ_up,僵 5s 验证被占用轮看门狗 + * 只发 nudge 不建议重听(H2),期望恰为 up 时上滑=作答; + * - 含 doubletap 的轮次(R2)与 up+doubletap 双占用轮(R6):gd_occ_dt / gd_occ_both; + * - 无尝试超时(R5/R7):干净轮僵 5s → 看门狗建议上滑重听 → 用掉两次免费重听, + * 第 1 次听到「免费重听还剩一次」(H3:余量句与真实余量一致——第 4 轮完成机会 + * +1 到 2 后才重听),第 2 次听到「免费重听用完了」;整轮边界收工。 */ export function describe(game) { const GN = { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' }; @@ -16,7 +18,13 @@ export function describe(game) { : '教学旁白在播,先听着'; case 'announce': return `第 ${game.round} 轮报数呢,听好了`; case 'playback': return `第 ${game.round} 轮序列演奏中(${game.sequence.length} 个音),记住顺序,先别动手`; - case 'input': return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势(双击就是双击那个音,要重听就向上滑)`; + case 'input': { + const occ = []; + if (game.sequence.includes('up')) occ.push('上滑'); + if (game.sequence.includes('doubletap')) occ.push('双击'); + const occNote = occ.length ? `(这轮${occ.join('和')}是作答,占用了)` : ''; + return `第 ${game.round} 轮轮到你了:已对上 ${game.inputIdx}/${game.sequence.length},照原顺序做手势${occNote}(要重听就向上滑)`; + } case 'roundend': return '刚答完一轮,播报中'; case 'gameover': return `游戏结束,完成 ${game.round - 1} 轮。单击再来一局,下滑开菜单`; case 'menu': return '菜单里:单击切选项,双击确认,上滑听规则'; @@ -39,69 +47,102 @@ export async function session(ctx, mock) { } await ctx.gesture(G[i]); // 照指令做对 if (i === G.length - 1) { - // 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 input 就接手, + // 收尾链(t7 两句 + 机会经济句)播完直达对局——一到 announce 就接手, // 不裸 waitIdle(会把 input 的看门狗窗口整段空烧掉,round1 剧本失真根源) - await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input' - || game.phase === 'gameover'); + await ctx.waitCond(() => game.phase === 'announce' || game.phase === 'playback' + || game.phase === 'input' || game.phase === 'gameover'); } else { await ctx.waitIdle(); } } ctx.status('教学讲完了:免费重听两次、用完扣机会,想重听就向上滑'); - // 正式对局:完整打完第 3 轮,在轮边界收工 - let relistenDone = false; - while (mock.now < 12 * 60 * 1000 && game.phase !== 'gameover') { - if (game.phase === 'input') { - // 第 1 轮开局先失忆僵 10 秒(还没动过手,input 看门狗在挂):听向导 - // 「向上滑可以重新听一遍序列」,照做——round3 G5:重听已改绑上滑。 - // 测试钩子把序列摆成无 up 的两音,保证上滑=重听不被「期望恰为 up」豁免干扰。 - if (game.round === 1 && !relistenDone && game.inputIdx === 0) { - relistenDone = true; - if (game.sequence.includes('up')) { game.sequence[0] = 'left'; game.sequence[1] = 'tap'; } - ctx.status('第 1 轮有点懵,先僵一会儿'); - ctx.silence(10, '想不起来序列了'); - // 10s 看门狗到点:nudge + gd_relisten。直接等提示语音开播(input 态无 - // BGM、无别的语音,voiceBusy=提示在播;别用「等满 10.6s」——定时刻度会 - // 恰好迈过语音结束点,等成第二次升级) - await ctx.waitCond(() => ctx.voiceBusy()); - ctx.status('听到提示:需要的话,向上滑可以重新听一遍序列'); - ctx.note('照提示向上滑,用掉一次免费重听'); - await ctx.gesture('up'); - await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'); - continue; - } - // 第 2 轮开头演示「期望恰为 up 时上滑=作答」(00 §5.3 豁免同款口径) - if (game.round === 2 && !game.__upDemoDone && game.inputIdx === 0) { - game.__upDemoDone = true; - game.sequence[0] = 'up'; - ctx.status('这个是上行音——上滑作答(期望恰为 up 时上滑=作答)'); - await ctx.gesture('up'); - continue; - } - if (game.round === 3 && game.inputIdx === game.sequence.length - 1) { - // round3 G5 演示钩子(13 号文档允许直读/改实例字段):把最后一个期望音 - // 摆成 doubletap,保证转写演示到「input 双击永远=作答」 - if (game.sequence[game.inputIdx] !== 'doubletap' && !game.__dtDemoDone) { - game.__dtDemoDone = true; - game.sequence[game.inputIdx] = 'doubletap'; - } - ctx.status(game.sequence[game.inputIdx] === 'doubletap' - ? '这个是双击音——双击作答(round3:input 双击永远=作答)' - : '最后一轮最后一个音,打完这轮就收'); - } else { - ctx.status('轮到我复现了'); - } - await ctx.gesture(game.sequence[game.inputIdx]); - continue; + // 测试钩子(13 号文档允许直改实例字段):每轮报数时点把序列摆成剧本需要的 + // 形状——占用音(up/doubletap)摆进指定轮,其余用不含占用音的手势补齐。 + const fixSeq = (highlights) => { + const fill = ['down', 'right', 'tap', 'left']; + for (let i = 0; i < game.sequence.length; i++) { + game.sequence[i] = i < highlights.length ? highlights[i] : fill[i % fill.length]; } - // 整轮边界收尾:第 3 轮答完(roundend),不等第 4 轮报数 - if (game.phase === 'roundend' && game.round === 3) break; - // announce/playback/roundend:等阶段推进即可,不裸 waitIdle(防吞掉 input 窗口) - const ph = game.phase; - const r = game.round; - await ctx.waitCond(() => game.phase !== ph || game.round !== r); - } + }; + const waitAnnounce = () => ctx.waitCond(() => game.phase === 'announce' || game.phase === 'gameover'); + const answerRound = async () => { + await ctx.waitCond(() => game.phase === 'input' || game.phase === 'gameover'); + while (game.phase === 'input') await ctx.gesture(game.sequence[game.inputIdx]); + }; + // 「无尝试超时」剧本点(干净轮):僵到 input 看门狗 5s 首级的提示语音开播 + //(input 态无 BGM 无别的语音,voiceBusy=提示在播;别盲等固定时长——harness 的 + // advance 没有时间上限,会恰好迈过提示语音结束点、一路空烧到 25s 升级) + const stallForAdvice = async (why) => { + ctx.silence(5, why); + await ctx.waitCond(() => ctx.voiceBusy()); + }; - ctx.status(`收工:完整打完 ${game.round} 轮,在轮与轮的边界停手(免费重听剩 ${game.chances} 次)`); + // R1:含 up 的轮次——占用预告 + 被占用轮看门狗只 nudge(H2)+ 上滑恰为作答 + await waitAnnounce(); + fixSeq(['up', 'tap']); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('序列一播完就听到「这一轮,向上滑是作答」——重听键这轮被占了'); + ctx.silence(5, '第一轮先不动手,看看它催不催'); + // 被占用轮 5s 级没有提示语音可等:僵到 25s 升级(turn+confirm 震)作为里程碑 + await ctx.waitVirtual(25600); + ctx.note('5 秒一声轻提醒、25 秒又一声加确认震——两级提醒都没建议「向上滑重听」,它知道这轮上滑是作答'); + ctx.status('先来一发上滑——期望恰为 up 时上滑就是作答'); + await answerRound(); + + // R2:含 doubletap 的轮次——占用预告 dt(重听建议不受影响,不僵) + await waitAnnounce(); + fixSeq(['down', 'doubletap', 'left']); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('这轮预告的是「双击是作答」'); + await answerRound(); + + // R3/R4:无占用轮直答(第 4 轮完成 → 机会 +1,免费重听凑满两次) + await waitAnnounce(); + fixSeq([]); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('这轮开场没有占用预告——上滑还能当重听键用'); + await answerRound(); + await waitAnnounce(); + fixSeq([]); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('机会加一之后,免费重听真的有两次了'); + await answerRound(); + + // R5:无尝试超时 + 第 1 次免费重听(余量如实报「还剩一次」,H3) + await waitAnnounce(); + fixSeq([]); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('第 5 轮序列有点长,先不动手'); + await stallForAdvice('想不起来序列了'); + ctx.status('5 秒就来了提示:需要的话,向上滑可以重新听一遍序列'); + ctx.note('照提示向上滑——第 1 次免费重听用掉,跟着听到「免费重听还剩一次」'); + await ctx.gesture('up'); + await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'); + await answerRound(); + + // R6:up+doubletap 双占用轮——gd_occ_both + 被占用轮看门狗只 nudge + await waitAnnounce(); + fixSeq(['up', 'doubletap']); + await ctx.waitCond(() => game.phase === 'input'); + ctx.status('这轮说「向上滑和双击都是作答」——两个键都被占用了'); + ctx.silence(5, '轮到我了,先心里过一遍序列'); + await ctx.waitVirtual(25600); // 同 R1:两级提醒都只发 earcon/标记,无重听建议 + ctx.note('又是两级提醒都没有重听建议——占用轮里那句建议自动闭嘴'); + await answerRound(); + + // R7:无尝试超时 + 第 2 次免费重听(余量归 0,H3) + await waitAnnounce(); + fixSeq([]); + await ctx.waitCond(() => game.phase === 'input'); + await stallForAdvice('长序列,又想不起来了'); + ctx.status('又是那句提示:向上滑可以重新听一遍序列'); + ctx.note('向上滑——第 2 次免费重听用掉,这回听到「免费重听用完了。之后重听会花一次机会」'); + await ctx.gesture('up'); + await ctx.waitCond(() => game.phase === 'playback' || game.phase === 'input'); + await answerRound(); + + // 轮边界收工 + await ctx.waitCond(() => game.phase === 'roundend' || game.phase === 'gameover'); + ctx.status(`收工:打完 ${game.round} 轮在轮边界停手(免费重听剩 ${game.chances} 次,账目和播的对上了)`); } diff --git a/web/harness/voice_text.json b/web/harness/voice_text.json index 5aac951b..cdbc3dc3 100644 --- a/web/harness/voice_text.json +++ b/web/harness/voice_text.json @@ -130,7 +130,11 @@ "menu_home": "回大厅。", "menu_back": "返回游戏。", "skip_hint": "单击可跳过旁白。", - "gd_next_fuse": "保险丝到手。接下来,去找电箱。" + "gd_next_fuse": "保险丝到手。接下来,去找电箱。", + "gd_room_l": "左面的房间。", + "gd_room_r": "右面的房间。", + "gd_room_u": "上面的房间。", + "gd_room_d": "下面的房间。" }, "simon": { "t0_welcome": "欢迎来到音序回响。先认识六种声音。", @@ -205,7 +209,12 @@ "gd_relisten": "需要的话,向上滑可以重新听一遍序列。", "gd_economy": "记不住就向上滑,重新听一遍。免费机会有两次,用完要扣一次机会。", "gd_clear": "漂亮,过了。", - "gd_record": "破纪录了。" + "gd_record": "破纪录了。", + "gd_occ_up": "这一轮,向上滑是作答。", + "gd_occ_dt": "这一轮,双击是作答。", + "gd_occ_both": "这一轮,向上滑和双击都是作答。", + "gd_chances_left1": "免费重听还剩一次。", + "gd_chances_left0": "免费重听用完了。之后重听会花一次机会,别怕,慢慢来。" }, "rhythm": { "welcome": "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。", @@ -246,7 +255,8 @@ "gd_miss_late": "慢了半拍。跟着节拍走。", "gd_miss_wrong": "方向不对。跟着音色滑。", "gd_slow": "我放慢一些,跟着试试。", - "gd_count": "四拍之后,继续。" + "gd_count": "四拍之后,继续。", + "gd_miss_none": "这拍没接。我停下的时候,就轮到你了。" }, "space": { "drill_welcome": "欢迎登上盲航。你只有一双耳朵。", diff --git a/web/rhythm/audio/voice/female/gd_miss_none.m4a b/web/rhythm/audio/voice/female/gd_miss_none.m4a new file mode 100644 index 00000000..25121eda Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_miss_none.m4a differ diff --git a/web/rhythm/audio/voice/female/gd_miss_none.ogg b/web/rhythm/audio/voice/female/gd_miss_none.ogg new file mode 100644 index 00000000..38537255 Binary files /dev/null and b/web/rhythm/audio/voice/female/gd_miss_none.ogg differ diff --git a/web/rhythm/audio/voice/male/gd_miss_none.m4a b/web/rhythm/audio/voice/male/gd_miss_none.m4a new file mode 100644 index 00000000..25e7eef7 Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_miss_none.m4a differ diff --git a/web/rhythm/audio/voice/male/gd_miss_none.ogg b/web/rhythm/audio/voice/male/gd_miss_none.ogg new file mode 100644 index 00000000..b198556b Binary files /dev/null and b/web/rhythm/audio/voice/male/gd_miss_none.ogg differ diff --git a/web/rhythm/game.js b/web/rhythm/game.js index 652a5f33..b639be48 100644 --- a/web/rhythm/game.js +++ b/web/rhythm/game.js @@ -35,12 +35,14 @@ * main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。 * 长按快进豁免(12 文档 §2.2:main 演奏期):audio.setRateBoostEnabled(false) * per-game 开关(引擎已支持),进 main 关、进菜单/结算/退出恢复。 - * 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3 + round3 G1~G3): + * 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.1/F3 + round3 G1~G3 + * + round4 H5): * 每曲首次领奏→应答切换在谱面定时队列直接落 gd_answer(不走 guide queue,保证 * 窗口前听到)+ turn earcon;round3 G1 修正排程点:落在首个应答窗口**前整整 * 1 小节**(= 领奏小节头;round2 排在窗口起点本身,P4 实测窗口开了才听到), * 菜单恢复对下一个未判定窗口重排同款标记;教学 Miss 首次即归因分档 - * (gd_miss_early/late/wrong,挂纠正链尾空闲点);连续 2 Miss 播 gd_miss(queue + * (round4 H5 四档:无窗内尝试 gd_miss_none 优先、真出手再分 early/late/wrong, + * 挂纠正链尾空闲点;正片连 2 Miss 仍 gd_miss);连续 2 Miss 播 gd_miss(queue * 插播,同曲一次);暂停断点续播前插 4 拍 count-in(round3 G3:底鼓 sfx 层可辨 * 音量 + full 档 gd_count 每局恢复限 1 次 + beat 震;round2 版因 push 后未重排 * _sched 被队头卡死从未按时响过;off 档保持即时续播);教学 DDA(round3 G2): @@ -388,14 +390,22 @@ export class RhythmGame extends Game { }); return; } - // F2.1(round2):教学 Miss 首次即归因、按失败原因分档——方向错 gd_miss_wrong / - // 抢拍 gd_miss_early / 慢拍 gd_miss_late(无在窗内尝试=没跟上拍,归入慢拍)。 + // F2.1(round2)+ H5(round4):教学 Miss 首次即归因、按失败原因分档。 + // H5 归因真话律:应答窗口无任何尝试(窗口关闭时该音未判定——出窗的自由 + // 演奏不算尝试)优先播 gd_miss_none「这拍没接」,早/晚/错分档只保留给 + // 真出手的情况(P1 round3:没出手被报「慢了半拍」是归因说假话——"宁可粗 + // 不可假";正片连 2 Miss 仍走 gd_miss,本档只改教学归因)。 // 向导句(full 档)挂在纠正链播完后的空闲点发:idle-only 语义下链中插播会被 // 跳过;之后 _tutLead 只排 sfx,不与归因句抢 voice 通道。 + const noShow = this._cues.some((cue) => { + const a = this._tutAttempts.find((x) => x.cue === cue); + return !a || Math.abs(a.dt) > GOOD; // 窗内无尝试 = 该音没接 + }); const wrong = this._tutAttempts.find((a) => !a.correct); const offbeat = this._tutAttempts.find((a) => a.correct && Math.abs(a.dt) > GOOD); - const missGrade = wrong ? 'gd_miss_wrong' - : offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late'; + const missGrade = noShow ? 'gd_miss_none' // none 优先级最高 + : wrong ? 'gd_miss_wrong' + : offbeat ? (offbeat.dt < 0 ? 'gd_miss_early' : 'gd_miss_late') : 'gd_miss_late'; // 纠正语音:手势错 > 过早 > 过晚 > 没出手(retry_listen) let correction = 'voice/retry_listen'; if (wrong) correction = 'voice/retry_gesture'; diff --git a/web/simon/audio/voice/female/gd_chances_left0.m4a b/web/simon/audio/voice/female/gd_chances_left0.m4a new file mode 100644 index 00000000..280b4c77 Binary files /dev/null and b/web/simon/audio/voice/female/gd_chances_left0.m4a differ diff --git a/web/simon/audio/voice/female/gd_chances_left0.ogg b/web/simon/audio/voice/female/gd_chances_left0.ogg new file mode 100644 index 00000000..0f8c9802 Binary files /dev/null and b/web/simon/audio/voice/female/gd_chances_left0.ogg differ diff --git a/web/simon/audio/voice/female/gd_chances_left1.m4a b/web/simon/audio/voice/female/gd_chances_left1.m4a new file mode 100644 index 00000000..fc901cfc Binary files /dev/null and b/web/simon/audio/voice/female/gd_chances_left1.m4a differ diff --git a/web/simon/audio/voice/female/gd_chances_left1.ogg b/web/simon/audio/voice/female/gd_chances_left1.ogg new file mode 100644 index 00000000..ecacfabc Binary files /dev/null and b/web/simon/audio/voice/female/gd_chances_left1.ogg differ diff --git a/web/simon/audio/voice/female/gd_occ_both.m4a b/web/simon/audio/voice/female/gd_occ_both.m4a new file mode 100644 index 00000000..1dcacc9f Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_both.m4a differ diff --git a/web/simon/audio/voice/female/gd_occ_both.ogg b/web/simon/audio/voice/female/gd_occ_both.ogg new file mode 100644 index 00000000..2b46bfb3 Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_both.ogg differ diff --git a/web/simon/audio/voice/female/gd_occ_dt.m4a b/web/simon/audio/voice/female/gd_occ_dt.m4a new file mode 100644 index 00000000..0e68efa8 Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_dt.m4a differ diff --git a/web/simon/audio/voice/female/gd_occ_dt.ogg b/web/simon/audio/voice/female/gd_occ_dt.ogg new file mode 100644 index 00000000..67eb5c9d Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_dt.ogg differ diff --git a/web/simon/audio/voice/female/gd_occ_up.m4a b/web/simon/audio/voice/female/gd_occ_up.m4a new file mode 100644 index 00000000..b644b161 Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_up.m4a differ diff --git a/web/simon/audio/voice/female/gd_occ_up.ogg b/web/simon/audio/voice/female/gd_occ_up.ogg new file mode 100644 index 00000000..f82b5edb Binary files /dev/null and b/web/simon/audio/voice/female/gd_occ_up.ogg differ diff --git a/web/simon/audio/voice/male/gd_chances_left0.m4a b/web/simon/audio/voice/male/gd_chances_left0.m4a new file mode 100644 index 00000000..9fa979c4 Binary files /dev/null and b/web/simon/audio/voice/male/gd_chances_left0.m4a differ diff --git a/web/simon/audio/voice/male/gd_chances_left0.ogg b/web/simon/audio/voice/male/gd_chances_left0.ogg new file mode 100644 index 00000000..f4546df5 Binary files /dev/null and b/web/simon/audio/voice/male/gd_chances_left0.ogg differ diff --git a/web/simon/audio/voice/male/gd_chances_left1.m4a b/web/simon/audio/voice/male/gd_chances_left1.m4a new file mode 100644 index 00000000..6ee57942 Binary files /dev/null and b/web/simon/audio/voice/male/gd_chances_left1.m4a differ diff --git a/web/simon/audio/voice/male/gd_chances_left1.ogg b/web/simon/audio/voice/male/gd_chances_left1.ogg new file mode 100644 index 00000000..dacb584e Binary files /dev/null and b/web/simon/audio/voice/male/gd_chances_left1.ogg differ diff --git a/web/simon/audio/voice/male/gd_occ_both.m4a b/web/simon/audio/voice/male/gd_occ_both.m4a new file mode 100644 index 00000000..11a2e015 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_both.m4a differ diff --git a/web/simon/audio/voice/male/gd_occ_both.ogg b/web/simon/audio/voice/male/gd_occ_both.ogg new file mode 100644 index 00000000..d2f28de0 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_both.ogg differ diff --git a/web/simon/audio/voice/male/gd_occ_dt.m4a b/web/simon/audio/voice/male/gd_occ_dt.m4a new file mode 100644 index 00000000..a106f998 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_dt.m4a differ diff --git a/web/simon/audio/voice/male/gd_occ_dt.ogg b/web/simon/audio/voice/male/gd_occ_dt.ogg new file mode 100644 index 00000000..e5e47f16 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_dt.ogg differ diff --git a/web/simon/audio/voice/male/gd_occ_up.m4a b/web/simon/audio/voice/male/gd_occ_up.m4a new file mode 100644 index 00000000..e74b22c8 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_up.m4a differ diff --git a/web/simon/audio/voice/male/gd_occ_up.ogg b/web/simon/audio/voice/male/gd_occ_up.ogg new file mode 100644 index 00000000..97178ae1 Binary files /dev/null and b/web/simon/audio/voice/male/gd_occ_up.ogg differ diff --git a/web/simon/game.js b/web/simon/game.js index 472f8b7d..cb13139c 100644 --- a/web/simon/game.js +++ b/web/simon/game.js @@ -26,6 +26,14 @@ * 答题输入的 00 §5.3 豁免同款)。非输入阶段行为不变(gameover/菜单上滑仍是规则/ * 帮助)。同一手势在 input 阶段不再有第二种含义(P1 round2 P0 根治)。 * + * round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis H2~H4): + * - H2 占用手势预告:每轮序列播放完成后、「轮到你」提示前,序列含 up/doubletap + * 则播 gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中);被占用轮次 input + * 看门狗只发 nudge earcon 不播重听建议句(say 动态置空)。 + * - H3 重听账目真实:主动重听使用时如实报余量(2→1 播 gd_chances_left1、 + * 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。 + * - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。 + * * 手势 → 音色映射: * up 上行琶音 / down 下行低音 / left 左声道弹拨 / right 右声道弹拨 / * tap 底鼓 / doubletap 双连响 @@ -66,10 +74,11 @@ const TEACH_BLUNT = { const BEST_KEY = 'soundgame.simon.best'; const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html) -// 向导层(14 号文档 + round2 synthesis F1.2):input 阶段静默看门狗——10s 轻提醒 -// +gd_relisten(不改变重听扣机会规则);25s 升级取消自动重播序列(P1:泄题且与 -// 扣机会规则矛盾)→ 改为 confirm 震 + turn earcon(纯标记,无语音) -const GUIDE_INPUT_WATCHDOG_AT = [10000, 25000]; +// 向导层(14 号文档 + round2 synthesis F1.2 + round4 H4):input 阶段静默看门狗 +// ——首级 5s 轻提醒 +gd_relisten(H4 round4:10s→5s,P4「10 秒干等」;升级级 +// 不变、次数不变);25s 升级取消自动重播序列(P1:泄题且与扣机会规则矛盾)→ +// 改为 confirm 震 + turn earcon(纯标记,无语音) +const GUIDE_INPUT_WATCHDOG_AT = [5000, 25000]; export class SimonGame extends Game { constructor(audio, root) { @@ -321,13 +330,15 @@ export class SimonGame extends Game { if (s !== this._session || this.phase !== 'playback') return; vibrate('confirm'); // "轮到你了" this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行) - if (this.round === 1) { - this.audio.play('voice/your_turn', { - onComplete: () => { - if (s === this._session && this.phase === 'playback') this._toInput(); - }, - }); - this._timer(() => { if (this.phase === 'playback') this._toInput(); }, 2500); // 兜底 + // H2(round4):序列播放完成后、「轮到你」提示前,按本轮序列播占用预告 + //(gd_occ_up/dt/both,游戏反馈层全档位,插在轮次链中)——重听键与答案音 + // 共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据(P1#1+P2)。 + const ids = this._occupancyVoices(); + if (this.round === 1) ids.push('voice/your_turn'); + if (ids.length) { + this._speakGuarded(ids, () => { + if (s === this._session && this.phase === 'playback') this._toInput(); + }, 2500 * ids.length); // 语音缺失兜底:每句 2.5s } else { this._toInput(); } @@ -337,17 +348,33 @@ export class SimonGame extends Game { step(); } + /** + * H2(round4):本轮占用预告句。序列含 up → gd_occ_up、含 doubletap → + * gd_occ_dt、两者皆有 → gd_occ_both;都不含则不播(空链直达 input)。 + */ + _occupancyVoices() { + const hasUp = this.sequence.includes('up'); + const hasDt = this.sequence.includes('doubletap'); + if (hasUp && hasDt) return ['voice/gd_occ_both']; + if (hasUp) return ['voice/gd_occ_up']; + if (hasDt) return ['voice/gd_occ_dt']; + return []; + } + _toInput() { this.phase = 'input'; this.inputIdx = 0; - // 向导层静默看门狗(round2 F1.2 礼仪重构):input 阶段无 BGM 无超时,挂机 - // 永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会规则);25s 升级 - // **取消免扣机会自动重播序列**(P1:等答案自动重念,双击重听的经济就矛盾了, - // 还顺手拆了记忆难度)→ 只发 confirm 震 + turn earcon(纯标记,无语音)。 + // 向导层静默看门狗(round2 F1.2 礼仪重构 + round4 H4/H2):input 阶段无 BGM + // 无超时,挂机永久静默。5s → nudge + gd_relisten 提示(H4:首级 10s→5s, + // P4「10 秒干等」;升级级 25s 与级数不变);25s 升级**取消免扣机会自动重播 + // 序列**(P1:等答案自动重念,双击重听的经济就矛盾了,还顺手拆了记忆难度) + // → 只发 confirm 震 + turn earcon(纯标记,无语音)。 + // H2:本轮 up 已被占用为作答时,重听建议句不再建议上滑(实现从简:被占用 + // 轮次看门狗只发 nudge earcon 不播重听建议句)。 this.guide.watchdog({ arm: true, at: GUIDE_INPUT_WATCHDOG_AT, - say: ['gd_relisten', null], + say: [this.sequence.includes('up') ? null : 'gd_relisten', null], onEscalate: (n) => { if (n === 2) this.guide.turn({ haptic: true }); }, cond: () => this.phase === 'input', }); @@ -371,7 +398,18 @@ export class SimonGame extends Game { if (this.inputIdx >= this.sequence.length) this._roundClear(); } - /** 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。 */ + /** + * 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。 + * H3(round4):使用时如实报余量——免费重听第 1 次用掉(余 1)播 + * gd_chances_left1「免费重听还剩一次。」、第 2 次用掉(余 0)播 + * gd_chances_left0「免费重听用完了。之后重听会花一次机会,别怕,慢慢来。」 + * (游戏档全级别,替换本路径旧「用完了」绝对句;P1:教学说两次、用一次即报 + * 用完了——账目不可知)。账目诊断:教学/演练/看门狗均无隐性扣减(watchdog + * 提示不消耗机会,教学期不触 chances),缺口在「免费两次」中的第二次要打完 + * 第 4 轮才发放(每 4 轮 +1、上限 2)——「用完了」因此只在真实余量为 0 时出现。 + * 答错自动消耗路径(retry_use + chances_out)与 0 次时再求重听的拒绝路径 + * (chances_out)是「答错可以消耗机会重听」的机会账,语义如实、维持不变。 + */ _relisten() { if (this.chances <= 0) { vibrate('error'); @@ -381,7 +419,8 @@ export class SimonGame extends Game { this.chances--; vibrate('pickup'); const ids = ['voice/relisten']; - if (this.chances === 0) ids.push('voice/chances_out'); // 机会归 0 提示 + if (this.chances === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次 + else if (this.chances === 0) ids.push('voice/gd_chances_left0'); // 余 0:用完了 const s = this._session; this.phase = 'roundend'; // 重听播报期间暂停接收输入 this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });