diff --git a/docs/design/00-global-ux.md b/docs/design/00-global-ux.md
index 321ebe31..cc1200cc 100644
--- a/docs/design/00-global-ux.md
+++ b/docs/design/00-global-ux.md
@@ -171,7 +171,7 @@ PUT /api/progress { game, data } (Bearer) -> 200 { ok, game }
### 5.4 通则
- 任何游戏里,**做了一个手势 3 秒内必须有反馈**(引擎内置震动 + 内容音/语音),被判定无效的手势给 `error` 震动;教学阶段除外(教学里错误手势给纠正语音)。
-- 单击有 ~300ms 引擎级延迟:即时判定(格挡、节奏打击)一律绑滑动或双击,单击只用于非即时操作(确认、检查、菜单)。新设计继续遵守。
+- 单击有 ~300ms 引擎级延迟:即时判定(格挡、节奏打击)一律绑滑动或双击,单击只用于非即时操作(确认、检查、菜单)。新设计继续遵守。**唯一例外**:rhythm 开启引擎 `immediateTap`(按下即发 tap、双击补发改判),因它的单击就是核心演奏输入;其他游戏不得开(每次按下都先发 tap,会污染滑动/长按语义)。
- 结算画面统一:单击=再来一局,下滑=菜单(回大厅)。教学可跳过:任何游戏的引导阶段,下滑=跳过引导(首次强制完整引导的标志位存进度里)。
## 6. 向导层(R7 增补,已实现:engine/guide.js)
diff --git a/docs/design/13-playtest-criteria.md b/docs/design/13-playtest-criteria.md
index 6d9c82e9..97583268 100644
--- a/docs/design/13-playtest-criteria.md
+++ b/docs/design/13-playtest-criteria.md
@@ -134,3 +134,613 @@
- 依据:docs/design/14-guide-layer.md「round3 v1.2」§8/G5;simon playtest 断言仅
上述 3 处改口(108 条总数不变),其余断言(含教学、复活、结算、菜单、序列约束、
速度下限)零改动。
+
+---
+
+## 9. round1-live 变更记录(simon 结算/菜单/反馈修正)
+
+> 依据 research/04-playtests/live/round1/simon-guoguo.md(果果,10 岁 persona 全拟真
+> 试玩)。按「playtest 断言零改动」红线的例外流程(§8 先例),本节只追加登记 §3
+> 受影响断言的替换口径与新增断言;§3 既有文字不回改,其余 5 个游戏断言零改动。
+
+- **结算屏单击改口(P0 言行不一)**:gameover 阶段单击**不再跳句**,无论语音是否
+ 在播一律直接重开(_restart,跳过教学直达第 1 轮)——结算播报承诺「单击屏幕,
+ 再来一局。」,单击必须言行一致。原「结算语音播放中单击=跳过本句(不重开)」
+ 断言替换为:**结算语音链播放中单击 → 立即进入新局第 1 轮**(phase='announce'、
+ round=1、round_01 开播、结算链被打断)。无语音时单击重开的原断言不变。结算屏
+ 上滑=重听规则、下滑=打开菜单,与 rules_prompt 播报一致,维持不变。
+- **新增边界断言(P1 菜单导航静音)**:菜单内右滑=下一项、左滑=上一项(首项再
+ 左滑回绕末项),切换即播报当前项(menu_restart/menu_home/menu_back);菜单内
+ 下滑=关闭菜单回结算页(§7.5「下滑关闭」约定在 simon 的落地补全)。
+- **新增边界断言(P1 未支持手势被吞)**:input 阶段 longpress → error 震动兜底,
+ 不判错、不耗机会、不进重听(同时修复 TONE['longpress'] undefined 导致
+ audio.play(undefined) 抛 TypeError 的隐性崩溃——live 中"长按 9 秒零反馈"的
+ 实际成因);longpressend(同一动作收尾)静默不重复反馈。
+- **新增边界断言(P1 正反馈稀缺)**:每轮答对必播一句表扬——既有台词池
+ [voice/correct, voice/gd_clear] 按轮轮播(第 1 轮 correct、第 2 轮 gd_clear、
+ 交替),替换原 G6「correct 每 4 轮一次 + gd_clear 每局限 1 次」口径;
+ gd_record 每局限 1 次、chance_plus 每 4 轮发放,均维持不变。
+- **组合占用提示改口(P2 每轮必念)**:gd_occ_up/dt/both 改为**每局每种组合只在
+ 首次出现轮播一次**;之后仅在玩家答错消耗机会重听时随 retry_use 链尾重播本轮
+ 组合提示。替换原 H2「每轮序列播放完成后按序列播占用预告」口径。新增断言:
+ 同一 gd_occ_* id 不在两个轮次开场重复播报;答错重听链尾重播当前组合提示
+ (序列含 up/doubletap 时计数 +1,不含时不变)。
+- **guide.test.mjs G6 simon 段同步改口**:原「轮成功链尾播 gd_clear(每局 1 次)」
+ 断言替换为「第 1 轮表扬 = correct」;「gd_clear 每局仅第 1 次轮成功播」替换为
+ 「第 2 轮表扬轮播到 gd_clear」;gd_record 断言保留,但需等表扬+纪录两句链播完
+ 再计数(链多句时立即计数会采到链中——一并修正原断言采样过早的时序问题)。
+
+---
+
+## 10. live round1 变更记录(storyline《声命线》老陈全拟真试玩修复)
+
+> 依据:research/04-playtests/live/round1/storyline-laochen.md(P2-1 结局双击重听
+> 失效 / P2-2 猎狗台词不认分支 / P2-3 静默无兜底 / P3-2 结局后状态播报滞后)。
+> 本节只做追加;§1 既有断言与 §7 全局断言零改动,其余 5 游戏不受影响。
+
+新增边界断言(storyline):
+- **结局双击重听**:结局链播完(结算态 awaitingRestart)双击 → 结局语音重播;
+ 重播期间单击 = 跳过本句(不触发重开,nodeId 不变);重播链播完回到结算态
+ (awaitingRestart === true),此后单击仍可重开。
+- **分支台词条件**:`n15` 右滑(钢索)路径进 `n18` **不播** `n18_dog`(无涵洞经历,
+ 台词「涵洞也拦不住你」不得出现,n18 只播抉择提示 p6);`n15` 左滑(涵洞)路径
+ 照播 `n18_dog`。台词文本不变、不新增语音 id。
+- **结局后状态播报**:结算态上滑 → help 后播**当前结局语音**(不再报 status3
+ 「这是最后一段路了」);播完直接回结算态(不二次重播节点)。
+- **静默兜底(standard 档)**:非抉择节点播完等待时,13s 无操作播 `gd_continue`、
+ 30s 播 `gd_again`(复用向导层看门狗计时/复位;玩家手势后重置,下一等待点
+ 重新挂载)。`guide=full` 节奏维持 [25s,50s] 不变(guide.test 锁定);
+ `guide=off` 不挂任何定时器(既定全静契约)。
+
+---
+
+## 11. live round1 变更记录(space《盲航》小赵全拟真试玩修复)
+
+> 依据 research/04-playtests/live/round1/space-xiaozhao.md(P1×4 / P2×2)。只改
+> 触发时机/判定逻辑,台词文本与语音 id 零变更。按 §8 先例只追加登记 §5 受影响
+> 断言的替换口径与新增断言;§5 既有文字不回改,其余 5 个游戏断言零改动。
+
+- §5 边界「同向判定:撞击前 1.5s 内同向滑 = dodge(dodges+1);反向滑 ≠ dodge」
+ → 替换为:**同向滑判定窗口 = 呼啸全程**——陨石 spawn(呼啸响起)到撞击点
+ +250ms 宽限之间的同向滑均判 dodge;spawn 之前的同向滑不判(`ob.spawnAt`
+ 下界)。新增回归断言:呼啸响起后 0.2s 内早滑(距撞击 >1.5s)也算躲开
+ (P1「早滑被静默吞掉」)。
+- §5 边界「护盾充能:doubletap 后 30s 内 shieldReadyAt 锁定,到时播 shield_ready」
+ → 替换为:doubletap 后 **15s** 内 shieldReadyAt 锁定,到时播 shield_ready
+ (queue 排队播,不顶 protect_end/归因句)。新增:充能中/未引入时双击 →
+ 播 feat_shield_done 应答 + HEARTBEAT 震动(4s 去抖,连刷不重播)。
+- 新增边界断言(P1 方向语音永远迟到):裸飞陨石 spawn 当刻 voice/dir_
+ 与 whoosh 同窗播出(播放日志同一时刻),撞击前 500ms 的 loud 警报档不再带
+ 方向呼喊;撞击点落在保护期内的陨石 spawn 时不播 dir_(protect_
+ 长句教学优先)。
+- 新增边界断言(P1 保护期断崖过渡):protect_end 后第 1~3 颗裸飞陨石逼近时长
+ 固定 = PROTECT_APPROACH_MS(4.0s,不收缩),第 4 颗起 < 4.0s 接入难度曲线。
+- 新增边界断言(P2 最后一命警告健壮性):hits===2 时 hit_ 在播中被
+ dir_ 呼喊打断,呼喊播完后 hit_last 仍自动接播(挂起 + 呼喊 onComplete
+ 接力,不再整链静默丢失)。
+- 新增边界断言(P2 feat 教学仅首局):同一 SpaceGame 实例第 2 局 advance 过
+ 20s/45s 不进 feature phase、不播 feat_boost/feat_shield;双击护盾/单击加速
+ 第 2 局开局即用(introDone 标记挂实例,不随 _resetRunState 重置)。
+- 依据:round1-live 试玩报告问题清单 1/2/3/4/5/6;space playtest 其余断言零改动。
+- 随上述改口的测试脚手架微调(非断言变更):dir 呼喊断言前补一次 `await tick()`
+ ——round4 R1 活体复验追加修复「voice 播放安全门」(_playVoice 异步竞态:同 tick
+ 第二个 play 漏杀前一个 → 双管线并播 + 节点泄漏;游戏层串行门 + 对局语音全量
+ prewarm 缓存热修复,引擎零改动),播放调用改为 microtask 落位,断言语义不变。
+ guide.test.mjs §⑨ G7 段同步改口为「呼喊与呼啸同窗、与 loud 警报错峰 ≥2s」。
+
+---
+
+## 12. round1-live 变更记录(escape《暗室》阿May全拟真试玩修复)
+
+> 依据:research/04-playtests/live/round1/escape-amay.md(P1 三项:卡关提示失控 /
+> 语音中单击吞检查 / 撞墙教学腔,唯一事实源)。本节只做**追加**,§2 既有文字不
+> 回改;受影响断言的替换口径与新增断言登记如下,escape playtest 其余断言零改动。
+
+- §2 边界「卡关提示:计时器快进 60s/120s/180s → 依次播 stuck_*_1 / stuck_*_2 /
+ stuck_*_3 + nav_*;任意进展事件后重置(`_stuckShown` 清空)」→ 替换为:**卡关
+ 提示按"无操作时长"计时**——PLAY 阶段任何有效手势重置计时(`_lastActiveAt`);
+ 连续无操作 60s/120s/180s → 依次播 L1(stuck_ask + stuck_*_1)/ L2(stuck_*_2)/
+ L3(stuck_*_3 + nav_*,此后每 60s 无操作重复);任意进展事件后 `_stuckShown`
+ 清空(下一段静默期从 L1 重新分级)。三级阈值与分级内容不变(01 号文档 §3.2)。
+- 新增边界断言(提示排队礼仪):卡关提示到点时若有语音在播,**只入待播队列,
+ 等当前语音自然播完才播,绝不顶掉进行中的语音**(断言:在播句未被截断、提示
+ 句开播时刻 ≥ 在播句开播时刻 + 其时长);待播期间玩家任何操作 → 该提示作废,
+ 未播级别不计入 `_stuckShown`(下段静默期可重新触发)。测试允许用钩子直写
+ `_lastActiveAt` 并直调 `_checkStuck()` 模拟"语音播放中到点"。
+- 新增边界断言(活跃免扰):PLAY 中每 ~25s 一个普通手势、持续 ~250s 且无任何
+ 进展事件 → 全程零 stuck_* 播报;停止操作 60s 后 L1 如期而至(计时器仍武装)。
+- §2 中"voice 播放中单击 = 跳过本句"的行为口径补全:可跳句播放中单击 = 跳过
+ 本句(链播继续)**并补发检查**——环境音即时重播、检查描述进待播队列(链播
+ 未完则排队,不顶句;链播已完则照常 800ms 后播)。不可跳句(关键获得/L3 带路)
+ 播放中单击 = 忽略,原断言(不跳句、不触发检查)不变。
+- §2 边界「撞墙」补充口径:PLAY 阶段撞墙 = wall 音效 + error 震动 + 位置不变 +
+ `voice/wall`("那边是墙,换个方向。"),**不再接播 guide_blunt_* 方位句**;
+ 教学腔方位句(guide_blunt_*)仅限 GUIDE 阶段(G2/G3 纠错路径断言不变)。
+- 移除向导 full 档「连续撞墙 ≥2 次 → 卡关阈值提前一级(L1 即刻/L2 60s/L3 120s)」
+ 的 escape 接线(14 号文档 v1 裁定 5/§2 escape 行):该机制在"无操作时长"语义
+ 下等价 L1 永触发,是 round1-live「机关枪提示边玩边扫射」的主因;`_wallBumps`
+ 状态一并移除。web/harness/sessions/escape.mjs 剧本第 58-64 行相关旁白(连撞
+ 两墙等提前提示)随本变更过时,剧本本身不断言、不影响运行。
+
+---
+
+## 13. round1-live 变更记录(rhythm《节拍行者》小江全拟真试玩修复)
+
+> 依据 research/04-playtests/live/round1/rhythm-xiaojiang.md(P0×2 / P1×4 / P2×3)。
+> 只改触发逻辑/判定参数/反馈音复用:台词文本与语音/音效资产零新增零变更
+> (Miss 音 = 既有 inst_hat_l 以 playbackRate 0.5 降调复用;教学上/下滑步复用
+> gd_answer「现在跟着我打」作引导句)。按 §8 先例只追加登记 §4 受影响断言的
+> 替换口径与新增断言;§4 既有文字不回改,其余 5 个游戏断言零改动。
+
+- §4 关键路径 1「教学 T1-T3」→ 替换为:**教学 5 步**(tut_1 单击 → tut_2 左右 →
+ 上滑(gd_answer)→ 下滑(gd_answer)→ tut_3 三音带双击压轴)——上/下滑音色
+ 教学补全(P3-11;tut_3「最后一句」台词保持为真,故上/下滑步插在其前)。
+ 下滑步中临近 down 音符时下滑 = 演奏应答(不跳教学);远离时下滑 = 跳过教学
+ (原行为不变)。
+- §4 边界「tap 补偿:回调时刻 -0.30s 判定」→ 替换为:**判定时刻 = 触摸时刻,
+ 按手势补偿识别延迟**:immediateTap(index.html 已开启,按下即发 tap)下 tap
+ 补偿 0(expected+0 注入判 Perfect);双击事件前 350ms 内无 tap 事件 → 闩锁
+ 回退旧引擎行为(补偿 TAP_DELAY 0.30s,expected+0.30 注入判 Perfect;测试钩子
+ `game._immediateTap = false` 直接置位验证旧路径);滑动补偿 SWIPE_DELAY
+ 0.15s、双击 DOUBLE_DELAY 0.20s(识别均在 touchend;expected+补偿 注入判
+ Perfect)。Miss 超时放宽 = 对应补偿 + 0.10s。
+- §4 边界「Miss=无乐器音」→ 替换为:Miss 不播**该音符的**乐器音(错过=没出声
+ 不变),但必播 **Miss 反馈音**(inst_hat_l 降调 0.5× 闷镲)+ error 震动
+ (P2-8 Miss 无声);长按 = 死输入同款反馈(Miss 音 + error 震动,不判定不
+ 归属,P0-2 附项);longpressend 静默。
+- 判定窗放宽(P2-10 / P0-1):PERFECT ±0.12 → **±0.15s**、GOOD ±0.25 →
+ **±0.35s**(吸收滑动/双击识别延迟与蓝牙余量);评级线不变(B≥0.70,与
+ grade_b 台词「超过七成」一致——窗口+补偿校准后真人认真打可达 B)。
+- 新增边界断言(P0-2 下滑折中加宽):down 应答音符自其领奏响起(expected-4 拍)
+ 至判定死线(expected+GOOD+SWIPE_DELAY+0.10)之间,下滑一律判演奏——慢滑
+ (识别 +0.35s)踩点下滑判 Good 不弹菜单;该窗口之外下滑才呼出菜单(不变)。
+- 判定归属改口(密集谱防抢占):归属改为**同手势最近未判定音符优先**——同句
+ 0.5 拍相邻的异手势音符(如 song2 第 7 句 down/up 相邻)不再被"最近归属"
+ 跨手势偷走;无同手势在窗音符时,最近异手势音符落在错手势窗(GOOD 的一半)
+ 内 = 窗口内做错动作 Miss(原"窗口内错手势判 Miss"语义保留,窗口收紧);
+ **tap 豁免异手势 Miss**(immediateTap 序曲 tap 防误判,真正错手势 tap 由该
+ 音符超时自动 Miss 兜底,同样有 Miss 音)。
+- 新增边界断言(P1-4 暂停真冻结):菜单开启 3s 期间无新 Miss(判定冻结)、无
+ 新排程音符(谱面冻结);关菜单即停菜单语音链(isVoicePlaying === false,
+ 菜单语音不骑恢复后的领奏——live「菜单不暂停」感知的主因)。
+- 新增边界断言(P1-3 结算 BGM 状态管理):结算 menu pad(bgm/menu)只在语音链
+ 末句(voice/restart)播完后起播(playLog 顺序断言),且只在仍处于结算态时
+ 起播(重开/下一首后过期看门狗 fire 不得把 menu pad 漏进正片)。
+- 新增边界断言(P1-5 回大厅):结算屏下滑 → 进 leaving 态 + 播确认语音
+ menu_lobby → 随后 window.location.href 置 '../'(真实跳转,不留尸体页面)。
+- 新增边界断言(P3-12 零通过不夸):教学全程零通过(逐步失败 2 次跳过)收尾播
+ retry_skip 不播 tut_done;有任一步通过则照常播 tut_done。
+- 新增边界断言(immediateTap 序曲 tap 治理):非演奏阶段(intro/select/menu/
+ end/leaving)tap 延迟 500ms 生效,期间任何其他手势到达即作废(防滑动/双击/
+ 长按的序曲 tap 误触发选曲/重开/菜单项/跳句);演奏阶段(tut/main)tap 只
+ 认领 tap 音符——应答 up/down 等音符前的序曲 tap 出声(kick)但不产生尝试
+ 记录、不判 Miss、不抢占归属。
+- 新增边界断言(滞留语音链看门狗作废,live 复验深挖):教学纠正链在播时下滑
+ 跳教学(链被顶掉)→ 进入 main 后越过 10s 看门狗窗口,phase 保持 main 不被
+ 拖回 tut。机制:`_speakGuarded` 看门狗只在"自己这条链仍是当前链"时可 fire
+ (语音链序号 `_voiceSeq`:任何更新的 _speak/_speakGuarded/_cleanup 都会作废
+ 更旧的滞留看门狗)——否则滞留看门狗会把 onDone(如 _tutLead)砸进毫不相干
+ 的相位(live 实测:纠正链被顶 10s 后游戏从 main 被拖回 tut,遗留的 mid_song
+ 定时事件跨相位乱入)。
+- P1-3 附记(证据保留):live 事件流中「重开后 track90 排程 0.5s 被 stop」实际
+ 停的是结算 menu pad(sid 对得上);混音 40-120Hz 频段能量各局一致,track90
+ 全程在播——「重开后 BGM 消失」是事件误读。真实缺陷 = 结算看门狗 10s 早火
+ (menu pad 骑语音链尾,报告 P3-15 同因)+ 过期 fire 无相位守卫,本条已修。
+
+---
+
+## 14. round1-live 变更记录(rpg《剑与声》小周全拟真试玩修复)
+
+> 依据 research/04-playtests/live/round1/rpg-xiaozhou.md(P1 格挡与血量播报矛盾 /
+> P2 闲置提示结算中插话被掐 / P2 回合提示口吃 / P2 教学"挡住了"与实际不符+重播 /
+> P3 选项循环误选返回)。设计裁定:格挡**仍有擦伤**(05-rpg §2「减伤 70%」、
+> 11-rpg-battle-rebalance §3「格挡成功 2-3 伤」),非全免伤——故修数值对齐设计 +
+> 教学明说减伤经济 + 播报与战况匹配,不做格挡全免。台词文本与语音 id 零新增零
+> 变更(TTS 环境缺失);只改触发逻辑/判定参数/选播既有句。按 §8 先例只追加登记
+> §6 受影响断言的替换口径与新增断言;§6 既有文字不回改,其余 5 个游戏断言零改动。
+
+- §6 关键路径 4(教学战四步)中 T1 播报口径改口:attack 步**不再播 tut_b3**
+ (其实录为旧两步教学"格挡→攻击"台词「挡住了!轮到你了。左右滑选攻击,单击
+ 出剑。」,新顺序 T1 在格挡之前播「挡住了!」与实际不符),改播**重播既有
+ tut_battle_2**「左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。」
+ (语义一致,不新增语音 id);tut_b3 资产保留但退出教学链(TTS 缺失不可重录,
+ 列遗留)。原断言「T0 切换 2 次 → T1 attack(tut_b3)」替换为「T1 attack 重播
+ tut_battle_2(段内计数 ≥2)且教学全程 tut_b3 计数 = 0」。教学 8s 重复提示
+ 补口径:挂载后玩家有任何操作则跳过本次重复提示(16s 兜底推进不变;
+ B1「全程不动 → tut_b0 重播 ≥2」断言不变)。
+- §6 关键路径 4 的 T2 结算链改口:parry 步结算(成功/失败两路径)在 tut_b2b /
+ tut_b4 与 tut_b6 之间插播**既有 tut_battle_4**「没把握就防御,伤害减半。金疮药
+ 可以回血。」——格挡体验点明说减伤经济(格挡≠免伤、防御减半、药回血;无
+ "格挡也有擦伤"原句资产,选播现有最近说明句,补录列遗留)。原断言
+ 「tut_b4 → tut_b6 有序」替换为「tut_b4 → tut_battle_4 → tut_b6 有序」。配套:
+ 该三句链真实时长 ~16s,兜底 10s→**20s**(原 10s 会在链中误触发进
+ PLAYER_SELECT、cmd 指令名拦腰顶断 tut_b6);defend 步教学看门狗改为**链播完
+ 进回合时才挂载**(8s/16s 计时从玩家可操作起算,不再抢在 tut_b6 播完前跳步
+ ——live 复验发现,本轮一并修复)。
+- §6 边界「格挡:前摇窗口内双击 → 伤害 ×0.3」补充口径:擦伤在 ×0.3(四舍五入)
+ 基础上**钳 1~3**——对齐 11 文档 §3「格挡成功 2-3 伤」(原实现无封顶,Boss
+ 二阶段 base 12 单发擦伤可达 4;min 1 保住"擦伤必有"的设计痛感)。B3 既有
+ 断言(base 4 → 1)不受影响。
+- 新增边界断言(P1 全格挡不掉 low):Lv3+铁剑+皮甲标准出装 Boss 战全格挡打法
+ (每次前摇内双击、不喝药)→ 全部敌方攻击均被格挡且擦伤 ∈[1,3];全程**不播
+ st_hp_low**;战后血量比率 ≥0.3;全格挡可击杀 Boss。确定性探针:base 12 的
+ 一刀被格挡 → 擦伤恒为 3。
+- 新增边界断言(P2 闲置提示不顶断结算语音,guide=full):PLAYER_SELECT 闲置
+ 30s → gd_stall 开播;其开播中玩家出手(击杀)→ 结算语音链首句
+ (btl_enemy_die)**queue:true 排队等 gd_stall 播完**,不拦腰顶断;无闲置提示
+ 在播时击杀语音不排队(对照,默认顶句语义不变)。机制:看门狗 cond 增加
+ `!b.resolving`(回合结算中禁止插入)+ `_speak` 在向导句余波 6s 内首句排队。
+- 新增边界断言(P2 指令名口吃):650ms 指令名定时器按 PLAYER_SELECT 代数
+ (selectSeq)作废旧回合滞留;同一指令名 1.2s 内已播过则跳过本次——折返滑回
+ 攻击+滞留定时器 → cmd_attack 不双播;相邻两回合连进 → cmd_attack 只播一次。
+- 新增边界断言(P3 返回项末位提示,guide=full):探索态右滑落到"返回"项
+ (各场景恒为末项)→ 报名 opt_back 后接播既有 gd_wrap_last「最后一个了。」
+ (每场景一次,与回绕提示共用 _wrapShown 去重;商店"离开"项同款,每次进商店
+ 一次);序号播报无现成语音资产,按任务裁定做前者,不新增语音 id。
+- 依据:round1-live 试玩报告问题清单 P1-2 / P2-1 / P2-2 / P2-3 / P3-3;rpg
+ playtest 断言 139 → 159(新增 20 条),其余断言零改动。
+
+---
+
+## 15. round2-live 变更记录(simon《音序回响》果果全拟真复测修复)
+
+> 依据 research/04-playtests/live/round2/simon-guoguo.md(P1-N1 免费重听货不对板
+> 冤死 / P2-N2 下滑首次进序列无提示 / P2-N3 菜单不教双击确认 / P2-N4 重播期手势
+> 被吞 / P3-N5、N6)。台词文本与语音 id 零新增零变更(TTS 环境缺失);只改计数
+> 逻辑。按 §8 先例只追加登记 §3 受影响断言的替换口径与新增断言;§3 既有文字不
+> 回改,其余 5 个游戏断言零改动。
+
+- **N1 修复:重听改两层账(规则级冤死根治)**。教学 gd_economy 承诺「免费机会
+ 有两次,用完要扣一次机会」,旧实现免费第二次要打完第 4 轮才随 chances +1
+ 发放——玩家第 1 次重听(chances 1→0)即听报「免费重听用完了」,第 2 次求
+ 重听被拒,之后答错一题立即 game over("听 2 遍+错 1 题=死")。§3 关键路径 4
+ 「主动重听 → 断言 chances 减少」口径改口为:**免费重听每局 2 次开局即给足
+ (`freeRelistens`,新实例字段,_beginGame 重置),主动重听(input 上滑)先耗
+ 免费额度、不碰 chances;免费用尽后第 3 次起重听才扣 chances;免费与真机会
+ 皆空时求重听 → 拒绝(error 震 + chances_out,不判错、不耗账)**。播报沿用
+ H3 既有句:免费 2→1 播 gd_chances_left1、1→0 播 gd_chances_left0;扣真机会
+ 且扣空(chances→0)追加 chances_out 如实报。答错自动消耗路径(retry_use +
+ chances_out,只走 chances 机会账)与每 4 轮 chances +1(上限 2)均不变。
+- §3 断言变更登记(simon playtest 123 → 132,净 +9):第 4 轮新增 1 条
+ 「免费重听每局 2 次开局即给足(freeRelistens === 2)」;第 5 轮原 6 条
+ (「主动重听耗机会:chances 1→0」「播 relisten 并重播」「重听后输入进度
+ 清零」「chances=0 上滑非期望:不判错」「chances=0 上滑播 chances_out」
+ 「chances 不变(不为负)」)替换为 14 条四层账块——①第 1 次重听用免费额度
+ (free 2→1、chances 不动、播 relisten+gd_chances_left1、重播后 inputIdx 清零);
+ ②第 2 次仍免费(free 1→0、chances 不动、播 gd_chances_left0);③第 3 次才
+ 扣真机会(chances 1→0、追加 chances_out);④两账皆空拒绝重听(不判错、
+ 两账不为负、播 chances_out)。链尾句断言一律在链播完(回到 input)后计数。
+- guide.test.mjs 同步(121 → 122):⑧ G5 simon 段两处加测试钩子
+ `game.freeRelistens = 0`(免费层置空,专测扣真机会路径,断言文本零改动);
+ ⑩a H3 段 5 条断言按两层账改口(旧钩子 `game.chances = 2` 模拟"免费第二次"
+ 作废)——余量句改锚免费余量(free 2→1 播 left1、1→0 播 left0、chances 全程
+ 不动),新增 1 条「免费重听每局 2 次开局即给足」;拒绝路径改用钩子
+ `game.chances = 0` 置空真机会后验证(两账皆空 → 拒绝 + chances_out)。
+- **N3 菜单不教"双击确认"**:查 voice_text.json simon 条目无可复用操作法句
+ (rules_prompt「下滑,打开菜单」在菜单内语义相反、skip_hint/t6_double 语义
+ 不符;rhythm/space 的 menu_main「菜单。单击切换选项,双击确认。」语义虽
+ 与 simon 菜单完全一致,但引入属新增语音 id,本轮约束不允许)。**记遗留
+ (需要新台词)**;菜单手势语义不做任何改动。
+
+### 遗留清单(round2-live simon,本轮不修)
+
+1. **菜单操作教学句(N3,需要新台词)**:补录 simon 版 menu_main(参照
+ rhythm/space 既有句「菜单。单击切换选项,双击确认。」,与 simon 菜单语义
+ 一致),进入菜单时链尾补播。
+2. **大厅零反馈 / 误入其他游戏(复验项 7,跨游戏)**:web/index.html 大厅对
+ 6 种手势全部零反馈、无回游戏路径;属跨游戏大厅问题且需要新台词(入口语音
+ + 每步音效),本轮不修。
+3. **N2 down 首次进序列无提示**:gd_occ_* 占用预告只覆盖双义手势 up/doubletap
+ (down 在 input 阶段无第二含义);要补"下"首次提示需新语音 id,列遗留。
+4. **N4 序列重播期间手势被吞**:playback「纯聆听阶段手势静默忽略」是 02 §4.1
+ 既定裁定且有断言锁定;加"等等哦"反馈需新音效/设计复议,列遗留。
+5. **长按等未教手势只有震动没有语音(round1 复验项 3 遗留)**:需要新台词
+ ("这个手势不算哦"类),TTS 环境缺失,列遗留。
+6. **N5 每局"第一次提示"重新计算 / N6 序列"前缀+1"累加**:报告自评可接受 /
+ 属玩法设计层面,本轮不处理。
+
+---
+
+## 16. round2-live 变更记录(storyline《声命线》老陈全拟真复测修复)
+
+> 依据 research/04-playtests/live/round2/storyline-laochen.md(N1 冷启动 intro
+> 末句被 30s 看门狗吃掉 / N2 钢索路径猎狗对峙戏整段失踪——round1 P2-2 修复
+> 引入的回归 / N3 抉择节点无静默兜底 / N4 结局后上滑无进度播报)。台词文本与
+> 语音 id 零新增零变更;引擎零改动(看门狗根因在 game.js 侧,用引擎公开
+> `isVoicePlaying()` 判定)。按 §8 先例只追加登记 §10 受影响断言的替换口径与
+> 新增断言;§10 既有文字不回改,其余 5 个游戏断言零改动。
+
+- **§10「分支台词条件」断言翻案(N2,回归修复)**:恢复涵洞/钢索两条路径都播
+ `n18_dog`——round1 按 `!viaCulvert` 整段滤掉 n18_dog,钢索路径猎狗堵桥对峙戏
+ 蒸发、p6 抉择(交出坐标还是拒绝)失去对手戏上下文,玩家裁定"比穿帮更糟"。
+ 替换口径:**n15 右滑(钢索)路径进 n18 照播 `n18_dog` + 抉择提示 p6**;钢索
+ 路径接受「涵洞也拦不住你」的轻微穿帮,标 known-issue 待 TTS 补录分支句后按
+ `viaCulvert` 选播(标记保留;不新增语音 id、台词文本不变)。
+- **新增边界断言(N1 看门狗不误掐在播长语音)**:单句看门狗(VOICE_WATCHDOG_MS
+ 30s)触发时先查引擎 `isVoicePlaying()`——为真(资产真实在播,自然播完必触发
+ onComplete)→ 续约再等一轮,不掐不告警;为假(资产缺失、引擎静默跳过)→ 照旧
+ 告警自动续链(§1 不卡死契约与"屏蔽资产 30s 告警续链"断言均不变)。根因:冷启动
+ 链为 intro+skip_hint,30s 固定阈值续链时 skip_hint 顶掉在播的 intro(71.7s,
+ 2.2x 也需 32.6s),末句「现在,单击屏幕,睁开眼睛」被吃;菜单重开时链上无
+ 下一句可顶故不咬人。常速下 e1/e2/e4(48~51s)越线风险同除。
+- **新增边界断言(N3 抉择节点静默兜底)**:抉择提示(p*)播完 25s 完全无操作 →
+ 重播当前 `voice/p*` 提示句(复用既有资产),50s 再复述一次后彻底静默(两级
+ 上限,与继续节点同一礼仪);玩家任何手势复位(抉择等待态空点重新挂载 = 计时
+ 重置;已缓存 pendingChoice 直接推进不挂);`guide=off` 不挂(全静契约)。
+ full/standard 档提示句均由游戏层播(p* 是剧情内容不是向导句)。与 14 号文档
+ 「抉择态(p* 播完)不插语音不挂看门狗」裁定的兼容性:该裁定本义是"提示播完
+ 立即可选、不插语音延迟抉择"(W2 契约,保留——choiceArmed 播完当刻即真、零
+ 插播),本兜底只在 25s 完全无操作后复述提示本身,不延迟抉择;变更依据 =
+ round2 玩家复测证据(p6/p8 实测 35~39s 零提醒,抉择点最易漏听选项)。
+- **N4 遗留(结局后上滑无进度播报)**:「已到达结局:XX」类播报需新语音资产,
+ 受"不新增语音 id"约束本轮不修;现状(help + 重播当前结局语音)维持 §10 口径,
+ 待 TTS 补录(如 status_end)后补办。
+- 依据:round2-live 试玩报告 N1/N2/N3/N4;storyline playtest 断言相应新增/替换
+ (钢索 n18 翻案 1 条 + N1 长语音 6 条 + N3 抉择兜底 7 条),guide.test 全绿不变,
+ 其余 5 个游戏断言零改动。
+
+---
+
+## 17. round2-live 变更记录(escape《暗室》阿May全拟真复测修复)
+
+> 依据 research/04-playtests/live/round2/escape-amay.md(P1-新① 引导链断裂
+> g1_move 永不播放 / P1-机关枪复验"部分修复" / P2-新② 逃脱台词开头丢失 /
+> P3-新③「先跟我走」信息量为零)。台词文本与语音 id 零新增零变更;只改
+> 触发逻辑与音效时序。按 §8 先例只追加登记 §2 受影响断言的替换口径与新增
+> 断言;§2 既有文字不回改,其余 5 个游戏断言零改动。
+
+- **P1-新① 引导链断裂修复:G0 干等"单击继续"却无人告知该手势(根因)**。
+ 复盘 events/transcript:g0_intro 完整播完后游戏停在 G0 等单击,但全部语音
+ 里没有一句说过"单击继续"——round1 靠解锁单击漏进游戏误推 G0→G1 被掩盖,
+ round1-live 修复解锁漏触后暴露为 intro 后 17~91s 死寂;玩家滑动只被
+ guide_offpath「先跟我走」拦回(不教方向),最后一次下滑触发跳过,8 步
+ 引导整体蒸发(三次复现路径同因)。修复(web/escape/game.js):
+ ① `_startGuide`:g0_intro 自然播完且 1.2s 内玩家未单击 → 自动
+ `_advance(1, [g1_move, skip_hint])`——**g1_move 必播**(台词零改动,
+ 单击仍可随时立即推进);② G0 在 intro 播放中的滑动改为忽略(原拿
+ guide_offpath 顶掉开场白、且 intro 被打断后 onDone 不发、自动推进永不
+ 挂载——死寂第二成因);③ **下滑跳过仅限答案不含滑动的步骤
+ (G0/G3/G5/G6)**:G1/G2 的正确答案是"任意滑动"(g1_move 原话「朝任意
+ 方向滑一下」)、G4 的答案就是下滑——这些步骤下滑 = 作答不可跳过
+ (G4 既有裁定同逻辑扩展;13 号文档原"任何步骤下滑跳过、G4 除外"口径
+ 就此改口)。§2 断言变更:关键路径 A 的 G0 段口径不变(单击仍即推 G1);
+ 新增场景 A2「冷启动引导链」14 条——intro 播完零手势 1.2s 自动播
+ g1_move、intro 播放中滑动不顶开场白且 g1_move 仍自动播出、G1/G2 下滑
+ = 作答(不播 guide_skip)、零 G0 单击走完全链进 PLAY、G3 下滑仍跳过。
+- **"卡关提示机关枪"复验翻案:计时器无 bug,stuck_key_2 来自双击兜底**。
+ 逐秒复盘 round2 会话 transcript(t=556.9 重开 → 603.9 tap / **605.4
+ doubletap** / 607.1 swipeup → 608.0 stuck_key_2):该句是 `_useItem`
+ 无目标兜底的设计内主动求提示(§3.2「双击无目标:先播背包,再播 L2 级
+ 明示」——双击后 2.6s 链播,非 5s 轮询到点);`_restart`/`_enterPlay`/
+ `_resetState` 均重置 `_lastActiveAt`,`_checkStuck` 按"无操作时长"
+ (60/120/180s)分级,重开后 61s 无操作的 stuck_ask(t=670.1)触发正确。
+ 代码零改动;新增场景 C4 冻结语义:重开后每秒操作 90s 零 stuck_*、停手
+ 60s L1 如期(计时器仍武装)。live 复验口径:高频操作 90 秒零卡关提示
+ (双击兜底属主动求提示,不计入)。
+- **P2-新② 逃脱台词开头丢失修复:音效后移避让(不改语音/不增 id)**。
+ 根因:`_escape` 同帧并发 gate_open(2.8s 轰鸣)+ win_chime 与 escape
+ 人声(14.92s),轰鸣恰好盖住头句「你扳动开关。电机轰鸣,」(三连复现
+ heard 均从「沉重的铁门缓缓升起」起)。修复:gate_open/win_chime 经
+ `_timer` 后移 2600ms(会话守卫,重开/停页即清)——人声头部(1.0x
+ ≈2.4s、1.7x ≈1.4s)无遮挡进耳朵,轰鸣落在「电机轰鸣,铁门缓缓升起」
+ 附近,声画同步反而更准。§2 关键路径断言替换口径:原「逃脱音效
+ gate_open + win_chime 各播 1 次(双击当下)」替换为「双击当下音效计数
+ = 0(人声头部 2.6s 无对撞)+ tick 2600ms 后各播 1 次」,断言条数 +1。
+- **P3-新③「先跟我走」信息量为零**:受"不得改台词文本/不新增语音 id"
+ 约束无法改写该句;本轮以 ①②③ 将其压缩为极小概率兜底(仅 intro 播完
+ 后 1.2s 空档内的滑动可触发,自动推进到点即被 g1_move 顶掉),记遗留
+ (待 TTS 补录带方向的兜底句,如"等我说方向,先跟我走")。
+- 依据:round2-live 试玩报告「如果只能改 3 件事」全部 3 条;escape
+ playtest 断言 121 → 140(新增 19 条:A2×14 + 逃脱音效时序×1 + C4×4),
+ 既有 121 条语义零回改(仅逃脱音效 1 条按上条口径替换计时),其余 5 个
+ 游戏断言零改动。
+
+---
+
+## 18. round2-live 变更记录(space《盲航》小赵全拟真复测修复)
+
+> 依据 research/04-playtests/live/round2/space-xiaozhao.md(B1 高速段误暂停
+> 两次复现 ta=664.99/1670.73 / B2 恢复后 3 秒暴毙 / B3 回大厅落无声死界面 /
+> B4+A5② 警告与结算叠播 / B5+A5① 高速段最后一命警告缺席)。台词文本与语音 id
+> 零新增零变更;只改触发逻辑与手势路由。按 §8 先例只追加登记受影响断言的
+> 替换口径与新增断言;§2 既有文字不回改,其余 5 个游戏断言零改动。
+
+- **B1(P1)高速撞击时偶发自动暂停跳菜单修复:下滑开菜单加判定冷静期**。
+ 根因:旧折中仅"无下方陨石悬置时下滑=菜单"——高速段玩家在撞击瞬间
+ 下滑躲避(陨石已判定出集合),被误判为开菜单(两次复现均为撞击后
+ 0.13s/0.43s 的下滑)。修复(web/space/game.js):`_resolve` 任何结局都记
+ `_lastResolveAt`;`_onPlayGesture` 下滑在**最近一次判定(撞/躲/格挡/吸收)
+ 后 800ms(MENU_CALM_MS)内一律按躲避意图忽略**,绝不开菜单;其余时刻
+ 保留旧折中(无下方陨石悬置即可开菜单)——躲避手势永不误开菜单,
+ 主动暂停可达性不变。
+- **B2(P1)暂停恢复无缓冲修复:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏**。
+ `_closeMenu` 重置 `_bareCount = 0`,复用保护期断崖过渡机制(4.0s 逼近 +
+ 4.0s 刷怪间隔),首颗撞击点落在恢复后约 4.8s——与"保护期结束 3 颗慢过渡"
+ 同款软重启,不再"继续飞行 = 3 秒暴毙"。
+- **B3(P1)回大厅落无声死界面修复:rhythm round1 同款收尾流程**。
+ `_goHome` 重写:幂等 `_goingHome` 标记 → `leaving` 收尾态(停刷怪/BGM,
+ 不再受理对局/菜单/结算手势,单击仍可跳确认句)→ `_speakGuarded` 播
+ menu_lobby 确认句(2s 看门狗兜底)→ `stop()` + 跳转大厅。旧版无确认
+ 直接跳,跳转失败时 tap/滑动全无响应,纯听觉玩家被关黑屋。
+- **B5/A5①(P1)高速段"最后一命"警告播不出修复:呼喊接力保留 + hit_last 在播期间呼喊抑制**。
+ 根因:round4 R1 呼喊接力机制里,归因句被下一颗 spawn 呼喊顶断后由呼喊
+ onComplete 接播 hit_last,但接播本身无保护——高速段刷怪 1.2s/颗,hit_last
+ 刚起播就被下一颗呼喊顶没(或致命撞击先至,链整体蒸发)。修复:新增
+ `_hitLastPlaying` 在播标记,hit_last 在播期间 spawn **不播方向呼喊**
+ (警告播完前不被顶断);归因在播期间呼喊保留生存优先级(顶断 → 呼喊播完
+ 接力,round1 实测 0.8s 低延迟警告路径不变);链被菜单/分批引入旁白顶断时
+ 恢复对局经 `_rearmRunTimers` 立即补播;里程碑与护盾不可用应答在链期间
+ 改 queue 排队不顶链。链结束呼喊恢复。
+- **B4/A5②(P2)死亡瞬间语音打架修复:坠毁即掐断警告链**。`_gameOver`
+ 清 `_hitLastPending/_hitLastPlaying` 并 `stopVoice()`——旧版 hit_last/
+ gd_last_life 与 1.2s 后的结算 time_* 叠播互相掐(live ASR 实录混成一团),
+ 现结算链在静音后干净开场。
+- 遗留(本轮不改):A7 保护期归因长句每颗重播(P3 烦感已降)、A8
+ second_run_ask 仅页面进入时播(restart 路径不经过,设计内)、B6 保护期
+ 时长恒定 60s 无缩水(报告按 shield_block 估算有误差)、B7 菜单选项播报
+ 延迟感(voice 安全门串行代价,可接受)。
+- 依据:round2-live 试玩报告「如果只能改 3 件事」全部 3 条 + B4/B5。
+ space playtest 断言 138 → 155:新增 17 条(hit_last 在播标记×2、接播期间
+ 呼喊抑制×1、呼喊恢复×1、坠毁前警告在播×1、坠毁掐链×1、恢复缓冲×4、
+ 误暂停守卫×2、回大厅收尾×5——含 leaving 态/确认句/播完前不跳转/滑动忽略/
+ 跳过即走),替换 1 条(菜单下滑前提补"过冷静期"),hit_last 段既有
+ "呼喊顶断+接力"断言语义零回改;guide.test 全绿(space G7 呼喊断言
+ 不受影响:该链路 hits=0)。
+- **live 复验**(web/harness/live/out/space-20260901063255,LIVE_PORT=8140,
+ 8128 被占按预案自起;voice=male/speed=1.35/bgm=1.0/guide=full 全拟真驱动):
+ ① 误暂停守卫——判定(dodge)ta=6.28 后下滑落点 ta=6.76(Δ=0.48s<0.8s
+ 冷静期)无 paused/bgm-menu;过冷静期 +0.9s 非下方悬置下滑正常开菜单
+ (paused ta=10.22),爬升段仍可开菜单(paused ta=82.65)。② 恢复缓冲——
+ 恢复 ta=83.23 后首颗 +0.80s 刷出,前 3 颗逼近实测 4.00/4.00/4.00s、
+ 刷怪间隔 4.00/4.00s,第 3 间隔起回曲线(2.68s)。③ 回大厅——双击确认后
+ menu_lobby 确认句起播(ta=118.94),随后探针在新文档重新就绪
+ (probe_ready t=12.3 ta=-0.882 = 跳转 '../' 成功)。④ 警告链——第 2 撞
+ ta=104.94 → spawn 呼喊顶断归因(生存优先级保留)→ 接力 hit_last
+ ta=105.61(+0.67s,与 round1 低速好评路径同型),在播 [105.61,107.42]
+ 无 dir_* 插队;坠毁 ta=107.42 即掐(vend kill 同刻),crash 后 1.0s 零
+ 新开语音,结算 time_3 于 +1.30s 干净开场,坠毁后无 hit_last/gd_last_life
+ 迟到。复验驱动与探针脚本:web/harness/live/out/verify-space-r2.mjs、
+ probe-gohome.mjs(out/ 不入库)。
+
+---
+
+## 19. round2-live 变更记录(rpg《剑与声》小周全拟真复测修复)
+
+> 依据 research/04-playtests/live/round2/rpg-xiaozhou.md(P1-2 复验「未修复·形式
+> 改变」:格挡与受伤播报错误连线 / P1-新① 探索菜单语音播放中吞 tap / P2-新②
+> 教学提示自动轮播互掐 / P2-新③ 杀敌瞬间腰斩回合提示音;P3 酌情)。设计依据:
+> 11-rpg-battle-rebalance §3 + 05-rpg-addendum §1 受击行「血量档位降档时
+> st_hp_mid/st_hp_low(降档才播,不每击都播)」——该播报本就是"受击(未格挡)"
+> 的附属,不是血量变化的全局钩子。台词文本与语音 id 零新增零变更;只改触发
+> 逻辑/排队语义。按 §8 先例只追加登记受影响断言的替换口径与新增断言;§6 与
+> §14 既有文字不回改,其余 5 个游戏断言零改动。
+
+- **P1-2 格挡受伤播报重连线(擦伤不触发 + 档位一致 + 同档节流)**。live 事件流
+ 复盘:全程 20 次格挡成功、0 hurt 音效,st_hp_mid/low 却精确落在格挡音后
+ 0.09s 播出(bandit 636.5s/841.2s、Boss 962.1s/984.0s)——根因:旧实现把降档
+ 播报挂在"血量变化"上(bandBefore/bandAfter 跨档即播),格挡擦伤(1~3)跨档
+ 同样触发,全格挡完胜被全程喊受伤,警报被驯化成背景音。逐秒重建血量轨迹另
+ 证实:播报时刻档位本身正确(984.0s 时 7/28=0.25 确属 low),玩家感知"喊 low
+ 实际 mid"来自胜利升级 +8 回血使旧播报变陈旧(997.1s 升级后 12/36=0.33)。
+ 裁定(game.js `_enemyResolve`,注释明写):a) 格挡擦伤静默走血,**不触发**
+ 任何受伤类播报(痛感由格挡音+confirm 震动承担,11 文档 §3"格挡成功 2-3 伤"
+ 是技巧收益而非受伤警报);b) 只在实质受击(格挡失败/未格挡)时播,且播报档
+ = 结算后当前实际档——`b.hpBandVoice` 记"玩家最后被告知档"(开战=当前档),
+ 比它差才播并同步(擦伤暗中跨档后,第一刀实质受击按当前实际档补播,绝不
+ 错位);c) 节流:同档不重复喊(low 档内再受击不重播);喝药回血升档静默,
+ 之后经中间档再次跌破视为新降档可重播一次(回血有 potion 音可感知,不属
+ "狼来了")。§14「新增边界断言(P1 全格挡不掉 low)」口径加强:全格挡
+ Boss 战不播 st_hp_low **也不播 st_hp_mid**(擦伤一律静默),该断言其余
+ 部分(擦伤 ∈[1,3]、战后血量 ≥0.3、可击杀)不变。新增场景 B10 断言 13 条
+ (擦伤跨 0.7 边界零播报/被告知档不变、实击降档播 st_hp_mid 且与实际一致、
+ 同档再击不重播、跌 low 播 st_hp_low、low 内再击不重播、回血直接跌回 low
+ 不重播、回血经中间档再跌破=新降档重播一次)。
+- **P1-新① 探索菜单语音播放中吞 tap(跳句 + 补发语义动作)**。live 证据:
+ 1264.6s 选项报名开播 2.3s 时单击被整个吞掉(语音被掐、场景未切、死寂
+ 72s);1370.1s「最后一个了。」开播 0.16s 时单击"返回"同样被吞;而战斗里
+ 语音中单击有效(844.5s),规则必须一致。12 文档 §1 跳句惯例在 rpg 探索态
+ 的口径补全:语音中单击**一律先跳句**;在播句为选项报名/边界提示
+ (`_exploreVoiceKind==='option'`)时单击同时**补发确认当前选项**;desc/
+ NPC/事件/状态链在播 = 只跳句不确认(A5/B6 契约不变)。实现要点:类别先
+ 取样再 skipVoice——skipVoice 同步触发 onComplete 推进链(末句接播选项报名
+ 会改写类别),跳句后读类别会把"跳 desc"误判成"确认选项"。新增场景 B11
+ 断言 7 条(报名在播单击=跳句+进商店、desc 在播单击=只跳句场景不变、
+ gd_wrap_last 在播单击"返回"=跳句+回上一张图,guide=full 段)。P3-3②
+ 「提示音播放期间 tap 被吞」随本条一并修复(同一路径)。
+- **P2-新② 教学提示自动轮播互掐("播完再上"统一治理)**。live 证据:
+ 333.09s tut_b0 自动重播播到 7.9s 处被游戏自己的 16s 兜底推进
+ (tut_battle_2)掐断——系统自己的提示链互相顶。裁定:自动轮播路径
+ (8s 重复提示 / 16s 兜底推进)一律"播完再上"——8s 重复提示到点遇语音
+ 在播则跳过本次(不重播不顶句);16s 兜底到点遇语音在播则每 1.5s 重试、
+ 等声道空出再推进(延后不死锁)。B1 既有断言不变(无语音占用时 8s 重播、
+ 16s 兜底原样触发);新增场景 B12 断言 5 条(占用声道时重复提示跳过、
+ 兜底延后且 teachStage 不变、声道空出后推进且推进语音开播时刻 ≥ 声道
+ 空出时刻)。
+- **P2-新③ 杀敌瞬间腰斩回合提示音(指令名礼让排队)**。live 证据:843.9s
+ 「攻击。」开播 0.9s 被 btl_enemy_die 掐成残句。裁定:`_speak` 礼让规则
+ 扩展(与 round1-live gd_stall 排队一脉相承)——刚开播的指令名
+ (cmd_* ≤1.2s 短句)还在播时(1.6s 余波窗),结算语音链首句 queue:true
+ 排队等它播完(杀敌/半血/降档/教学推进链同受此规则);余波窗外恢复默认
+ 顶句语义。B8 对照断言(无语音在播时击杀语音不排队)不受影响。新增场景
+ B13 断言 4 条(指令名开播 1.6s 内击杀语音 queue:true、排队后胜利链正常
+ 走完不死锁)。
+- **P3 酌情与遗留**:P3-1 商店折扣报价仍报原价("铁剑,一百金")、购后无
+ 折后/余额播报——无折扣价目与余额整句资产,受"不得改台词文本、不新增
+ 语音 id"约束无法修复,列遗留(同 tut_b3 先例,待 TTS 补录折扣价目句与
+ 购后余额句;九折扣款 `shop_discount` 逻辑本身正确,A 场景断言覆盖)。
+ P3-2 前摇节奏"敌人等台词说完再出手"需教程明说:无现成教学句资产,列
+ 遗留。P3-3① 左端反绕到末项无提示:round1-live 已落地"落到末项即播
+ (不分方向,每场景/每次进商店一次)"——167.4s 系同次进商店第二次落
+ 末项被 `_wrapShown` 去重,属设计内防抖,维持不变。P1-1 NPC male 语音
+ 资产缺失:资产层遗留(非代码),照旧列遗留。ASR 短句误识:备查非游戏
+ bug,不处理。
+- 依据:round2-live 试玩报告 P1-2 复验 + 新发现问题 1/2/3 +「如果只能改
+ 3 件事」全部 3 条;rpg playtest 断言 159 → 188(新增 29 条:B10×13 +
+ B11×7 + B12×5 + B13×4),既有 159 条语义零回改(§14 全格挡断言按上文
+ 口径加强,不删减),其余 5 个游戏断言零改动。
+
+---
+
+## 15. round2-live 变更记录(rhythm《节拍行者》小江全拟真第 2 轮复测修复)
+
+> 依据 research/04-playtests/live/round2/rhythm-xiaojiang.md(N1 immediateTap
+> 爆音计入判定 / A2 正片踩点下滑弹菜单 / N2 菜单瞬穿 / A4 BGM 死亡)。选型
+> 任务书方案 a:引擎 gesture.js 增加 `tapcancel` 事件(仅 immediateTap 模式
+> 补发,其余 5 游戏零行为变化);rhythm 游戏层配套判定账簿与复活机制。
+> 台词文本与音频资产零新增零变更。按 §8 先例只追加登记;§6/§13 既有文字
+> 不回改。
+
+- **事件流对质结论(A4 BGM 死亡 = 事件误读,§13 P1-3 附记结论成立)**:
+ round2 三个"BGM 排程 0.45s 即被 stop"现场(out/rhythm-20260901020139 ta
+ 378.67/691.56、rhythm-20260901022139 ta 257.99)被 stop 的 sid 全部是
+ **结算/选曲的 menu pad**(引擎层 bgm/menu:sid 108/235/1),由 `_startSong`
+ 的 `stopBgm(400)` 淡出停止(设计行为,防双 BGM 叠加);曲目 BGM track90
+ (sid 109/236/2)均按网格正常起播并持续到下一次菜单/结算。混音光谱对质:
+ retry 局 40-150Hz 低频段 RMS −21.47dB 与"玩家确认有 BGM"的教学后首进段
+ (−21.50dB)一致,menu pad 只有段 −31.84dB——**retry/选曲路径 BGM 实际
+ 在播,"BGM 死亡"是 stop 事件的 sid 张冠李戴**。唯一真实的 BGM 中断是菜单
+ 劫持连带(下滑误判弹菜单 → `_stopBgmSrc`),随正片下滑修复(下条)根治。
+ BGM 生命周期代码零改动。
+- **N1(P0)immediateTap 爆音判定治理(引擎 tapcancel + 游戏判定账簿)**:
+ gesture.js immediateTap 模式补发 `tapcancel`——按下已发 tap 后,移动超
+ tapSlop 转滑动 / longpress 成立 / 触摸 cancel(含无 move 事件快滑在滑动
+ 事件前补发)时各补发一次;双击序曲不发(归属由游戏按双击窗口处理)。
+ rhythm 新增序曲 tap 判定账簿 `_tapJudg`:演奏阶段(tut/main)每次 tap
+ 判定(命中/自由演奏/教学尝试)记账;`tapcancel` 到达撤销最近 1 笔
+ (≤0.6s),`doubletap` 到达撤销最近 2 笔(≤0.5s)——声音已出收不回,
+ 判定/统计回滚且不计 Miss;被误认领的命中撤销后音符回到未判定(真不接
+ 由超时 Miss 兜底)。另:非演奏阶段的 500ms 延迟裁决 tap 由 tapcancel
+ 提前作废(慢滑在裁决窗内超 slop 即撤,选曲慢滑不再误触发"选定曲目")。
+- **超时 Miss 复活(N1 判定污染的事件流真凶之一)**:round2 事件流实证
+ miss 全部落在泵的超时死线(expected+GOOD+补偿+0.10)上而非序曲 tap 的
+ 错手势认领——滑动识别 +0.3s/双击 +0.2s/触程余量仅 +0.10s,手势还在
+ 路上时音符常先被拖死几十 ms。新增归属①′:同手势、超时类(kind=
+ timeout,错手势类不复活)、REVIVE_WINDOW_S 0.6s 内、补偿 dt ≤
+ REVIVE_DT_S 0.5s(GOOD 0.35 + 慢滑/慢双击补偿误差 0.15)→ `_reviveMiss`
+ 回滚统计后按 dt 改判 Perfect/Good。tap 不参与复活(immediateTap 下
+ tap 识别零延迟,回调晚于死线 = 触摸本身已晚;且防双击序曲误复活)。
+- **A2(P1)正片踩点下滑弹菜单续修**:§13 的 `_downNotePending` 窗口在
+ 正片从未失效,但事件流 3/3 复现的时序是"超时 Miss 先于下滑回调 2~114ms"
+ ——音符在滑动触程中已被泵拖死,回调时 `_downNotePending` 判假 → 弹菜单。
+ 修复:down 音符刚被超时拖死(REVIVE_WINDOW_S 内)同样算"临近"——下滑
+ 不弹菜单,先进复活改判(dt ≤ 0.5s)/ 超时差自由演奏 tom_lo;该期间下滑
+ 豁免异手势 Miss(不顺杀邻音)。窗口过后(>0.6s)下滑呼出菜单的折中不变。
+- **N2(P1)菜单去抖独占期**:菜单打开 1s(MENU_DEBOUNCE_MS)内不收任何
+ 手势——演奏途中被弹菜单时,玩家已在路上的手势(双击确认/下滑关闭)
+ 不得穿透菜单(实测 menu_main 说明语音 0.6s 未读完菜单即被"确认")。
+ 配套:guide.test.mjs §⑥ G3 两处关菜单手势前补 1.05s 等待(行为变更的
+ 既有断言适配,断言语义不变);rhythm playtest 场景 A/B 既有菜单用例
+ (开菜单后 2s/1.5s 才关)天然越过独占期,零改动。
+- **新增断言(rhythm playtest 109 → 141,新增 32 条,既有零回改)**:
+ F3 教学 tapcancel 撤销序曲 tap 尝试记录;F1 正片 tapcancel 撤销序曲 tap
+ 命中(音符回未判定、统计回滚、可重接 Good);F2 双击序曲 tap+tap+
+ doubletap 归属(双击改判 Perfect、序曲零 Miss、不误伤更早真实命中);
+ F2c 序曲 tap 误认领命中被双击撤销、撤销后真不接由超时 Miss 兜底;F4
+ 上滑超时拖死(missKind=timeout)后慢滑回调复活改判 Good、Miss 统计
+ 回滚;F5 正片 down 音符超时拖死后下滑不弹菜单且复活改判 Good 播
+ tom_lo;F5b 窗口内晚下滑(dt 超 0.5s)不弹菜单、Miss 维持、tom_lo
+ 自由演奏;F5c 窗口过后下滑正常呼出菜单;F6 菜单 1s 去抖期(双击/下滑
+ 不穿透、菜单语音不被掐)+ 期后双击正常确认恢复。
+- **引擎回归**:gesture.js 改动 opt-in(immediateTap 默认 false 零行为
+ 变化);6 个 playtest 全绿(escape 140 / rpg 188 / simon / space 152 /
+ storyline / rhythm 141)+ guide.test 122 断言全绿。
diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md
index fe8b5b95..226cdddf 100644
--- a/docs/web-engine-api.md
+++ b/docs/web-engine-api.md
@@ -100,7 +100,10 @@ iOS Safari 和 Chrome 都要求 AudioContext 必须在一次真实用户手势
3. 遮罩移除,Promise resolve → 你调 `game.start()` 接管。
**任何 `play()` 在解锁前调用只会 console.warn 并跳过。**
-解锁后再 `attachGestures`,这样开始那次点按不会被游戏误收。
+解锁后再 `attachGestures`。开始那次点按不会被游戏误收,引擎双保险:
+遮罩对触摸 `preventDefault`(不再合成鼠标事件),且 gesture.js 在
+document 捕获级记录全页面最后一次触摸,700ms 内忽略一切合成鼠标回退
+(早期版本只靠 root 级守卫,解锁 tap 的合成 click 会穿透成"单击跳旁白")。
## engine/audio.js — `WebAudioEngine`
@@ -277,7 +280,8 @@ vibrate([50, 30, 50]); // 自定义震型
```js
const detach = attachGestures(element, onGesture, config?)
// onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap'
-// |'longpress'|'longpressend') => void
+// |'longpress'|'longpressend'|'tapcancel') => void
+// ('tapcancel' 仅 immediateTap 模式补发,见下)
// detach(): 解除绑定
```
@@ -290,6 +294,7 @@ const detach = attachGestures(element, onGesture, config?)
| `doubleTapWindow` | 300 ms | 双击判定窗口 |
| `tapSlop` | 24 px | 位移小于此才算点按 |
| `longpressMs` | 450 ms | 按住不动超过此值触发 `longpress` |
+| `immediateTap` | false | true = 按下即发 tap,不等双击窗口(演奏类 opt-in,见下) |
- 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。
- 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。
@@ -312,6 +317,22 @@ const detach = attachGestures(element, onGesture, config?)
"继续剧情""检查房间"这类非即时操作(Android 端 reflex/storyline/escape
均如此设计)。
+**例外(opt-in):`attachGestures(root, cb, { immediateTap: true })`**——
+按下(touchstart/mousedown)立即回调 `tap`,不等双击窗口;若 300ms 内
+第二下也成立为点按,松开时补发 `doubletap` 改判(第一次 `tap` 已发出,
+是否撤回其效果由游戏层决定,如 rhythm 多打一下 kick 无害)。代价:每次
+按下都先发 `tap`——若该次按下最终判成滑动/长按,这声 `tap` 不可撤回,
+所以只有"按下即出声"的演奏类游戏(rhythm)应开启,默认 false 行为不变。
+
+**`tapcancel`(仅 immediateTap 模式补发,live round2 新增)**:序曲 `tap`
+的声音收不回,但**判定可以撤回**——本次按下一旦确定不再是点按(移动超
+`tapSlop` 转滑动、`longpress` 成立、触摸被 cancel;无 move 事件的快滑在
+滑动事件之前补发),引擎补发 `tapcancel`,游戏层应撤销该次 tap 的判定/
+统计(rhythm 据此把爆音移出判定链)。双击序曲(`tap`+`tap`+`doubletap`
+序列)**不发** `tapcancel`——归属撤销由游戏层按双击窗口自行处理。
+`onGesture` 签名因此扩为:`'up'|'down'|'left'|'right'|'tap'|'doubletap'|
+'longpress'|'longpressend'|'tapcancel'`。
+
## engine/game.js — `Game` / `StateMachineGame`
```js
diff --git a/web/engine/audio.js b/web/engine/audio.js
index 33b0b18f..7321f571 100644
--- a/web/engine/audio.js
+++ b/web/engine/audio.js
@@ -170,8 +170,15 @@ export class WebAudioEngine {
resolve();
});
};
- // 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起
- overlay.addEventListener('touchend', onTap);
+ // 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起。
+ // 触摸路径必须 preventDefault(需 passive:false 才有效):否则浏览器
+ // 会为这次触摸补发合成 mousedown/mouseup/click。遮罩移除后合成事件
+ // 落到手势 root 上,gesture.js 的鼠标回退通道会把它误判成一次 tap——
+ // "解锁单击"误杀开场旁白的根因(gesture.js 另有 document 级 700ms
+ // 合成鼠标守卫兜底,双保险)。
+ overlay.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
+ overlay.addEventListener('touchend', (e) => { e.preventDefault(); onTap(); }, { passive: false });
+ // 桌面鼠标 / 辅助技术触发的 click 不受影响,照常解锁
overlay.addEventListener('click', onTap);
});
}
diff --git a/web/engine/gesture.js b/web/engine/gesture.js
index 1b5b5426..8bc1ab81 100644
--- a/web/engine/gesture.js
+++ b/web/engine/gesture.js
@@ -11,6 +11,18 @@
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
+ * **例外:`{ immediateTap: true }`(opt-in,默认 false 行为完全不变)——
+ * 按下(touchstart/mousedown)立即回调 tap,不等双击窗口;若 300ms 内
+ * 第二下也成立为点按,松开时补发 'doubletap' 改判(第一次 tap 已发出,
+ * 是否撤回其效果由游戏层决定)。代价:每次按下都先发 tap——本次按下
+ * 若最终判成滑动/长按,那一下 tap 的声音已发出无法撤回(演奏类游戏
+ * "按下即出声"正好要这个语义,如 rhythm 单击=kick),但**判定可以
+ * 撤回**:本次按下一旦确定不再是点按——移动超 tapSlop 转滑动、长按
+ * 成立、触摸被 cancel——引擎补发 'tapcancel'(仅 immediateTap 模式;
+ * 若该触摸最终以滑动收尾且滑动前未曾超 slop,补在滑动事件之前)。
+ * 游戏层收到后应撤销该次 tap 的判定/统计(声音已出收不回)。
+ * 双击序曲(tap+tap+doubletap 序列)不发 tapcancel——归属撤销由游戏层
+ * 按双击窗口自行处理(见 rhythm:doubletap 到达时撤回窗口内的序曲 tap)。**
* - **长按**:按住不动 >= longpressMs(默认 450ms)触发 'longpress',
* 松开触发 'longpressend';长按期间不触发 tap;位移超 tapSlop 取消
* (已激活的长按会先补发 'longpressend',之后滑动照常判定)。
@@ -40,8 +52,25 @@ export const DEFAULT_GESTURE_CONFIG = {
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
tapSlop: 24, // px,位移小于此值才算点按
longpressMs: 450, // ms,按住不动超过此值触发 longpress
+ immediateTap: false, // true = 按下即发 tap(演奏类 opt-in,见文件头注释)
};
+/**
+ * 全局合成鼠标守卫(模块级,所有 attachGestures 实例共享)。
+ * 启动遮罩(audio.js showStartOverlay)挂在 body 上、不是手势 root 的
+ * 子元素:解锁那次触摸不经过任何手势实例,实例级 lastTouchEndTime 守卫
+ * 对它无效——遮罩移除后该次触摸合成的 mousedown/mouseup 落到 root 上,
+ * 会被鼠标回退通道误判成 tap("解锁单击"误杀开场旁白的根因)。
+ * 在 document 捕获阶段记录全页面最后一次 touchend/touchcancel,
+ * recentlyTouched() 取两者较大值,把解锁 tap 也纳入 700ms 守卫。
+ */
+let lastGlobalTouchEnd = 0;
+if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
+ const markGlobalTouchEnd = () => { lastGlobalTouchEnd = performance.now(); };
+ document.addEventListener('touchend', markGlobalTouchEnd, true);
+ document.addEventListener('touchcancel', markGlobalTouchEnd, true);
+}
+
/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */
function _dispatchLongpress(type) {
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
@@ -65,6 +94,7 @@ export function attachGestures(element, onGesture, config = {}) {
// 点按状态
let lastTapTime = 0;
let pendingTapTimer = null;
+ let tapPending = false; // immediateTap:本次按下已发 tap 且尚未定案(可补 tapcancel)
// 触摸发生后短时间内忽略合成鼠标事件
let lastTouchEndTime = 0;
@@ -91,18 +121,34 @@ export function attachGestures(element, onGesture, config = {}) {
onGesture('longpressend');
}
+ /** immediateTap:本次按下的序曲 tap 确定作废(转滑动/长按/触摸取消)时补发 tapcancel。 */
+ function cancelImmediateTap() {
+ if (!tapPending) return;
+ tapPending = false;
+ onGesture('tapcancel');
+ }
+
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
startTime = performance.now();
+ if (cfg.immediateTap) {
+ // 即时模式:按下立即回调 tap,不等双击窗口关闭。
+ // 注意此时尚不知本次按下会判成什么——若最终是滑动/长按,
+ // 这声 tap 已发出不可撤回,但引擎会补 tapcancel 让游戏层撤销判定。
+ tapPending = true;
+ vibrate('confirm');
+ onGesture('tap');
+ }
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (!tracking || moved) return;
- // 触发长按:作废等待中的单击
+ // 触发长按:作废等待中的单击;immediateTap 的序曲 tap 补发 tapcancel
cancelPendingTap();
+ cancelImmediateTap();
longPressActive = true;
_dispatchLongpress('longpress');
onGesture('longpress');
@@ -112,6 +158,7 @@ export function attachGestures(element, onGesture, config = {}) {
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) {
+ if (!moved) cancelImmediateTap(); // 位移超 slop:本次按下确定不是点按
moved = true;
cancelLongPress(); // 滑动取消长按判定
endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定)
@@ -125,6 +172,7 @@ export function attachGestures(element, onGesture, config = {}) {
if (longPressActive) {
endLongPress();
lastTapTime = 0;
+ tapPending = false;
return; // 长按后不触发 tap/doubletap
}
@@ -137,6 +185,7 @@ export function attachGestures(element, onGesture, config = {}) {
if (dist >= cfg.swipeMinDistance && velocity >= cfg.swipeMinVelocity) {
// 主轴归一:位移较大的轴决定方向
cancelPendingTap(); // 滑动作废等待中的单击
+ cancelImmediateTap(); // 序曲 tap 定案作废(无 move 事件的快滑在此补发)
lastTapTime = 0;
let g;
if (Math.abs(dx) > Math.abs(dy)) g = dx > 0 ? 'right' : 'left';
@@ -147,13 +196,18 @@ export function attachGestures(element, onGesture, config = {}) {
}
if (!moved && dist < cfg.tapSlop) {
+ tapPending = false; // 点按成立:序曲 tap 定案保留(双击序曲归属由游戏层处理)
const now = performance.now();
if (now - lastTapTime <= cfg.doubleTapWindow) {
- // 双击:取消尚未触发的单击
+ // 双击:取消尚未触发的单击(immediateTap 模式没有挂起的单击——
+ // 第一下 tap 在按下时已发出,此处补发 doubletap 改判,
+ // 是否撤回第一次 tap 的效果由游戏层决定)
cancelPendingTap();
lastTapTime = 0;
vibrate('confirm'); // 双击
onGesture('doubletap');
+ } else if (cfg.immediateTap) {
+ lastTapTime = now; // tap 已在 handleDown 发出,这里只记双击窗起点
} else {
lastTapTime = now;
cancelPendingTap();
@@ -165,12 +219,16 @@ export function attachGestures(element, onGesture, config = {}) {
onGesture('tap');
}, cfg.doubleTapWindow);
}
+ return;
}
+
+ cancelImmediateTap(); // 既非滑动也非点按(慢拖/回拉):序曲 tap 定案作废
}
function handleCancel() {
tracking = false;
cancelLongPress();
+ cancelImmediateTap(); // 触摸被系统取消:序曲 tap 定案作废
endLongPress();
}
@@ -203,7 +261,8 @@ export function attachGestures(element, onGesture, config = {}) {
// ---- 鼠标(桌面调试)----
let mouseDown = false;
function recentlyTouched() {
- return performance.now() - lastTouchEndTime < 700;
+ // 实例级(root 上的触摸)与全局级(遮罩等页面任意处的触摸)取较晚者
+ return performance.now() - Math.max(lastTouchEndTime, lastGlobalTouchEnd) < 700;
}
function onMouseDown(e) {
if (recentlyTouched() || e.button !== 0) return;
diff --git a/web/escape/game.js b/web/escape/game.js
index 44f64b89..405905f6 100644
--- a/web/escape/game.js
+++ b/web/escape/game.js
@@ -1,25 +1,36 @@
/**
* 《暗室》Web 版 R2 —— 3x3 黑暗房间网格密室逃脱(按 docs/design/01-escape-redesign.md 实现)。
*
- * 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导,下滑可跳过)
+ * 流程:start → REOPEN(二周目询问,可选)→ GUIDE(G0-G7 交互式引导;
+ * G0 intro 播完 1.2s 无操作自动推进 G1——g1_move 必播;下滑跳过仅限答案
+ * 不含滑动的步骤 G0/G3/G5/G6,G1/G2/G4 下滑 = 作答)
* → PLAY(自由探索 + 卡关三级提示计时器)→ ENDING → 单击重开 / 下滑菜单。
*
* 手势映射(偏离全局默认,引导中讲清):
- * - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播)
+ * - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播;
+ * PLAY 阶段只播「那边是墙」,教学腔方位句 guide_blunt_* 仅限引导期)
* - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙);
- * voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)。
+ * voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)
+ * 并补发检查——环境音即时重播、描述进待播队列等链播播完(不顶句)。
* 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/
* unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*)
* - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门);
* 无可用目标时播报背包 + L2 级明示提示
- * - 引导阶段下滑:跳过引导(G4 除外——G4 的答案就是下滑);ENDING 下滑:菜单
+ * - 引导阶段下滑:跳过引导(仅限答案不含滑动的步骤 G0/G3/G5/G6;G1/G2 任意
+ * 滑动即答案、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过);
+ * ENDING 下滑:菜单
*
* 状态 BGM(10 号文档):引导/探索 = bgm/drone;菜单 = bgm/menu(400ms);
* 逃脱结算 = escape 语音链播完淡入 bgm/menu(volume 0.5,1500ms)。
*
- * 卡关检测:进展事件(拾钥匙/开柜/修电箱/首到关键房间/逃脱)重置计时;
- * 60s 无进展 L1 暗示(stuck_ask + stuck_*_1)、120s L2 明示(stuck_*_2)、
- * 180s L3 带路(stuck_*_3 + 按当前坐标算路链播 nav_*,此后每 60s 重复)。
+ * 卡关检测:按"无操作时长"计时——PLAY 阶段任何有效手势重置计时
+ * (round1-live 修复:原按"无进展"计时,玩家活跃操作也每 35~40s 机关枪式
+ * 报答案,还三次顶掉进行中的语音)。60s 无操作 L1 暗示(stuck_ask +
+ * stuck_*_1)、120s L2 明示(stuck_*_2)、180s L3 带路(stuck_*_3 + 按当前
+ * 坐标算路链播 nav_*,此后每 60s 无操作重复);进展事件(拾钥匙/开柜/
+ * 修电箱/首到关键房间/逃脱)清空已播级别,下段静默期从 L1 重新分级。
+ * 提示到点只进待播队列:等当前语音自然播完再播、绝不顶掉进行中的语音;
+ * 待播期间玩家任何操作即作废该提示(未播级别不计入已播)。
*
* round4 H1(docs/design/14-guide-layer.md「round4 v1.3」):阶段目标句
* (found_key / gd_next_fuse / fix_box)播完后,按房间图计算目标房间相对当前
@@ -107,11 +118,12 @@ export class EscapeGame extends Game {
this.guideSwipes = 0; // G2 撞墙计数
this.guideErrs = 0; // 当前步骤连续错误数
this._guideBusy = false; // 步骤推进语音播放中(忽略非跳过分手势)
- // 卡关检测
- this._lastProgressAt = Date.now();
+ // 卡关检测(按"无操作时长"计时,见 _checkStuck)
+ this._lastActiveAt = Date.now();
this._stuckShown = new Set(); // 当前进展段内已自动播过的级别 'L1'|'L2'|'L3'
this._lastL3At = 0;
- this._wallBumps = 0; // 连续撞墙计数(向导层提前一级提示用)
+ this._pendingVoice = []; // 待播语音队列:排队等当前语音播完,绝不顶句
+ this._playSeq = 0; // voice 播放请求序号(onComplete 内判断是否链播新句)
// 菜单
this._menuReturn = Phase.ENDING;
this._menuIdx = 0;
@@ -145,6 +157,11 @@ export class EscapeGame extends Game {
onGesture(g) {
console.log('[EscapeGame] gesture:', g, 'phase=', this.phase, 'pos=', this.x, this.y);
this.guide.disarm(); // 任何手势复位向导状态(guide=off 时 no-op)
+ if (this.phase === Phase.PLAY) {
+ // 卡关提示按"无操作时长"计时:任何有效手势重置计时,并作废待播提示
+ this._lastActiveAt = Date.now();
+ this._dropPendingStuck();
+ }
switch (this.phase) {
case Phase.REOPEN:
if (g === G.UP) { this.audio.stopVoice(); this._startGuide(); }
@@ -181,7 +198,17 @@ export class EscapeGame extends Game {
this._resetState();
this.phase = Phase.GUIDE;
this.guideStep = 0;
- this._speak(['voice/g0_intro'], null, { skippable: false });
+ // round2-live P1 回归修复:intro 自然播完后若玩家没有单击,1.2s 自动推进
+ // G1——g1_move(「先活动一下。朝任意方向滑一下…」)必须播出。原实现干等
+ // "单击继续"却从无任何一句语音告知该手势(第 1 轮靠解锁单击漏进游戏误推
+ // 一步被掩盖,解锁修复后暴露为 intro 后 17~91s 死寂、引导链整体进不去)。
+ this._speak(['voice/g0_intro'], () => {
+ this._timer(() => {
+ if (this.phase === Phase.GUIDE && this.guideStep === 0 && !this._guideBusy) {
+ this._advance(1, ['voice/g1_move', 'voice/skip_hint']);
+ }
+ }, 1200);
+ }, { skippable: false });
}
/**
@@ -210,14 +237,23 @@ export class EscapeGame extends Game {
}
_onGuideGesture(g) {
- // 任何步骤可下滑跳过——G4 除外:该步的正确答案就是下滑(01 号文档"G4=下滑"
- // 与"任何步骤下滑跳过"冲突,以 13 号试玩标准为准:G4 下滑 = 作答,不可跳过)
- if (g === G.DOWN && this.guideStep !== 4) { this._skipGuide(); return; }
+ // 下滑跳过仅限"答案不含滑动"的步骤(G0/G3/G5/G6):G1/G2 的正确答案是
+ // 任意滑动、G4 的答案就是下滑——这些步骤下滑 = 作答,不可跳过(13 号文档
+ // 裁定;round2-live:g1_move 明说"朝任意方向滑一下",玩家下滑却被保送
+ // 自由探索,教学链就此断裂)
+ if (g === G.DOWN && this.guideStep !== 1 && this.guideStep !== 2 && this.guideStep !== 4) {
+ this._skipGuide();
+ return;
+ }
if (this._guideBusy) return; // 推进语音播放中,忽略
switch (this.guideStep) {
- case 0: // G0:单击继续
+ case 0: // G0:单击继续(intro 播完 1.2s 未操作也会自动推进 G1,见 _startGuide)
if (g === G.TAP) this._advance(1, ['voice/g1_move', 'voice/skip_hint']); // skip_hint 首局插播一次
- else if (this._isSwipe(g)) this._playVoice('voice/guide_offpath', { skippable: false }); // "先跟我走"
+ // intro 播放中的滑动忽略(不拿"先跟我走"顶掉开场白)——intro 播完自动
+ // 接 g1_move;intro 已播完(自动推进前的 1.2s 空档)才温和拦回
+ else if (this._isSwipe(g) && !this.audio.isVoicePlaying()) {
+ this._playVoice('voice/guide_offpath', { skippable: false }); // "先跟我走"
+ }
break;
case 1: // G1:任意滑动,走一小步
if (this._isSwipe(g)) {
@@ -339,11 +375,14 @@ export class EscapeGame extends Game {
case G.LEFT: this._move(-1, 0); break;
case G.RIGHT: this._move(1, 0); break;
case G.TAP:
- // voice 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/
- // L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。
- // 无 voice 时单击 = 原检查语义。
+ // voice 播放中:可跳句单击 = 跳过本句(链播继续)+ 补发检查(round1-live
+ // 修复:原把整个单击当"跳过键"吃掉,检查动作无声丢失)——环境音即时重播,
+ // 描述进待播队列等链播播完,不顶句。不可跳句(关键获得/L3 带路)单击 =
+ // 忽略——若照旧触发检查,描述会顶掉该句。无 voice 时单击 = 原检查语义。
if (this.audio.isVoicePlaying()) {
- if (this._voiceSkippable) this.audio.skipVoice();
+ if (!this._voiceSkippable) break;
+ this.audio.skipVoice();
+ this._inspect({ queue: true });
break;
}
this._inspect();
@@ -358,15 +397,10 @@ export class EscapeGame extends Game {
if (nx < 0 || nx > 2 || ny < 0 || ny > 2) {
this.audio.play('sfx/wall');
vibrate('error');
- // F2.3(round2):撞墙反馈接既有 guide_blunt_ 方向句(纯逻辑接线,
- // 资产 v1 已有)——「那边是墙」之后补一句方位("手指贴着屏幕,向上推一下"),
- // 噪音环境下听不出墙音方向时也知道刚才顶到的是哪边。排在本句播完之后;
- // 被下一次滑动打断则自然作废(不迟到误导)。
- const dir = dy < 0 ? 'up' : dy > 0 ? 'down' : dx < 0 ? 'left' : 'right';
- this._playVoice('voice/wall', {
- onComplete: () => this._playVoice(`voice/guide_blunt_${dir}`),
- });
- this._wallBumps++; // 向导层:连续撞墙 = 卡关信号(_checkStuck 提前一级)
+ // 撞墙极简反馈:只播「那边是墙,换个方向。」(round1-live 修复:原接播
+ // guide_blunt_「手指贴着屏幕,向 X 推一下」教学腔方位句,中后期出戏;
+ // 教学腔仅限引导期 G2/G3 纠错路径,见 _onGuideGesture/_guideWrong)
+ this._playVoice('voice/wall');
return;
}
this.x = nx;
@@ -396,13 +430,21 @@ export class EscapeGame extends Game {
this.audio.play(this._ambientId(), { volume: 0.9 });
}
- /** 单击 = 重播当前房间环境音 + 检查描述。 */
- _inspect() {
+ /**
+ * 单击 = 重播当前房间环境音 + 检查描述。
+ * queue=true(语音播放中单击跳过后的补发):描述进待播队列等当前语音播完,
+ * 不顶句;queue=false(无语音时的原检查语义):描述照常 800ms 后直接播。
+ */
+ _inspect({ queue = false } = {}) {
this._playAmbient();
const s = this._session;
+ const emit = (id, skippable, onComplete) => {
+ if (queue) this._enqueueVoice('describe', [id], { skippable, onDone: onComplete });
+ else this._playVoice(id, { skippable, onComplete });
+ };
// skippable:found_key 是关键获得(不可跳),其余检查描述可跳
const describe = (id, skippable = true) => this._timer(() => {
- if (s === this._session) this._playVoice(id, { skippable });
+ if (s === this._session) emit(id, skippable);
}, 800); // 先让环境音进耳朵再播描述
const { x, y } = this;
@@ -415,10 +457,7 @@ export class EscapeGame extends Game {
// 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),
- });
+ emit('voice/found_key', false, () => this._playGoalDir(STAGES.cabinet.target));
}, 800); // 先让环境音进耳朵再播描述
} else {
describe('voice/empty_2');
@@ -531,43 +570,44 @@ export class EscapeGame extends Game {
}
_resetStuck() {
- this._lastProgressAt = Date.now();
+ this._lastActiveAt = Date.now();
this._stuckShown = new Set();
this._lastL3At = 0;
- this._wallBumps = 0;
}
- /** 进展事件:重置卡关计时与已播级别。 */
+ /** 进展事件:清空已播提示级别,下段静默期从 L1 重新分级(计时由手势重置)。 */
_progressEvent() {
- this._resetStuck();
+ this._stuckShown.clear();
+ this._lastL3At = 0;
}
+ /**
+ * 卡关轮询(5s):按"无操作时长"分级触发提示(60s 暗示 / 120s 明示 / 180s 带路)。
+ * 到点只进待播队列(_enqueueVoice):语音在播则排队等其自然播完,绝不顶句;
+ * 待播期间玩家任何操作即作废(见 _dropPendingStuck)。
+ */
_checkStuck() {
if (this.phase !== Phase.PLAY) return;
- const idleMs = Date.now() - this._lastProgressAt;
+ const idleMs = Date.now() - this._lastActiveAt;
const stage = this._stage();
- // 向导层(schemes §2):连续撞墙 ≥2 次是明确的卡关信号——向导 full 档时
- // 三级阈值整体提前一级(L1 即刻、L2 60s、L3 120s);off/standard 保持原阈值。
- const early = this._wallBumps >= 2 && this.guide.level() === 'full';
- const T_L1 = early ? 0 : 60000;
- const T_L2 = early ? 60000 : 120000;
- const T_L3 = early ? 120000 : 180000;
- if (idleMs >= T_L3) {
+ if (idleMs >= 180000) {
// L3 带路:首次或每 60s 重复,按当前坐标重算路线(带路语音不可单击跳过)
if (!this._stuckShown.has('L3') || Date.now() - this._lastL3At >= 60000) {
this._stuckShown.add('L3');
this._lastL3At = Date.now();
vibrate('warning');
- this._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], null, { skippable: false });
+ this._enqueueVoice('stuck',
+ [`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)],
+ { skippable: false, level: 'L3' });
}
- } else if (idleMs >= T_L2 && !this._stuckShown.has('L2')) {
+ } else if (idleMs >= 120000 && !this._stuckShown.has('L2')) {
this._stuckShown.add('L2');
vibrate('warning');
- this._speak([`voice/stuck_${stage}_2`]);
- } else if (idleMs >= T_L1 && !this._stuckShown.has('L1')) {
+ this._enqueueVoice('stuck', [`voice/stuck_${stage}_2`], { level: 'L2' });
+ } else if (idleMs >= 60000 && !this._stuckShown.has('L1')) {
this._stuckShown.add('L1');
vibrate('warning');
- this._speak(['voice/stuck_ask', `voice/stuck_${stage}_1`]);
+ this._enqueueVoice('stuck', ['voice/stuck_ask', `voice/stuck_${stage}_1`], { level: 'L1' });
}
}
@@ -634,8 +674,6 @@ export class EscapeGame extends Game {
this.phase = Phase.ENDING;
this._progressEvent();
vibrate('success');
- this.audio.play('sfx/gate_open');
- this.audio.play('sfx/win_chime', { volume: 0.8 });
this.audio.stopBgm(2500);
this.guide.state('settle'); // 状态签名:结算(逃脱)
this._endingBgmStarted = false;
@@ -644,6 +682,13 @@ export class EscapeGame extends Game {
// 结算语音链(关键获得,不可跳);播完淡入 menu pad(10 号文档:volume 0.5,1500ms)
this._speak(['voice/escape', this._timeBandVoice(totalSec), 'voice/again'],
() => this._startEndingBgm(), { skippable: false });
+ // 开门/胜利音效后移 2.6s(round2-live P2 修复:原与 escape 人声同帧对撞,
+ // gate_open 2.8s 轰鸣恰好盖住台词头句「你扳动开关。电机轰鸣,」,三次 live
+ // 开头全丢;后移后人声头部无遮挡,轰鸣落在「电机轰鸣,铁门缓缓升起」附近)
+ this._timer(() => {
+ this.audio.play('sfx/gate_open');
+ this.audio.play('sfx/win_chime', { volume: 0.8 });
+ }, 2600);
// 语音资产缺失时链不推进,30s 兜底起结算 pad(escape+time_band+again 正常 ~10s 内播完)
this._timer(() => this._startEndingBgm(), 30000);
}
@@ -713,10 +758,55 @@ export class EscapeGame extends Game {
return g === G.UP || g === G.DOWN || g === G.LEFT || g === G.RIGHT;
}
- /** 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。 */
+ /**
+ * 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。
+ * 本句自然播完(含被单击跳过)且没有链播新句时,补播待播队列里的语音。
+ */
_playVoice(id, { skippable = true, onComplete } = {}) {
+ this._playSeq++;
this._voiceSkippable = skippable;
- this.audio.play(id, { onComplete });
+ this.audio.play(id, {
+ onComplete: () => {
+ const seq = this._playSeq;
+ if (onComplete) onComplete();
+ if (seq === this._playSeq && !this.audio.isVoicePlaying()) this._pumpPending();
+ },
+ });
+ }
+
+ /**
+ * 待播语音队列:排队等当前语音自然播完,绝不顶掉进行中的语音(round1-live
+ * 修复:卡关提示/补发检查原直接开播,三次顶掉进行中的语音)。
+ * kind='stuck'(卡关提示,带 level):待播期间玩家任何操作即作废;
+ * kind='describe'(跳过语音后补发的检查描述):连点时只补最新一次。
+ */
+ _enqueueVoice(kind, ids, { skippable = true, onDone = null, level = null } = {}) {
+ if (kind === 'describe') {
+ this._pendingVoice = this._pendingVoice.filter((p) => p.kind !== 'describe');
+ }
+ this._pendingVoice.push({ kind, level, ids, skippable, onDone, s: this._session });
+ this._pumpPending();
+ }
+
+ /** 当前无语音在播时播出队首待播句(仅限 PLAY 阶段;跨会话条目直接作废)。 */
+ _pumpPending() {
+ if (this.phase !== Phase.PLAY || this.audio.isVoicePlaying()) return;
+ while (this._pendingVoice.length) {
+ const item = this._pendingVoice.shift();
+ if (item.s !== this._session) continue;
+ this._speak(item.ids, item.onDone, { skippable: item.skippable });
+ return;
+ }
+ }
+
+ /** 玩家有操作了:作废待播的卡关提示;未播级别不计入已播,下段静默期可重新触发。 */
+ _dropPendingStuck() {
+ this._pendingVoice = this._pendingVoice.filter((p) => {
+ if (p.kind !== 'stuck') return true;
+ this._stuckShown.delete(p.level);
+ if (p.level === 'L3') this._lastL3At = 0;
+ return false;
+ });
}
/** 对白排队依次播放(onComplete 链式触发;被打断即作废整条链)。 */
diff --git a/web/escape/test/playtest.mjs b/web/escape/test/playtest.mjs
index b0e69822..61ba22b7 100644
--- a/web/escape/test/playtest.mjs
+++ b/web/escape/test/playtest.mjs
@@ -334,9 +334,13 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
await gs(game, 'doubletap');
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
- assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime');
+ assert(rec.count('sfx/gate_open') === 0 && rec.count('sfx/win_chime') === 0,
+ '逃脱音效后移:人声头部 2.6s 内无 gate_open/win_chime 对撞(round2-live P2)');
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
+ await tick(2600);
+ assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1,
+ '逃脱音效 2.6s 后播出(人声头部避让,轰鸣落在「电机轰鸣」附近)');
await finishVoices(1);
assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)');
await finishVoices(2); // time_band1 + again → 链完 → 结算 pad
@@ -386,12 +390,17 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
await tick(800);
assert(rec.last('voice/start_room') && rec.last('voice/start_room').t - rec.last('sfx/amb_drip').t === 800,
'描述在环境音 800ms 后播');
- // start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句
+ // start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句 + 补发检查
+ // (round1-live:跳过后检查意图不丢——环境音即时重播,描述 800ms 后补播)
const skipsBefore = rec.skips();
const ambBefore = rec.count('sfx/amb_drip');
+ const startRoomBefore = rec.count('voice/start_room');
await gs(game, 'tap');
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
- assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查');
+ assert(rec.count('sfx/amb_drip') === ambBefore + 1, '跳过后补发检查:环境音即时重播');
+ assert(rec.count('voice/start_room') === startRoomBefore, '补发的检查描述 800ms 内未播');
+ await tick(800);
+ assert(rec.count('voice/start_room') === startRoomBefore + 1, '跳过后补发检查:描述 800ms 后补播');
await tick(200);
// ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ----
@@ -420,6 +429,81 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
game.stop();
}
+// ================================================================
+// 场景 A2:冷启动引导链回归(round2-live P1)——intro 后不点不滑,
+// g1_move 必须自动播出;全程零单击(G0 不点)也能逐步走完 8 步引导
+// ================================================================
+section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走通(round2-live 回归)');
+{
+ store.clear();
+ const { game, rec } = await newGame();
+ game.start();
+ await flush();
+ assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
+ await tick(VOICE_END_SLACK); // intro 播完(虚拟 1s 句)
+ assert(rec.count('voice/g1_move') === 0, 'intro 刚播完:g1_move 尚未播(1.2s 自动推进未到点)');
+ await tick(1200); // 自动推进到点
+ assert(rec.count('voice/g1_move') === 1, '无任何手势:intro 后 1.2s 自动播 g1_move(P1 回归断言)');
+ await finishVoices(2); // g1_move + skip_hint
+ assert(game.guideStep === 1 && !game._guideBusy, '自动进入 G1,输入解锁');
+
+ // intro 播放中滑动不顶开场白(对照:原"先跟我走"会 kill intro 且不再自动推进)
+ store.clear();
+ const g1b = await newGame();
+ g1b.game.start();
+ await flush();
+ await tick(400); // intro 播放中
+ await gs(g1b.game, 'right'); // 抢跑滑动:应被忽略,不顶 intro
+ assert(g1b.rec.count('voice/guide_offpath') === 0, 'intro 播放中滑动:不播 guide_offpath 顶开场白');
+ await tick(VOICE_END_SLACK + 1200);
+ assert(g1b.rec.count('voice/g1_move') === 1, 'intro 播放中滑动后:g1_move 仍自动播出');
+ g1b.game.stop();
+
+ // G1 下滑 = 作答(g1_move 明说"任意方向"),不再是跳过引导
+ await gs(game, 'down');
+ assert(game.phase === 'GUIDE' && game.guideStep !== 0, 'G1 下滑不跳过引导(仍在 GUIDE)');
+ assert(rec.count('voice/guide_skip') === 0, 'G1 下滑不播 guide_skip');
+ await finishVoices(2); // g1_done + g2_wall
+ assert(game.guideStep === 2, 'G1 下滑作答进入 G2');
+
+ // G2 下滑也计入撞墙滑动(再补一滑凑满 2 次推进)
+ await gs(game, 'down');
+ await gs(game, 'up');
+ await finishVoices(2); // wall + g3_left
+ assert(game.guideStep === 3, 'G2 下滑作答计入,两滑后进入 G3');
+
+ // 零单击走完全链:G3 左 → G4 下 → G5 单击(答题单击,非 G0 继续单击)
+ await gs(game, 'left');
+ await finishVoices(1); // g4_down
+ await gs(game, 'down'); // G4 下滑 = 作答
+ await finishVoices(1); // g5_inspect
+ assert(game.guideStep === 5, 'G4 下滑作答进入 G5');
+ await gs(game, 'tap'); // G5 检查拾钥匙
+ await finishVoices(2); // g5_key + g6_doubletap
+ await gs(game, 'doubletap'); // G6
+ await finishVoices(3); // inv_key + g6_done + g7_goal
+ assert(game.phase === 'PLAY', '冷启动零 G0 单击:教学链逐步走通进 PLAY');
+ game.stop();
+
+ // 下滑跳过在答案不含滑动的步骤仍生效(G3)
+ store.clear();
+ const g2b = await newGame();
+ g2b.game.start();
+ await flush();
+ await gs(g2b.game, 'tap'); // G0 → G1
+ await finishVoices(2);
+ await gs(g2b.game, 'up'); // G1
+ await finishVoices(2);
+ await gs(g2b.game, 'up'); // G2 ×2
+ await gs(g2b.game, 'up');
+ await finishVoices(2); // → G3
+ await gs(g2b.game, 'down'); // G3 答案 = 左滑,下滑 = 跳过
+ assert(g2b.rec.count('voice/guide_skip') === 1, 'G3 下滑仍跳过引导');
+ await tick(VOICE_END_SLACK);
+ assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY');
+ g2b.game.stop();
+}
+
// ================================================================
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
// ================================================================
@@ -468,9 +552,9 @@ section('B. 引导纠错与跳过');
}
// ================================================================
-// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定)
+// 场景 C:卡关三级提示(无操作 60s/120s/180s + nav 带路 + 进展重置 + 跳过补发检查)
// ================================================================
-section('C. 卡关提示计时器');
+section('C. 卡关提示计时器(无操作时长语义)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
@@ -484,49 +568,166 @@ section('C. 卡关提示计时器');
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
await finishVoices(1); // start_room
- // 60s 无进展 → L1(stuck_ask + stuck_key_1)
+ // 无操作 60s → L1(stuck_ask + stuck_key_1)
const warn0 = vibrateCount(WARN_PAT);
await tick(60000);
- assert(rec.count('voice/stuck_ask') === 1, '60s 播 stuck_ask');
- // L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1
+ assert(rec.count('voice/stuck_ask') === 1, '无操作 60s 播 stuck_ask');
+ assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
+ // L1 链可跳:播放中单击 = 跳过本句(链播 stuck_key_1)+ 补发检查(round1-live)
const skips0 = rec.skips();
+ const ambDrip0 = rec.count('sfx/amb_drip');
await gs(game, 'tap');
- assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过');
+ assert(rec.skips() === skips0 + 1, 'L1 提示链播放中单击 = 跳过本句');
await flush();
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
- assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
- await finishVoices(1);
+ assert(rec.count('sfx/amb_drip') === ambDrip0 + 1, '跳过后补发检查:环境音即时重播');
+ await tick(800); // 补发描述到点:stuck_key_1 仍在播 → 进待播队列
+ assert(rec.count('voice/start_room') === 1, '链播未完,补发描述排队等待(不顶句)');
+ await finishVoices(1); // stuck_key_1 播完 → 补发描述开播
+ assert(rec.count('voice/start_room') === 2, '链播播完后补发检查描述');
+ await finishVoices(1); // 补发描述播完
- // 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
- await tick(60000);
- assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2');
+ // 自上次操作起连续无操作 120s → L2 明示;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
+ await tick(125000);
+ assert(rec.count('voice/stuck_key_2') === 1, '无操作 120s 播 stuck_key_2');
await finishVoices(1);
await tick(60000);
- assert(rec.count('voice/stuck_key_3') === 1, '180s 播 stuck_key_3');
+ assert(rec.count('voice/stuck_key_3') === 1, '无操作 180s 播 stuck_key_3');
await finishVoices(1); // stuck_key_3 播完 → nav_left 起
assert(rec.count('voice/nav_left') === 1, 'L3 带路算路:(1,1)→(0,2) 先播 nav_left');
await finishVoices(1); // nav_left 播完 → nav_down 起
assert(rec.count('voice/nav_down') === 1, 'L3 带路算路:再播 nav_down');
- // L3 带路不可跳:播放中单击 = 忽略
+ await finishVoices(1); // nav_down 播完
+
+ // L3 之后每 60s 无操作重复带路
+ await tick(60000);
+ assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 无操作重复带路');
+ await finishVoices(3);
+
+ // L3 带路不可跳:播放中单击 = 忽略(新一轮带路起播后验证)
+ await tick(60000);
+ assert(rec.count('voice/stuck_key_3') === 3, '持续无操作 L3 再重复');
const skips1 = rec.skips();
const ambC = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过');
assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查');
- await finishVoices(1); // nav_down 播完
+ await finishVoices(3); // 该轮带路链播完
- // L3 之后每 60s 重复带路
- await tick(60000);
- assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路');
- await finishVoices(3);
-
- // 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无进展约 60s 重新从 L1 起
+ // 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无操作约 60s 重新从 L1 起
// (卡关检查每 5s 一次,相位对齐需略多于 60s)
await gs(game, 'right'); // (2,1) 铁柜房,首到 = 进展
assert(game._stuckShown.size === 0, '进展事件后 _stuckShown 清空');
await finishVoices(1); // room_cabinet
await tick(66000);
- assert(rec.count('voice/stuck_ask') === 2, '进展重置后 60s 重新播 L1');
+ assert(rec.count('voice/stuck_ask') === 2, '进展重置后无操作 60s 重新播 L1');
+ game.stop();
+}
+
+// ================================================================
+// 场景 C2:活跃操作不触发卡关提示(round1-live:按"无操作时长"计时)
+// ================================================================
+section('C2. 活跃操作时不触发卡关提示');
+{
+ store.clear();
+ store.set('soundgame.escape.guideDone', '1');
+ const { game, rec } = await newGame();
+ game.start();
+ await flush();
+ await gs(game, 'tap'); // REOPEN → PLAY
+ await finishVoices(1); // start_room
+
+ // 每 ~25s 一个普通移动手势(无进展事件),持续 ~250s:任何卡关提示都不应出现
+ for (let i = 0; i < 10; i++) {
+ await tick(25000);
+ await gs(game, i % 2 === 0 ? 'left' : 'right');
+ }
+ assert(game.x === 1 && game.y === 1, '往返移动 10 次回到 (1,1)');
+ assert(rec.count('voice/stuck_ask') === 0, '活跃操作 250s:无 L1(stuck_ask)');
+ assert(
+ rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
+ '活跃操作 250s:无 L2/L3 提示(机关枪回归断言)',
+ );
+ // 停止操作 60s 后 L1 如期而至:计时器仍武装
+ await tick(65000);
+ assert(rec.count('voice/stuck_ask') === 1, '停止操作 60s 后播 L1');
+ game.stop();
+}
+
+// ================================================================
+// 场景 C3:提示排队礼仪——不顶掉进行中的语音;待播期间玩家操作即作废
+// ================================================================
+section('C3. 卡关提示排队:不顶语音、操作作废');
+{
+ store.clear();
+ store.set('soundgame.escape.guideDone', '1');
+ const { audio, game, rec } = await newGame();
+ game.start();
+ await flush();
+ await gs(game, 'tap'); // REOPEN → PLAY
+ await finishVoices(1); // start_room
+
+ // —— 语音播放中到点:提示入队,等当前句自然播完 ——
+ await gs(game, 'tap'); // 单击检查(无语音 → 直接检查)
+ await tick(800); // 检查描述开播(start_room,虚拟时长 1s)
+ assert(audio.isVoicePlaying(), '前提:检查描述播放中');
+ game._lastActiveAt = now - 61000; // 测试钩子:回拨无操作计时(模拟已 61s 无操作)
+ game._checkStuck(); // 直调:等效卡关轮询到点
+ assert(rec.count('voice/stuck_ask') === 0, '语音播放中 L1 只入队不开播');
+ assert(audio.isVoicePlaying(), '进行中的语音未被顶掉');
+ await finishVoices(1); // 描述自然播完
+ assert(rec.count('voice/stuck_ask') === 1, '语音播完后 L1 补播 stuck_ask');
+ const desc = rec.last('voice/start_room');
+ const hint = rec.last('voice/stuck_ask');
+ assert(hint.t >= desc.t + 1000, 'stuck_ask 在描述自然播完之后才开播(未 kill)');
+ await finishVoices(2); // stuck_ask + stuck_key_1 播完
+
+ // —— 待播期间玩家操作:提示作废,且不按已播计入 ——
+ await gs(game, 'tap'); // 再次检查(无语音 → 直接检查)
+ await tick(800); // 描述开播
+ game._lastActiveAt = now - 121000; // 钩子:121s 无操作 → L2 到点
+ game._checkStuck();
+ assert(rec.count('voice/stuck_key_2') === 0, '语音播放中 L2 只入队不开播');
+ await gs(game, 'tap'); // 玩家操作(跳过描述 + 补发检查)→ 待播 L2 作废
+ await finishVoices(2);
+ assert(rec.count('voice/stuck_key_2') === 0, '玩家操作后待播 L2 不再播出');
+ assert(!game._stuckShown.has('L2'), '作废的 L2 不计入已播级别');
+ game.stop();
+}
+
+// ================================================================
+// 场景 C4:重开后高频操作零卡关提示(round2-live:复测 stuck_key_2 实为
+// 双击兜底主动求提示,非计时器——本场景冻结计时器语义:重开重置无操作计时)
+// ================================================================
+section('C4. 重开后高频操作 90s 零卡关提示(round2-live 回归)');
+{
+ store.clear();
+ store.set('soundgame.escape.guideDone', '1');
+ const { game, rec } = await newGame();
+ game.start();
+ await flush();
+ await gs(game, 'tap'); // REOPEN → PLAY
+ await finishVoices(1);
+
+ game._restart(); // 结算/菜单重开路径:计时必须重置
+ await flush();
+ assert(rec.count('voice/replay') === 1, '重开播 replay');
+ await finishVoices(1);
+ // 每秒一个操作(滑动/单击交替,不用双击——双击无目标是设计内主动求提示),
+ // 持续 90s:任何卡关提示都不应出现
+ for (let i = 0; i < 90; i++) {
+ await tick(1000);
+ await gs(game, i % 2 === 0 ? 'left' : 'tap');
+ if (i % 2 === 1) await tick(900); // 单击补发描述到点播出,下一句前消化
+ }
+ assert(rec.count('voice/stuck_ask') === 0, '重开后高频操作 90s:无 L1(stuck_ask)');
+ assert(
+ rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
+ '重开后高频操作 90s:无 L2/L3(复测 stuck_key_2 = 双击兜底,非计时器)',
+ );
+ // 停手 60s 后 L1 如期而至:重开后计时器仍武装
+ await tick(66000);
+ assert(rec.count('voice/stuck_ask') === 1, '重开后停手 60s 播 L1(计时器仍武装)');
game.stop();
}
diff --git a/web/harness/guide.test.mjs b/web/harness/guide.test.mjs
index 069661e3..3e31570a 100644
--- a/web/harness/guide.test.mjs
+++ b/web/harness/guide.test.mjs
@@ -423,6 +423,9 @@ realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小
await advance(() => tSec() >= game._bgmT0 + 1.5); // BGM 已起、首窗口未到
await gesture(game, 'down');
assert(game.phase === 'menu', 'G3:曲中下滑呼出菜单(无悬置 down 音符)');
+ // live round2 P1:菜单 1s 去抖独占期(防瞬穿)——关菜单手势须在独占期后到达
+ const menuT0 = tSec();
+ await advance(() => tSec() >= menuT0 + 1.05);
const kicksBefore = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').length;
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === 1, 'G3:恢复先播 gd_count(full 档)');
@@ -448,6 +451,8 @@ realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小
// G3:gd_count 每局恢复限 1 次(第二次恢复直接 count-in)
await gesture(game, 'down');
+ const menuT1 = tSec();
+ await advance(() => tSec() >= menuT1 + 1.05); // live round2 P1:菜单 1s 去抖独占期后再操作
const cntBefore = voicePlays(playLog, 'voice/gd_count');
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === cntBefore, 'G3:gd_count 每局恢复限 1 次');
@@ -536,28 +541,33 @@ function driveSimonToInput(rig) {
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 doubletap 时双击=作答(inputIdx 前进)');
const ch = game.chances;
const relistenN = voicePlays(playLog, 'voice/relisten');
+ game.freeRelistens = 0; // 钩子:免费层置空,本段专测扣真机会路径(round2 live P1 两层账,免费层断言见 simon playtest 与 13 号文档 §15)
await gesture(game, 'up'); // 期望非 up → 主动重听
assert(game.chances === ch - 1 && voicePlays(playLog, 'voice/relisten') === relistenN + 1,
'G5:input 上滑=主动重听(耗机会、播 relisten)');
await advance(() => game.phase === 'input');
assert(game.inputIdx === 0, 'G5:重听后输入进度清零');
- // G6:第 1 轮答完 → 轮结算链尾 gd_clear(每局 1 次)
+ // G6(round1 live P1 改口,13 号文档 §9):轮结算**每轮必播表扬**,既有台词池
+ // [correct, gd_clear] 按轮轮播——第 1 轮 = correct「答对了!」(原"gd_clear 每局
+ // 限 1 次"口径已被 round1 live 推翻:12 轮仅 3 轮有表扬)
await gesture(game, 'doubletap'); // 期望 doubletap → 答对
await gesture(game, 'left');
await advance(() => game.phase === 'roundend');
- assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:轮成功链尾播 gd_clear(漂亮,过了)');
+ assert(voicePlays(playLog, 'voice/correct') === 1, 'G6:每轮答对必播表扬(第 1 轮 correct「答对了!」)');
await advance(() => game.phase === 'input' && game.round === 2);
- // G6:第 2 轮答完且超过预设历史纪录(钩子 best=1)→ 追加 gd_record(每局 1 次)
+ // G6:第 2 轮答完 → 表扬轮播到 gd_clear;且超过预设历史纪录(钩子 best=1)→
+ // 同链追加 gd_record(每局 1 次)。链有两句(gd_clear 在前),须等链尾播完再计数。
game.best = 1; // 模拟上局留下纪录(测试钩子)
game.sequence[0] = 'up'; // 顺带验证期望恰为 up 时上滑=作答(00 §5.3 豁免同款)
await gesture(game, 'up');
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 up 时上滑=作答');
for (let i = 0; i < 8 && game.phase === 'input'; i++) await gesture(game, game.sequence[game.inputIdx]);
await advance(() => game.phase === 'roundend' && game.round === 2);
+ await advance(() => voicePlays(playLog, 'voice/gd_record') === 1); // 等表扬+纪录链播完再断言
+ assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:表扬池轮播(第 2 轮 gd_clear「漂亮,过了。」)');
assert(voicePlays(playLog, 'voice/gd_record') === 1, 'G6:局中破个人纪录播 gd_record(破纪录了)');
- assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:gd_clear 每局仅第 1 次轮成功播');
await advance(() => game.phase === 'input' && game.round === 3);
// 双击永远=作答的判错面:chances=0(重听已耗)时双击期望非双击 → 判错到底(gameover),不是 chances_out
@@ -576,6 +586,7 @@ function driveSimonToInput(rig) {
game.sequence[1] = 'left';
await gesture(game, 'doubletap');
assert(game.inputIdx === 1, 'off:双击=作答同样成立(玩法映射与向导档无关)');
+ game.freeRelistens = 0; // 钩子:免费层置空,上滑直接走扣真机会路径(round2 live P1)
await gesture(game, 'up');
assert(game.chances === 0, 'off:上滑=主动重听同样成立');
await advance(() => game.phase === 'input');
@@ -589,8 +600,14 @@ function driveSimonToInput(rig) {
// ================================================================ ⑨ G7 space 方向呼喊
-realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超短方向呼喊 ==');
+realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) ==');
{
+ // 设计依据(research/04-playtests/live/round1/space-xiaozhao.md P1-1 + 13 号文档 §11):
+ // G7 原裁定为「loud 警报档(撞击前 500ms)同窗呼喊」,但第 1 轮全拟真实测每颗陨石的
+ // 呼喊都落在撞击判定前 ~0.4s——听完再滑必死,与教学培养的"听语音滑"直接矛盾。
+ // round4 R1 把呼喊提前到**陨石呼啸响起瞬间**(spawn 当刻):呼喊=反应窗口起点的指引,
+ // 与 whoosh 同窗;loud 警报保留在撞击前 500ms 承担"临近警示",二者**明确错峰**、
+ // 不再绑定(撞击点落在保护期内的陨石不呼喊,protect_ 长句教学优先)。
const { audio, playLog, game } = makeRig(SpaceGame);
await audio.unlock();
game.start();
@@ -600,9 +617,17 @@ realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超
assert(game.phase === 'playing', '前提:跳过演练进对局');
await advance(() => playLog.some((e) => e.id.startsWith('voice/dir_')));
const shout = playLog.find((e) => e.id.startsWith('voice/dir_'));
- const louds = playLog.filter((e) => e.id === 'sfx/alarm' && e.opts.volume === 1).map((e) => e.t);
- assert(['left', 'right', 'up', 'down'].includes(shout.id.slice('voice/dir_'.length)), 'G7:呼喊方向合法');
- assert(louds.some((t) => Math.abs(t - shout.t) < 60), `G7:dir_* 与 loud 警报同窗(≤50ms,实际最小 ${Math.min(...louds.map((t) => Math.abs(t - shout.t)))}ms)`);
+ const dir = shout.id.slice('voice/dir_'.length);
+ assert(['left', 'right', 'up', 'down'].includes(dir), 'G7:呼喊方向合法');
+ const whooshes = playLog.filter((e) => e.id === `sfx/whoosh_${dir}`).map((e) => e.t);
+ assert(whooshes.some((t) => Math.abs(t - shout.t) < 60),
+ `G7:dir_* 与同向呼啸同窗(≤60ms,实际最小 ${Math.min(...whooshes.map((t) => Math.abs(t - shout.t)))}ms)`);
+ // 错峰验证:呼喊后第一声 loud 警报(同颗陨石的撞击前 500ms 档)应 ≥2s 才到
+ // (逼近时长 ≥2.8s → 呼喊到 loud 档间隔 ≥2.3s,取 2s 保守下界)
+ await advance(() => playLog.some((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t));
+ const nextLoud = playLog.find((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t);
+ assert(nextLoud.t - shout.t >= 2000,
+ `G7:呼喊先于其 loud 警报 ≥2s(错峰:呼喊在窗口起点,实际 ${nextLoud.t - shout.t}ms)`);
game.stop();
resetTimers();
}
@@ -680,29 +705,32 @@ function fixSequence(game, recipe) {
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
+ // H3(round2 live P1 改口,13 号文档 §15):重听两层账——免费 2 次/局开局即给足、
+ // 先用不碰 chances;余量句与免费余量一致:第 1 次(2→1)播 left1、第 2 次(1→0)
+ // 播 left0;免费与真机会皆空时再求重听 → 拒绝并播 chances_out(机会账路径不变)。
fixSequence(game, []); // 无占用填充轮
await advance(() => game.phase === 'input');
- game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2)
+ assert(game.freeRelistens === 2, 'H3:免费重听每局 2 次开局即给足(教学 gd_economy 承诺两次)');
const rel0 = voicePlays(playLog, 'voice/relisten');
const l1_0 = voicePlays(playLog, 'voice/gd_chances_left1');
- await gesture(game, 'up'); // 期望非 up → 主动重听①:2→1
+ 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(game.freeRelistens === 1 && 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 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 时出现)');
+ assert(game.freeRelistens === 0 && game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1,
+ 'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在免费余量为 0 时出现)');
await advance(() => game.phase === 'input');
+ game.chances = 0; // 测试钩子:真机会一并置空,下一求重听走拒绝路径
const co0 = voicePlays(playLog, 'voice/chances_out');
- await gesture(game, 'up'); // 余 0 再求重听:拒绝路径(机会账)行为不变
+ await gesture(game, 'up'); // 两账皆空再求重听:拒绝路径(机会账)行为不变
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
- 'H3:余 0 再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
+ 'H3:两账皆空再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]);
game.stop();
resetTimers();
diff --git a/web/rhythm/README.md b/web/rhythm/README.md
index 413aec50..0d147a3c 100644
--- a/web/rhythm/README.md
+++ b/web/rhythm/README.md
@@ -7,8 +7,10 @@
- 每个乐句 = **领奏 1 小节 + 应答 1 小节**:先听系统用打击乐器演奏
2-4 个音的节奏型,然后在下一小节的相同拍点用相同手势跟打。
-- 判定:**Perfect ±120ms**(乐器音 + 亮音 accent)/ **Good ±250ms**(只播乐器音)/
- **Miss**(手势错或错过拍点:不出声 + 震动提醒)。Miss 不中断不掉血,歌照唱完。
+- 判定:**Perfect ±150ms**(乐器音 + 亮音 accent)/ **Good ±350ms**(只播乐器音)/
+ **Miss**(手势错或错过拍点:该音符不出声 + 一声闷镲 + 震动提醒)。
+ 判定时刻按真实触摸时刻算(引擎 immediateTap 按下即发 tap;滑动/双击按
+ 识别延迟补偿回触摸时刻)。Miss 不中断不掉血,歌照唱完。
- 计分:Perfect=2 / Good=1 / Miss=0,评级 **S≥95% / A≥85% / B≥70% / C**。
- 连击每 16 次有震动鼓励;乐句全 Perfect 有额外震动。
- **3 首曲目解锁制**:《启程》90 BPM → 《追风》105 BPM(反拍)→ 《星爆》120 BPM
@@ -24,25 +26,31 @@
| 右滑 | 开放镲 hat | 极右 |
| 上滑 | 高通 tom | 升调感 |
| 下滑 | 低通 tom | 降调感 |
+| 长按 | —(非演奏手势) | Miss 反馈音 + 震动提醒 |
你的每次出手都会实时奏响对应乐器音——跟对了,就能听到自己在演奏。
## 流程
-- **首次进入**:一句欢迎后进入 3 步交互教学(单底鼓 → 左右 → 三音带双击),
- 做错有纠正语音(早了/晚了/不是这个手势),同一步失败 2 次自动跳过;
- 教学中**下滑 = 跳过教学**。
+- **首次进入**:一句欢迎后进入 5 步交互教学(单击 → 左右滑 → 上滑 → 下滑 →
+ 三音带双击),做错有纠正语音(早了/晚了/不是这个手势),同一步失败 2 次
+ 自动跳过;全程零通过时收尾播中性句(不夸"很好");教学中**下滑 = 跳过
+ 教学**(教到下滑步、临近下滑音符时除外——那时它是演奏)。
- **选曲**(二周目起):左右滑切曲目(播曲目名/未解锁提示),单击开始,
上滑听规则,下滑回大厅。
- **结算**:播报评级(含准确率档位)与连击纪录;**单击 = 再来一遍(同曲),
- 右滑 = 下一首(需已解锁),下滑 = 回大厅**。
+ 右滑 = 下一首(需已解锁),下滑 = 回大厅**(先播"回大厅"确认再跳转)。
## 菜单
-演奏中**下滑呼出**(模态暂停,BGM 断点续播)→ **单击循环选项**(继续演奏/回大厅)
-→ **双击确认** → **上滑 = 帮助**(规则 + 设置引导)。
-注意:下滑同时是"低通 tom"的演奏手势——**仅当没有悬置或临近(1 秒内)的下滑
-音符时**,下滑才会呼出菜单;有下滑音符时它永远是演奏。
+演奏中**下滑呼出**(模态暂停:谱面、判定、BGM 全部冻结,BGM 断点续播)→
+**单击循环选项**(继续演奏/回大厅)→ **双击确认** → **上滑 = 帮助**(规则 +
+设置引导)。菜单打开后 **1 秒去抖独占期**:期内不收任何手势(防演奏途中被
+弹菜单时,已在路上的手势直接穿透菜单误确认)。
+注意:下滑同时是"低通 tom"的演奏手势——**自下滑音符的领奏响起到判定
+死线之间**,下滑永远是演奏;音符刚被判定死线拖死的短窗内(识别延迟、手势
+还在路上)同样按演奏处理,绝不弹菜单;仅当没有悬置、临近或刚拖死的下滑
+音符时才呼出菜单。
## 运行
diff --git a/web/rhythm/game.js b/web/rhythm/game.js
index b639be48..1b962fa5 100644
--- a/web/rhythm/game.js
+++ b/web/rhythm/game.js
@@ -6,11 +6,16 @@
* 的相同拍点用相同手势跟打。3 首曲目(90/105/120 BPM)解锁制:曲目 n 拿到 B 级
* 以上解锁 n+1(localStorage 记解锁状态,已登录同步服务器)。
*
- * 判定(施工契约 §2):
- * Perfect ±0.12s(手势乐器音 + inst_accent 亮音叠加)
- * Good ±0.25s(只播乐器音;蓝牙延迟由此吸收)
- * Miss 窗口内手势错 / 超时应答缺失:不播乐器音(错过=没出声)+ error 震动
- * tap 判定时刻 -0.30s(TAP_DELAY 引擎延迟补偿);tap Miss 超时放宽 TAP_DELAY+0.08
+ * 判定(live round1 §13 校准版;03 文档 §2 契约的窗口放宽):
+ * Perfect ±0.15s(手势乐器音 + inst_accent 亮音叠加)
+ * Good ±0.35s(只播乐器音;蓝牙延迟与滑动/双击识别延迟由此吸收)
+ * Miss 窗口内手势错 / 超时应答缺失:不播该音符的乐器音(错过=没出声)
+ * + 闷镲 Miss 反馈音(inst_hat_l 降调 0.5× 复用,零新资产)+ error 震动;
+ * 长按 = 死输入同款反馈(不判定不归属)
+ * 判定时刻 = 触摸时刻(回调时刻 - 识别补偿):immediateTap(index.html 已
+ * 开启,按下即发 tap)下 tap 补偿 0;旧引擎(双击窗 300ms)补偿 TAP_DELAY
+ * 0.30s——运行证据自动闩锁回退(见 onGesture)。滑动补偿 SWIPE_DELAY 0.15s、
+ * 双击 DOUBLE_DELAY 0.20s(识别都在 touchend);Miss 超时放宽 = 补偿 + 0.10
* 计分:Perfect=2 / Good=1 / Miss=0;rate = 得分/(2×总音符);
* 评级 S≥0.95 / A≥0.85 / B≥0.70 / C 其余。不掉血不中断,歌照唱完。
*
@@ -24,12 +29,32 @@
*
* 菜单(round2-review 全球约定):下滑呼出(模态暂停,BGM 断点续播)→
* 单击循环 → 双击确认 → 上滑帮助。下滑与"下滑=低音 tom"冲突的折中:
- * 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README)。
+ * 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README);临近窗口 =
+ * 该 down 音符领奏响起(expected-4 拍)至判定死线(live round1 P0 加宽——
+ * 旧 ±1s/+0.55s 窗口跟不上滑动识别延迟,踩点下滑曾必弹菜单);live round2
+ * 续修:down 音符刚被超时拖死(识别延迟,REVIVE_WINDOW_S 内)同样算临近——
+ * 下滑还在路上时音符被判死不再弹菜单(事件流实证 3/3:超时 Miss 先于下滑
+ * 回调 2~114ms)。暂停真冻结(泵停 = 谱面/判定/定时动作全冻结,BGM 停源记
+ * 断点);关菜单先停菜单语音链再恢复(菜单语音骑在恢复后领奏上是"菜单
+ * 不暂停"感知的主因)。菜单打开 1s 去抖独占期(live round2 P1:演奏途中
+ * 被弹菜单时,已在路上的手势不得穿透菜单直接确认——实测 0.6s"瞬穿")。
+ *
+ * immediateTap 兼容(引擎 opt-in,index.html 已开启,按下即发 tap):
+ * 每次按下都先发 tap——滑动/双击/长按的触摸起点也会先出一声 tap。演奏
+ * 阶段(tut/main)序曲 tap 出声(kick)但只认领 tap 音符(不抢归属不判
+ * Miss);live round2 P0 判定治理:序曲 tap 判定记账,转滑动/长按(引擎
+ * 补 tapcancel)或改判双击时按账撤销——声音已出收不回,判定/统计更正
+ * 且不计 Miss;识别延迟把音符超时拖死而手势仍在路上的(同手势、超时类、
+ * REVIVE_WINDOW_S 内、补偿 dt ≤ REVIVE_DT_S)复活改判。非演奏阶段
+ * (intro/select/menu/end/leaving)tap 延迟 STRAY_TAP_MS 生效、期间来了
+ * 其他手势或 tapcancel 即作废(否则选曲右滑会先误触发"选定曲目")。
*
* 状态 BGM(docs/design/10-feedback2-state-bgm.md §5):
* 选曲界面 = bgm/menu(引擎层淡入 800ms);选定曲目 stopBgm(400) 后按既有
* 网格起 track90/105/120(手动排程不变);暂停菜单保持静默(断点续播,
- * 不叠 menu pad);结算评级语音链播完后淡入 bgm/menu 800ms。
+ * 不叠 menu pad);结算评级语音链播完后淡入 bgm/menu 800ms——且只在仍处
+ * 结算态时起播,看门狗按链长缩放(live round1 P1:10s 看门狗曾早火把
+ * menu pad 骑到语音链尾上;重开后过期 fire 会把 menu pad 漏进正片)。
* 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/
* end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断);
* main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。
@@ -53,13 +78,25 @@
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { vibrate } from '../engine/haptics.js';
+import { settings } from '../engine/settings.js';
-// ---- 判定与调度常量(03 文档 §2 施工契约) ----
-const PERFECT = 0.12;
-const GOOD = 0.25;
-const TAP_DELAY = 0.30;
+// ---- 判定与调度常量(live round1 §13:03 文档 §2 契约的窗口放宽版) ----
+const PERFECT = 0.15;
+const GOOD = 0.35;
+const TAP_DELAY = 0.30; // 旧引擎 tap 回调延迟(双击窗 300ms)补偿;immediateTap 下为 0
+const SWIPE_DELAY = 0.15; // 滑动识别在 touchend:补平均滑程(判定回到触摸时刻)
+const DOUBLE_DELAY = 0.20; // 双击识别在第二次 touchend:补首按到改判的间隔
+const STRAY_TAP_MS = 500; // immediateTap 序曲 tap 裁决窗(> 引擎 longpressMs 450,长按也能抢先作废)
+const MISS_SFX = 'sfx/inst_hat_l'; // Miss 反馈音:闭镲降调复用(闷镲),零新资产
+const MISS_RATE = 0.5;
const LOOKAHEAD = 0.6;
const PUMP_MS = 40;
+// ---- live round2 判定治理常量 ----
+const MENU_DEBOUNCE_MS = 1000; // 菜单独占去抖期:开菜单 1s 内不收任何手势(防"瞬穿")
+const TAPCANCEL_REVOKE_S = 0.6; // tapcancel 撤销窗(覆盖长按 450ms 成立点与滑动 slop 判定)
+const DOUBLE_REVOKE_S = 0.5; // 双击序曲 tap 撤销窗(双击窗 300ms + 两段触程余量)
+const REVIVE_WINDOW_S = 0.6; // 超时 Miss 可复活窗(识别延迟把音符拖死、手势还在路上)
+const REVIVE_DT_S = 0.5; // 复活判定时差上限(GOOD 0.35 + 慢滑/慢双击补偿误差 0.15)
// ---- 手势 → 乐器音映射(03 文档 §4;accent 为 Perfect 叠加层) ----
const INST = {
@@ -131,10 +168,13 @@ const SONGS = [
},
];
-// 教学 3 步(T 组台词;T1 单底鼓 → T2 左右 → T3 三音带双击)
+// 教学 5 步(T 组台词 + 复用 gd_answer「现在跟着我打」——上/下滑无专属台词,
+// 零新语音资产;tut_3「最后一句」要保持为真,故上/下滑步插在其前)
const TUT_STEPS = [
{ voice: 'voice/tut_1', notes: [[1, 'tap']] },
{ voice: 'voice/tut_2', notes: [[1, 'left'], [3, 'right']] },
+ { voice: 'voice/gd_answer', notes: [[1, 'up']] }, // 上滑 = 高通 tom
+ { voice: 'voice/gd_answer', notes: [[1, 'down']] }, // 下滑 = 低通 tom(下滑折中同款:临近时是演奏)
{ voice: 'voice/tut_3', notes: [[1, 'tap'], [2, 'doubletap'], [3, 'tap']] },
];
@@ -176,18 +216,33 @@ export class RhythmGame extends Game {
this._gdCountUsed = false; // G3:gd_count 每局(曲)恢复限 1 次
this._answerArmed = false; // G1:gd_answer+turn 交接标记是否已在 _timed 里待响
this._consecMiss = 0; // 连续 Miss 计数(向导 gd_miss:连 2 次提示一次)
+ // immediateTap(引擎 opt-in,index.html 已开启,按下即发 tap):
+ // true = tap 判定零补偿 + 非演奏阶段 tap 延迟 STRAY_TAP_MS 裁决(防序曲 tap
+ // 误触发);运行证据自动闩锁回退 false(onGesture 双击闩锁,见注释)
+ this._immediateTap = true;
+ this._lastTapEventAt = -Infinity; // 最近一次 tap 事件(performance.now() ms,闩锁证据用)
+ this._pendingTap = null; // 非演奏阶段延迟裁决的 tap / 教学期延迟裁决的跳句
+ this._tutPassedAny = false; // 教学是否有任一步通过(零通过收尾换中性句,不夸"很好")
+ this._leadBus = null; // 领奏总线(提示语音 ducking 作用于此)
+ this._midDuckTimer = null;
+ this._goingHome = false;
+ this._voiceSeq = 0; // 语音链序号(_speakGuarded 滞留看门狗作废用,见该方法注释)
+ this._tapJudg = []; // 序曲 tap 判定账簿(tapcancel/doubletap 到达时撤销,live round2 P0)
+ this._menuOpenedAt = -Infinity; // 菜单打开时刻(performance.now ms,去抖独占期用)
}
// ---------------- 生命周期 ----------------
start() {
this.audio.prewarm([...ALL_SFX, 'voice/welcome', 'voice/welcome_back',
- 'voice/tut_1', 'voice/select_hint', 'bgm/menu', ...SONGS.map((s) => s.bgm)]);
+ 'voice/tut_1', 'voice/select_hint', 'voice/mid_song', 'bgm/menu', ...SONGS.map((s) => s.bgm)]);
const rid = ++this._runId;
this._cleanup();
this._tutRate = 1.0;
this._tutSlowUsed = false; // G2:每教学局重新封顶
this._gdCountUsed = false; // G3:每局(曲)恢复限 1 次
+ this._tutPassedAny = false;
+ this._goingHome = false;
if (this._progress.tutorialDone) {
// 二周目起:缩减开场 → 选曲
this.phase = 'intro';
@@ -209,6 +264,11 @@ export class RhythmGame extends Game {
_cleanup() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
+ if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; }
+ if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; }
+ this._duckLeads(false);
+ this._voiceSeq++; // 作废全部滞留语音链守卫
+ this._tapJudg = []; // 序曲 tap 账簿随相位重置(跨相位不撤销)
this._sched = [];
this._timed = [];
this._cues = [];
@@ -249,7 +309,7 @@ export class RhythmGame extends Game {
// ---------------- 节拍调度泵 ----------------
- /** 在绝对时间 when 播放 sfx(走引擎 sfx 总线,保持音量/duck 语义)。 */
+ /** 在绝对时间 when 播放 sfx(走领奏总线 → 引擎 sfx 总线,保持音量/duck 语义)。 */
async _scheduleSfx(id, when, volume = 1) {
const buf = await this.audio._load(id);
if (!buf || !this.audio.ctx) return;
@@ -258,10 +318,70 @@ export class RhythmGame extends Game {
const g = this.audio.ctx.createGain();
g.gain.value = this.audio.sfxVolume * volume;
src.connect(g);
- g.connect(this.audio._sfxBus);
+ g.connect(this._leadBusNode());
src.start(when);
}
+ /** 领奏总线(全部预排程音符经此接引擎 sfx 总线;提示语音期间整体 duck)。 */
+ _leadBusNode() {
+ if (!this._leadBus) {
+ this._leadBus = this.audio.ctx.createGain();
+ this._leadBus.connect(this.audio._sfxBus);
+ }
+ return this._leadBus;
+ }
+
+ /** 领奏 ducking(live round1 P2 提示语音压领奏):压到 1/4 / 恢复,200ms 渐变。 */
+ _duckLeads(on) {
+ if (!this.audio.ctx || !this._leadBus) return;
+ const t = this.audio.ctx.currentTime;
+ const g = this._leadBus.gain;
+ g.cancelScheduledValues(t);
+ g.setValueAtTime(g.value, t);
+ g.linearRampToValueAtTime(on ? 0.25 : 1.0, t + 0.2);
+ }
+
+ /** mid_song 提示语音:播前把领奏 duck 到 1/4,语音播完(按buffer时长估算)恢复。 */
+ async _playMidSong() {
+ if (!this.audio.ctx) return;
+ this._duckLeads(true);
+ if (this._midDuckTimer) clearTimeout(this._midDuckTimer);
+ const buf = await this.audio._load('voice/mid_song');
+ const speed = settings.get().speed || 1;
+ const ms = buf
+ ? Math.min(4500, Math.max(1200, (buf.duration / speed + 0.5) * 1000))
+ : 2500; // 资产缺失兜底:固定时长后恢复
+ this._midDuckTimer = setTimeout(() => this._duckLeads(false), ms);
+ this.audio.play('voice/mid_song');
+ }
+
+ /** Miss 反馈音:闭镲降调 0.5×(闷掉的踩镲;复用现有资产,零新文件)。 */
+ async _playMissSfx() {
+ const buf = await this.audio._load(MISS_SFX);
+ if (!buf || !this.audio.ctx) return;
+ const src = this.audio.ctx.createBufferSource();
+ src.buffer = buf;
+ if (src.playbackRate) src.playbackRate.value = MISS_RATE;
+ const g = this.audio.ctx.createGain();
+ g.gain.value = this.audio.sfxVolume * 0.9;
+ src.connect(g);
+ g.connect(this.audio._sfxBus);
+ src.start();
+ }
+
+ /** 手势识别补偿(判定时刻 = 回调时刻 - 补偿 ≈ 真实触摸时刻)。 */
+ _compFor(g) {
+ if (g === 'tap') return this._immediateTap ? 0 : TAP_DELAY;
+ if (g === 'doubletap') return DOUBLE_DELAY;
+ return SWIPE_DELAY; // 四向滑动
+ }
+
+ /** 当前阶段的秒/拍(教学计降速;无曲目上下文按曲目一)。 */
+ _spb() {
+ if (this.phase === 'tut') return (60 / SONGS[0].bpm) / this._tutRate;
+ return this._song ? 60 / this._song.bpm : 60 / SONGS[0].bpm;
+ }
+
_pump(rid) {
if (this._runId !== rid || !this.audio.ctx) return;
const now = this.audio.ctx.currentTime;
@@ -281,10 +401,10 @@ export class RhythmGame extends Game {
}
if (this.phase === 'main') {
- // Miss 超时检查:tap 指令回调天然晚 ~300ms 到达,放宽同样余量
+ // Miss 超时检查:放宽 = 该手势的识别补偿 + 0.10s(补偿后仍超窗才判超时)
for (const cue of this._cues) {
if (cue.judged) continue;
- const margin = cue.gesture === 'tap' ? TAP_DELAY + 0.08 : 0.05;
+ const margin = this._compFor(cue.gesture) + 0.10;
if (now > cue.expected + GOOD + margin) this._applyMiss(cue);
}
if (now >= this._phaseEnd) this._finish(rid);
@@ -309,6 +429,8 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this._tutIdx = idx;
this._tutFails = 0;
+ this._cues = []; // 步骤引导语音期间无 cue("语音播放中单击=跳句"的判定语境)
+ this._tutAttempts = [];
this._speakGuarded([TUT_STEPS[idx].voice], () => this._tutLead(rid));
}
@@ -350,13 +472,12 @@ export class RhythmGame extends Game {
return a && a.correct && Math.abs(a.dt) <= GOOD;
});
if (pass) {
+ this._tutPassedAny = true;
this._tutRate = 1.0; // G2:该步做对,恢复正常速度
if (this._tutIdx + 1 < TUT_STEPS.length) {
this._beginTutorial(rid, this._tutIdx + 1);
} else {
- this._progress.tutorialDone = true;
- this._saveProgress();
- this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0));
+ this._finishTutorial(rid);
}
return;
}
@@ -382,11 +503,7 @@ export class RhythmGame extends Game {
// 同一步失败 2 次跳过,不卡流程(归因已在首次失败时给过,此处不再叠加)
this._speakGuarded(['voice/retry_skip'], () => {
if (this._tutIdx + 1 < TUT_STEPS.length) this._beginTutorial(rid, this._tutIdx + 1);
- else {
- this._progress.tutorialDone = true;
- this._saveProgress();
- this._speakGuarded(['voice/tut_done'], () => this._startSong(rid, 0));
- }
+ else this._finishTutorial(rid);
});
return;
}
@@ -416,6 +533,18 @@ export class RhythmGame extends Game {
});
}
+ /**
+ * 教学收尾:有任一步通过播 tut_done「很好,演出开始」;全程零通过换中性句
+ * retry_skip(live round1 P2:零命中被夸"很好"出戏——台词文本不变,只改
+ * 触发逻辑)。
+ */
+ _finishTutorial(rid) {
+ this._progress.tutorialDone = true;
+ this._saveProgress();
+ const line = this._tutPassedAny ? 'voice/tut_done' : 'voice/retry_skip';
+ this._speakGuarded([line], () => this._startSong(rid, 0));
+ }
+
// ---------------- 选曲 ----------------
_enterSelect(rid) {
@@ -480,7 +609,8 @@ export class RhythmGame extends Game {
this._cues.push({ gesture: g, expected: t + 4 * spb, judged: false, result: null, phrase: i });
}
if (i === midPhrase) {
- this._timed.push({ time: leadBarT0, fn: () => this.audio.play('voice/mid_song') });
+ // 提示语音不压领奏(live round1 P2):经领奏总线 ducking 到 1/4
+ this._timed.push({ time: leadBarT0, fn: () => this._playMidSong() });
}
if (i === 0) {
// 向导层(round3 G1):每次进 main,首个应答窗口**前整整 1 小节**(= 领奏
@@ -530,16 +660,75 @@ export class RhythmGame extends Game {
onGesture(g) {
const rid = this._runId;
+ // 旧引擎侦测闩锁:immediateTap 下双击前必有 ≤300ms 的序曲 tap(第一下按下即发);
+ // 旧引擎的双击会取消挂起的单击——双击事件前 350ms 内必无 tap 事件。据此一次性
+ // 回退旧补偿与旧路径(index.html 未开 immediateTap 时兜底;反过来不闩)。
+ if (g === 'doubletap' && this._immediateTap
+ && performance.now() - this._lastTapEventAt > 350) {
+ this._immediateTap = false;
+ }
+ if (g === 'tap') this._lastTapEventAt = performance.now();
+ // tapcancel(引擎 immediateTap 补发:按下后转滑动/长按/触摸取消)——序曲 tap
+ // 的声音已出收不回,但判定必须撤销且不计 Miss(live round2 P0:爆音不进判定链)。
+ // 非演奏阶段的延迟裁决 tap 同步作废——比"后续手势到达才作废"更早(慢滑在
+ // 500ms 裁决窗内超 slop 即撤,选曲慢滑不再误触发"选定曲目")。
+ if (g === 'tapcancel') {
+ if (this._pendingTap) { clearTimeout(this._pendingTap); this._pendingTap = null; }
+ if (this._isPlayPhase()) this._revokeTaps(TAPCANCEL_REVOKE_S, 1);
+ return;
+ }
+ // 双击序曲治理(tap+tap+doubletap 序列的判定归属):两次按下的序曲 tap
+ // 判定(含可能误认领的命中)一并撤销后,本次双击再正常判定
+ if (g === 'doubletap' && this._immediateTap && this._isPlayPhase()) {
+ this._revokeTaps(DOUBLE_REVOKE_S, 2);
+ }
+ // immediateTap 序曲 tap 治理:非演奏阶段的 tap 可能是一次滑动/双击/长按的
+ // 触摸起点——延迟 STRAY_TAP_MS 生效;窗口内来了任何其他手势说明那是序曲,
+ // 作废这次 tap(窗口 > longpressMs 450ms,长按也能抢先作废;否则选曲界面
+ // 右滑切歌会先误触发"选定曲目")
+ if (g === 'tap' && this._immediateTap && !this._isPlayPhase()) {
+ if (this._pendingTap) clearTimeout(this._pendingTap);
+ this._pendingTap = setTimeout(() => {
+ this._pendingTap = null;
+ this._dispatch(rid, 'tap');
+ }, STRAY_TAP_MS);
+ return;
+ }
+ if (g !== 'tap' && this._pendingTap) {
+ clearTimeout(this._pendingTap);
+ this._pendingTap = null;
+ }
+ this._dispatch(rid, g);
+ }
+
+ /** 演奏阶段(tap 是 kick 演奏输入,即刻生效不延迟裁决)。 */
+ _isPlayPhase() {
+ return this.phase === 'tut' || this.phase === 'main';
+ }
+
+ /** 手势分派(onGesture 已完成序曲 tap 裁决与旧引擎闩锁)。 */
+ _dispatch(rid, g) {
switch (this.phase) {
case 'tut':
- if (g === 'down') { // 下滑 = 跳过教学(T1-T3 均不含 down,无冲突)
+ // 下滑折中(教学同款):临近 down 音符时是演奏,否则下滑 = 跳过教学
+ if (g === 'down' && !this._downNotePending()) {
this._progress.tutorialDone = true;
this._saveProgress();
this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0));
return;
}
- // 教学语音播放中单击 = 跳过本句(12 文档 §1.2);无语音时才是跟打手势
- if (this._trySkipVoice(g)) return;
+ // 教学语音播放中单击 = 跳过本句(12 文档 §1.2);无语音时才是跟打手势。
+ // immediateTap 下跳句延迟裁决(序曲 tap 不跳句),演奏部分照常即时处理
+ if (g === 'tap' && this.audio.isVoicePlaying()) {
+ if (!this._immediateTap) { this.audio.skipVoice(); return; }
+ if (this._pendingTap) clearTimeout(this._pendingTap);
+ this._pendingTap = setTimeout(() => {
+ this._pendingTap = null;
+ if (this._runId === rid && this.phase === 'tut' && this.audio.isVoicePlaying()) {
+ this.audio.skipVoice();
+ }
+ }, STRAY_TAP_MS);
+ }
this._onTutGesture(g);
return;
case 'select':
@@ -583,58 +772,176 @@ export class RhythmGame extends Game {
_onTutGesture(g) {
if (!INST[g] || !this.audio.ctx) return;
this.audio.play(INST[g]);
- let t = this.audio.ctx.currentTime;
- if (g === 'tap') t -= TAP_DELAY;
+ const t = this.audio.ctx.currentTime - this._compFor(g); // 判定回到触摸时刻
let best = null;
let bestDt = Infinity;
for (const cue of this._cues) {
const dt = Math.abs(t - cue.expected);
if (dt < bestDt) { bestDt = dt; best = cue; }
}
- if (best) {
- this._tutAttempts.push({ cue: best, gesture: g, dt: t - best.expected, correct: g === best.gesture });
+ // tap 只认领 tap 音符的尝试(immediateTap 序曲 tap 不算"做错手势")
+ if (best && (g !== 'tap' || best.gesture === 'tap')) {
+ const att = { cue: best, gesture: g, dt: t - best.expected, correct: g === best.gesture };
+ this._tutAttempts.push(att);
+ if (g === 'tap') this._recordTapJudg(null, false, att); // tapcancel 可撤
}
}
+ /**
+ * 序曲 tap 判定账簿(live round2 P0):immediateTap 下每次按下先发 tap,
+ * 该 tap 的判定先记账;若本次按下最终转滑动/长按(引擎补 tapcancel)或
+ * 改判双击(doubletap 到达),按账撤销——声音已出收不回,判定/统计必须
+ * 更正且不计 Miss。cue=null 表示自由演奏(无判定可撤,仅登记供对账)。
+ */
+ _recordTapJudg(cue, perfect, tutAttempt = null) {
+ this._tapJudg.push({
+ at: this.audio.ctx.currentTime, cue, perfect, tutAttempt,
+ prevCombo: this._stats ? this._stats.combo : 0,
+ prevConsec: this._consecMiss,
+ });
+ if (this._tapJudg.length > 8) this._tapJudg.shift();
+ }
+
+ /** 撤销最近 maxCount 笔、账龄 ≤ maxAgeS 的 tap 判定(新→旧,超龄即停)。 */
+ _revokeTaps(maxAgeS, maxCount = 1) {
+ if (!this.audio.ctx) return;
+ const now = this.audio.ctx.currentTime;
+ let n = 0;
+ for (let i = this._tapJudg.length - 1; i >= 0 && n < maxCount; i--) {
+ const j = this._tapJudg[i];
+ if (now - j.at > maxAgeS) break; // 账簿按时间升序,更旧的与本手势无关
+ this._tapJudg.splice(i, 1);
+ n++;
+ if (j.tutAttempt) {
+ const k = this._tutAttempts.indexOf(j.tutAttempt);
+ if (k >= 0) this._tutAttempts.splice(k, 1);
+ }
+ if (j.cue && j.cue.judged && j.cue.result !== 'miss' && this._stats) {
+ // 序曲 tap 曾认领的命中撤销:音符回到未判定(真不接则由超时 Miss 兜底)
+ j.cue.judged = false;
+ j.cue.result = null;
+ if (j.perfect) this._stats.perfect--; else this._stats.good--;
+ this._stats.combo = j.prevCombo;
+ this._consecMiss = j.prevConsec;
+ }
+ }
+ }
+
+ /**
+ * 同手势音符刚被超时判 Miss(识别延迟把音符拖死、手势还在路上)——找最近
+ * 一个 REVIVE_WINDOW_S 内 timeout 类 Miss 的同手势 cue;只认超时类(错手势
+ * 类是真错误,不复活)。tap 不需要复活:immediateTap 下 tap 识别零延迟,
+ * 回调晚于死线 = 触摸本身已晚(且序曲 tap 不得复活音符——双击序曲会误复活)。
+ */
+ _recentTimeoutMiss(g) {
+ if (!this.audio.ctx || g === 'tap') return null;
+ const now = this.audio.ctx.currentTime;
+ let best = null;
+ let bestDt = Infinity;
+ for (const cue of this._cues) {
+ if (!cue.judged || cue.result !== 'miss' || cue.missKind !== 'timeout'
+ || cue.gesture !== g) continue;
+ if (now - cue.missedAt > REVIVE_WINDOW_S) continue;
+ const dt = Math.abs(now - cue.expected);
+ if (dt < bestDt) { bestDt = dt; best = cue; }
+ }
+ return best;
+ }
+
+ /** 复活超时误判:统计回滚、音符回到未判定(闷镲 Miss 音已出收不回)。 */
+ _reviveMiss(cue) {
+ this._stats.miss--;
+ this._stats.combo = cue.comboBefore;
+ this._consecMiss = cue.consecBefore;
+ cue.judged = false;
+ cue.result = null;
+ }
+
_onMainGesture(rid, g) {
if (!this.audio.ctx) return;
- // 下滑折中:有悬置/临近的 down 音符时是演奏,否则呼出菜单
- if (g === 'down' && !this._downNotePending()) {
- this._openMenu(rid);
- return;
+ // 长按 = 死输入给定义(live round1 P0):Miss 反馈音 + error 震动,不判定不归属
+ if (g === 'longpress') { this._playMissSfx(); vibrate('error'); return; }
+ if (g === 'longpressend') return;
+ // 下滑折中:有悬置/临近的 down 音符时是演奏,否则呼出菜单。
+ // live round2 P1 续修:down 音符刚被超时判 Miss(识别延迟拖死、下滑还在路上)
+ // 同样算"临近"——旧版此时必弹菜单(round2 事件流 3/3 复现:超时 Miss 与下滑
+ // 回调相差 2~114ms,_downNotePending 已判假)。aimed=true 期间下滑永不弹菜单。
+ let downAimed = false;
+ if (g === 'down') {
+ downAimed = !!this._recentTimeoutMiss('down');
+ if (!this._downNotePending() && !downAimed) {
+ this._openMenu(rid);
+ return;
+ }
}
if (!INST[g]) return;
- let t = this.audio.ctx.currentTime;
- if (g === 'tap') t -= TAP_DELAY; // 补偿引擎 tap 回调延迟
+ const t = this.audio.ctx.currentTime - this._compFor(g); // 判定回到触摸时刻
- // 归属:期望时刻最近的未判定音符(现版逻辑保留)
+ // 归属①:同手势的最近未判定音符优先(防密集谱跨手势抢占——同句 0.5 拍相邻
+ // 的两个异手势音符,按正确手势略偏时刻出手不得被邻音偷走判定)
let best = null;
let bestDt = Infinity;
for (const cue of this._cues) {
- if (cue.judged) continue;
+ if (cue.judged || cue.gesture !== g) continue;
const dt = Math.abs(t - cue.expected);
if (dt < bestDt) { bestDt = dt; best = cue; }
}
- if (!best || bestDt > GOOD) {
- this.audio.play(INST[g]); // 窗口外自由演奏:出声音符但不判定
+ if (best && bestDt <= GOOD) {
+ // Perfect / Good:出手即播乐器音(演奏感),Perfect 叠 accent 亮音
+ const perfect = bestDt <= PERFECT;
+ this.audio.play(INST[g]);
+ if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
+ if (g === 'tap') this._recordTapJudg(best, perfect);
+ this._applyHit(best, perfect);
return;
}
- if (g !== best.gesture) {
- this._applyMiss(best); // 窗口内做错动作 = Miss(不播乐器音)
+ // 归属①′(live round2 P0):同手势音符刚被超时拖死 → 复活改判。
+ // 事件流实证:双击识别 +0.2s/滑动 +0.3s,超时死线 expected+0.60~0.65s 常在
+ // 手势到达前几十 ms 先判 Miss;补偿后 dt 仍在 GOOD+0.15 内 = 触摸其实在线内
+ const revive = this._recentTimeoutMiss(g);
+ if (revive && Math.abs(t - revive.expected) <= REVIVE_DT_S) {
+ this._reviveMiss(revive);
+ const perfect = Math.abs(t - revive.expected) <= PERFECT;
+ this.audio.play(INST[g]);
+ if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
+ this._applyHit(revive, perfect);
return;
}
- // Perfect / Good:出手即播乐器音(演奏感),Perfect 叠 accent 亮音
- const perfect = bestDt <= PERFECT;
- this.audio.play(INST[g]);
- if (perfect) this.audio.play(ACCENT, { volume: 0.8 });
- this._applyHit(best, perfect);
+ // 归属②:最近的异手势音符落在错手势窗(GOOD 的一半)内 = 窗口内做错动作
+ // = Miss(不播该音符的乐器音)。tap 豁免——immediateTap 下每次滑动/双击的
+ // 触摸起点先发序曲 tap,若认领异手势 Miss,每次滑动前那声 tap 都会把目标
+ // 音符误判掉;真正的错手势 tap 由该音符超时自动 Miss 兜底(同样有 Miss 音)。
+ // downAimed 同样豁免——冲着刚拖死的 down 音符去的下滑,不得顺杀邻音。
+ let wrong = null;
+ let wrongDt = Infinity;
+ if (g !== 'tap' && !downAimed) {
+ for (const cue of this._cues) {
+ if (cue.judged || cue.gesture === g) continue;
+ const dt = Math.abs(t - cue.expected);
+ if (dt < wrongDt) { wrongDt = dt; wrong = cue; }
+ }
+ }
+ if (wrong && wrongDt <= GOOD * 0.5) {
+ this._applyMiss(wrong, 'wrong');
+ return;
+ }
+ this.audio.play(INST[g]); // 窗口外自由演奏:出声音符但不判定
+ if (g === 'tap') this._recordTapJudg(null, false);
}
- /** 当前有无悬置或临近(1s 内)的 down 应答音符。 */
+ /**
+ * 当前有无悬置或临近的 down 应答音符(live round1 P0 加宽:自该音符领奏响起
+ * ——expected-4 拍,玩家已有出手理由——到判定死线之间都算临近。旧窗口
+ * (expected-1s ~ +0.55s)跟不上滑动识别延迟:识别在 touchend,踩点下滑到达
+ * 时音符常已被判超时,于是必弹菜单)。
+ */
_downNotePending() {
+ if (!this.audio.ctx) return false;
const now = this.audio.ctx.currentTime;
+ const spb = this._spb();
return this._cues.some((cue) => !cue.judged && cue.gesture === 'down'
- && cue.expected > now - GOOD - TAP_DELAY && cue.expected < now + 1.0);
+ && cue.expected > now - GOOD - SWIPE_DELAY - 0.10
+ && cue.expected - 4 * spb < now + 0.2);
}
_applyHit(cue, perfect) {
@@ -650,13 +957,22 @@ export class RhythmGame extends Game {
this._checkPhraseAllPerfect(cue.phrase);
}
- _applyMiss(cue) {
+ _applyMiss(cue, kind = 'timeout') {
if (cue.judged) return;
cue.judged = true;
cue.result = 'miss';
+ // 复活账(live round2 P0):超时类 Miss 可能是识别延迟拖死的误判——手势
+ // 到达时若补偿时刻仍在线内则 _reviveMiss 回滚;kind=wrong 的真错误不复活
+ cue.missKind = kind;
+ cue.missedAt = this.audio.ctx ? this.audio.ctx.currentTime : 0;
+ cue.comboBefore = this._stats.combo;
+ cue.consecBefore = this._consecMiss;
this._stats.miss++;
this._stats.combo = 0;
- vibrate('error'); // 错过=没出声(不播乐器音),震动是唯一反馈
+ // Miss 不再无声(live round1 P1):该音符的乐器音仍不播(错过=没出声),
+ // 但给可区分的闷镲反馈音 + error 震动
+ this._playMissSfx();
+ vibrate('error');
// 向导层(schemes §2):连续 2 次 Miss 播 gd_miss(同曲限 1 次;已震过 error,
// skipHaptic 免重复震;voice 走 queue 插播,不顶 mid_song/结算链)
this._consecMiss++;
@@ -681,10 +997,14 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this.phase = 'menu';
this._menuIdx = 0;
+ this._menuOpenedAt = performance.now(); // 去抖独占期起点(live round2 P1 瞬穿)
this.audio.setRateBoostEnabled(true); // 暂停期不是演奏期,恢复长按快进
this.guide.state('menu'); // 状态签名:菜单(模态暂停)
- // 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完
+ // 暂停:停泵、停 BGM(记断点),已排程的 ≤0.6s 音符自然播完;
+ // 提示语音 ducking 一并复位(恢复后领奏不再被压)
if (this._timer) { clearInterval(this._timer); this._timer = null; }
+ if (this._midDuckTimer) { clearTimeout(this._midDuckTimer); this._midDuckTimer = null; }
+ this._duckLeads(false);
this._resumeSeq++; // G3:作废还在等 gd_count 播完的旧恢复
this._pauseAt = this.audio.ctx.currentTime;
// 断点续播偏移只在 BGM 真在播时刷新(count-in 语音期间再开菜单时 BGM 未起,
@@ -695,6 +1015,10 @@ export class RhythmGame extends Game {
}
_onMenuGesture(rid, g) {
+ // 去抖独占期(live round2 P1):菜单打开 1s 内不收任何手势——演奏途中被
+ // 弹菜单时,玩家已经在路上的手势(双击/下滑)不得直接穿透菜单当成菜单
+ // 操作(实测 menu_main 说明语音 0.6s 未读完菜单就被"确认"没了)
+ if (performance.now() - this._menuOpenedAt < MENU_DEBOUNCE_MS) return;
if (g === 'tap') {
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
this.audio.play(MENU_ITEMS[this._menuIdx]);
@@ -712,6 +1036,9 @@ export class RhythmGame extends Game {
if (this._runId !== rid) return;
this.phase = 'main';
this.audio.setRateBoostEnabled(false); // 回到演奏期
+ // 关菜单先停菜单语音链(live round1 P0:menu_main/menu_resume 曾继续骑在
+ // 恢复后的数拍与领奏上,是"菜单不暂停"感知的主因)
+ this.audio.stopVoice();
const token = ++this._resumeSeq; // G3:恢复令牌(语音等待期间再开菜单则作废)
const resume = () => {
if (this._runId !== rid || token !== this._resumeSeq) return;
@@ -750,8 +1077,20 @@ export class RhythmGame extends Game {
}
_goHome() {
- this.stop();
- window.location.href = '../';
+ if (this._goingHome) return;
+ this._goingHome = true;
+ const rid = this._runId;
+ this.phase = 'leaving'; // 收尾态:不再受理演奏/菜单手势(单击仍可跳确认句)
+ // 先停谱面与 BGM(保留 voice 通道播确认句);确认句播完(或看门狗兜底)
+ // 再跳大厅——live round1 P1:旧版无确认直接跳,玩家面对静默页面以为游戏死了
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
+ this._stopBgmSrc();
+ this.audio.stopBgm(300);
+ this._speakGuarded(['voice/menu_lobby'], () => {
+ if (rid !== this._runId) return;
+ this.stop();
+ window.location.href = '../';
+ }, 2000);
}
// ---------------- 结算 ----------------
@@ -820,8 +1159,13 @@ export class RhythmGame extends Game {
ids.push('voice/restart');
console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase());
// 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms;
- // 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM
- this._speakGuarded(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 }));
+ // 链换 _speakGuarded(清点 §4.6 缺口):grade_* 等资产缺失也能起结算 BGM。
+ // live round1 P1:① 看门狗按链长缩放——固定 10s 曾比 grade+restart 链还短,
+ // menu pad 骑到语音链尾上(报告 P3-15 同因);② 只在仍处结算态起播——
+ // 重开/下一首后过期的看门狗 fire 不得把 menu pad 漏进正片
+ this._speakGuarded(ids, () => {
+ if (this.phase === 'end') this.audio.play('bgm/menu', { fadeMs: 800 });
+ }, 5000 + ids.length * 7000);
}
// ---------------- 进度存储 ----------------
@@ -881,15 +1225,20 @@ export class RhythmGame extends Game {
// ---------------- 工具 ----------------
- /** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
- _speak(ids, onDone) {
+ /**
+ * 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。
+ * 链序号:每条新链(_chainSeq 缺省)分配新序号并作废更旧的链守卫
+ * (见 _speakGuarded);链内续播沿用同一序号,不算"更新的链"。
+ */
+ _speak(ids, onDone, _chainSeq) {
if (!ids.length) { if (onDone) onDone(); return; }
const rid = this._runId;
+ const seq = _chainSeq ?? ++this._voiceSeq;
const [head, ...rest] = ids;
this.audio.play(head, {
onComplete: () => {
if (rid !== this._runId) return;
- this._speak(rest, onDone);
+ this._speak(rest, onDone, seq);
},
});
}
@@ -897,16 +1246,23 @@ export class RhythmGame extends Game {
/**
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
* 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。
+ * live round1 深修:看门狗只在"自己这条链仍是当前链"时才可 fire——
+ * 链被顶掉/打断(stopVoice、更新的 _speak/_speakGuarded、_cleanup)后,
+ * 滞留看门狗若照常在 guardMs 后 fire,会把 onDone(如 _tutLead)砸进
+ * 毫不相干的相位(实测:教学纠正链被下滑跳教学顶掉后,10s 滞留看门狗
+ * 把游戏从 main 拖回 tut)。
*/
_speakGuarded(ids, onDone, guardMs = 10000) {
const rid = this._runId;
+ const seq = ++this._voiceSeq; // 本守卫链的序号(先于 _speak 分配,盖过 _speak 内部沿用它)
let fired = false;
const fire = () => {
if (fired || rid !== this._runId) return;
+ if (seq !== this._voiceSeq) return; // 链已被更新的语音链取代:滞留看门狗作废
fired = true;
if (onDone) onDone();
};
- this._speak(ids, fire);
+ this._speak(ids, fire, seq);
setTimeout(fire, guardMs);
}
}
diff --git a/web/rhythm/index.html b/web/rhythm/index.html
index 5880d884..47ea12be 100644
--- a/web/rhythm/index.html
+++ b/web/rhythm/index.html
@@ -27,7 +27,10 @@
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
// 走到这里 AudioContext 已解锁,可以出声了
game.start();
- attachGestures(root, (g) => game.onGesture(g));
+ // immediateTap:单击是演奏输入(kick),按下立即回调,不等 300ms
+ // 双击窗关闭;若第二下构成双击,引擎补发 doubletap 改判(多打一下
+ // kick 无害)。其他游戏勿开——每次按下都会先发 tap。
+ attachGestures(root, (g) => game.onGesture(g), { immediateTap: true });
// 切后台 AudioContext 会被系统挂起,回前台时恢复(节拍时钟基于
// ctx.currentTime,挂起期间时钟也停,回前台后排程自然续上)
diff --git a/web/rhythm/test/playtest.mjs b/web/rhythm/test/playtest.mjs
index 40f1fc2b..9025add6 100644
--- a/web/rhythm/test/playtest.mjs
+++ b/web/rhythm/test/playtest.mjs
@@ -176,9 +176,16 @@ const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p
const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; };
const voicePlayed = (id) => voicePlays().includes(id);
-// 按 cue 期望时刻注入 Perfect 应答;tap 模拟真实回调延迟 +0.30s(补偿判定路径)
+// 按 cue 期望时刻注入应答;模拟真实识别延迟(immediateTap:tap 按下即达 +0;
+// 滑动识别在 touchend +0.15 = SWIPE_DELAY;双击 +0.20 = DOUBLE_DELAY)——
+// game.js 判定补偿回到触摸时刻后 dt≈0(§13 替换口径)。
+// 双击前先注入序曲 tap——immediateTap 的真实行为(第一下按下即发 tap),
+// 否则游戏的旧引擎闩锁(双击前 350ms 无 tap = 旧引擎)会把测试误判成旧引擎
async function answerCue(cue) {
- await runUntil(cue.expected + (cue.gesture === 'tap' ? 0.30 : 0));
+ if (cue.judged) return;
+ const lag = cue.gesture === 'tap' ? 0 : cue.gesture === 'doubletap' ? 0.20 : 0.15;
+ await runUntil(cue.expected + lag);
+ if (cue.gesture === 'doubletap') G('tap'); // 序曲 tap(附近无 0.35s 内未判定 tap 音符,出声不判定)
G(cue.gesture);
await flush();
}
@@ -192,20 +199,29 @@ await flush();
assert(game.phase === 'intro', 'start 后进 intro');
assert(voicePlayed('voice/welcome'), 'welcome 旁白开始播放');
-// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(链推进到 tut_1,不重头)——
+// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(immediateTap 下延迟
+// 500ms 裁决——防序曲 tap;advance 后链推进到 tut_1,不重头)——
assert(!!audio._voice, 'welcome 播放中(voice token 存在)');
G('tap'); // 单击跳过 welcome
-await flush();
+await advance(600);
assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1');
-// —— 教学 T1-T3:按 cues.expected 做手势(tap 注入在 expected+0.30 仍应判对)——
-for (let step = 0; step < 3; step++) {
+// —— 教学 5 步(§13:单击 → 左右 → 上滑 → 下滑 → 三音带双击):按 cues.expected
+// 做手势(识别延迟注入,补偿后 dt≈0)——
+for (let step = 0; step < 5; step++) {
await waitFor(() => game._tutIdx === step && game.phase === 'tut'
&& game._cues.length > 0 && game._tutAttempts.length === 0, `T${step + 1} 领奏排程`);
assert(game._tutIdx === step, `教学步进 T${step + 1}(_tutIdx=${step})`);
const leadNoteCount = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length;
+ if (step === 2) { // 上滑步:先注入序曲 tap(immediateTap 副作用),不得产生尝试记录
+ await runUntil(game._cues[0].expected);
+ G('tap');
+ await flush();
+ assert(game._tutAttempts.length === 0, 'T3 序曲 tap 不产生尝试记录(tap 只认领 tap 音符)');
+ }
for (const cue of game._cues) await answerCue(cue);
- assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.25), `T${step + 1} 应答全部正确且在 Good 窗口内`);
+ assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), `T${step + 1} 应答全部正确且在 Good 窗口内`);
+ if (step === 3) assert(game.phase === 'tut', 'T4 下滑步:临近 down 音符时下滑 = 演奏应答,不跳教学');
assert(srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length > leadNoteCount, `T${step + 1} 领奏音符已排程播放`);
await runUntil(game._phaseEnd + 0.2); // 触发 _tutEvaluate
await flush();
@@ -218,9 +234,15 @@ assert(voicePlayed('voice/tut_done'), 'tut_done 播报');
await waitFor(() => game.phase === 'main' && game._cues.length > 0, '曲目一 main 开始');
assert(game._song.id === 'song1' && game._songIdx === 0, '教学后进入曲目一');
assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track90')), '曲目一 BGM track90 按网格启动');
-const firstTapCue = game._cues.find((c) => c.gesture === 'tap');
+// —— 旧引擎 tap 补偿(§13 闩锁回退路径):_immediateTap=false 时回调 -0.30s 判定 ——
+const legacyTapCue = game._cues.find((c) => c.gesture === 'tap');
+game._immediateTap = false;
+await runUntil(legacyTapCue.expected + 0.30);
+G('tap');
+await flush();
+assert(legacyTapCue.result === 'perfect', '旧引擎 tap 补偿:闩锁回退后 expected+0.30s 注入的 tap 仍判 Perfect');
+game._immediateTap = true;
for (const cue of game._cues) await answerCue(cue);
-assert(firstTapCue.result === 'perfect', 'tap 补偿:expected+0.30s 注入的 tap 仍判 Perfect(回调时刻 -0.30s 判定)');
assert(game._stats.perfect === game._cues.length, `曲目一 31 个音符全部 Perfect(perfect=${game._stats.perfect})`);
assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, '无 Miss 且连击不断');
@@ -231,20 +253,26 @@ assert(game._progress.unlocked === 2, 'unlocked === 2(B 以上解锁下一首
await flush();
assert(voicePlayed('voice/grade_s'), 'grade_s 播报(结算语音链首句)');
-// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句,不是"再来一遍" ——
+// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句(immediateTap 下延迟 500ms
+// 裁决生效),不是"再来一遍" ——
await waitFor(() => !!audio._voice, '结算语音链开始');
-const phaseBeforeSkip = game.phase;
G('tap');
-await flush();
-assert(game.phase === 'end' && phaseBeforeSkip === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
+await advance(600);
+assert(game.phase === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
+assert(lastVoice() === 'voice/new_best_combo', '单击跳过 grade_s → 链推进到 new_best_combo');
-// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5)——
+// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5);§13:起播点不早于链末句 ——
await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入');
assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)');
-assert(true, '结算语音链播完后 bgm/menu 播放(引擎层)');
+{
+ const menuPlayIdx = playLog.findIndex((p) => p.id === 'bgm/menu');
+ const restartIdx = playLog.map((p) => p.id).lastIndexOf('voice/restart');
+ assert(menuPlayIdx > restartIdx && restartIdx >= 0, '结算 menu pad 在语音链末句之后起播(不骑链尾)');
+}
// —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 ——
G('tap');
+await advance(600);
await flush();
assert(game.phase === 'main' && game._songIdx === 0, '结算单击 = 同曲再来一遍');
assert(audio._bgmWanted === null && audio._bgm === null, '选定曲目后 menu pad 停止(stopBgm(400))');
@@ -271,9 +299,15 @@ await flush();
assert(game.phase === 'menu', '下滑折中:无临近 down 音符时下滑 = 呼出菜单');
assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= menuOpenAt), '暂停菜单保持静默(不叠 menu pad,10 文档 §5)');
assert(voicePlayed('voice/menu_main'), '菜单语音 menu_main 播报');
-await advance(3000); // 菜单停留 3s
+// —— 暂停真冻结(§13,live round1 P1):菜单期间判定与谱面全停 ——
+const missAtMenu = game._stats.miss;
+const srcAtMenu = srcLog.length;
+await advance(2000); // 菜单停留 2s(menu_main 播完、menu_resume 在播)
+assert(game._stats.miss === missAtMenu, '菜单开启 2s:无新 Miss(判定冻结)');
+assert(srcLog.length === srcAtMenu, '菜单开启 2s:无新排程音符(谱面冻结)');
G('down'); // 关菜单
await flush();
+assert(!audio.isVoicePlaying(), '关菜单即停菜单语音链(菜单语音不骑恢复后的领奏)');
const pauseDur = (nowMs + 100 - menuOpenAt) / 1000; // resumeAt = 关菜单时刻 +0.1
assert(game.phase === 'main', '关菜单恢复 main');
const shift = game._bgmT0 - bgmT0Before;
@@ -283,16 +317,18 @@ assert(Math.abs(game._phaseEnd - phaseEndBefore - shift) < 1e-6, '_phaseEnd 同
const lastTrack105 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track105')).pop();
assert(lastTrack105 && lastTrack105.offset > 0, `BGM 断点续播(offset=${lastTrack105 && lastTrack105.offset.toFixed(2)}s > 0)`);
-// —— Miss:窗口内做错手势 → 无乐器音 + error 震动 + combo 清零 ——
+// —— Miss:窗口内做错手势 → 不播该音符的乐器音 + 闷镲 Miss 音 + error 震动 + combo 清零 ——
const missCue = game._cues.find((c) => !c.judged && c.gesture === 'tap');
await runUntil(missCue.expected + 0.1);
-const instPlaysBefore = playLog.filter((p) => p.id.startsWith('sfx/inst_')).length;
+const kickPlaysBefore = playLog.filter((p) => p.id === 'sfx/inst_kick').length;
+const srcBeforeMiss = srcLog.length;
const vibBefore = vibLog.length;
game._stats.combo = 5; // 制造非零 combo,验证清零
G('left'); // 错手势(该 cue 期望 tap,附近无 left cue)
await flush();
assert(missCue.result === 'miss', '窗口内错手势判 Miss');
-assert(playLog.filter((p) => p.id.startsWith('sfx/inst_')).length === instPlaysBefore, 'Miss 不播乐器音(错过=没出声)');
+assert(playLog.filter((p) => p.id === 'sfx/inst_kick').length === kickPlaysBefore, 'Miss 不播该音符的乐器音(错过=没出声)');
+assert(srcLog.slice(srcBeforeMiss).some((s) => s.url && s.url.includes('inst_hat_l')), 'Miss 有闷镲反馈音(inst_hat_l 降调复用,§13)');
assert(vibLog.slice(vibBefore).some((p) => Array.isArray(p) && p[0] === 60), 'Miss 触发 error 震动');
assert(game._stats.combo === 0, 'Miss 后 combo 清零');
@@ -304,13 +340,34 @@ for (const cue of game._cues) { // 把 down cue 之前的未判定音符应答
}
const downCue = game._cues.find((c) => !c.judged && c.gesture === 'down');
assert(!!downCue, '找到 song2 的 down 应答音符');
-await runUntil(downCue.expected);
+await runUntil(downCue.expected + 0.15); // 真实滑动识别延迟 +0.15s(补偿后 dt≈0)
G('down');
await flush();
assert(downCue.result === 'perfect', '临近 down 音符时下滑 = 演奏判定(Perfect)');
assert(game.phase === 'main', '演奏判定后不开菜单(phase 保持 main)');
assert(playLog.some((p) => p.id === 'sfx/inst_tom_lo'), 'down 命中播放 inst_tom_lo');
+// —— 下滑折中③(§13,live round1 P0):慢滑(识别 +0.35s)踩点下滑仍判演奏 ——
+const downCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down');
+assert(!!downCue2, '找到 song2 的第二个 down 应答音符');
+await runUntil(downCue2.expected + 0.35);
+G('down');
+await flush();
+assert(downCue2.result === 'good', '慢滑(识别 +0.35s)踩点下滑判 Good,不弹菜单');
+assert(game.phase === 'main', '慢滑下滑后 phase 保持 main(未开菜单)');
+
+// —— 长按定义(§13,live round1 P0):Miss 反馈音 + error 震动,不判定不归属 ——
+const judgedBeforeLp = game._cues.filter((c) => c.judged).length;
+const srcBeforeLp = srcLog.length;
+const vibBeforeLp = vibLog.length;
+G('longpress');
+await flush();
+assert(game._cues.filter((c) => c.judged).length === judgedBeforeLp, '长按不判定任何音符');
+assert(srcLog.slice(srcBeforeLp).some((s) => s.url && s.url.includes('inst_hat_l')), '长按播 Miss 反馈音(死输入给定义)');
+assert(vibLog.slice(vibBeforeLp).some((p) => Array.isArray(p) && p[0] === 60), '长按触发 error 震动');
+G('longpressend');
+await flush();
+
// —— 结算末位:song3 结算页右滑 → 播 restart,不播 song_locked(round3-4 裁定①回归) ——
game._startSong(game._runId, 2);
game._finish(game._runId);
@@ -323,6 +380,13 @@ const newVoices = playLog.slice(playMark).map((p) => p.id);
assert(newVoices.includes('voice/restart'), '末位曲目右滑 → 播 voice/restart');
assert(!newVoices.includes('voice/song_locked'), '末位曲目右滑 → 不播 song_locked');
+// —— 回大厅(§13,live round1 P1):确认语音 + leaving 收尾态 + 真实跳转 ——
+G('down');
+await flush();
+assert(game.phase === 'leaving', '结算屏下滑进入 leaving(收尾态,不再受理手势)');
+assert(voicePlayed('voice/menu_lobby'), '回大厅前播确认语音 menu_lobby');
+await waitFor(() => window.location.href === '../', '确认语音后跳转大厅');
+
assert(gestures <= 200, `手势预算内(${gestures} ≤ 200)`);
// ================= 场景 B:二周目选曲界面(menu pad + 选曲播报跳过 + 负偏移保护) =================
@@ -335,20 +399,23 @@ await waitFor(() => game.phase === 'select', '进入选曲界面');
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '选曲界面播放 bgm/menu(10 文档 §5)');
await waitFor(() => !!audio._voice, 'select_hint 播报中');
-G('tap'); // 跳过 select_hint → song1_name
-await flush();
+G('tap'); // 跳过 select_hint → song1_name(immediateTap 下延迟 500ms 裁决)
+await advance(600);
assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)');
G('right'); // 切到 song2(未解锁)
-await flush();
+await advance(600); // 序曲 tap 裁决窗:右滑不得误触发"选定曲目"
+assert(game.phase === 'select', '选曲右滑的序曲 tap 被作废(phase 保持 select,未误选)');
assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked');
G('tap'); // 跳过 song_locked
-await flush();
+await advance(600);
assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲');
-G('left'); // 切回 song1
-await flush();
-assert(game._songIdx === 0, '左滑切回 song1');
+G('tap'); // 序曲 tap(模拟 immediateTap 副作用:左滑的触摸起点)
+G('left'); // 随后滑动到达 → 序曲 tap 应被作废
+await advance(600);
+assert(game.phase === 'select' && game._songIdx === 0, '序曲 tap 被随后滑动作废;左滑正常切回 song1');
await waitFor(() => !audio._voice, '选曲播报播完');
G('tap'); // 无语音时单击 = 确认选曲
+await advance(600);
await flush();
assert(game.phase === 'main' && game._song.id === 'song1', '无语音时单击 = 选定曲目进 main');
assert(audio._bgmWanted === null && audio._bgm === null, '进曲目后 menu pad 停止');
@@ -369,7 +436,8 @@ console.log('\n[场景 C] 教学失败重教 → 2 次失败跳过该步 → 下
await newWorld();
game.start();
await flush();
-G('tap'); // 跳 welcome
+G('tap'); // 跳 welcome(immediateTap 下延迟 500ms 裁决)
+await advance(600);
await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'T1 领奏');
// 失败①:做错手势
await runUntil(game._cues[0].expected + 0.3);
@@ -392,6 +460,48 @@ assert(voicePlayed('voice/tut_skip'), '下滑跳教学播 tut_skip');
await waitFor(() => game.phase === 'main', '跳教学后进入曲目一');
assert(game._progress.tutorialDone === true, '跳教学同样持久化 tutorialDone');
+// ================= 场景 C2:教学全程零通过 → 收尾播中性句不夸"很好"(§13) =================
+console.log('\n[场景 C2] 教学 5 步全部失败跳过 → retry_skip 收尾,不播 tut_done');
+await newWorld();
+game.start();
+await flush();
+G('tap'); // 跳 welcome
+await advance(600);
+for (let step = 0; step < 5; step++) {
+ await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0, `C2 T${step + 1} 领奏`);
+ const leadMark = game._cues[0].expected;
+ await runUntil(game._phaseEnd + 0.2); // 失败①:完全不出手
+ await flush();
+ // 零尝试时 attempts 恒为空——必须等"重教的新领奏"(cues 预期时刻刷新),
+ // 否则条件瞬间为真、下一步 runUntil(Infinity) 直接跑完整个教学
+ await waitFor(() => game._tutFails === 1 && game._cues.length > 0
+ && game._cues[0].expected !== leadMark && game.phase === 'tut', `C2 T${step + 1} 重教`);
+ await runUntil(game._phaseEnd + 0.2); // 失败②:完全不出手 → 跳过该步
+ await flush();
+}
+await waitFor(() => game.phase === 'main', 'C2 零通过后进入曲目一');
+assert(game._tutPassedAny === false, 'C2 全程零通过(_tutPassedAny === false)');
+assert(voicePlayed('voice/retry_skip'), 'C2 零通过收尾播 retry_skip(中性句)');
+assert(!voicePlayed('voice/tut_done'), 'C2 零通过不播 tut_done(不夸"很好")');
+
+// ================= 场景 C3:滞留语音链看门狗作废(§13,live 实测深修回归) =================
+console.log('\n[场景 C3] 教学纠正链被跳教学顶掉后,滞留看门狗不得把游戏拖回 tut');
+await newWorld();
+game.start();
+await flush();
+G('tap'); // 跳 welcome
+await advance(600);
+await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'C3 T1 领奏');
+await runUntil(game._phaseEnd + 0.2); // 失败①:不出手 → 纠正链 [retry_listen, retry_listen]
+await flush();
+assert(game._tutFails === 1 && audio.isVoicePlaying(), 'C3 纠正链在播(fail=1)');
+G('down'); // 下滑跳教学:tut_skip 链顶掉纠正链(其看门狗 10s 后到期)
+await flush();
+assert(voicePlayed('voice/tut_skip'), 'C3 下滑跳教学播 tut_skip');
+await waitFor(() => game.phase === 'main', 'C3 跳教学后进入曲目一');
+await advance(12000); // 越过滞留看门狗的 10s 窗口
+assert(game.phase === 'main', 'C3 滞留看门狗已作废:12s 后仍在 main(未被拖回 tut)');
+
// ================= 场景 D:语音资产缺失 → 看门狗推进,不卡死不崩 =================
console.log('\n[场景 D] 看门狗:屏蔽 welcome 资产');
await newWorld();
@@ -432,6 +542,139 @@ settings.set({ bgm: 1.0 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM');
+// ================= 场景 F:live round2 判定治理(序曲 tap 撤销 / 超时复活 / 下滑不弹菜单 / 菜单去抖) =================
+console.log('\n[场景 F] live round2:tapcancel 撤销 / 超时 Miss 复活 / 正片下滑不弹菜单 / 菜单去抖独占');
+// —— F3 教学:tapcancel 撤销序曲 tap 尝试记录(转滑动/长按的序曲不算出手)——
+await newWorld();
+game.start();
+await flush();
+G('tap'); // 跳 welcome(延迟 500ms 裁决)
+await advance(600);
+await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'F3 T1 领奏');
+await runUntil(game._cues[0].expected);
+G('tap'); // 序曲 tap(T1 是 tap 音符,先记尝试)
+await flush();
+assert(game._tutAttempts.length === 1, 'F3 序曲 tap 先记教学尝试');
+G('tapcancel'); // 引擎补发:本次按下转滑动/长按 → 序曲 tap 作废
+await flush();
+assert(game._tutAttempts.length === 0, 'F3 tapcancel 撤销教学尝试记录(live round2 P0)');
+
+// —— 正片判定治理:钩子直进 song2(105BPM,含 3 个 down 音符)——
+await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 });
+game.start();
+await flush();
+await waitFor(() => game.phase === 'select', 'F 进入选曲');
+game._startSong(game._runId, 1);
+await flush();
+await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'F song2 排程就绪');
+
+// —— F1 tapcancel 撤销序曲 tap 命中(声音已出收不回,判定必须撤销)——
+const fTapCue = game._cues.find((c) => c.gesture === 'tap');
+await runUntil(fTapCue.expected);
+G('tap');
+await flush();
+assert(fTapCue.result === 'perfect', 'F1 序曲 tap 先按 tap 音符判定(Perfect)');
+assert(game._tapJudg.length === 1, 'F1 序曲 tap 判定已记账');
+G('tapcancel'); // 引擎补发:转滑动/长按 → 序曲 tap 作废
+await flush();
+assert(fTapCue.judged === false && fTapCue.result === null, 'F1 tapcancel 撤销序曲 tap 判定(音符回到未判定,不计 Miss)');
+assert(game._stats.perfect === 0 && game._stats.combo === 0, 'F1 命中统计回滚(perfect/combo 归零)');
+await runUntil(fTapCue.expected + 0.2);
+G('tap'); // 重新接打(撤销不是免判)
+await flush();
+assert(fTapCue.result === 'good', 'F1 撤销后重新接打 → Good');
+
+// —— F2 双击序曲治理(tap+tap+doubletap 序列的判定归属)——
+const fDblCue = game._cues.find((c) => !c.judged && c.gesture === 'doubletap'); // phrase2 beat2
+await runUntil(fDblCue.expected - 0.2);
+G('tap'); // 序曲 tap1(双击第一下按下,自由演奏)
+await flush();
+await advance(200);
+G('tap'); // 序曲 tap2(双击第二下按下)
+await flush();
+await advance(250);
+const fMiss2 = game._stats.miss;
+G('doubletap'); // 双击改判到达(识别在第二次 touchend)
+await flush();
+assert(fDblCue.result === 'perfect', 'F2 双击改判 Perfect(序曲 tap 撤销后判定)');
+assert(game._stats.miss === fMiss2, 'F2 序曲 tap 不产生 Miss(爆音不进判定链)');
+assert(fTapCue.result === 'good', 'F2 双击撤销窗不误伤更早的真实 tap 命中');
+assert(game._tapJudg.length === 1, 'F2 两笔序曲 tap 账已撤销(账簿只剩真实命中)');
+
+// —— F2c 序曲 tap 误认领命中 → 双击到达时撤销;撤销后真不接 → 超时 Miss 兜底 ——
+const fTapCue2 = game._cues.find((c) => !c.judged && c.gesture === 'tap'); // phrase2 beat3
+await runUntil(fTapCue2.expected);
+G('tap'); // 序曲 tap1 误认领(其实是双击第一下)
+await flush();
+assert(fTapCue2.result === 'perfect', 'F2c 序曲 tap 先认领命中');
+const fPerf2c = game._stats.perfect;
+await advance(200);
+G('tap'); // 序曲 tap2(自由演奏)
+await flush();
+await advance(250);
+G('doubletap'); // 双击改判 → 两笔序曲判定一并撤销(本次双击附近无 doubletap 音符,自由演奏)
+await flush();
+assert(fTapCue2.judged === false && fTapCue2.result === null, 'F2c 双击到达撤销序曲 tap 误认领的命中');
+assert(game._stats.perfect === fPerf2c - 1, 'F2c Perfect 统计回滚');
+await runUntil(fTapCue2.expected + 0.55); // 越过 tap 超时死线(+0.45)
+await flush();
+assert(fTapCue2.result === 'miss', 'F2c 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)');
+
+// —— F4 超时 Miss 复活:识别延迟把音符拖死、手势还在路上(live round2 事件流实证)——
+const fUpCue = game._cues.find((c) => !c.judged && c.gesture === 'up'); // phrase3 beat1
+await runUntil(fUpCue.expected + 0.64); // 超时死线 +0.60 已过:泵先判 Miss(滑动回调尚未到达)
+await flush();
+assert(fUpCue.result === 'miss' && fUpCue.missKind === 'timeout', 'F4 上滑音符先被超时拖死(事件流同款时序)');
+const fMiss4 = game._stats.miss;
+G('up'); // 慢滑回调此刻才到(触摸 expected+0.49、触程 0.15)
+await flush();
+assert(fUpCue.result === 'good', 'F4 超时误判复活改判 Good(补偿 dt=+0.49 ≤ REVIVE_DT_S)');
+assert(game._stats.miss === fMiss4 - 1, 'F4 复活回滚 Miss 统计');
+
+// —— F5 正片踩点下滑不弹菜单(live round2 P1 续修:超时拖死 ≠ 无临近 down 音符)——
+const fDownCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase3 beat3
+await runUntil(fDownCue.expected + 0.64); // 死线 +0.60 已过,下滑回调 +0.64 才到(round2 实测差 2~114ms)
+await flush();
+assert(fDownCue.result === 'miss' && fDownCue.missKind === 'timeout', 'F5 down 音符先被超时拖死');
+const fTomMark = playLog.length;
+G('down');
+await flush();
+assert(game.phase === 'main', 'F5 正片踩点下滑不弹菜单(live round2 P1 续修)');
+assert(fDownCue.result === 'good', 'F5 下滑复活改判 Good');
+assert(playLog.slice(fTomMark).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5 下滑复活播放 inst_tom_lo');
+
+// —— F5b 窗口内晚下滑(超复活时差上限):不弹菜单、Miss 维持、tom_lo 自由演奏 ——
+const fDownCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase7 beat3
+await runUntil(fDownCue2.expected + 1.1); // 死线过 ~0.5s(账龄仍在 REVIVE_WINDOW_S 内),dt 超 REVIVE_DT_S
+await flush();
+assert(fDownCue2.result === 'miss', 'F5b down 音符先被超时拖死');
+const fTomMark2 = playLog.length;
+G('down');
+await flush();
+assert(game.phase === 'main', 'F5b 窗口内晚下滑仍不弹菜单(瞄准 down 音符证据明确)');
+assert(fDownCue2.result === 'miss', 'F5b 超复活时差上限,Miss 维持不复活');
+assert(playLog.slice(fTomMark2).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5b tom_lo 自由演奏出声');
+
+// —— F5c 窗口过后下滑正常呼出菜单(折中不破)——
+await advance(300); // downCue2 的 Miss 账龄 > REVIVE_WINDOW_S
+G('down');
+await flush();
+assert(game.phase === 'menu', 'F5c 窗口过后下滑正常呼出菜单');
+
+// —— F6 菜单去抖独占期(live round2 P1 瞬穿):开菜单 1s 内手势不穿透 ——
+assert(audio.isVoicePlaying(), 'F6 菜单语音 menu_main 在播');
+G('doubletap'); // 开菜单 <1s:路上手势(双击)不得穿透菜单确认
+await flush();
+assert(game.phase === 'menu', 'F6 去抖期内双击不穿透菜单(phase 保持 menu)');
+assert(audio.isVoicePlaying(), 'F6 去抖期内菜单语音不被掐断');
+G('down'); // 去抖期内下滑也不得关菜单
+await flush();
+assert(game.phase === 'menu', 'F6 去抖期内下滑不穿透菜单');
+await advance(1100); // 越过 1s 去抖独占期
+G('doubletap'); // 双击确认"继续演奏"
+await flush();
+assert(game.phase === 'main', 'F6 去抖期后双击正常确认(恢复演奏)');
+
// ================= 资产引用校验(fs:ogg+m4a 双格式零缺失) =================
console.log('\n[资产校验] game.js 引用的 id 对照 audio/ 目录');
{
diff --git a/web/rpg/game.js b/web/rpg/game.js
index d86f138c..947fb9c2 100644
--- a/web/rpg/game.js
+++ b/web/rpg/game.js
@@ -8,7 +8,8 @@
* 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 /
* 上滑=状态播报 / 下滑=菜单。
* 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 /
- * 双击=格挡(敌方前摇窗口内,减伤 70%,即时回调规避 tap 延迟)/
+ * 双击=格挡(敌方前摇窗口内,减伤 70%、擦伤钳 1~3——11 文档 §3「格挡成功
+ * 2-3 伤」,即时回调规避 tap 延迟)/
* 上滑=敌我状态 / 下滑=逃跑(Lv<3 必成;Lv≥3 70%,失败=敌方一个正常
* 前摇回合;Boss/剧情战无效)。
* 战斗重构(docs/design/11-rpg-battle-rebalance.md):首战四步强制教学
@@ -22,8 +23,10 @@
* 快切 bgm/battle|bgm/boss(amb 停);胜利=sfx/victory → 战斗 BGM 淡出 1.5s
* → 探索音乐淡入 2s;章结算=bgm/ending。bgm=0 时引擎只记意图不播放。
* 旁白跳过(12 文档 §1):探索态 voice 播放中单击=skipVoice() 跳过本句
- * (链继续);战斗指令名 cmd_* 与教学句 tut_b* 不可跳。开场插播一次
- * voice/skip_hint。长按=引擎内置旁白 1.7 倍速(游戏零改动)。
+ * (链继续);在播句为选项报名/边界提示时单击同时=确认当前选项(跳句+
+ * 补发语义动作,round2-live P1 探索菜单吞 tap 修复)。战斗指令名 cmd_*
+ * 与教学句 tut_b* 不可跳。开场插播一次 voice/skip_hint。长按=引擎内置
+ * 旁白 1.7 倍速(游戏零改动)。
* 震动(addendum §1 配合总表):前摇开窗 beat / 格挡 confirm / 受击 hit /
* 击杀 success / 升级 success / 窗口内误触 error / 金疮药 pickup /
* Boss 半血 warning / 血量告急 warning。
@@ -139,6 +142,15 @@ export class RpgGame extends Game {
this._segStart = null;
// F7:回绕提示去重(同一次菜单打开内每句最多 1 次)
this._wrapShown = new Set();
+ // round1-live P2:向导插播句(gd_stall)开播时刻(看门狗 onEscalate 记录),
+ // _speak 据此让结算语音链排队等它播完(不拦腰顶断);_lastGestureAt 供教学
+ // 8s 重复提示的"玩家有操作则跳过"判活
+ this._guideVoiceAt = 0;
+ this._lastGestureAt = 0;
+ // round2-live P1:探索态当前在播语音的类别('scene' desc 链/'option' 选项报名
+ // 与边界提示/'status' 状态链/'event' 事件·NPC 句)——语音中单击的语义据此分流:
+ // 'option' = 跳句+补发确认(执行当前选项),其余 = 只跳句(12 文档 §1 跳句惯例)
+ this._exploreVoiceKind = null;
this._newRun();
}
@@ -225,6 +237,7 @@ export class RpgGame extends Game {
onGesture(g) {
this.guide.disarm(); // 任何手势复位 PLAYER_SELECT 看门狗(guide=off 时 no-op)
+ this._lastGestureAt = performance.now(); // 教学 8s 重复提示的判活依据(round1-live P2-3)
switch (this.phase) {
case 'ask':
if (g === 'tap') { const s = this._loadSave(); if (s) this._continueGame(s); else this._startNewGame(false); }
@@ -277,6 +290,7 @@ export class RpgGame extends Game {
desc = `voice/desc_${id}_re`; // gate/boss1 无 _re 句:boss1 不走此分支,gate 有 desc_gate 但无 _re——回退全句
if (id === 'gate') desc = 'voice/desc_gate';
}
+ this._exploreVoiceKind = 'scene'; // round2-live P1:desc 链在播=只跳句不确认
this._speakGuarded([...prepend, desc], () => {
this._save();
if (this._checkEncounter()) return;
@@ -314,6 +328,9 @@ export class RpgGame extends Game {
if (this.phase !== 'explore') return;
const opts = SCENES[this.scene].options;
if (!opts.length) return;
+ // round2-live P1:选项报名(及其 onComplete 接播的边界提示)在播期间,
+ // 单击=跳句+确认当前选项(探索菜单吞 tap 修复,见 _onExploreGesture)
+ this._exploreVoiceKind = 'option';
this.audio.play(`voice/opt_${opts[this.optIdx]}`, { onComplete });
}
@@ -336,17 +353,35 @@ export class RpgGame extends Game {
this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n;
const wrap = n >= 2 && ((g === 'right' && this.optIdx === 0) || (g === 'left' && this.optIdx === n - 1))
? (this.optIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
- // 回绕提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
- this._speakOption(wrap ? () => this._wrapCue(wrap) : undefined);
+ // round1-live P3:右滑落到"返回"项(各场景恒为末项)也补一句末位提示
+ // 「最后一个了。」——给循环列表一个听觉边界,防多滑一格误选返回被弹回
+ // 上一张图(每场景一次,与回绕提示共用 _wrapShown 去重;序号播报无现成
+ // 语音资产,按任务裁定做前者,不新增语音 id)
+ const cue = wrap || (opts[this.optIdx] === 'back' ? 'gd_wrap_last' : null);
+ // 回绕/末位提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
+ this._speakOption(cue ? () => this._wrapCue(cue) : undefined);
} else if (g === 'tap') {
// voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳),
// 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。
- if (this._voicePlaying()) { this.audio.skipVoice(); return; }
+ // round2-live P1:在播句是"选项报名/边界提示"(_exploreVoiceKind==='option')时,
+ // 单击在跳句同时补发确认语义(执行当前选项)——live 证据:选项报名开播 2.3s 时
+ // 单击被整个吞掉(语音被掐、场景未切、死寂 72s,1264.6s),「最后一个了。」开播
+ // 0.16s 时单击"返回"同样被吞(1370.1s);战斗里语音中单击却有效(844.5s),
+ // 规则必须一致:动作绝不石沉大海。desc/NPC/状态链播放中单击仍只跳句(A5/B6 契约)。
+ if (this._voicePlaying()) {
+ // 先取样类别:skipVoice 同步触发 onComplete 推进链(末句会接播选项报名并
+ // 改写 _exploreVoiceKind),若跳句后再读类别会把"跳 desc"误判成"确认选项"
+ const kind = this._exploreVoiceKind;
+ this.audio.skipVoice();
+ if (kind === 'option') this._executeOption(opts[this.optIdx]);
+ return;
+ }
this._executeOption(opts[this.optIdx]);
} else if (g === 'doubletap') {
// 重听场景描述 + 当前选项(amb 循环持续,无需重播)
const id = this.scene;
const desc = id === 'boss1' ? 'voice/desc_hall' : (id === 'gate' ? 'voice/desc_gate' : `voice/desc_${id}_re`);
+ this._exploreVoiceKind = 'scene'; // round2-live P1:重听链视同 desc(语音中单击=只跳句)
this._speak([desc, `voice/opt_${opts[this.optIdx]}`]);
} else if (g === 'up') {
this._speakStatus();
@@ -357,13 +392,15 @@ export class RpgGame extends Game {
_executeOption(opt) {
if (GO_TARGET[opt]) { this._enterScene(GO_TARGET[opt]); return; }
+ if (opt === 'back') { this._enterScene(BACK_MAP[this.scene] || 'village'); return; }
+ // round2-live P1:以下选项原地播事件/NPC/状态语音(非选项报名)——标记后
+ // 语音中单击=只跳句、不重复触发动作(shop 切 phase 后本标记不再被读取)
+ this._exploreVoiceKind = 'event';
switch (opt) {
- case 'back':
- this._enterScene(BACK_MAP[this.scene] || 'village');
- break;
case 'shop':
this.phase = 'shop';
this._shopIdx = 0;
+ this._wrapShown = new Set(); // 商店子菜单重新"打开"(round1-live P3:离开项末位提示重新可用)
this._speak(['voice/shop_open', SHOP_ITEMS[0].priceVoice]);
break;
case 'rest':
@@ -463,6 +500,7 @@ export class RpgGame extends Game {
const weaponVoice = p.weapon ? WEAPONS[p.weapon].stVoice : 'voice/st_weapon_none';
const armorVoice = p.armor ? ARMORS[p.armor].stVoice : 'voice/st_armor_none';
if (this._hpBand(p.hp, this.maxHp) === 'low') vibrate('warning');
+ this._exploreVoiceKind = 'status'; // round2-live P1:状态链在播=只跳句不确认
this._speak([lvVoice, hpVoice, goldVoice, weaponVoice, armorVoice], onDone);
}
@@ -472,7 +510,10 @@ export class RpgGame extends Game {
if (g === 'left' || g === 'right') {
const n = SHOP_ITEMS.length;
this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : n - 1)) % n;
- this.audio.play(SHOP_ITEMS[this._shopIdx].priceVoice);
+ const it = SHOP_ITEMS[this._shopIdx];
+ // round1-live P3:落到"离开"项(末项)补末位提示「最后一个了。」(每次进商店一次)
+ this.audio.play(it.priceVoice,
+ it.type === 'leave' ? { onComplete: () => this._wrapCue('gd_wrap_last') } : undefined);
} else if (g === 'tap') {
this._buy(SHOP_ITEMS[this._shopIdx]);
} else if (g === 'down') {
@@ -576,6 +617,16 @@ export class RpgGame extends Game {
// 四步强制教学(11 文档 §2):menu(认识指令)→attack→parry→defend→自由击杀
teachStage: enc.teach ? 'menu' : null,
teachSwipes: 0,
+ // round1-live P2:resolving=回合结算中(闲置提示禁止插入);
+ // selectSeq=PLAYER_SELECT 代数(旧回合滞留的指令名定时器据此作废);
+ // cmdVoiceIdx/cmdVoiceAt=最近一次指令名播报(1.2s 去重防"攻攻击"口吃)
+ resolving: false,
+ selectSeq: 0,
+ cmdVoiceIdx: -1,
+ cmdVoiceAt: 0,
+ // round2-live P1:hpBandVoice=玩家"最后被告知"的血量档(开战=当前档)——
+ // 受伤播报只在实质受击且比它差时播当前实际档(擦伤不触发、同档不重复)
+ hpBandVoice: this._hpBand(this.player.hp, this.maxHp),
// 难度档位(11 文档 §3):教学战特例前摇 1.8s;其余按 battlesWon 取档,Boss 恒标准
tier: enc.teach ? { telSec: 1.8, dmgMul: 1.0 } : this._battleTier(enc),
telRate: 1,
@@ -622,31 +673,55 @@ export class RpgGame extends Game {
const b = this._battle;
if (!b) return;
b.state = 'PLAYER_SELECT';
+ b.resolving = false; // round1-live P2:回合结算结束、回到等待输入,闲置提示恢复可入
+ b.selectSeq++; // 指令名定时器代数+1:上一回合滞留的 650ms 定时器据此作废
this._wrapShown = new Set(); // F7:指令循环重新"打开",回绕提示重新可用
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
this.audio.play('sfx/turn_player');
// 向导层静默看门狗(round2 F1.3:60s→30s,等待默认低频但不再干晾一分钟):
- // PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)
+ // PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)。
+ // round1-live P2:回合结算/语音链进行中禁止闲置提示插入(cond 加 !b.resolving);
+ // onEscalate 记开播时刻,_speak 据此让结算语音排队等 gd_stall 播完(不拦腰顶断)
this.guide.watchdog({
arm: true,
at: [30000],
say: ['gd_stall'],
- cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT',
+ onEscalate: () => { this._guideVoiceAt = performance.now(); },
+ cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT' && !b.resolving,
});
const cmdIdx = b.cmdIdx;
+ const seq = b.selectSeq;
this._after(650, () => {
- if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) {
- this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
+ if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx && b.selectSeq === seq) {
+ this._playCmdVoice();
}
});
}
+ /**
+ * 指令名播报(round1-live P2 口吃修复):同一指令 1.2s 内已播过则跳过本次——
+ * 手势打断刚开播的指令名后,滞留定时器/折返滑不立刻原样重播("攻攻击。"卡碟)。
+ */
+ _playCmdVoice(onComplete) {
+ const b = this._battle;
+ if (!b) { if (onComplete) onComplete(); return; }
+ const now = performance.now();
+ if (b.cmdVoiceIdx === b.cmdIdx && now - b.cmdVoiceAt < 1200) return; // 去重:跳过本次
+ b.cmdVoiceIdx = b.cmdIdx;
+ b.cmdVoiceAt = now;
+ this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`, onComplete ? { onComplete } : undefined);
+ }
+
/** 教学步推进(11 文档 §2 四步:menu→attack→parry→defend→free)。 */
_teachAdvance(next) {
const b = this._battle;
if (!b || !b.teachStage) return;
b.teachStage = next === 'free' ? null : next;
- if (next === 'attack') this._speakGuarded(['voice/tut_b3'], () => this._playerSelect(), 8000);
+ // round1-live P2-3:T1 不再播 tut_b3——其实录为旧两步教学(格挡→攻击)台词
+ // 「挡住了!轮到你了。左右滑选攻击,单击出剑。」,新顺序 T1 在格挡之前播
+ // 「挡住了!」与实际不符;改播 tut_battle_2(现有指令教学句,语义一致)。
+ // tut_b3 资产保留但退出教学链(TTS 缺失不可重录,列遗留)。
+ if (next === 'attack') this._speakGuarded(['voice/tut_battle_2'], () => this._playerSelect(), 8000);
else if (next === 'parry') this._speakGuarded(['voice/tut_b1'], () => this._enemyTelegraph(), 8000);
else if (next === 'defend') this._speakGuarded(['voice/tut_b6'], () => this._playerSelect(), 8000);
else this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000); // free:收尾
@@ -660,22 +735,33 @@ export class RpgGame extends Game {
_armTeachWatchdog(stage) {
const b = this._battle;
if (!b || !stage) return;
- const hint = { menu: ['voice/tut_b0'], attack: ['voice/tut_b3'], defend: ['voice/tut_b6'] }[stage];
+ const hint = { menu: ['voice/tut_b0'], attack: ['voice/tut_battle_2'], defend: ['voice/tut_b6'] }[stage];
+ // round1-live P2-3:挂载时刻之后玩家有任何操作 → 8s 重复提示跳过
+ // (玩家正在边滑边想,重复提示=循环重播打断出手);16s 兜底推进不变
+ const armedAt = performance.now();
this._after(8000, () => {
- if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint) {
+ // round2-live P2:重复提示是"自动轮播"语音——有语音在播(如链尾句未播完)
+ // 则跳过本次,绝不拦腰顶断(统一"播完再上"治理,live 333s 互掐证据)
+ if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint
+ && this._lastGestureAt <= armedAt && !this._voicePlaying()) {
this._speak(hint); // 重复提示一次
}
});
- this._after(16000, () => {
+ // round2-live P2:16s 兜底到点遇语音在播(如 8s 重复提示仍在播)→ 每 1.5s
+ // 重试、等播完再推进,不拦腰斩句(live 333.09s:tut_b0 重复句播到 7.9s 被
+ // 游戏自己兜底推进的 tut_battle_2 掐断);语音通道空出即推进,不卡死
+ const tryAdvance = () => {
if (this._battle !== b || b.teachStage !== stage) return;
if (b.state !== 'PLAYER_SELECT') return; // 回合交换已在推进,无卡死风险
+ if (this._voicePlaying()) { this._after(1500, tryAdvance); return; }
if (stage === 'menu') this._teachAdvance('attack');
else if (stage === 'attack') this._teachAdvance('parry');
else if (stage === 'defend') {
b.teachStage = null;
this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000);
}
- });
+ };
+ this._after(16000, tryAdvance);
}
_onBattleGesture(g) {
@@ -687,14 +773,13 @@ export class RpgGame extends Game {
// F7:指令循环回绕提示(探索菜单同款,full 档,跟在本项报名之后)
const wrap = (g === 'right' && b.cmdIdx === 0) || (g === 'left' && b.cmdIdx === CMDS.length - 1)
? (b.cmdIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
- this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`,
- wrap ? { onComplete: () => this._wrapCue(wrap) } : undefined);
+ this._playCmdVoice(wrap ? () => this._wrapCue(wrap) : undefined);
// 教学 T0:切换 2 次(听到指令名切换)即过关
if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack');
} else if (g === 'tap') {
this._playerExecute();
} else if (g === 'up') {
- this._speakStatus(() => { if (this._battle === b && b.state === 'PLAYER_SELECT') this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); });
+ this._speakStatus(() => { if (this._battle === b && b.state === 'PLAYER_SELECT') this._playCmdVoice(); });
} else if (g === 'down') {
this._fleeCheck();
}
@@ -721,6 +806,7 @@ export class RpgGame extends Game {
const p = this.player;
const e = b.enemy;
b.state = 'PLAYER_EXECUTE';
+ b.resolving = true; // round1-live P2:回合结算开始,闲置提示禁止插入
const cmd = CMDS[b.cmdIdx];
if (cmd === 'skill' && p.mp < 3) {
this._speakGuarded(['voice/btl_nomp'], () => this._playerSelect(), 4000);
@@ -841,34 +927,63 @@ export class RpgGame extends Game {
let dmg = this._dmg(e.atk, this.def);
dmg = Math.max(1, Math.floor(dmg * b.tier.dmgMul)); // 新手保护伤害系数(向下取整 min 1,11 文档 §3)
if (b.parry) {
- dmg = Math.max(0, Math.round(dmg * 0.3)); // 格挡减伤 70%(不震 hit,已震 confirm)
+ // 格挡减伤 70%(不震 hit,已震 confirm)。round1-live P1:擦伤钳 1~3——
+ // 对齐 11 文档 §3「格挡成功 2-3 伤」(原实现 round 无封顶,Boss 二阶段
+ // 单发擦伤可达 4,全格挡 8 刀可把 36 血磨进 low 档,播报与实际战况矛盾);
+ // min 1 保住"格挡也有擦伤"的痛感(设计意图=擦伤,非全免伤,05-rpg §2 减伤 70%)
+ dmg = Math.min(3, Math.max(1, Math.round(dmg * 0.3)));
} else {
if (b.defending) dmg = Math.round(dmg * 0.5); // 防御减半(与格挡不叠加)
this.audio.play('sfx/hurt');
vibrate('hit');
}
b.defending = false;
- const bandBefore = this._hpBand(p.hp, this.maxHp);
p.hp -= dmg;
if (b.enc.teach) p.hp = Math.max(1, p.hp); // 教学战 HP 锁底 1(必不死)
console.log(`[RpgGame] 敌方攻击 dmg=${dmg} 格挡=${b.parry} 玩家HP=${p.hp}/${this.maxHp}`);
// 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2)。
// F4(round2):格挡**成功**通过时先插 tut_b2b「挡得漂亮。」确认表现,
// 消除 P1/P4「明明挡住了还说来不及格挡」的歧义(失败路径 tut_b4 不变)。
+ // round1-live P1:两条路径都插 tut_battle_4「没把握就防御,伤害减半。金疮药
+ // 可以回血。」——在格挡体验点明说减伤经济(格挡≠免伤、防御减半、药回血)。
+ // 无"格挡也有擦伤"原句资产(TTS 缺失不可新录,列遗留补录),选播现有最近说明句。
if (b.teachStage === 'parry') {
b.teachStage = 'defend';
- const ids = b.parry ? ['voice/tut_b2b', 'voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6'];
- this._speakGuarded(ids, () => this._playerSelect(), 10000);
- this._armTeachWatchdog('defend');
+ const ids = b.parry
+ ? ['voice/tut_b2b', 'voice/tut_battle_4', 'voice/tut_b6']
+ : ['voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'];
+ // 三句链真实时长 ~16s:兜底放宽到 20s(原 10s 会在链中误触发进 PLAYER_SELECT,
+ // cmd 指令名拦腰顶断 tut_b6);教学看门狗改到链播完进回合时才挂载,
+ // 8s/16s 计时从玩家真正可操作起算(16s 兜底不再抢在 tut_b6 播完前跳步)
+ this._speakGuarded(ids, () => {
+ this._playerSelect();
+ this._armTeachWatchdog('defend');
+ }, 20000);
return;
}
if (p.hp <= 0) { this._defeat(); return; }
- // 血量降档才播(不每击都播)
- const bandAfter = this._hpBand(p.hp, this.maxHp);
- if (bandAfter !== bandBefore) {
- if (bandAfter === 'low') vibrate('warning');
- this._speakGuarded([`voice/st_hp_${bandAfter}`], () => { b.round++; this._playerSelect(); }, 5000);
- return;
+ // round2-live P1 血量档位播报重连线(设计依据:addendum §1 受击行——st_hp_mid/
+ // st_hp_low 本就是"受击(未格挡)"的附属播报「降档才播」,不是血量变化的全局
+ // 钩子;live 事件流证据:全格挡 20 次、0 hurt 音效,播报却精确落在格挡音后
+ // 0.09s——962.1/984.0s 等 3 次"受伤/命悬一线"全是擦伤跨档触发,警报被驯化成
+ // 背景音)。裁定:
+ // a) 格挡擦伤(1~3)静默走血,绝不触发受伤类播报——痛感由格挡音+confirm 震动
+ // 承担(11 文档 §3"格挡成功 2-3 伤"是技巧收益,不该配受伤警报);
+ // b) 只在实质受击(格挡失败/未格挡)时播,且播报档=结算后当前实际档——
+ // hpBandVoice 记"玩家最后被告知档",比它差才播并同步,播报与实际档位
+ // 恒一致(擦伤暗中跨档后,第一刀实质受击会按当前实际档补播,不会错位);
+ // c) 节流:同档不重复喊(中途回血升档静默;之后经中间档再次跌破视为新降档,
+ // 可重播一次)。
+ if (!b.parry) {
+ const band = this._hpBand(p.hp, this.maxHp);
+ const WORSE = { high: 0, mid: 1, low: 2 };
+ if (WORSE[band] > WORSE[b.hpBandVoice]) {
+ b.hpBandVoice = band;
+ if (band === 'low') vibrate('warning');
+ this._speakGuarded([`voice/st_hp_${band}`], () => { b.round++; this._playerSelect(); }, 5000);
+ return;
+ }
+ b.hpBandVoice = band; // 未降档(含回血后升档):静默同步"最后被告知档"
}
b.round++;
this._playerSelect();
@@ -876,6 +991,7 @@ export class RpgGame extends Game {
_fleeCheck() {
const b = this._battle;
+ b.resolving = true; // round1-live P2:逃跑判定=回合结算,闲置提示禁止插入
if (!b.enc.flee) {
// Boss/剧情战不可逃
vibrate('error');
@@ -1109,12 +1225,25 @@ export class RpgGame extends Game {
if (!ids.length) { if (onDone) onDone(); return; }
const s = this._session;
const [head, ...rest] = ids;
- this.audio.play(head, {
+ // round1-live P2:向导插播句(gd_stall 等)刚开播还在播时,本链首句排队等它
+ // 播完,不拦腰顶断("想好了吗?"说半句被结算语音掐掉);余波 6s 后自愈
+ // (覆盖 gd_stall 男声 ~4.9s 全长),之后恢复默认顶句语义(不影响正常链间切换)
+ const justGuide = this._guideVoiceAt > 0
+ && performance.now() - this._guideVoiceAt < 6000 && this._voicePlaying();
+ // round2-live P2:刚开播的指令名(cmd_* ≤1.2s 短句)还在播时,本链首句同样
+ // 排队等它播完——杀敌/半血/降档等结算语音不拦腰斩指令名(live 843.9s:
+ // 「攻击。」开播 0.9s 被 enemy_die 掐成残句);余波 1.6s 后恢复默认顶句语义
+ const b = this._battle;
+ const justCmd = !!(b && b.cmdVoiceAt > 0
+ && performance.now() - b.cmdVoiceAt < 1600 && this._voicePlaying());
+ const opts = {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
- });
+ };
+ if (justGuide || justCmd) opts.queue = true;
+ this.audio.play(head, opts);
}
/** 带看门狗的语音链:播完或超时先到先触发 onDone 且只一次——流程绝不卡死。 */
diff --git a/web/rpg/test/playtest.mjs b/web/rpg/test/playtest.mjs
index 2445d38e..0b752937 100644
--- a/web/rpg/test/playtest.mjs
+++ b/web/rpg/test/playtest.mjs
@@ -428,10 +428,11 @@ async function scenarioA() {
assert(t2 - t1 >= 400, 'A8: turn_player 与指令名之间留间隙不叠放(≥400ms)');
}
- // T0:左右滑 2 次 → attack 步
+ // T0:左右滑 2 次 → attack 步(round1-live P2-3:T1 改播 tut_battle_2,不再播 tut_b3)
g(game, 'right'); g(game, 'left');
await tick(1500);
- assert(bt.teachStage === 'attack' && plays(audio, mark).has('voice/tut_b3'), 'A8: T0 切换 2 次 → T1 attack(tut_b3)');
+ assert(bt.teachStage === 'attack' && plays(audio, mark).count('voice/tut_battle_2') >= 2,
+ 'A8: T0 切换 2 次 → T1 attack(重播 tut_battle_2,不再播 tut_b3)');
// T1:选中攻击单击命中 → parry 步
await tick(1200);
const wolfHpBefore = bt.enemy.hp;
@@ -448,8 +449,9 @@ async function scenarioA() {
assert(game.player.hp < hpBeforeHit && game.player.hp >= 1, 'A8: T2 受击掉血且 ≥1');
assert(bt.teachStage === 'defend', 'A8: T2 受击后 → T3 defend');
// T3:选防御单击 → tut_b5 收尾;防御减伤 50% 演示(狼 base 2 → 1)
- await tick(2500); // tut_b4+tut_b6 播完进 PLAYER_SELECT
- assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_b6'), 'A8: 格挡失败反馈 tut_b4 + 防御教学 tut_b6');
+ await tick(4200); // tut_b4+tut_battle_4+tut_b6 三句播完进 PLAYER_SELECT(round1-live P1 链扩为 3 句)
+ assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'),
+ 'A8: 格挡失败反馈 tut_b4 + 减伤教学 tut_battle_4 + 防御教学 tut_b6(round1-live P1 明说减伤经济)');
selectCmd(game, 'defend');
await tick(1200);
g(game, 'tap');
@@ -469,6 +471,8 @@ async function scenarioA() {
assert(p.orderOk('sfx/enemy_die', 'voice/btl_enemy_die', 'sfx/victory', 'voice/btl_win', 'voice/btl_loot_small'),
'A8: 胜利序列 enemy_die → victory sfx → btl_win → loot 有序');
assert(game.flags.tutorial_battle_done === true, 'A8: tutorial_battle_done 置位');
+ assert(plays(audio, mark).count('voice/tut_b3') === 0,
+ 'A8: 教学全程不播 tut_b3(「挡住了!」与未格挡实况矛盾,round1-live P2-3 退出教学链)');
assert(game.stats.battlesWon === 1, 'A8: battlesWon 0→1(难度档位依据)');
{
const stop = audio.log.filter((e) => e.id === '(stop)').at(-1);
@@ -842,6 +846,278 @@ async function scenarioB6() {
assert(!plays(audio, mark).has('voice/boss1_warn'), 'B6: Lv3 到寨门不播 boss1_warn');
}
+async function scenarioB7() {
+ origConsoleLog('\n=== 场景 B7:Boss 战全格挡擦伤上限(round1-live P1) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ game.start();
+ await tick(2000);
+ // 设计标准出装(11 文档 §3 数值表):Lv3 + 铁剑 + 皮甲,HP36
+ game.player.lv = 3;
+ game.player.hp = game.maxHp;
+ game.player.weapon = 'iron_sword';
+ game.player.armor = 'leather';
+ game._startBattle('enc_boss1'); // 钩子:直接进 Boss 战
+ await battlePump(game, audio);
+ const mark = audio.log.length;
+ const logsBefore = consoleLogs.length;
+ await fightSmart(game, audio, { maxRounds: 30 }); // 全格挡打法(每次前摇内双击,不喝药)
+ assert(game.flags.boss1_done === true, 'B7: 全格挡可击杀 Boss(章节 flags 置位)');
+ const dmgs = consoleLogs.slice(logsBefore)
+ .map((l) => l.match(/敌方攻击 dmg=(\d+) 格挡=(true|false)/))
+ .filter(Boolean);
+ assert(dmgs.length >= 3, `B7: Boss 战发生多次敌方攻击(${dmgs.length} 次)`);
+ assert(dmgs.every((m) => m[2] === 'true'), 'B7: 全部敌方攻击均被格挡(fightSmart 全双击)');
+ assert(dmgs.every((m) => Number(m[1]) >= 1 && Number(m[1]) <= 3),
+ `B7: 格挡擦伤全部 ∈[1,3](11 文档 §3「格挡成功 2-3 伤」+ 钳顶,实测 ${dmgs.map((m) => m[1]).join(',')})`);
+ assert(!plays(audio, mark).has('voice/st_hp_low'),
+ 'B7: 全格挡 Boss 战不播 st_hp_low(血量播报与实际战况匹配)');
+ assert(game.player.hp / game.maxHp >= 0.3,
+ `B7: 全格挡战后血量不掉 low 档(${game.player.hp}/${game.maxHp})`);
+ // 确定性探针:base 12 的一刀被格挡 → 擦伤恒为 3(原实现 round(12×0.3)=4 超设计上限)
+ await battlePump(game, audio); // 章结算链走完回村
+ game._dmg = () => 12; // 钩子:伤害确定化
+ game._startBattle('enc_boss1');
+ await battlePump(game, audio, 5000);
+ g(game, 'tap'); // 攻击(敌 45→33)
+ g(game, 'doubletap'); // 前摇内双击格挡
+ await tick(500);
+ assert(lastEnemyDmg() === 3, `B7: 擦伤钳顶确定性探针(base 12 格挡 → ${lastEnemyDmg()},期望 3)`);
+}
+
+async function scenarioB8() {
+ origConsoleLog('\n=== 场景 B8:闲置提示与结算语音不互掐 + 返回项末位提示(round1-live P2/P3,guide=full) ===');
+ localStorageMock.clear();
+ settings.set({ guide: 'full' });
+ try {
+ const { game, audio } = newGame();
+ const guideEvents = [];
+ game.guide.setEventHook((e) => guideEvents.push(e));
+ await setupWolfBattle(game);
+ await battlePump(game, audio, 5000);
+ const b = game._battle;
+ assert(b && b.state === 'PLAYER_SELECT', 'B8: 野狼战进玩家回合');
+ // 对照组:无闲置提示在播时击杀 → btl_enemy_die 不排队(默认顶句语义不变)
+ b.enemy.hp = 1; // 钩子:一刀死
+ g(game, 'tap');
+ await tick(500);
+ const diePlain = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
+ assert(diePlain && !diePlain.opts.queue, 'B8: 无闲置提示在播时击杀语音不排队(对照)');
+ await battlePump(game, audio); // 胜利链走完回探索
+ assert(game.phase === 'explore', 'B8: 首狼击杀回探索态');
+ // 第二只狼:PLAYER_SELECT 闲置 30s → gd_stall 开播 → 立即出手
+ game._wolfDone = false; // 钩子:允许再次遇狼
+ await reenterStream(game);
+ await battlePump(game, audio, 5000);
+ const b2 = game._battle;
+ assert(b2 && b2.state === 'PLAYER_SELECT', 'B8: 第二只狼进玩家回合');
+ guideEvents.length = 0;
+ let waited = 0;
+ while (!guideEvents.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_stall' && !e.skipped)
+ && waited < 45000) { await tick(500); waited += 500; }
+ assert(waited < 45000, 'B8: 闲置 30s 看门狗播 gd_stall(未跳过)');
+ assert(audio._voice && audio._voice.id === 'voice/gd_stall', 'B8: gd_stall 正在播');
+ b2.enemy.hp = 1; // 钩子:一刀死
+ g(game, 'tap'); // 结算链首句应排到 gd_stall 之后播完,不拦腰顶断
+ await tick(300);
+ const dieQueued = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
+ assert(dieQueued && dieQueued.opts.queue === true,
+ 'B8: gd_stall 开播中结算语音排队等播完(queue:true,不顶断)');
+ await battlePump(game, audio); // 胜利回探索
+ // P3:探索态右滑落到"返回"项 → 报名后接末位提示(每场景一次)
+ g(game, 'right'); await tick(1200); // go_hunter
+ g(game, 'tap'); await tick(3000); // 猎人小屋
+ const m3 = audio.log.length;
+ g(game, 'right'); await tick(1200); // go_deepwood
+ g(game, 'right'); await tick(1200); // back(末项)
+ assert(plays(audio, m3).orderOk('voice/opt_back', 'voice/gd_wrap_last'),
+ 'B8: 落到"返回"项 → 报名 opt_back 后接末位提示 gd_wrap_last(每场景一次)');
+ g(game, 'left'); await tick(1200);
+ g(game, 'right'); await tick(1200); // 再次落到 back
+ assert(plays(audio, m3).count('voice/gd_wrap_last') === 1,
+ 'B8: 同一场景内末位提示不重复(_wrapShown 去重)');
+ } finally {
+ settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
+ }
+}
+
+async function scenarioB9() {
+ origConsoleLog('\n=== 场景 B9:指令名去重防口吃(round1-live P2「攻攻击。」) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ await setupWolfBattle(game);
+ await battlePump(game, audio, 5000);
+ const b = game._battle;
+ assert(b && b.state === 'PLAYER_SELECT', 'B9: 野狼战进玩家回合');
+ // 折返滑 + 滞留 650ms 定时器:指令名不双播
+ game._playerSelect(); // 钩子:回合重进(650ms 指令名定时器挂载中)
+ const m1 = audio.log.length;
+ g(game, 'right'); g(game, 'left'); // 攻击→破空斩→攻击(<650ms 折返)
+ await tick(900);
+ assert(plays(audio, m1).count('voice/cmd_attack') === 1,
+ 'B9: 折返滑回攻击+滞留定时器 → cmd_attack 不双播(1.2s 去重跳过本次)');
+ // 相邻两回合连进:旧回合定时器作废
+ await tick(1500); // 让过去重窗口,隔离下一段
+ game._playerSelect();
+ const m2 = audio.log.length;
+ game._playerSelect(); // 钩子:相邻两回合连进(旧回合 650ms 定时器滞留)
+ await tick(900);
+ assert(plays(audio, m2).count('voice/cmd_attack') === 1,
+ 'B9: 连进两回合 → 旧回合指令名定时器作废(cmd_attack 只播一次)');
+}
+
+async function scenarioB10() {
+ origConsoleLog('\n=== 场景 B10:受伤播报只挂实质受击 + 档位一致 + 同档节流(round2-live P1) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ await setupWolfBattle(game);
+ game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0)
+ game._battle.tier = { telSec: 1.0, dmgMul: 1.0 };
+ game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → 实击 4、擦伤 1
+ await battlePump(game, audio, 5000);
+ const b = game._battle;
+ assert(b && b.state === 'PLAYER_SELECT', 'B10: 野狼战进玩家回合');
+ game.player.hp = 15; // 钩子:15/20=0.75 高档,一刀擦伤即跨 0.7 边界
+ b.enemy.hp = 99; // 钩子:全程打不死,专测受击播报
+ assert(b.hpBandVoice === 'high', 'B10: 开战"最后被告知档"=当前实际档(high)');
+ const mark = audio.log.length;
+ const attackRound = async (parry) => {
+ selectCmd(game, 'attack');
+ await tick(1200);
+ g(game, 'tap');
+ if (parry) g(game, 'doubletap'); // 前摇内即时双击格挡
+ await tick(2200); // 前摇 1.0s 结算 + 可能的降档句(1s)+ 回合交接
+ };
+ const itemRound = async () => {
+ selectCmd(game, 'item');
+ await tick(1200);
+ g(game, 'tap');
+ await tick(2200); // 喝药 + 敌方一个前摇回合(硬吃)
+ };
+ // ① 格挡擦伤跨档(15→14,0.70 跌进 mid):静默走血,不播任何受伤播报
+ await attackRound(true);
+ assert(game.player.hp === 14, `B10: 格挡擦伤 15→14(实际 ${game.player.hp})`);
+ assert(plays(audio, mark).count('voice/st_hp_mid') === 0 && plays(audio, mark).count('voice/st_hp_low') === 0,
+ 'B10: 擦伤跨档不触发"受伤"播报(addendum §1:st_hp_* 是受击附属,非血量全局钩子)');
+ assert(b.hpBandVoice === 'high', 'B10: 擦伤静默走血不改写"最后被告知档"');
+ // ② 实质受击降档(14→10,mid):播当前实际档 st_hp_mid(不多不少)
+ await attackRound(false);
+ assert(game.player.hp === 10 && plays(audio, mark).count('voice/st_hp_mid') === 1,
+ 'B10: 实质受击降档 → 播 st_hp_mid(与实际档位一致)');
+ assert(plays(audio, mark).count('voice/st_hp_low') === 0 && b.hpBandVoice === 'mid',
+ 'B10: 播报档不高估(mid 不喊 low),"最后被告知档"同步为 mid');
+ // ③ 同档再受击(10→6,0.30 仍 mid):不重复喊
+ await attackRound(false);
+ assert(game.player.hp === 6 && plays(audio, mark).count('voice/st_hp_mid') === 1,
+ 'B10: 同档再受击不重复播(节流:mid 仍 1 次)');
+ // ④ 再受击跌 low(6→2,0.10):播 st_hp_low
+ await attackRound(false);
+ assert(game.player.hp === 2 && plays(audio, mark).count('voice/st_hp_low') === 1 && b.hpBandVoice === 'low',
+ 'B10: 实质受击跌 low → 播 st_hp_low(命悬一线与实际档位一致)');
+ // ⑤ 同档再受击(钩子回血到 5 → 1,仍 low):不重复喊
+ game.player.hp = 5;
+ await attackRound(false);
+ assert(game.player.hp === 1 && plays(audio, mark).count('voice/st_hp_low') === 1,
+ 'B10: low 档内再受击不重复播(节流:low 仍 1 次)');
+ // ⑥ 喝药回血(1→9)后硬吃一刀(9→5,回 low):与"最后被告知档"同档 → 不重播
+ game.player.potions = 3; // 钩子:药管够
+ await itemRound();
+ assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 1,
+ 'B10: 回血后一刀直接跌回 low(无中间档):同档不重播(low 仍 1 次)');
+ // ⑦ 再喝药(5→13)硬吃一刀(13→9,mid):升档静默同步;再受击(9→5)跌 low = 新降档 → 重播一次
+ await itemRound();
+ assert(game.player.hp === 9 && plays(audio, mark).count('voice/st_hp_mid') === 1 && b.hpBandVoice === 'mid',
+ 'B10: 回血升档静默(mid 不重播),"最后被告知档"经实击同步回 mid');
+ await attackRound(false);
+ assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 2 && b.hpBandVoice === 'low',
+ 'B10: 经中间档再次跌破 low = 新降档,重播一次 st_hp_low(不属"狼来了")');
+}
+
+async function scenarioB11() {
+ origConsoleLog('\n=== 场景 B11:探索菜单语音播放中单击=跳句+补发确认(round2-live P1) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ game.start();
+ await tick(60000); // 序章+进村,选项报名已播完
+ // ① 选项报名在播中单击 → 跳句 + 执行当前选项(live 1264.6s 证据场景)
+ g(game, 'right'); // 切到 shop:opt_shop 开播(mock 1s/句)
+ assert(audio._voice && audio._voice.id === 'voice/opt_shop', 'B11: 选项报名在播(opt_shop)');
+ const skips0 = audio.skipCount;
+ g(game, 'tap');
+ assert(audio.skipCount === skips0 + 1, 'B11: 报名在播中单击触发跳句(skipVoice)');
+ assert(game.phase === 'shop', 'B11: 跳句同时补发确认语义:进商店(动作不石沉大海)');
+ // ② desc 链在播中单击 → 只跳句不确认(12 文档 §1 跳句惯例不回退)
+ g(game, 'down'); await tick(2000); // 离店回探索(选项报名播完)
+ g(game, 'left'); await tick(1200); // 切回 go_path
+ g(game, 'tap'); // 确认 go_path → desc_path 开播
+ assert(game.scene === 'path' && audio._voice, 'B11: 确认 go_path 进 path(desc 在播)');
+ const skips1 = audio.skipCount;
+ g(game, 'tap');
+ assert(audio.skipCount === skips1 + 1 && game.scene === 'path',
+ 'B11: desc 在播中单击=只跳句不确认(场景不变,A5 契约不回退)');
+ await tick(3000); // 链走完 → 选项报名
+ // ③ 边界提示在播中单击"返回" → 跳句+执行返回(live 1370.1s 证据场景,guide=full)
+ settings.set({ guide: 'full' });
+ try {
+ g(game, 'left'); await tick(1200); // path 首项左滑反绕到末项 back → opt_back 播完接 gd_wrap_last
+ assert(audio._voice && audio._voice.id === 'voice/gd_wrap_last', 'B11: 边界提示 gd_wrap_last 在播');
+ const skips2 = audio.skipCount;
+ g(game, 'tap');
+ assert(audio.skipCount === skips2 + 1 && game.scene === 'village',
+ 'B11: 边界提示在播中单击"返回"=跳句+执行返回(回 village,动作不丢)');
+ } finally {
+ settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
+ }
+}
+
+async function scenarioB12() {
+ origConsoleLog('\n=== 场景 B12:教学自动轮播"播完再上"不互掐(round2-live P2) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ await walkToTeachBattle(game);
+ const b = game._battle;
+ // 等教学链播完进 PLAYER_SELECT(T0 menu,看门狗挂载点)
+ let waited = 0;
+ while (!(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu') && waited < 30000) { await tick(500); waited += 500; }
+ assert(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu', 'B12: 教学 T0 进玩家回合(看门狗已挂载)');
+ await tick(2500); // 让 650ms 指令名定时器播完(隔离声道,避免顶掉占用钩子)
+ // 声道占用钩子:blocked 的 voice 永不触发 onComplete(模拟"上一句仍在播")
+ audio.blocked.add('voice/tut_b5');
+ audio.play('voice/tut_b5');
+ await tick(9000); // 越过 8s 重复提示触发点
+ assert(plays(audio).count('voice/tut_b0') === 1,
+ 'B12: 语音在播时 8s 重复提示跳过(自动轮播不顶句,tut_b0 仍 1 次)');
+ await tick(8000); // 越过 16s 兜底推进点
+ assert(b.teachStage === 'menu' && plays(audio).count('voice/tut_battle_2') === 1,
+ 'B12: 语音在播时 16s 兜底延后推进(不拦腰斩句,live 333.09s 互掐场景)');
+ const tFree = vnow;
+ audio.stopVoice(); // 模拟在播句播完(声道空出)
+ await tick(2500); // 1.5s 重试间隔内应推进
+ assert(b.teachStage === 'attack', 'B12: 声道空出后兜底推进 menu→attack(延后不死锁)');
+ const tAdv = audio.log.filter((e) => e.id === 'voice/tut_battle_2').at(-1).t;
+ assert(tAdv >= tFree, `B12: 推进语音等声道空出才开播("播完再上",${tAdv} ≥ ${tFree})`);
+}
+
+async function scenarioB13() {
+ origConsoleLog('\n=== 场景 B13:杀敌结算语音不腰斩刚开播的指令名(round2-live P2) ===');
+ localStorageMock.clear();
+ const { game, audio } = newGame();
+ await setupWolfBattle(game);
+ await battlePump(game, audio, 5000);
+ const b = game._battle;
+ assert(b && b.state === 'PLAYER_SELECT', 'B13: 野狼战进玩家回合');
+ game._playCmdVoice(); // 钩子:指令名刚开播(复现 live 843.9s:「攻击。」播到一半出手)
+ assert(audio._voice && audio._voice.id === 'voice/cmd_attack', 'B13: 指令名在播');
+ b.enemy.hp = 1; // 钩子:一刀死
+ g(game, 'tap');
+ await tick(300);
+ const die = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
+ assert(die && die.opts.queue === true,
+ 'B13: 指令名开播 1.6s 内击杀语音 queue 排队等播完(不拦腰斩句)');
+ await battlePump(game, audio);
+ assert(game.phase === 'explore', 'B13: 排队后胜利链正常走完回探索(不死锁)');
+}
+
// ================================ 资产引用校验 ================================
function assetAudit() {
@@ -896,6 +1172,13 @@ try {
await scenarioB4();
await scenarioB5();
await scenarioB6();
+ await scenarioB7();
+ await scenarioB8();
+ await scenarioB9();
+ await scenarioB10();
+ await scenarioB11();
+ await scenarioB12();
+ await scenarioB13();
} catch (e) {
failures.push(`未捕获异常: ${e.stack}`);
}
diff --git a/web/simon/README.md b/web/simon/README.md
index 1cc31c68..fb0baa1d 100644
--- a/web/simon/README.md
+++ b/web/simon/README.md
@@ -24,7 +24,8 @@
3. 正式对局:报「第 N 轮」→ 播放序列(此阶段手势忽略)→ 「轮到你了」
(仅第 1 轮)→ 玩家复现;每个手势即时回声放对应音色。
4. 全部复现正确:答对反馈,进入下一轮(序列尾部 +1,速度加快,第 12 轮起 420ms 下限)。
-5. 复现错误:有机会则自动消耗重听(输入阶段双击=主动重听)→ 无机会则
+5. 复现错误:有机会则自动消耗重听(输入阶段上滑=主动重听——免费重听每局 2 次
+ 先用,不耗真机会;免费用尽后重听才扣机会)→ 无机会则
播报成绩与新纪录 → 单击重开(重开跳过教学)。
6. 旁白播放中单击=跳过当前句(skipVoice,链播继续);无语音时单击=各阶段原语义。
diff --git a/web/simon/game.js b/web/simon/game.js
index cb13139c..cd05baef 100644
--- a/web/simon/game.js
+++ b/web/simon/game.js
@@ -8,14 +8,18 @@
* - 单击跳过旁白(12 号文档):voice 播放中单击 = audio.skipVoice()(截断当前句、
* 语音链继续跳下一句);无语音时单击 = 原语义。例外:教学"教一个练一个"的
* 等待输入阶段(teachAwait)单击仍是答题;playback 阶段手势静默忽略、菜单
- * 单击循环菜单项(均维持原裁定)。开场 t0 附近插播一次 voice/skip_hint。
+ * 单击循环菜单项(均维持原裁定);**结算(gameover)单击不可跳句**——播报
+ * 承诺「单击屏幕,再来一局。」,单击必须言行一致直接重开(round1 live P0)。
+ * 开场 t0 附近插播一次 voice/skip_hint。
* - 旁白全部整句化:轮次 round_01..round_20 + round_more 兜底;成绩 score_band1..6
* 分段整句;删除 di/lun/num_0–9/score_pre/best_pre 拼接体系与旧教学连播。
* - 交互式教学(T0-T7):教一个音色练一次该手势,做对才教下一个;教学期下滑跳过。
* - 播放序列每音配 BEAT 轻震(节拍锚点);播完 400ms 给 CONFIRM 震动表示"轮到你了"
* (第 1 轮同时播 your_turn 语音)。
- * - 复活机制:每局初始 1 次重听机会,每完成 4 轮 +1(上限 2)。输入阶段双击=主动
- * 重听(耗机会);答错有机会则自动消耗重播同一序列(序列不重新随机)。
+ * - 复活机制:重听分两层账(round2 live P1 改口)——免费重听每局 2 次(教学
+ * gd_economy 承诺「免费机会有两次」,先说先给、不碰真机会);免费用完后
+ * 主动重听(input 阶段上滑)才扣 chances。chances 每局初始 1、每完成 4 轮
+ * +1(上限 2),答错有机会则自动消耗重播同一序列(序列不重新随机)。
* - 反馈全部改震动:对/错 SUCCESS/ERROR,sfx/correct、wrong、newrecord 已删除;
* sfx/gameover 保留为内容性结束音。
*
@@ -27,9 +31,11 @@
* 帮助)。同一手势在 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 动态置空)。
+ * - H2 占用手势预告(round1 live P2 改口):序列含 up/doubletap 则播
+ * gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中),但**每种组合每局只在
+ * 首次出现轮播一次**(原"每轮必念"12 轮连念 12 遍被点名为 P2 不适);之后仅
+ * 在答错消耗机会重听时随 retry_use 重播本轮组合提示。被占用轮次 input 看门狗
+ * 只发 nudge earcon 不播重听建议句(say 动态置空)不变。
* - H3 重听账目真实:主动重听使用时如实报余量(2→1 播 gd_chances_left1、
* 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。
* - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。
@@ -74,6 +80,10 @@ const TEACH_BLUNT = {
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
+// 表扬句池(既有台词,轮播;round1 live P1:每轮答对必播一句——原 G6 口径
+//「correct 每 4 轮一次 + gd_clear 每局限 1 次」12 轮仅 3 轮有表扬,被推翻)
+const PRAISE = ['voice/correct', 'voice/gd_clear'];
+
// 向导层(14 号文档 + round2 synthesis F1.2 + round4 H4):input 阶段静默看门狗
// ——首级 5s 轻提醒 +gd_relisten(H4 round4:10s→5s,P4「10 秒干等」;升级级
// 不变、次数不变);25s 升级取消自动重播序列(P1:泄题且与扣机会规则矛盾)→
@@ -89,6 +99,9 @@ export class SimonGame extends Game {
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
this.inputIdx = 0;
this.chances = 1; // 重听机会(初始 1,每 4 轮 +1,上限 2)
+ // 免费重听(round2 live P1):每局 2 次,教学 gd_economy 承诺「免费机会有两次」
+ // ——承诺几次给几次,先用免费额度不碰 chances,用完主动重听才扣真机会
+ this.freeRelistens = 2;
this.best = this._loadBest(); // 历史最高(完成轮数),localStorage 持久
// 教学状态
this.teachIdx = 0;
@@ -98,9 +111,10 @@ export class SimonGame extends Game {
this._menuIdx = 0;
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
this._timers = [];
- // G6(round3):轮结算链尾轻量反馈每局各限 1 次
- this._clearSaid = false;
+ // 轮结算反馈:gd_record 每局限 1 次(表扬每轮必播,见 _roundClear)
this._recordSaid = false;
+ // P2(round1 live):占用组合预告每局每种只播首次(答错时重播,见 _wrongAnswer)
+ this._occAnnounced = new Set();
}
start() {
@@ -145,7 +159,10 @@ export class SimonGame extends Game {
/**
* 单击是否应判定为"跳过当前旁白句"。规则:有 voice 在播 + 当前阶段允许跳过。
* - tutorial:仅讲解/反馈语音播放中可跳;teachAwait(等玩家练手势)单击=答题;
- * - announce/roundend/gameover:轮次/成绩/结算播报可跳(高轮次玩家刚需);
+ * - announce/roundend:轮次/成绩播报可跳(高轮次玩家刚需);
+ * - gameover:**不可跳**——结算播报承诺「单击屏幕,再来一局。」,单击必须
+ * 言行一致直接重开(round1 live P0:玩家照播报单击却只跳过一句,随后进
+ * 菜单迷路流失);
* - playback:维持"序列播放期手势静默忽略"裁定(含第 1 轮 your_turn);
* - input:答题阶段无语音;menu:单击循环菜单项是原语义,不拦截。
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
@@ -155,8 +172,7 @@ export class SimonGame extends Game {
switch (this.phase) {
case 'tutorial': return !this.teachAwait;
case 'announce':
- case 'roundend':
- case 'gameover': return true;
+ case 'roundend': return true;
default: return false;
}
}
@@ -249,8 +265,9 @@ export class SimonGame extends Game {
this.sequence = [];
this.round = 0;
this.chances = 1;
- this._clearSaid = false; // G6:每局重新封顶
+ this.freeRelistens = 2; // 免费重听每局重置(round2 live P1)
this._recordSaid = false;
+ this._occAnnounced.clear(); // P2:新一局重新封顶(每种组合首次再播)
this._nextRound();
}
@@ -330,10 +347,14 @@ export class SimonGame extends Game {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm'); // "轮到你了"
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
- // H2(round4):序列播放完成后、「轮到你」提示前,按本轮序列播占用预告
- //(gd_occ_up/dt/both,游戏反馈层全档位,插在轮次链中)——重听键与答案音
- // 共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据(P1#1+P2)。
- const ids = this._occupancyVoices();
+ // H2(round4)→ round1 live P2 改口:占用预告每种组合每局只播首次
+ //(_occAnnounced 记录;之后仅答错重听时随 retry_use 重播,见 _wrongAnswer)。
+ // 重听键与答案音共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据。
+ let ids = this._occupancyVoices();
+ if (ids.length) {
+ if (this._occAnnounced.has(ids[0])) ids = [];
+ else this._occAnnounced.add(ids[0]);
+ }
if (this.round === 1) ids.push('voice/your_turn');
if (ids.length) {
this._speakGuarded(ids, () => {
@@ -388,6 +409,14 @@ export class SimonGame extends Game {
this._relisten();
return;
}
+ // 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)——
+ // 即时 error 震,不判错、不耗机会、不进重听。此前 TONE['longpress'] 为
+ // undefined,audio.play(undefined) 直接抛 TypeError(事件回调内静默崩溃,
+ // 表象正是"手势被吞")。longpressend 是同一动作的收尾,静默不重复反馈。
+ if (!TONE[g]) {
+ if (g !== 'longpressend') vibrate('error');
+ return;
+ }
// 立即回声放玩家手势的音色(sfx 低延迟;识别震动引擎内置,不重复震)
this.audio.play(TONE[g]);
if (g !== this.sequence[this.inputIdx]) {
@@ -399,28 +428,35 @@ export class SimonGame extends Game {
}
/**
- * 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。
- * 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)是「答错可以消耗机会重听」的机会账,语义如实、维持不变。
+ * 主动重听:重播当前序列(进度清零重输,序列不变)。两层账(round2 live P1):
+ * - 免费重听每局 2 次,先用免费额度、**不碰 chances**——教学 gd_economy 承诺
+ * 「免费机会有两次」,承诺几次给几次(旧账免费第二次要打完第 4 轮才随
+ * chances +1 发放,玩家第 1 次重听就听报「用完了」、答错一题即死,规则级
+ * 冤死)。第 1 次用掉(余 1)播 gd_chances_left1「免费重听还剩一次。」、
+ * 第 2 次用掉(余 0)播 gd_chances_left0「免费重听用完了。之后重听会花一次
+ * 机会,别怕,慢慢来。」(H3 round4 如实报余量,游戏档全级别)。
+ * - 免费用完后重听扣 chances(真机会);扣空(chances 1→0)追加 chances_out
+ * 如实报「重听机会用完了,小心。」;免费与真机会皆空时再求重听 → 拒绝
+ * (error 震 + chances_out,不判错、不耗账)。
+ * 答错自动消耗路径(retry_use + chances_out)是「答错可以消耗机会重听」的
+ * 机会账,与主动重听分账,语义如实、维持不变。
*/
_relisten() {
- if (this.chances <= 0) {
+ if (this.freeRelistens <= 0 && this.chances <= 0) {
vibrate('error');
this.audio.play('voice/chances_out');
return;
}
- this.chances--;
vibrate('pickup');
const ids = ['voice/relisten'];
- if (this.chances === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
- else if (this.chances === 0) ids.push('voice/gd_chances_left0'); // 余 0:用完了
+ if (this.freeRelistens > 0) {
+ this.freeRelistens--;
+ if (this.freeRelistens === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
+ else ids.push('voice/gd_chances_left0'); // 余 0:免费用完,之后扣机会
+ } else {
+ this.chances--; // 第 3 次起扣真机会
+ if (this.chances === 0) ids.push('voice/chances_out'); // 真机会也空了,如实报
+ }
const s = this._session;
this.phase = 'roundend'; // 重听播报期间暂停接收输入
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
@@ -430,22 +466,18 @@ export class SimonGame extends Game {
this.phase = 'roundend';
const s = this._session;
vibrate('success');
- const ids = [];
- if (this.round % 4 === 0) ids.push('voice/correct'); // 每 4 轮播一次,其余仅震动
+ // 每轮答对都播一句表扬(PRAISE 池轮播)——round1 live P1:小孩每轮都要听到
+ // 一句夸奖,"过关没声音夸我,我都怀疑是不是其实没答对"。
+ const ids = [PRAISE[(this.round - 1) % PRAISE.length]];
// 每完成 4 轮机会 +1(上限 2)并播报
if (this.round % 4 === 0 && this.chances < 2) {
this.chances++;
vibrate('pickup');
ids.push('voice/chance_plus');
}
- // G6(round3):轮结算链尾轻量反馈——gd_clear「漂亮,过了。」每局 1 次;
- // 本轮完成数超过历史最佳(且确有旧纪录可破)时追加 gd_record「破纪录了。」
- // 每局 1 次(结算另有 new_record,此处是局中破纪录的即时一刻)。游戏规则句
+ // gd_record「破纪录了。」每局限 1 次:本轮完成数超过历史最佳(且确有旧纪录
+ // 可破)时追加——局中破纪录的即时一刻(结算另有 new_record)。游戏规则句
// 随链播(全档位,gd_economy 先例),不走向导通道。
- if (!this._clearSaid) {
- this._clearSaid = true;
- ids.push('voice/gd_clear');
- }
if (!this._recordSaid && this.best > 0 && this.round > this.best) {
this._recordSaid = true;
ids.push('voice/gd_record');
@@ -464,6 +496,9 @@ export class SimonGame extends Game {
this._timer(() => vibrate('warning'), 300);
const ids = ['voice/retry_use'];
if (this.chances === 0) ids.push('voice/chances_out');
+ // P2(round1 live):答错重听时重播本轮组合占用提示(首次播报之后的唯一
+ // 重播途径——"能不能只在我答错的时候才提醒我")
+ ids.push(...this._occupancyVoices());
const s = this._session;
this.phase = 'roundend';
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
@@ -532,11 +567,18 @@ export class SimonGame extends Game {
_onMenuGesture(g) {
const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back'];
- if (g === 'tap') {
+ // 单击/右滑=下一项、左滑=上一项,切换即播报当前项(round1 live P1:原来
+ // 左右滑只有震动没有语音,"不知道有几项、指在哪项")
+ if (g === 'tap' || g === 'right') {
this._menuIdx = (this._menuIdx + 1) % items.length;
this.audio.play(items[this._menuIdx]);
+ } else if (g === 'left') {
+ this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
+ this.audio.play(items[this._menuIdx]);
} else if (g === 'up') {
this._speakRules();
+ } else if (g === 'down') {
+ this.phase = 'gameover'; // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定)
} else if (g === 'doubletap') {
const sel = this._menuIdx;
if (sel === 0) this._restart();
diff --git a/web/simon/test/playtest.mjs b/web/simon/test/playtest.mjs
index d5f496cf..f5c67d8a 100644
--- a/web/simon/test/playtest.mjs
+++ b/web/simon/test/playtest.mjs
@@ -1,5 +1,5 @@
/**
- * playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。
+ * playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7 + §9 round1-live 变更)。
*
* 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext),
* 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js,
@@ -232,6 +232,8 @@ audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); };
const played = (id) => playLog.some((e) => e.id === id);
const playCount = (id) => playLog.filter((e) => e.id === id).length;
+const OCC_IDS = ['voice/gd_occ_up', 'voice/gd_occ_dt', 'voice/gd_occ_both'];
+const occTotal = () => OCC_IDS.reduce((n, id) => n + playCount(id), 0);
const lastVoice = () => {
for (let i = playLog.length - 1; i >= 0; i--) {
if (playLog[i].id.startsWith('voice/')) return playLog[i].id;
@@ -331,8 +333,10 @@ async function toInputViaSkip(round) {
const seqSnapshot = game.sequence.join(',');
await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳)
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响');
+ const pl0 = playLog.length;
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`);
+ checkOccAnnouncedOnce(pl0);
checkSeqConstraints();
}
@@ -341,12 +345,25 @@ async function toInputViaWatchdog(round) {
await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` });
await advance(300);
assert(!audio._voice, '被屏蔽的语音确实没有播起来');
+ const pl0 = playLog.length;
await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 });
assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error');
const seqSnapshot = game.sequence.join(',');
await gesture('left');
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略');
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
+ checkOccAnnouncedOnce(pl0);
+}
+
+// P2(round1 live,13 号文档 §9):组合占用提示 gd_occ_* 每局每种只在首次出现
+// 轮播一次——在轮次开场窗口(playback→input)跨轮跟踪,重复播报即失败。
+const occAtRoundStart = new Set();
+function checkOccAnnouncedOnce(fromIdx) {
+ for (const e of playLog.slice(fromIdx)) {
+ if (!e.id.startsWith('voice/gd_occ_')) continue;
+ assert(!occAtRoundStart.has(e.id), `P2:${e.id} 只在首次出现轮播(第 ${game.round} 轮开场重复播报)`);
+ occAtRoundStart.add(e.id);
+ }
}
let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题
@@ -388,25 +405,30 @@ assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
await toInputViaSkip(1);
await answerAll();
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
+assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
await toInputViaSkip(2);
await answerAll();
+assert(played('voice/gd_clear'), 'P1:每轮答对都播表扬(第 2 轮 gd_clear,池轮播)');
blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产
await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' });
await toInputViaWatchdog(3);
await answerAll();
+assert(playCount('voice/correct') === 2, 'P1:每轮答对都播表扬(第 3 轮 correct 轮播)');
await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' });
// ---------------------------------------------------------------- 第 4 轮:答错触发复活
section('第 4 轮:答错消耗复活、序列不变重播');
await toInputViaSkip(4);
assert(game.chances === 1, '初始 1 次重听机会');
+assert(game.freeRelistens === 2, 'P1(round2 live):免费重听每局 2 次开局即给足(教学承诺「免费机会有两次」)');
{
const prevSeq = game.sequence;
const exp = game.sequence[0];
// round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错)
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
+ const occBefore = occTotal();
await gesture(wrong);
assert(game.chances === 0, '答错自动消耗复活:chances 1→0');
assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use');
@@ -416,40 +438,84 @@ assert(game.chances === 1, '初始 1 次重听机会');
await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' });
assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)');
assert(game.inputIdx === 0, '复活后输入进度清零');
+ // P2(round1 live,13 号文档 §9):答错重听链尾重播本轮组合占用提示
+ const seqHasOcc = game.sequence.includes('up') || game.sequence.includes('doubletap');
+ assert(occTotal() === occBefore + (seqHasOcc ? 1 : 0), 'P2:答错重听时重播本轮组合提示(唯一重播途径)');
}
await answerAll();
await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' });
assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)');
assert(played('voice/chance_plus'), '播 chance_plus');
+assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4 轮 gd_clear 轮播)');
// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界
-section('第 5 轮:主动重听(上滑)/ chances=0 上滑 / 双击=作答(round3 G5,13 号文档 §8 变更记录)');
+section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
await toInputViaSkip(5);
+{
+ // 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
+ const idx = game.inputIdx;
+ const ch = game.chances;
+ const vib0 = vibLog.length;
+ await gesture('longpress');
+ assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch,
+ 'P1:input 阶段长按不判错、不耗机会、不进重听');
+ assert(vibLog.slice(vib0).some((p) => Array.isArray(p) && p[0] === 60),
+ 'P1:input 阶段长按发 error 震动兜底(不再零反馈,也不再抛 TypeError)');
+ const vib1 = vibLog.length;
+ await gesture('longpressend');
+ assert(vibLog.length === vib1 && game.inputIdx === idx, 'P1:longpressend(同一动作收尾)静默不重复反馈');
+}
{
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
+ // round2 live P1(13 号文档 §15):重听分两层账——免费 2 次/局先用(不碰真机会),
+ // 第 3 次起才扣 chances。教学 gd_economy 承诺「免费机会有两次」,承诺几次给几次。
await answerPastDoubletaps();
await answerPastUps(); // 先把期望恰为 up 的音答掉
const relistenCount = playCount('voice/relisten');
- await gesture('up'); // 期望非 up → 主动重听
- assert(game.chances === 0, '主动重听耗机会:chances 1→0');
+ assert(game.freeRelistens === 2 && game.chances === 1, 'P1:第 5 轮账目起点 = 免费 2 次 + 真机会 1 次');
+ await gesture('up'); // 期望非 up → 第 1 次主动重听(免费)
+ assert(game.freeRelistens === 1 && game.chances === 1, 'P1:第 1 次重听用免费额度(2→1),真机会不动');
assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播');
await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' });
+ assert(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
assert(game.inputIdx === 0, '重听后输入进度清零');
}
{
- // chances=0 时上滑非期望手势:不判错、播 chances_out
+ // 第 2 次主动重听:仍走免费额度(兑现教学承诺的第二次)
+ await answerPastDoubletaps();
+ await answerPastUps();
+ await gesture('up');
+ assert(game.freeRelistens === 0 && game.chances === 1, 'P1:第 2 次重听仍免费(1→0),真机会仍不动');
+ await advanceUntil(() => game.phase === 'input', { desc: '第 2 次重听重播后回输入' });
+ assert(played('voice/gd_chances_left0'), 'P1:第 2 次后播 gd_chances_left0(免费用完、之后扣机会,链尾句)');
+ assert(game.inputIdx === 0, '第 2 次重听后输入进度清零');
+}
+{
+ // 第 3 次主动重听:免费已尽,扣真机会(chances 1→0);真机会扣空如实报 chances_out
+ await answerPastDoubletaps();
+ await answerPastUps();
+ const coCount = playCount('voice/chances_out');
+ await gesture('up');
+ assert(game.freeRelistens === 0 && game.chances === 0, 'P1:第 3 次重听才扣真机会(chances 1→0)');
+ await advanceUntil(() => game.phase === 'input', { desc: '第 3 次重听重播后回输入' });
+ assert(playCount('voice/chances_out') === coCount + 1, 'P1:真机会扣空追加播 chances_out(链尾句)');
+ assert(game.inputIdx === 0, '第 3 次重听后输入进度清零');
+}
+{
+ // 免费+真机会皆空时上滑非期望手势:拒绝重听——不判错、不耗账、播 chances_out
await answerPastDoubletaps();
await answerPastUps();
const round = game.round;
const idx = game.inputIdx;
const coCount = playCount('voice/chances_out');
await gesture('up');
- assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, 'chances=0 上滑非期望:不判错(round/inputIdx 不变)');
- assert(playCount('voice/chances_out') === coCount + 1, 'chances=0 上滑播 chances_out');
- assert(game.chances === 0, 'chances 不变(不为负)');
+ assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, '两账皆空上滑非期望:拒绝重听不判错(round/inputIdx 不变)');
+ assert(playCount('voice/chances_out') === coCount + 1, '拒绝重听播 chances_out');
+ assert(game.chances === 0 && game.freeRelistens === 0, '两账不变(不为负)');
}
// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找
await answerAll();
+assert(playCount('voice/correct') === 3, 'P1:每轮答对都播表扬(第 5 轮 correct 轮播)');
while (!dtAnswerTested && game.round < 10) {
await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' });
const r = game.round;
@@ -481,14 +547,9 @@ const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/scor
: score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4'
: score <= 14 ? 'voice/score_band5' : 'voice/score_band6';
assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM');
-// 结算语音链播放中单击 = 跳过本句(不是重开)
+// 结算语音链自然播完,不在此插手——播放中单击=直接重开(P0 言行一致),
+// 该断言需要一次专门的 gameover,见后文「结算屏言行一致」专项场景。
await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' });
-for (let i = 0; i < 8 && audio._voice; i++) {
- const ph = game.phase;
- await gesture('tap');
- assert(game.phase === ph && game.phase === 'gameover', '结算语音播放中单击=跳过本句(不重开)');
- await advance(50);
-}
await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' });
assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`);
assert(played('voice/new_record'), '破纪录播 new_record');
@@ -521,6 +582,16 @@ section('菜单(下滑呼出)与单击重开');
await gesture('down');
assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单');
assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)');
+// P1(round1 live,13 号文档 §9):菜单项切换逐项播报——右滑=下一项、左滑=上一项
+await gesture('right');
+assert(game._menuIdx === 1 && played('voice/menu_home'), 'P1:菜单右滑=下一项并播报("回大厅")');
+const mr = playCount('voice/menu_restart'); // _openMenu 链上的 menu_restart 可能已被右滑顶掉,用增量断言
+await gesture('left');
+assert(game._menuIdx === 0 && playCount('voice/menu_restart') === mr + 1, 'P1:菜单左滑=上一项并播报("重新开始")');
+await gesture('left');
+assert(game._menuIdx === 2 && played('voice/menu_back'), 'P1:菜单首项再左滑回绕末项并播报("返回游戏")');
+await gesture('right');
+assert(game._menuIdx === 0, 'P1:菜单右滑回到首项');
await gesture('tap');
assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"');
await gesture('tap');
@@ -530,8 +601,13 @@ assert(played('voice/rules'), '菜单上滑=帮助(规则播报)');
await gesture('doubletap'); // 选中"返回"
assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页');
await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' });
+// 下滑=关闭菜单(13 号文档 §7.5 菜单约定的 simon 落地补全)
+await gesture('down');
+assert(game.phase === 'menu', '再次下滑呼出菜单');
+await gesture('down');
+assert(game.phase === 'gameover', '菜单内下滑=关闭菜单回结算页');
const stopsBefore = stopBgmLog.length;
-await gesture('tap'); // 结算页无语音时单击 = 重开
+await gesture('tap'); // 结算页单击 = 直接重开(P0 言行一致,无论语音是否在播)
assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默');
await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' });
assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)');
@@ -539,6 +615,28 @@ await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输
await answerAll();
assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常');
+// ---------------------------------------------------------------- 结算屏言行一致(round1 live P0)
+section('结算屏言行一致:结算语音链播放中单击=直接重开');
+// 新局第 2 轮故意连错两次耗尽复活,再造一次 gameover
+await advanceUntil(() => game.phase === 'input' && game.round === 2, { desc: '新局第 2 轮输入' });
+{
+ const wrong = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
+ await gesture(wrong);
+ assert(game.phase === 'roundend' && game.chances === 0, '新局答错消耗唯一复活');
+ await advanceUntil(() => game.phase === 'input', { desc: '新局重播后回输入' });
+ const wrong2 = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
+ await gesture(wrong2);
+ assert(game.phase === 'gameover', '复活耗尽再答错 → 第二次 gameover');
+}
+await advanceUntil(() => !!audio._voice, { desc: '第二次结算语音链开播' });
+{
+ const r1 = playCount('voice/round_01');
+ await gesture('tap'); // 语音链播放中单击:不再跳句,直接开新局(言行一致是铁律)
+ assert(game.phase === 'announce' && game.round === 1, 'P0:结算语音播放中单击=直接开新局(不再跳句)');
+ assert(playCount('voice/round_01') === r1 + 1, 'P0:新局立即开播 round_01(结算语音链被打断)');
+}
+await advanceUntil(() => game.phase === 'input', { desc: '第三次开局进入第 1 轮输入' });
+
// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3)
section('settings.bgm 边界');
settings.set({ bgm: 0 });
diff --git a/web/space/README.md b/web/space/README.md
index f8596f81..741427c7 100644
--- a/web/space/README.md
+++ b/web/space/README.md
@@ -7,10 +7,13 @@
- 陨石从四个方位逼近,听声辨位:
- **左 / 右声道** 的呼啸 = 左 / 右侧来袭
- **上方高频呼啸** = 上方来袭,**下方低频轰隆** = 下方来袭
+ - 呼啸响起的**同一瞬间**有超短方向呼喊("左!""右!"……),听到就能滑
- 警报声越响越急,陨石越近。**朝来袭方向的同向滑动**把陨石拨开
(左来左滑、右来右滑、上来上滑、下来下滑)——零推理,反射直达。
+ 从呼啸响起到撞击点之间滑都有效(早滑不会被吞)。
- 被撞击 **3 次**坠毁(前 60 秒保护期内撞击不掉血,训练护盾吸收并语音讲解方位);
- 结算播报存活时间与躲避数分档整句;保护期后陨石越快越密(120 秒爬满)。
+ 结算播报存活时间与躲避数分档整句;保护期结束后前 3 颗陨石保持保护期的慢节奏
+ 作过渡,之后陨石越快越密(120 秒爬满)。
- 躲过 10 / 25 / 50 / 100 颗有里程碑播报;破纪录有"新纪录!"播报。
- **旁白播放中单击 = 跳过本句**(语音链跳下一句);**按住不放 = 旁白加速播放**
(引擎内置,不变调)。首局演练开场会提示一次。
@@ -24,16 +27,17 @@
单击直接出发"。
开局后**第 20 秒**引入单击加速、**第 45 秒**引入双击护盾(暂停刷怪各教一次,
-8 秒没操作自动继续,机制之后照常生效)。**第 60 秒**保护期结束,难度开始爬坡。
+8 秒没操作自动继续,机制之后照常生效;**教学仅首局出现**,第 2 局起机制开局即用、
+不再重播)。**第 60 秒**保护期结束,难度开始爬坡。
## 手势
| 手势 | 作用 |
|---|---|
-| 上 / 下 / 左 / 右滑 | **同向闪避**:把对应方位来的陨石拨开(撞击前 1.5s 内有效) |
-| 单击 | 旁白在播 = 跳过本句;否则 = 引擎短促加速 ~1.8s,增益期间冲过空隙(规避单击 300ms 判定延迟,持续增益非即时判定);第 20s 解锁 |
-| 双击 | 展开护盾 6s,挡下一次撞击;之后 30s 充能冷却,充能完毕有语音提示;第 45s 解锁 |
-| 下滑 | **无下方陨石悬置时**呼出菜单(有下方陨石时下滑永远是闪避) |
+| 上 / 下 / 左 / 右滑 | **同向闪避**:把对应方位来的陨石拨开(呼啸响起到撞击点之间滑都有效) |
+| 单击 | 旁白在播 = 跳过本句;否则 = 引擎短促加速 ~1.8s,增益期间冲过空隙(规避单击 300ms 判定延迟,持续增益非即时判定);第 20s 解锁(仅首局教学) |
+| 双击 | 展开护盾 6s,挡下一次撞击;之后 15s 充能冷却,充能完毕有语音提示;未解锁/充能中双击会有即时语音应答("会自动充能,好了我会告诉你")+ 心跳震动;第 45s 解锁(仅首局教学) |
+| 下滑 | **无下方陨石悬置时**呼出菜单(有下方陨石时下滑永远是闪避);**最近一次撞/躲判定后 0.8s 冷静期内的下滑一律按躲避意图处理,绝不开菜单**——高速段撞击瞬间的躲避不会误暂停 |
| 单击(结算后) | 跳过结算播报;播报结束后 = 再飞一次 |
| 下滑(结算后) | 回大厅 |
@@ -43,6 +47,13 @@
**上滑 = 帮助**(操作要点 + 设置引导),下滑或"继续飞行"关闭菜单。
选项:继续飞行 / 回大厅 / 重新听教学要点。
+- **恢复缓冲**:「继续飞行」后前 3 颗陨石回到 4 秒慢节奏(与保护期结束后的
+ 过渡同款机制),不再恢复即全速弹幕。
+- **回大厅**:确认后先播"回大厅"确认句、停刷怪与 BGM,播完再跳转
+ (跳转期间单击可跳过确认句立即走)——不会落进无声死界面。
+- **最后一命警告**:第 2 次撞击后"船体只剩最后一次"经呼喊接力紧随归因播出;
+ 警告在播期间方向呼喊暂时让路(不被顶断);坠毁瞬间警告即刻静音,结算播报不叠播。
+
## 运行
```bash
diff --git a/web/space/game.js b/web/space/game.js
index 6bbd2060..38373597 100644
--- a/web/space/game.js
+++ b/web/space/game.js
@@ -7,17 +7,39 @@
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
+ * 演练陨石呼啸响起时同窗播 dir_,与实战"听声即滑"的配对一致(round4 R1)。
* - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收
* (shield_block 音 + WARNING 震动 + protect_ 归因句)。60s 播 protect_end
* 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。
- * - **单击/双击分批引入**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),第 45s 教
- * 双击护盾(6s,30s 充能,shield_ready 语音);8s 内没做不等待直接继续。
- * - **死亡即教学**:保护期后被撞播 hit_ 归因句,第 2 次加 hit_last;
- * 3 次撞击坠毁。结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
+ * 过渡不断崖:保护结束后的前 3 颗裸飞陨石保持 4.0s 逼近 / 4.0s 刷怪,
+ * 第 4 颗起才接入难度曲线(round4 R1:protect_end 后第一颗疑似必中、10s 三连撞)。
+ * - **闪避窗口覆盖呼啸全程**(round4 R1:早滑被静默吞掉):同向滑在陨石呼啸响起
+ * (spawn)到撞击点 +250ms 宽限之间都有效,不再只开撞击前 1.5s;spawn 之前的
+ * "预言家"滑动不算(按 ob.spawnAt 下界)。
+ * - **单击/双击分批引入(仅首局)**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),
+ * 第 45s 教双击护盾(6s,15s 充能,shield_ready 语音排队播);8s 内没做不等待
+ * 直接继续。introDone 标记挂在实例上(不随 _resetRunState 重置),第 2 局起
+ * 不再重播教学、机制开局即用(round4 R1:feat_* 每局重播)。护盾不可用
+ * (未引入/充能中)时双击有即时应答(feat_shield_done 现成句 + HEARTBEAT 震,
+ * 4s 去抖;round4 R1:panic 双击零反馈)。
+ * - **死亡即教学**:保护期后被撞播 hit_ 归因句,第 2 次追加 hit_last
+ * (呼喊接力——归因被 dir_ 呼喊顶断时 hit_last 挂起,由呼喊播完的
+ * onComplete 自动接播,"最后一命"不再被静默吞掉,round4 R1);3 次撞击坠毁。
+ * 结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
+ * round2 R2(高速段警告播不出/叠播):**hit_last 在播期间方向呼喊抑制**
+ * (警告播完前不被下一颗呼喊顶断;归因在播期间呼喊保留生存优先级——
+ * 顶断 → 呼喊播完接力,警告低延迟路径不变);里程碑/护盾不可用应答在
+ * 警告链期间排队不顶链;被菜单/分批引入顶断的警告恢复对局即补播;
+ * **坠毁即掐断警告链**,结算链 1.2s 后干净开场不叠播(live B4)。
* - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。
* - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 →
* 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时**
* 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。
+ * round2 R2 误暂停修复(live ta=664.99/1670.73:高速段撞击瞬间的下滑躲避
+ * 被当成开菜单):**最近一次判定(撞/躲/格挡/吸收)后 800ms 冷静期内的下滑
+ * 一律按躲避意图忽略,绝不开菜单**(其余时刻保留旧折中,暂停可达性不变);「继续飞行」恢复缓冲——前 3 颗裸飞陨石回
+ * 4.0s 慢节奏(复用断崖过渡机制,不再 3 秒暴毙);「回大厅」改 rhythm 同款收尾:
+ * leaving 态停刷怪/BGM、播确认句后再跳转(旧版直接跳,跳转失败落无声死界面)。
* - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回
* `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。
* - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报
@@ -26,7 +48,8 @@
*
* 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件——
* 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm /
- * 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success。
+ * 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success /
+ * 护盾不可用 heartbeat(round4 R1)。
*
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
@@ -34,9 +57,17 @@
* 向导层(docs/design/14-guide-layer.md + round2 synthesis F2.2/F6 + round3 G7):
* 对局第 1 次受击(保护期外)在归因链播完后补指令式 gd_hit_hint、第 2 次补
* gd_last_life(idle-only:挂在语音空闲点);警报音效 600ms 去重、danger 震
- * ≥400ms 去抖;loud 警报档(撞击前 500ms)同窗播 dir_ 超短方向呼喊
- * (voice 层直达、全档位,G7);结算链换 _speakGuarded(资产缺失不卡结算 BGM);
- * 对局期长按快进豁免(setRateBoostEnabled);状态签名 earcon 与状态 BGM 同点。
+ * ≥400ms 去抖;dir_ 超短方向呼喊在陨石呼啸响起瞬间同窗直出(round4 R1
+ * 改口:G7 原挂在 loud 警报档=撞击前 500ms,实测"听语音再滑"永远来不及;
+ * 提前到 spawn 后语音指引与反应窗口同起点。撞击点落在保护期内的陨石不播——
+ * 保护期有 protect_ 长句教学,呼喊会把它顶断);结算链换 _speakGuarded
+ * (资产缺失不卡结算 BGM);对局期长按快进豁免(setRateBoostEnabled);
+ * 状态签名 earcon 与状态 BGM 同点。
+ *
+ * voice 播放安全门(round4 R1 活体复验追加):_playVoice 令牌异步落位,同 tick
+ * 第二个 play 漏杀前一个 → 双管线并播 + ScriptProcessor 节点永久泄漏(live 探针
+ * 实录 dir_up/hit_down 交替抽读百余次)。本层 voice 播放全部经 _voicePlay 串行门
+ * (等上次令牌落位再发下一次)+ 对局语音全量 prewarm 缓存热,引擎零改动。
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
@@ -53,17 +84,19 @@ const APPROACH_SFX = {
// ---- 调参 ----
const HULL_MAX = 3; // 撞击上限(保护期后)
-const DODGE_WINDOW_MS = 1500; // 撞击前多久内的正确滑动有效
-const DODGE_GRACE_MS = 250; // 撞击点后允许的迟到滑动宽限
+const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程,见头注)
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
const SHIELD_DURATION_MS = 6000;
-const SHIELD_COOLDOWN_MS = 30000;
+const SHIELD_COOLDOWN_MS = 15000; // round4 R1:30s→15s(冷却过长 + 失败零反馈是 P1)
+const SHIELD_DENY_DEBOUNCE_MS = 4000; // 护盾不可用应答(语音+震动)去抖
const BOOST_DURATION_MS = 1800;
const BOOST_COOLDOWN_MS = 3000;
const RAMP_MS = 120000; // 难度爬升到满所需时长(保护期后起算)
const PROTECTION_MS = 60000; // 保护期
const PROTECT_APPROACH_MS = 4000;
const PROTECT_SPAWN_MS = 4000;
+const BARE_GRACE_METEORS = 3; // 保护期结束后前 N 颗裸飞陨石保持保护期节奏(round4 R1 不断崖)
+const MENU_CALM_MS = 800; // 下滑开菜单冷静期:最近判定后 N ms 内下滑=躲避意图(round2 R2 误暂停修复)
const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
const BOOST_INTRO_MS = 20000; // 单击引入时刻
const SHIELD_INTRO_MS = 45000; // 双击引入时刻
@@ -100,6 +133,21 @@ export class SpaceGame extends Game {
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
this._timers = [];
+ this._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay)
+ // voice 播放安全门接线(round4 R1 活体实测发现,详见 _voicePlay):
+ // 本层(含向导层)所有 voice id 播放一律走串行门,bgm/sfx 直通透传。
+ // 与 playtest/guide.test 的 play 包装兼容(它们先包,本门包在最外层)。
+ this._origPlay = audio.play.bind(audio);
+ this.audio.play = (id, opts) => (
+ typeof id === 'string' && id.startsWith('voice/')
+ ? this._voicePlay(id, opts)
+ : this._origPlay(id, opts)
+ );
+ // 单击/双击引入教学标记:挂实例不挂局(_resetRunState 不重置)——
+ // 只在首次进入后的首局各教一次,第 2 局起机制开局即用(round4 R1 P3-4)
+ this.boostIntroDone = false;
+ this.shieldIntroDone = false;
+ this._goingHome = false; // "回大厅收尾中"标记(_goHome 幂等 + leaving 态)
this._resetRunState();
}
@@ -118,10 +166,13 @@ export class SpaceGame extends Game {
this.shieldReadyAt = 0;
this.boostUntil = 0;
this.boostReadyAt = 0;
- this.boostIntroDone = false;
- this.shieldIntroDone = false;
this.featureKind = null;
this._menuIdx = 0;
+ this._bareCount = 0; // 保护期结束后已刷裸飞陨石数(断崖过渡用)
+ this._shieldDenyAt = -Infinity; // 护盾不可用应答去抖时间戳
+ this._hitLastPending = false; // hits===2 时 hit_last 待接播标记(呼喊接力用)
+ this._hitLastPlaying = false; // hit_last 在播标记(警告链呼喊抑制用,round2 R2)
+ this._lastResolveAt = -Infinity; // 最近一次陨石判定时刻(下滑开菜单冷静期,round2 R2)
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
this._alarmSoftAt = -Infinity;
this._alarmLoudAt = -Infinity;
@@ -135,11 +186,23 @@ export class SpaceGame extends Game {
'sfx/whoosh_left', 'sfx/whoosh_right', 'sfx/whoosh_up', 'sfx/whoosh_down',
'sfx/alarm', 'sfx/boost', 'sfx/shield', 'sfx/shield_block',
'sfx/dodge', 'sfx/hit', 'sfx/crash',
+ // 对局期可能并发的语音全量预热(voice 播放安全门配套:缓存热 = 令牌落位
+ // 只需数个 microtask,门内零等待):方向呼喊/归因/保护/护盾/引入/里程碑/向导
+ 'voice/dir_left', 'voice/dir_right', 'voice/dir_up', 'voice/dir_down',
+ 'voice/hit_left', 'voice/hit_right', 'voice/hit_up', 'voice/hit_down', 'voice/hit_last',
+ 'voice/protect_left', 'voice/protect_right', 'voice/protect_up', 'voice/protect_down',
+ 'voice/protect_end', 'voice/shield_ready', 'voice/shield_block',
+ 'voice/feat_boost', 'voice/feat_boost_done', 'voice/feat_shield', 'voice/feat_shield_done',
+ 'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100',
+ 'voice/gd_hit_hint', 'voice/gd_last_life',
+ 'voice/paused', 'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby',
+ 'voice/menu_help', 'voice/help_recap', 'voice/settings_hint',
'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask',
'voice/skip_hint',
]);
this._session++;
this._clearTimers();
+ this._goingHome = false; // 页面重进时复位"回大厅收尾中"标记(leaving 态才可再入)
const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1';
if (drilled) {
// 二周目:上滑重听演练 / 单击直接出发
@@ -178,6 +241,10 @@ export class SpaceGame extends Game {
case 'menu':
this._onMenuGesture(g);
break;
+ case 'leaving':
+ // 回大厅收尾中:仅受理"单击跳确认句"(链自然推进跳转,看门狗兜底)
+ if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
+ break;
case 'over':
// 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次
if (g === 'tap') {
@@ -221,10 +288,13 @@ export class SpaceGame extends Game {
_spawnDrillOb() {
const d = DRILLS[this.drillIdx];
const now = performance.now();
- const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS };
+ const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS, spawnAt: now };
this.obstacles.add(ob);
this.drillOb = ob;
+ // 呼啸 + dir_ 同窗:演练就建立"声音一出立刻滑"的配对(round4 R1),
+ // 此处 voice 层必空闲(演练陨石只在旁白播完/被跳过后 spawn)
this.audio.play(APPROACH_SFX[d.dir]);
+ this.audio.play(`voice/dir_${d.dir}`);
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
}
@@ -292,8 +362,9 @@ export class SpaceGame extends Game {
this.audio.play('bgm/engine');
this.guide.state('battle'); // 状态签名:对局(与状态 BGM 同点)
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
- this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
- this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
+ // 单击/双击引入教学仅首局(标记在实例上,重开不再调度,第 2 局起机制开局即用)
+ if (!this.boostIntroDone) this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
+ if (!this.shieldIntroDone) this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
}
@@ -309,11 +380,15 @@ export class SpaceGame extends Game {
this._trimSwipes(now);
break;
case 'down':
- // 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单
+ // 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单。
+ // round2 R2 误暂停修复(live ta=664.99/1670.73):高速段玩家在撞击瞬间
+ // 下滑躲避(陨石已判定出集合)被当成开菜单——加冷静期:最近一次判定
+ // (撞/躲/格挡/吸收)后 MENU_CALM_MS 内的下滑一律按躲避意图忽略,绝不开菜单;
+ // 其余时刻保留旧折中(无下方陨石悬置即可开菜单),主动暂停可达性不变。
if ([...this.obstacles].some((ob) => ob.dir === 'down')) {
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
- } else {
+ } else if (now - this._lastResolveAt > MENU_CALM_MS) {
this._openMenu();
}
break;
@@ -329,23 +404,38 @@ export class SpaceGame extends Game {
}
break;
case 'doubletap':
- // 第 45s 引入后才解锁
+ // 第 45s 引入后才解锁(仅首局引入,第 2 局起开局即用)
if (this.shieldIntroDone && now >= this.shieldReadyAt) {
this.shieldUpUntil = now + SHIELD_DURATION_MS;
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
this.audio.play('sfx/shield');
this._scheduleShieldReady();
+ } else {
+ this._shieldDeny(now); // 不可用必须有回音(round4 R1:panic 双击零反馈是 P1)
}
break;
}
}
- /** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。 */
+ /**
+ * 护盾不可用(未引入/充能中)的即时应答:现成句 feat_shield_done
+ * ("会自动充能,好了我会告诉你"语义正合)+ HEARTBEAT 震,4s 去抖防连刷。
+ * 注:voice_text.json 无"护盾还没好"专用句,复用此句属 round4 R1 裁定。
+ */
+ _shieldDeny(now) {
+ if (now - this._shieldDenyAt < SHIELD_DENY_DEBOUNCE_MS) return;
+ this._shieldDenyAt = now;
+ vibrate('heartbeat');
+ // 警告链在播时排队到链尾,不顶断"最后一命"(round2 R2)
+ this.audio.play('voice/feat_shield_done', this._warnActive() ? { queue: true } : undefined);
+ }
+
+ /** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。语音排队播,不顶 protect_end/归因句。 */
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
this._after(delay, () => {
if (this.phase === 'playing') {
vibrate('pickup');
- this.audio.play('voice/shield_ready');
+ this.audio.play('voice/shield_ready', { queue: true });
}
});
}
@@ -419,6 +509,8 @@ export class SpaceGame extends Game {
if (this.shieldReadyAt > now) {
this._scheduleShieldReady(this.shieldReadyAt - now);
}
+ // "最后一命"警告链被菜单/分批引入旁白顶断时,恢复对局立即补播(round2 R2)
+ if (this._hitLastPending) this._drainHitLast();
}
// ---------------- 保护期 ----------------
@@ -442,7 +534,10 @@ export class SpaceGame extends Game {
if (this.phase !== 'playing') return;
this._spawnObstacle();
this._scheduleSpawn(
- this._inProtection() ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()),
+ // 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗保持保护期刷怪节奏(round4 R1)
+ this._inProtection() || this._bareCount < BARE_GRACE_METEORS
+ ? PROTECT_SPAWN_MS
+ : spawnIntervalMs(this._difficultyNow()),
);
});
}
@@ -450,11 +545,25 @@ export class SpaceGame extends Game {
_spawnObstacle() {
const dir = DIRS[Math.floor(Math.random() * DIRS.length)];
const now = performance.now();
- const impactAt = now + (this._inProtection() ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
- const ob = { dir, impactAt };
+ const inProt = this._inProtection();
+ // 断崖过渡:保护期后前 BARE_GRACE_METEORS 颗逼近时长固定 4.0s 不收缩(round4 R1)
+ const grace = !inProt && this._bareCount < BARE_GRACE_METEORS;
+ const impactAt = now + (inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
+ const ob = { dir, impactAt, spawnAt: now };
this.obstacles.add(ob);
+ if (!inProt) this._bareCount++;
this.audio.play(APPROACH_SFX[dir]);
+ // dir_ 超短方向呼喊与呼啸同窗直出(round4 R1 改口,原 G7 挂在撞击前 500ms
+ // 的 loud 警报档,"听语音再滑"永远来不及;提前到 spawn 后语音指引 = 反应窗口起点)。
+ // 撞击点落在保护期内的不播:protect_ 长句教学在播时呼喊会把它顶断。
+ // onComplete 挂 hit_last 接力:呼喊顶断归因句时,呼喊播完自动接播"最后一命"。
+ // round2 R2:hit_last 在播期间(_hitLastPlaying)**不呼喊**——警告播完前不再
+ // 被下一颗的呼喊顶断(live B5:高速段警告刚起播就被顶没);归因在播期间呼喊
+ // 保留生存优先级(顶断 → 接力,低延迟警告路径不变)。
+ if (impactAt > this.protectionUntil && !this._hitLastPlaying) {
+ this.audio.play(`voice/dir_${dir}`, { onComplete: () => this._drainHitLast() });
+ }
// 警报两档渐强,DANGER 震动同步叠加。
// F6(round2):警报音效同 600ms 窗口去重(双陨石同时逼近只起一波警报,
// 按 soft/loud 各自去重,单颗的两段渐强不受影响);danger 震 ≥400ms 去抖。
@@ -462,15 +571,7 @@ export class SpaceGame extends Game {
if (this.obstacles.has(ob)) this._alarm(0.5);
});
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
- if (this.obstacles.has(ob)) {
- this._alarm(1);
- // G7(round3):loud 档(撞击前 500ms)同窗超短方向呼喊 dir_——与
- // 警报分属 voice/sfx 两通道,天然同时不互顶(引擎 duck 压 BGM 保证可闻),
- // 噪音环境下方位不再只装在呼啸单通道(P3 round2 P0)。游戏反馈层全档位
- // (gd_economy 先例);直达式播放不排队——呼喊必须赶在撞击前,宁可顶掉
- // 里程/充能类短句,也不排到链尾迟到。
- this.audio.play(`voice/dir_${ob.dir}`);
- }
+ if (this.obstacles.has(ob)) this._alarm(1);
});
this._after(impactAt - now, () => this._resolve(ob));
}
@@ -493,15 +594,20 @@ export class SpaceGame extends Game {
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
this.obstacles.delete(ob);
const now = performance.now();
+ this._lastResolveAt = now; // 下滑开菜单冷静期锚点(任何结局都算,round2 R2)
// 1) 加速增益期间:冲过空隙
if (now <= this.boostUntil + DODGE_GRACE_MS) {
this._onDodge();
return;
}
- // 2) 同向滑动拨开:撞击前 DODGE_WINDOW 内有过同方向滑动
+ // 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效——
+ // 窗口覆盖呼啸全程(round4 R1:旧版只开撞击前 1.5s,呼啸起点 2.8~4s,
+ // 早滑被静默吞掉);spawn 前的"预言家"滑动不算(spawnAt 下界)。
const hit = this.swipes.find((s) =>
- s.dir === ob.dir && now - s.t <= DODGE_WINDOW_MS && s.t - ob.impactAt <= DODGE_GRACE_MS);
+ s.dir === ob.dir
+ && s.t >= (ob.spawnAt || 0) - DODGE_GRACE_MS
+ && s.t - ob.impactAt <= DODGE_GRACE_MS);
if (hit) {
this.swipes.splice(this.swipes.indexOf(hit), 1); // 一次滑动只躲一颗
this._onDodge();
@@ -529,30 +635,59 @@ export class SpaceGame extends Game {
if (this.hits >= HULL_MAX) {
this._gameOver();
} else {
- const ids = [`voice/hit_${ob.dir}`];
- if (this.hits === 2) ids.push('voice/hit_last');
- // 向导层(round2 F1.4/F2.2):受击提示挂在归因链播完后的空闲点——
- // idle-only 语义下若插在链头会被判为「对白在播」整段跳过。
- // 第 1 次受击补指令式 gd_hit_hint(hit_ 已归因方位,这句补"该怎么拨",
- // onceKey 同局一次);第 2 次受击播 gd_last_life(F2.2:生存经济透明,
- // 与游戏层 hit_last「船体只剩最后一次」互补"撑住"的口径),已震 hit 免重复。
- this._speak(ids, () => {
- if (this.hits === 1) {
- this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
- } else if (this.hits === 2) {
- this.guide.error({ voice: 'gd_last_life', skipHaptic: true });
- }
+ // hit_last 接力机制(round4 R1:旧版 _speak 链被 dir_ 呼喊顶断时
+ // onComplete 不触发、hit_last 静默丢失——"最后一命"警告从没被听到;queue:true
+ // 也靠不住——_playVoice 异步设令牌,同步连调时 queue 检查落空会变同播)。
+ // 正常路径:归因自然播完/被跳过 → onComplete → _drainHitLast 接播;
+ // 顶断路径:归因被呼喊顶掉 → 呼喊播完的 onComplete(_spawnObstacle 挂的)
+ // → _drainHitLast 接播。重开/回大厅由 _resetRunState 清挂起。
+ if (this.hits === 2) this._hitLastPending = true;
+ this.audio.play(`voice/hit_${ob.dir}`, {
+ onComplete: () => {
+ if (this.hits === 1) {
+ // 第 1 次受击补指令式 gd_hit_hint(hit_ 已归因方位,这句补
+ // "该怎么拨",onceKey 同局一次)
+ this.guide.error({ voice: 'gd_hit_hint', onceKey: 'space_hit_hint', skipHaptic: true });
+ }
+ this._drainHitLast();
+ },
});
}
}
+ /**
+ * "最后一命"接播:hits===2 时挂起,归因播完或顶断它的呼喊播完两条路径汇到这里。
+ * 播完补 gd_last_life(F2.2:生存经济透明,与 hit_last「船体只剩最后一次」互补
+ * "撑住"口径),已震 hit 免重复;被打断跳过可接受(向导层 idle-only 语义不变)。
+ * round2 R2:接播期间置 _hitLastPlaying(此期间 spawn 呼喊抑制,警告不再被
+ * 顶断);已离开对局(菜单/坠毁/收尾)不再接播。
+ */
+ _drainHitLast() {
+ if (!this._hitLastPending) return;
+ this._hitLastPending = false;
+ if (this.phase !== 'playing') return;
+ this._hitLastPlaying = true;
+ this.audio.play('voice/hit_last', {
+ onComplete: () => {
+ this._hitLastPlaying = false;
+ this.guide.error({ voice: 'gd_last_life', skipHaptic: true });
+ },
+ });
+ }
+
+ /** "最后一命"警告链进行中(归因在播待接播 / hit_last 在播)——此时旁白排队不顶链。 */
+ _warnActive() {
+ return this._hitLastPending || this._hitLastPlaying;
+ }
+
_onDodge() {
this.dodges += 1;
this.audio.play('sfx/dodge');
const ms = MILESTONES[this.dodges];
if (ms) {
vibrate('confirm');
- this.audio.play(ms);
+ // 警告链在播时里程碑排队到链尾,不顶断"最后一命"(round2 R2)
+ this.audio.play(ms, this._warnActive() ? { queue: true } : undefined);
}
}
@@ -591,13 +726,29 @@ export class SpaceGame extends Game {
this.audio.setRateBoostEnabled(false); // 回到对局期
this.guide.state('battle');
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
+ // round2 R2 恢复缓冲(live B2:恢复后全速弹幕 3 秒暴毙):恢复后前
+ // BARE_GRACE_METEORS 颗裸飞陨石回 4.0s 慢节奏,复用保护期断崖过渡机制。
+ this._bareCount = 0;
this._scheduleSpawn(800);
this._rearmRunTimers();
}
+ /**
+ * 回大厅(rhythm round1 同款收尾):先进 leaving 收尾态停刷怪/BGM,播回大厅
+ * 确认句,播完(或 2s 看门狗兜底)再跳转——round2 R2(live B3):旧版无确认
+ * 直接跳,跳转失败时玩家落进无声死界面,纯听觉玩家被关黑屋。
+ */
_goHome() {
- this.stop();
- window.location.href = '../';
+ if (this._goingHome) return;
+ this._goingHome = true;
+ this.phase = 'leaving'; // 收尾态:不再受理对局/菜单/结算手势(单击仍可跳确认句)
+ this._clearTimers(); // 停刷怪与进行中陨石调度(保留 voice 通道播确认句)
+ this.obstacles.clear();
+ this.audio.stopBgm(300);
+ this._speakGuarded(['voice/menu_lobby'], () => {
+ this.stop();
+ window.location.href = '../';
+ }, 2000);
}
// ---------------- 坠毁结算 ----------------
@@ -609,6 +760,11 @@ export class SpaceGame extends Game {
this._clearTimers();
this.obstacles.clear();
this.audio.stopBgm(600);
+ // round2 R2(live B4):坠毁即掐断"最后一命"警告链(归因/hit_last/gd_last_life
+ // 可能在播)——旧版 hit_last 与 1.2s 后的结算 time_* 叠播互相掐,谁都没听清。
+ this._hitLastPending = false;
+ this._hitLastPlaying = false;
+ this.audio.stopVoice();
vibrate('error');
this._after(300, () => vibrate('hit'));
this.audio.play('sfx/crash');
@@ -699,6 +855,27 @@ export class SpaceGame extends Game {
return this.audio.isVoicePlaying(); // 公开 API(原读私有 _voice,清点 §0)
}
+ /**
+ * voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载
+ * 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条 SoundTouch 管线
+ * 并播(台词交叠),先落位的令牌成永久孤儿(ScriptProcessor 节点不断开、
+ * 探针/监听层被交替抽读污染)。本层所有 voice play 走此门串行:等上一次 play
+ * 的令牌落位再发下一次,恢复引擎"后播杀先播"语义。
+ * 等待是纯 microtask 的(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能
+ * 并发的语音全部 prewarm 缓存热(_load 命中已解码 Promise,落位仅数个
+ * microtask);资产缺失等异常路径 64 轮后放行,门绝不卡死。
+ * 会话令牌失效(重开/回大厅)后未发的门内播放直接作废。
+ */
+ _voicePlay(id, opts = {}) {
+ const s = this._session;
+ this._voiceGate = this._voiceGate.then(async () => {
+ if (s !== this._session) return;
+ this._origPlay(id, opts);
+ for (let i = 0; i < 64 && !this.audio.isVoicePlaying(); i++) await Promise.resolve();
+ });
+ return this._voiceGate;
+ }
+
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
_speak(ids, onDone) {
if (!ids.length) { if (onDone) onDone(); return; }
@@ -743,8 +920,8 @@ export class SpaceGame extends Game {
this._timers = [];
}
- /** 清掉过期的滑动记录(超过任何可能的判定窗口)。 */
+ /** 清掉过期的滑动记录(超过任何可能的判定窗口 = 最长逼近时长 + 宽限)。 */
_trimSwipes(now) {
- this.swipes = this.swipes.filter((s) => now - s.t <= DODGE_WINDOW_MS + 500);
+ this.swipes = this.swipes.filter((s) => now - s.t <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500);
}
}
diff --git a/web/space/test/playtest.mjs b/web/space/test/playtest.mjs
index cfe5bcba..3e7576bf 100644
--- a/web/space/test/playtest.mjs
+++ b/web/space/test/playtest.mjs
@@ -1,6 +1,9 @@
/**
* 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
- * + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)。
+ * + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
+ * §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
+ * 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
+ * (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
*
* 用法:tools/node/bin/node web/space/test/playtest.mjs
*
@@ -161,13 +164,16 @@ async function advance(ms) { clock.advance(ms); await tick(); }
function makeHarness() {
const audio = new WebAudioEngine('audio/');
- const plays = [], skips = [];
+ const plays = [], skips = [], voiceStarts = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
+ // _playVoice 记录 voice 实际起播(queue 排队件由引擎内部接力,不经 play() 调用日志)
+ const origPlayVoice = audio._playVoice.bind(audio);
+ audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPlayVoice(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
const game = new SpaceGame(audio, {});
- return { audio, game, plays, skips };
+ return { audio, game, plays, skips, voiceStarts };
}
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const lastVoice = (plays) => voices(plays).at(-1);
@@ -257,6 +263,8 @@ try {
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
assert(lastVoice(plays) === 'voice/protect_left', 'protect_ 与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
+ assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
+ '撞击点落在保护期内的陨石 spawn 不播 dir_(§11:protect 长句教学优先)');
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
await advance(t0 + 20000 - clock.now);
@@ -284,7 +292,7 @@ try {
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
- assert(game.shieldReadyAt === clock.now + 30000, '护盾充能 30s 锁定');
+ assert(game.shieldReadyAt === clock.now + 15000, '护盾充能 15s 锁定(§11 改口,原 30s)');
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
// 护盾格挡一颗(消耗护盾,不掉血)
@@ -298,6 +306,17 @@ try {
await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音
assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出');
+ // -- §11 护盾充能中双击:即时应答 + 4s 去抖(P1 panic 双击零反馈) --
+ const featDoneCount = () => voices(plays).filter((id) => id === 'voice/feat_shield_done').length;
+ const featDoneBefore = featDoneCount();
+ game.onGesture('doubletap'); await tick();
+ assert(game.shieldUpUntil === 0, '充能中双击不展开护盾');
+ assert(featDoneCount() === featDoneBefore + 1,
+ '护盾不可用即时应答:播 feat_shield_done 现成句(§11,不新增语音 id)');
+ assert(vibrated('40,100,40'), '护盾不可用 HEARTBEAT 震动(与撞击/警报可区分)');
+ game.onGesture('doubletap'); await tick();
+ assert(featDoneCount() === featDoneBefore + 1, '护盾不可用应答 4s 去抖(连刷不重播)');
+
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
const naturalSpawn = game._spawnObstacle.bind(game);
game._spawnObstacle = () => {};
@@ -312,21 +331,46 @@ try {
'shield_ready 充能完毕播报恰好一次');
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
- // -- §5.4 正常闪避 3 颗(同向滑,须在撞击前 1.5s 判定窗口内出手) --
+ // -- §5.4/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
+ // 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
let expectDodges = game.dodges;
+ let bareIdx = 0;
for (const rv of [0, 0.25, 0.5]) { // left / right / up
+ bareIdx++;
randQueue.push(rv);
game.obstacles.clear(); game.swipes = [];
+ const tSpawn = clock.now;
naturalSpawn();
const ob = [...game.obstacles].at(-1);
+ await tick(); // voice 播放安全门:播放经 microtask 落位(虚拟时钟不走,t 仍 = tSpawn)
+ assert(ob.impactAt - tSpawn === 4000,
+ `断崖过渡:裸飞第 ${bareIdx} 颗逼近 4.0s 不收缩(§11)`);
+ assert(plays.some((p) => p.id === `voice/dir_${ob.dir}` && p.t === tSpawn),
+ `方向呼喊 dir_${ob.dir} 与呼啸同窗(spawn 当刻,§11)`);
await advance(ob.impactAt - clock.now - 800); // 逼近到撞击前 800ms
game.onGesture(ob.dir); // 判定窗口内同向滑
await advance(900); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges, `同向滑闪避成功(${ob.dir},dodges=${expectDodges})`);
assert(game.hits === 0, '同向闪避不掉血');
+ assert(plays.filter((p) => p.id === `voice/dir_${ob.dir}` && p.t >= tSpawn).length === 1,
+ '撞击前 500ms loud 警报档不再重复方向呼喊(§11)');
}
+ // -- §11 早滑不再被吞:呼啸响起立刻滑(距撞击 >1.5s)也判躲开(P1 核心回归) --
+ randQueue.push(0.75); // down
+ game.obstacles.clear(); game.swipes = [];
+ const tEarly = clock.now;
+ naturalSpawn();
+ const obEarly = [...game.obstacles].at(-1);
+ assert(obEarly.impactAt - tEarly < 4000, '断崖过渡:裸飞第 4 颗起接入难度曲线(<4.0s,§11)');
+ await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧版 1.5s 窗口之外,必被吞的位置)
+ game.onGesture('down');
+ await advance(obEarly.impactAt - clock.now + 100); // 过撞击点
+ expectDodges++;
+ assert(game.dodges === expectDodges && game.hits === 0,
+ '早滑(spawn+0.2s,距撞击 >1.5s)判定躲开(§11 窗口=呼啸全程)');
+
// -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) --
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
@@ -343,18 +387,42 @@ try {
game.onGesture('tap'); await tick();
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
- // -- 边界:第 2 次被撞追加 hit_last --
+ // -- 边界/§11+round2 R2:第 2 次被撞追加 hit_last;呼喊接力保留 + 接播期间呼喊抑制 --
randQueue.push(0.25); // right
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH2 = [...game.obstacles].at(-1);
await advance(obH2.impactAt - clock.now + 50);
assert(game.hits === 2 && game.phase === 'playing', 'hits===2 不坠毁');
- await advance(1300); // hit_right 播完
- await advance(1300); // hit_last 播出并播完
- const vi = voices(plays);
- const h2i = vi.lastIndexOf('voice/hit_right');
- assert(h2i >= 0 && vi[h2i + 1] === 'voice/hit_last', '第 2 次被撞追加 hit_last');
+ assert(h.voiceStarts.at(-1).id === 'voice/hit_right', '归因 hit_right 开播');
+ assert(game._hitLastPending === true, 'hit_last 挂起待接播(§11 呼喊接力)');
+ randQueue.push(0); // left:刷下一颗裸飞陨石,其 spawn 呼喊顶断归因句(生存优先级保留)
+ naturalSpawn();
+ const obNext = [...game.obstacles].at(-1);
+ await tick();
+ assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
+ await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
+ assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
+ '归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
+ assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
+ assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
+ // round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
+ const shoutMark2 = h.voiceStarts.length;
+ randQueue.push(0.5); // up
+ naturalSpawn();
+ const obNext2 = [...game.obstacles].at(-1);
+ await tick();
+ assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
+ 'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
+ await advance(1300); // hit_last 播完 → 呼喊抑制解除
+ assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
+ randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
+ naturalSpawn();
+ await tick();
+ assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
+ game.obstacles.delete(obNext);
+ game.obstacles.delete(obNext2);
+ game.obstacles.clear(); game.swipes = [];
// -- 边界:里程碑 dodges=10 播 ms_10 --
game.dodges = 9; // 13 号文档允许的测试钩子
@@ -370,14 +438,19 @@ try {
assert(vibrated('20'), '里程碑 CONFIRM 震动');
await advance(1300);
- // -- §5.4 第 3 次撞击坠毁 --
+ // -- §5.4 第 3 次撞击坠毁(round2 R2 B4:警告链在播时坠毁即掐断,结算不叠播) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH3 = [...game.obstacles].at(-1);
const crashAt = obH3.impactAt;
- await advance(obH3.impactAt - clock.now + 50);
+ await advance(obH3.impactAt - clock.now - 500); // 撞击前 500ms:重挂"最后一命"警告链
+ game._hitLastPending = true; // 测试钩子:模拟第 2 撞后归因刚播完、警告待接播
+ game._drainHitLast(); await tick();
+ assert(h.voiceStarts.at(-1).id === 'voice/hit_last' && !!audio._voice, '坠毁前 hit_last 在播');
+ await advance(600); // 过撞击点 → hits===3 → 坠毁
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
+ assert(!audio._voice, '坠毁即掐断警告链:hit_last 不再与结算叠播(round2 R2 B4)');
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
@@ -410,12 +483,26 @@ try {
assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局');
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine');
+ // -- §11 feat 教学仅首局:第 2 局过 20s/45s 不进 feature、不重播,机制开局即用 --
+ const featMark = plays.length;
+ await advance(20050); // 过单击引入时刻(保护期内陨石不伤血)
+ assert(game.phase === 'playing', '第 2 局过 20s 不进 feature phase(§11)');
+ game.onGesture('tap'); await tick(); // 无旁白在播 → 加速直接生效
+ assert(game.boostUntil > clock.now, '第 2 局单击加速开局即用(§11)');
+ game.onGesture('doubletap'); await tick();
+ assert(game.shieldUpUntil > clock.now, '第 2 局双击护盾开局即用(§11)');
+ await advance(25050); // 过双击引入时刻
+ assert(game.phase === 'playing', '第 2 局过 45s 仍不进 feature phase(§11)');
+ assert(!plays.slice(featMark).some((p) => p.id === 'voice/feat_boost' || p.id === 'voice/feat_shield'),
+ '第 2 局 feat_boost/feat_shield 不再重播(§11)');
+
// ======================================================================
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
// ======================================================================
- game.obstacles.clear();
+ game.obstacles.clear(); game.swipes = [];
+ game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期(round2 R2)
game.onGesture('down'); await tick();
- assert(game.phase === 'menu', '无下方陨石时下滑 = 菜单(下滑折中)');
+ assert(game.phase === 'menu', '无悬置陨石且过冷静期时下滑 = 菜单(下滑折中)');
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300),
'进菜单 crossfade 300ms 切 bgm/menu');
await tick();
@@ -435,6 +522,39 @@ try {
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine');
+ // -- round2 R2 恢复缓冲:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏(软重启,不再 3 秒暴毙) --
+ game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
+ game._protectAnnounced = true; // 不再补播 protect_end
+ for (let i = 1; i <= 3; i++) {
+ randQueue.push(0); // left
+ game.obstacles.clear(); game.swipes = [];
+ const tR = clock.now;
+ naturalSpawn();
+ const obR = [...game.obstacles].at(-1);
+ assert(obR.impactAt - tR === 4000,
+ `恢复缓冲:暂停恢复后第 ${i} 颗裸飞陨石逼近 4.0s 不收缩(round2 R2)`);
+ }
+ randQueue.push(0);
+ game.obstacles.clear(); game.swipes = [];
+ const tR4 = clock.now;
+ naturalSpawn();
+ const obR4 = [...game.obstacles].at(-1);
+ assert(obR4.impactAt - tR4 < 4000, '恢复缓冲:第 4 颗起回到难度曲线(round2 R2)');
+ game.obstacles.clear(); game.swipes = [];
+
+ // -- round2 R2 误暂停守卫:判定后冷静期内的下滑绝不开菜单;过冷静期保留旧折中 --
+ game._lastResolveAt = clock.now; // 测试钩子:模拟"刚判定完(撞/躲)"
+ game.onGesture('down'); await tick();
+ assert(game.phase === 'playing', '判定后 800ms 冷静期内下滑不开菜单(round2 R2 误暂停修复)');
+ game._lastResolveAt = -Infinity;
+ randQueue.push(0); // left:非下方陨石悬置中(过冷静期保留旧折中,仍可开菜单)
+ naturalSpawn();
+ await tick();
+ game.onGesture('down'); await tick();
+ assert(game.phase === 'menu', '非下方陨石悬置且过冷静期下滑 = 菜单(旧折中保留,round2 R2)');
+ game._closeMenu(); await tick(); // 恢复对局继续后续用例
+ game.obstacles.clear(); game.swipes = [];
+
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
@@ -447,13 +567,22 @@ try {
await advance(900);
assert(game.dodges === 1, '下滑闪避生效 dodges=1');
- // 菜单"回大厅"
- game.obstacles.clear();
+ // 菜单"回大厅"(round2 R2 收尾流程:leaving 态 + 确认句 → 跳转,不再落无声死界面)
+ game.obstacles.clear(); game.swipes = [];
+ game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期
game.onGesture('down'); await tick();
+ assert(game.phase === 'menu', '过冷静期后下滑再开菜单(回大厅前置)');
game.onGesture('tap'); await tick(); // → menu_lobby
game.onGesture('doubletap'); await tick();
- assert(window.location.href === '../', '菜单双击确认 menu_lobby = 回大厅');
+ assert(game.phase === 'leaving', '回大厅确认中:leaving 收尾态(round2 R2)');
+ assert(lastVoice(plays) === 'voice/menu_lobby', '先播回大厅确认句再跳转(round2 R2)');
+ assert(window.location.href !== '../', '确认句播完前不跳转(round2 R2)');
+ game.onGesture('left'); await tick(); // leaving 中滑动不再受理
+ assert(game.phase === 'leaving', 'leaving 中滑动手势被忽略(round2 R2)');
+ game.onGesture('tap'); await tick(); // leaving 中单击 = 跳过确认句 → 立即跳转
+ assert(window.location.href === '../', '确认句跳过后跳转大厅(round2 R2)');
window.location.href = '';
+ game.stop();
// ======================================================================
section('S3 二周目:ask 流程 + skip_hint 不重复插播');
@@ -557,6 +686,7 @@ try {
for (const d of ['left', 'right', 'up', 'down']) {
ids.add(`voice/protect_${d}`);
ids.add(`voice/hit_${d}`);
+ ids.add(`voice/dir_${d}`);
}
const missing = [];
for (const id of ids) {
diff --git a/web/storyline/game.js b/web/storyline/game.js
index 88bfe91d..81b1e1b0 100644
--- a/web/storyline/game.js
+++ b/web/storyline/game.js
@@ -5,7 +5,8 @@
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
* 结局后 = 重新开始
- * - doubletap :重听当前节点的全部台词
+ * - doubletap :重听当前节点的全部台词(含结局节点——重播期间单击=跳句,
+ * 播完回到结算态;live round1 P2-1)
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
@@ -15,8 +16,8 @@
*
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
- * 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死——
- * 13 号文档 §1 边界断言)。
+ * 单句看门狗(VOICE_WATCHDOG_MS 超时且引擎确认 voice 未在播 = 资产缺失,
+ * 才自动续链不卡死;真实在播的长语音续约不掐——13 号文档 §1 + §16 N1)。
*
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
@@ -76,17 +77,37 @@ const ALL_SFX = [
const MODE = { STORY: 'STORY', MENU: 'MENU' };
-/** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */
+/** 单句语音看门狗:超时未 onComplete 且引擎确认 voice 未在播才视为资产缺失,
+ * 自动推进链(不卡死);voice 真在播(长句)则续约等待,不得掐断(N1)。 */
const VOICE_WATCHDOG_MS = 30000;
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
const PROMPT_RE = /^voice\/p\d/;
-// 向导层(14 号文档 + round2 synthesis F1.1):剧情类等待默认低频——25s 轻提醒
-// +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
-// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)
+// 向导层(14 号文档 + round2 synthesis F1.1):full 档 25s 轻提醒+gd_continue,
+// 50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
+// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)。
+// full 档节奏由 web/harness/guide.test.mjs 锁定,不可改。
const GUIDE_WATCHDOG_AT = [25000, 50000];
+// live round1 P2-3(静默无兜底):standard 档 guide 看门狗只发 nudge earcon、
+// 无语音(14 号文档 #2),玩家等不到可听提示(老陈开局 45s 空白)。复用同一
+// 看门狗(计时/复位/升级/取消都在向导层,不另造定时器):standard 档提前到
+// 13s/30s,由 onEscalate 补播同 id 提示句(游戏反馈层,gd_economy 全档位先例);
+// off 档看门狗整体不挂(既定全静契约 + 6 playtest 零干扰红线)。
+const IDLE_HINT_AT = [13000, 30000];
+
+// live round2 N3(抉择节点无静默兜底):p6/p8 实测 35~39s 无操作零提醒——
+// 抉择点恰恰最容易漏听选项、最需要复述。14 号文档原裁定「抉择态(p* 播完)
+// 不插语音不挂看门狗」的本义是"提示播完立即可选、不插语音延迟抉择"(W2
+// 契约,保留:播完当刻 choiceArmed 即真、零插播);本兜底与之不冲突——只在
+// 播完 25s 完全无操作后重播当前 p* 提示句本身(复用既有资产,不新增语音
+// id),50s 再复述一次后彻底静默(与继续节点同一两级上限礼仪);玩家任何
+// 手势经 guide.disarm 复位。新裁定依据 = round2 玩家复测证据(13 号文档 §16)。
+// full/standard 档提示句都由游戏层播(p* 是剧情内容不是向导句,standard 档
+// 玩家同样必须能复述选项);off 档整体不挂(既定全静契约)。
+const CHOICE_HINT_AT = [25000, 50000];
+
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
@@ -106,6 +127,7 @@ export class StorylineGame extends Game {
this.companion = false; // 带上了小满
this.knowTrap = false; // 装死时听到北区陷阱的情报
this.betrayed = false; // 向猎狗交出过坐标
+ this.viaCulvert = false; // n15 左滑走了涵洞(分支经历标记,保留给 TTS 分支句启用)
}
// ---------------------------------------------------------------- Game
@@ -137,6 +159,8 @@ export class StorylineGame extends Game {
// 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 p* 不可跳
if (!this.promptPlaying) this.audio.skipVoice();
} else if (node.next != null) this.playNode(this.resolve(node.next));
+ // 抉择等待态空点 = 无进展手势:重新挂载兜底(25s 计时重置,N3「操作即复位」)
+ else if (node.prompt != null && this.choiceArmed) this.armChoiceHint(node);
break;
case 'doubletap':
this.replayNode();
@@ -197,7 +221,7 @@ export class StorylineGame extends Game {
this.pendingChoice = null;
this.awaitingRestart = false;
if (node.sfx) this.audio.play(node.sfx);
- const seq = [...node.voices];
+ const seq = this.nodeVoices(id);
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
@@ -214,7 +238,9 @@ export class StorylineGame extends Game {
}
replayNode() {
- if (this.awaitingRestart) return;
+ // 结局节点同样兑现"双击:重听刚才的台词"(live round1 P2-1:结局播完双击
+ // 只有震动不重播)。重播期间清结算态——单击回到"跳过本句"而非重开;
+ // 播完由 onNodeSeqDone 重新进入结算态。
this.seqToken++;
this.audio.stopVoice();
// 重播当前节点(不重复进入音效之外的状态重置)
@@ -222,12 +248,28 @@ export class StorylineGame extends Game {
if (!node) return;
this.choiceArmed = false;
this.pendingChoice = null;
- const seq = [...node.voices];
+ this.awaitingRestart = false;
+ const seq = this.nodeVoices(this.nodeId);
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.playSeq(seq, () => this.onNodeSeqDone(node));
}
+ /**
+ * 节点基础语音链。
+ * known-issue(live round2 N2,13 号文档 §16):round1 修 P2-2「涵洞也拦不住
+ * 你」穿帮时按 !viaCulvert 整段滤掉 n18_dog,结果钢索路径猎狗对峙戏蒸发、
+ * p6 抉择(交出坐标还是拒绝)失去对手戏上下文——玩家裁定这比穿帮更糟。
+ * 现恢复涵洞/钢索两条路径都播 n18_dog:钢索路径接受该句的轻微穿帮,待
+ * TTS 补录分支句后按 viaCulvert 选播(voice_text.json 无替代资产,台词
+ * 文本不可改、不新增语音 id)。
+ */
+ nodeVoices(id) {
+ const node = NODES[id];
+ if (!node) return [];
+ return [...node.voices];
+ }
+
/** playNode / replayNode 的语音链自然播完后的公共收尾。 */
onNodeSeqDone(node) {
this.voicePlaying = false;
@@ -237,24 +279,53 @@ export class StorylineGame extends Game {
const g = this.pendingChoice;
this.pendingChoice = null;
this.choose(g);
+ } else {
+ // 抉择提示播完即可选(W2 契约:播完当刻 choiceArmed 即真、零插播)。
+ // 在此之上挂静默兜底:25s 无操作重播 p* 提示(N3,见 CHOICE_HINT_AT 注释)
+ this.armChoiceHint(node);
}
- // 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
} else if (node.ending) {
this.awaitingRestart = true;
}
- // W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue;
- // 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽
- // 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。
+ // W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):full 档 25s nudge+
+ // gd_continue、50s 升级 gd_again——**不重播 help+进度+当前节点**,两级用尽
+ // 彻底静默。live round1 P2-3:standard 档 guide 只发 earcon 无语音,同一
+ // 看门狗换用 IDLE_HINT_AT(13s/30s)并由 onEscalate 补播同 id 提示句
+ // (游戏反馈层,off 档整体不挂)。玩家手势后由 guide.disarm 复位,下一
+ // 节点播完重新挂载。语音播放中不挂;抉择态改挂 armChoiceHint(N3 兜底)。
if (node.prompt == null) {
this.guide.watchdog({
arm: true,
- at: GUIDE_WATCHDOG_AT,
+ at: this.guide.level() === 'full' ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
say: ['gd_continue', 'gd_again'],
+ onEscalate: (n) => {
+ // full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播
+ if (this.guide.level() === 'full') return;
+ this.audio.play(`voice/${n === 1 ? 'gd_continue' : 'gd_again'}`, { queue: true });
+ },
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
});
}
}
+ /**
+ * N3 抉择节点静默兜底:25s/50s 无操作重播当前 p* 提示句(复用既有资产),
+ * 两级用尽彻底静默;玩家任何手势经 guide.disarm 复位(抉择态空点由
+ * onStoryGesture 重新挂载 = 计时重置;已缓存 pendingChoice 直接推进不挂)。
+ * guide=off 时 guide.watchdog 整体 no-op(不挂定时器,全静契约)。
+ */
+ armChoiceHint(node) {
+ this.guide.watchdog({
+ arm: true,
+ at: CHOICE_HINT_AT,
+ onEscalate: () => {
+ if (node.prompt) this.audio.play(node.prompt, { queue: true });
+ },
+ // 已抉择(choiceArmed=false)/离开剧情态/新链在播 → 到点静默取消
+ cond: () => this.mode === MODE.STORY && this.choiceArmed && !this.voicePlaying,
+ });
+ }
+
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
playSeq(ids, onDone) {
const token = ++this.seqToken;
@@ -270,13 +341,22 @@ export class StorylineGame extends Game {
this.currentVoiceId = ids[i];
this.promptPlaying = PROMPT_RE.test(ids[i]);
let advanced = false;
- // 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)
- const wd = setTimeout(() => {
+ // 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)。
+ // live round2 N1:固定 30s 阈值会把真实在播的长语音误判为卡死——冷启动
+ // n00_intro(71.7s,2.2x 也要 32.6s)在 30s 处被续链的下一句 skip_hint
+ // 顶掉,末句「现在,单击屏幕,睁开眼睛」被吃(菜单重开时链上无下一句
+ // 可顶,故只在冷启动咬人);常速下 e1/e2/e4(48~51s)同样越线。判定
+ // 对齐真实播放状态:引擎 isVoicePlaying() 为真 = 资产在播(自然播完
+ // 必触发 onComplete),续约再等一轮,不得掐;为假才是资产缺失(引擎
+ // 静默跳过、永不回调),照旧告警续链(13 号文档 §1 不卡死断言不变)。
+ const armWd = () => setTimeout(() => {
if (advanced || token !== this.seqToken) return;
+ if (this.audio.isVoicePlaying()) { wd = armWd(); return; }
advanced = true;
- console.warn('[StorylineGame] 语音看门狗超时,自动续链:', ids[i]);
+ console.warn('[StorylineGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
step(i + 1);
}, VOICE_WATCHDOG_MS);
+ let wd = armWd();
this.audio.play(ids[i], {
onComplete: () => {
if (advanced || token !== this.seqToken) return;
@@ -297,6 +377,7 @@ export class StorylineGame extends Game {
// 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标)
if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true; // 装死听情报
if (this.nodeId === 'n11' && g === 'left') this.companion = true;
+ if (this.nodeId === 'n15') this.viaCulvert = g === 'left'; // 涵洞/钢索(分支经历标记)
if (this.nodeId === 'n18' && g === 'left') this.betrayed = true;
this.playNode(this.resolve(g === 'left' ? node.left : node.right));
}
@@ -313,12 +394,20 @@ export class StorylineGame extends Game {
playHelpAndResume(backToMenu = false) {
this.seqToken++;
this.audio.stopVoice();
+ const node = NODES[this.nodeId];
+ const ending = !!(node && node.ending);
const act = this.actOf(this.nodeId);
- const status = act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
+ // live round1 P3-2:结局后状态播报 = 当前结局语音(句尾自带"单击重新开始"),
+ // 不再报"这是最后一段路了"(status3)。结局句本身已是节点重播,播完直接
+ // 回结算态,不二次重播节点。
+ const status = ending ? node.voices[0]
+ : act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
this.playSeq(['voice/help', status], () => {
this.voicePlaying = false;
if (backToMenu) {
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
+ } else if (ending) {
+ this.onNodeSeqDone(node); // 回结算态(awaitingRestart + 静默兜底重挂)
} else {
this.playNode(this.nodeId); // 重播当前剧情节点
}
@@ -353,6 +442,7 @@ export class StorylineGame extends Game {
this.companion = false;
this.knowTrap = false;
this.betrayed = false;
+ this.viaCulvert = false;
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
diff --git a/web/storyline/test/playtest.mjs b/web/storyline/test/playtest.mjs
index 8f592743..f3864c68 100644
--- a/web/storyline/test/playtest.mjs
+++ b/web/storyline/test/playtest.mjs
@@ -136,6 +136,10 @@ class MockAudioContext {
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
+ // 向导层 earcon 运行时合成所需(guide=standard/full 时 guide.state/nudge 会创建)
+ createOscillator() {
+ return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
+ }
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
@@ -394,6 +398,175 @@ realLog('== 看门狗路径(屏蔽 voice/n05_signal)==');
blockedAssets.clear();
}
+// ================================================================ live round2 N1
+// (docs/design/13-playtest-criteria.md §16:真实在播的长语音不被 30s 看门狗掐断)
+
+realLog('== live round2 N1:长语音看门狗续约不掐(§16)==');
+{
+ settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
+ const { audio, playLog, game } = makeRig();
+ await audio.unlock();
+ // 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待)
+ audio._cache.set('female|voice/n00_intro', Promise.resolve({
+ duration: 40.0, numberOfChannels: 1, sampleRate: 44100,
+ getChannelData: () => new Float32Array(16),
+ }));
+ const warnBefore = consoleWarns.length;
+ const t0 = now;
+ game.start();
+ await flush();
+ assert(game.currentVoiceId === 'voice/n00_intro', 'N1:40s 长 intro 开播');
+ // 越过 30s 看门狗阈值:voice 真在播 → 不掐、不续链、无告警
+ await advance(() => now - t0 >= 30000);
+ assert(game.currentVoiceId === 'voice/n00_intro' && audio.isVoicePlaying()
+ && game.nodeId === 'n00',
+ 'N1:30s 看门狗触发时 voice 真在播 → 不续链(仍停在 intro)');
+ assert(!consoleWarns.slice(warnBefore).some((w) => w.includes('看门狗')),
+ 'N1:在播长语音不触发看门狗告警');
+ // 自然播完才续链下一句(skip_hint 不再顶掉 intro 末句——冷启动末句被吃的根因)
+ await advance(() => game.currentVoiceId === 'voice/skip_hint');
+ assert(now - t0 >= 40000, `N1:intro 自然播完才续链 skip_hint(实际 ${now - t0}ms)`);
+ assert(voicePlays(playLog, 'voice/skip_hint') === 1, 'N1:skip_hint 在 intro 播完后播出');
+ await idle();
+ assert(!game.voicePlaying && game.nodeId === 'n00', 'N1:长链正常走完进入等待态');
+}
+
+// ================================================================ live round1 修复断言
+// (docs/design/13-playtest-criteria.md §10:结局双击重听 / 结局状态播报;
+// §10 分支台词口径经 §16 N2 翻案——钢索路径恢复播 n18_dog)
+
+realLog('== live round1 修复断言(§10+§16:结局重听 / n18 两路径 / 结局状态)==');
+{
+ settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
+ const { audio, playLog, game } = makeRig();
+ await audio.unlock();
+ game.start();
+ await idle(); // n00 链播完
+
+ // 钢索路径推进到 n18:n01→n02(right→n04)→n05→n06→n07(right→n09)→n10(right→n14)→n15
+ await g(game, 'tap'); await idle(); // → n01
+ await g(game, 'tap'); await idle(); // → n02(抉择)
+ await g(game, 'right'); await idle(); // → n04
+ await g(game, 'tap'); await idle(); // → n05
+ await g(game, 'tap'); await idle(); // → n06
+ await g(game, 'tap'); await idle(); // → n07(抉择)
+ await g(game, 'right'); await idle(); // → n09
+ await g(game, 'tap'); await idle(); // → n10(抉择)
+ await g(game, 'right'); await idle(); // → n14
+ await g(game, 'tap'); await idle(); // → n15(抉择)
+ await g(game, 'right'); await idle(); // 钢索 → n17(无涵洞经历)
+ assert(game.nodeId === 'n17' && game.viaCulvert === false,
+ 'n15 右滑 → 钢索 n17(viaCulvert === false)');
+
+ // round2 N2(§10 P2-2 矫枉过正翻案):钢索路径恢复播 n18_dog 对峙戏 + p6
+ await g(game, 'tap'); await idle(); // → n18
+ assert(game.nodeId === 'n18' && voicePlays(playLog, 'voice/n18_dog') === 1
+ && voicePlays(playLog, 'voice/p6') === 1,
+ 'N2:钢索路径 n18 恢复播 n18_dog(对峙戏)+ 抉择提示 p6(known-issue:涵洞句轻微穿帮待 TTS 分支句)');
+
+ // 推进到结局 e2:n19→n20(left→n21)→e2
+ await g(game, 'left'); await idle(); // n18 交坐标 → n19
+ await g(game, 'tap'); await idle(); // → n20(抉择)
+ await g(game, 'left'); await idle(); // → n21
+ await g(game, 'tap'); await idle(); // → e2
+ assert(game.nodeId === 'e2' && game.awaitingRestart === true, '到达结局 e2 进入结算态');
+
+ // P2-1 结局双击重听:结算态双击 → 结局语音重播;重播中单击 = 跳句不重开
+ const e2before = voicePlays(playLog, 'voice/e2_price');
+ await g(game, 'doubletap');
+ assert(game.voicePlaying && game.currentVoiceId === 'voice/e2_price'
+ && voicePlays(playLog, 'voice/e2_price') === e2before + 1,
+ 'P2-1:结局结算态双击 = 重播结局语音');
+ assert(game.awaitingRestart === false, 'P2-1:结局重播期间离开结算态(单击回到跳句语义)');
+ await g(game, 'tap'); // 重播中单击 = 跳过本句
+ assert(game.nodeId === 'e2', 'P2-1:结局重播中单击跳过本句,不触发重开');
+ await idle();
+ assert(game.awaitingRestart === true && !game.voicePlaying, 'P2-1:结局重播链播完回到结算态');
+
+ // P3-2 结局后状态播报:help 后播当前结局语音(不再报 status3),播完回结算态
+ const e2again = voicePlays(playLog, 'voice/e2_price');
+ await g(game, 'up');
+ assert(game.currentVoiceId === 'voice/help', 'P3-2:结局后上滑先播 help');
+ await idle();
+ assert(voicePlays(playLog, 'voice/e2_price') === e2again + 1
+ && voicePlays(playLog, 'voice/status3') === 0,
+ 'P3-2:结局后状态播报 = 当前结局语音(不再报 status3)');
+ assert(game.awaitingRestart === true && !game.voicePlaying,
+ 'P3-2:状态播完直接回结算态(不二次重播节点)');
+}
+
+// ================================================================ 静默兜底(standard 档)
+// (docs/design/13-playtest-criteria.md §10:13s gd_continue / 30s gd_again / 手势重置)
+
+realLog('== 静默兜底(guide=standard,§10 P2-3)==');
+{
+ settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
+ const { audio, playLog, game } = makeRig();
+ await audio.unlock();
+ game.start();
+ await advance(() => !game.voicePlaying); // n00 链播完(精确停链尾,不越过看门狗)
+ assert(game.nodeId === 'n00' && !game.voicePlaying, 'standard:n00 链播完进入等待态');
+
+ const tArm = now;
+ await advance(() => voicePlays(playLog, 'voice/gd_continue') >= 1);
+ assert(now - tArm === 13000, `standard:13s 无操作播 gd_continue(实际 ${now - tArm}ms)`);
+ await advance(() => voicePlays(playLog, 'voice/gd_again') >= 1);
+ assert(now - tArm === 30000, `standard:30s 升级播 gd_again(实际 ${now - tArm}ms)`);
+ await idle(); // 两级用尽彻底静默(无更多定时器)
+
+ // 玩家手势后重置:新节点播完重新挂载,13s 再次提示
+ await g(game, 'tap'); // → n01
+ await advance(() => !game.voicePlaying);
+ assert(game.nodeId === 'n01' && !game.voicePlaying, 'standard:手势后进入 n01 等待态');
+ const gdBefore = voicePlays(playLog, 'voice/gd_continue');
+ const tWait = now;
+ await advance(() => voicePlays(playLog, 'voice/gd_continue') > gdBefore);
+ assert(now - tWait === 13000, `standard:手势重置后新等待点 13s 再提示(实际 ${now - tWait}ms)`);
+ settings.set({ guide: 'off' });
+}
+
+// ================================================================ 抉择节点静默兜底
+// (docs/design/13-playtest-criteria.md §16 N3:25s/50s 重播 p* 提示、手势复位)
+
+realLog('== 抉择节点静默兜底(guide=standard,§16 N3)==');
+{
+ settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
+ const { audio, playLog, game } = makeRig();
+ await audio.unlock();
+ game.start();
+ await advance(() => !game.voicePlaying); // n00 播完
+ await g(game, 'tap'); // → n01
+ await advance(() => !game.voicePlaying);
+ await g(game, 'tap'); // → n02(抉择:n02_linwan + p1)
+ await advance(() => !game.voicePlaying);
+ assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:n02 抉择链播完,等待抉择');
+
+ const p1first = voicePlays(playLog, 'voice/p1');
+ const tArm = now;
+ await advance(() => voicePlays(playLog, 'voice/p1') > p1first);
+ assert(now - tArm === 25000, `N3:抉择播完 25s 无操作重播 p1(实际 ${now - tArm}ms)`);
+ await advance(() => voicePlays(playLog, 'voice/p1') > p1first + 1);
+ assert(now - tArm === 50000, `N3:50s 再复述一次 p1(实际 ${now - tArm}ms)`);
+ await idle(); // 两级用尽彻底静默
+ assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:复述期间剧情不推进、仍待抉择');
+
+ // 空点复位:抉择等待态单击(无进展手势)重置计时,再 25s 复述
+ await g(game, 'tap');
+ const p1again = voicePlays(playLog, 'voice/p1');
+ const tTap = now;
+ await advance(() => voicePlays(playLog, 'voice/p1') > p1again);
+ assert(now - tTap === 25000, `N3:空点后计时重置,25s 再复述(实际 ${now - tTap}ms)`);
+
+ // 玩家抉择后兜底终止:右滑 → n04,此后不再复述 p1
+ await g(game, 'right');
+ await advance(() => !game.voicePlaying);
+ assert(game.nodeId === 'n04', 'N3:右滑抉择 → n04 剧情推进');
+ const p1final = voicePlays(playLog, 'voice/p1');
+ await idle();
+ assert(voicePlays(playLog, 'voice/p1') === p1final, 'N3:抉择后兜底终止(不再复述)');
+ settings.set({ guide: 'off' });
+}
+
// ================================================================ 资产引用校验
realLog('== 资产引用校验 ==');