From 4e9970e2279b44d2c57cdcb70f86960442b59c2b Mon Sep 17 00:00:00 2001 From: xsl Date: Tue, 1 Sep 2026 22:00:00 +0800 Subject: [PATCH] =?UTF-8?q?live=E8=AF=95=E7=8E=A9round1+round2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D:=20=E5=BC=95=E6=93=8Egesture=E6=96=B0=E5=A2=9Etapcanc?= =?UTF-8?q?el=EF=BC=88immediateTap=E8=AF=AF=E5=88=A4=E5=AE=9A=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=E9=9B=B6?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=8F=98=E5=8C=96=EF=BC=89+=20rhythm?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=B4=A6=E7=B0=BF/=E8=B6=85=E6=97=B6Miss?= =?UTF-8?q?=E5=A4=8D=E6=B4=BB/=E8=8F=9C=E5=8D=951s=E5=8E=BB=E6=8A=96/?= =?UTF-8?q?=E6=AD=A3=E7=89=87=E4=B8=8B=E6=BB=91=E6=B0=B8=E4=B8=8D=E5=BC=B9?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=20+=20space=E5=88=A4=E5=AE=9A=E5=86=B7?= =?UTF-8?q?=E9=9D=99=E6=9C=9F800ms/=E6=9A=82=E5=81=9C=E6=81=A2=E5=A4=8D3?= =?UTF-8?q?=E9=A2=97=E7=BC=93=E5=86=B2/=E5=9B=9E=E5=A4=A7=E5=8E=85leaving?= =?UTF-8?q?=E6=94=B6=E5=B0=BE/hit=5Flast=E5=9C=A8=E6=92=AD=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=20+=20storyline=20intro=E6=BB=A1=E6=92=AD=E4=B8=8D?= =?UTF-8?q?=E6=8E=90/=E6=8A=89=E6=8B=A925s=E5=85=9C=E5=BA=95=20+=20simon?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=E9=87=8D=E5=90=AC=E4=B8=A4=E5=B1=82=E8=B4=A6?= =?UTF-8?q?=20+=20escape=E6=95=99=E5=AD=A6=E9=93=BE=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8E=A5=E6=92=AD/90s=E9=AB=98=E9=A2=91=E9=9B=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=20+=20rpg=E6=93=A6=E4=BC=A4=E9=9B=B6=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5/tap=E4=B8=8D=E4=B8=A2=20+=20guide.test=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=8E=BB=E6=8A=96=E6=9C=9F=EF=BC=88=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=EF=BC=89+=20=E6=96=87=E6=A1=A3=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8813=E5=8F=B7=C2=A79-=C2=A719/web-engine-api/RE?= =?UTF-8?q?ADME=EF=BC=89=E3=80=82=E5=9B=9E=E5=BD=92=EF=BC=9Astoryline82/es?= =?UTF-8?q?cape140/simon132/rhythm141/space155/rpg188/guide122=E5=85=B1921?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/00-global-ux.md | 2 +- docs/design/13-playtest-criteria.md | 610 ++++++++++++++++++++++++++++ docs/web-engine-api.md | 25 +- web/engine/audio.js | 11 +- web/engine/gesture.js | 65 ++- web/escape/game.js | 204 +++++++--- web/escape/test/playtest.mjs | 249 ++++++++++-- web/harness/guide.test.mjs | 64 ++- web/rhythm/README.md | 28 +- web/rhythm/game.js | 482 +++++++++++++++++++--- web/rhythm/index.html | 5 +- web/rhythm/test/playtest.mjs | 299 ++++++++++++-- web/rpg/game.js | 199 +++++++-- web/rpg/test/playtest.mjs | 291 ++++++++++++- web/simon/README.md | 3 +- web/simon/game.js | 122 ++++-- web/simon/test/playtest.mjs | 130 +++++- web/space/README.md | 23 +- web/space/game.js | 283 ++++++++++--- web/space/test/playtest.mjs | 166 +++++++- web/storyline/game.js | 128 +++++- web/storyline/test/playtest.mjs | 173 ++++++++ 22 files changed, 3161 insertions(+), 401 deletions(-) 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('== 资产引用校验 ==');