diff --git a/docs/audio/asset-manifest.md b/docs/audio/asset-manifest.md index e89440d3..365f4472 100644 --- a/docs/audio/asset-manifest.md +++ b/docs/audio/asset-manifest.md @@ -37,7 +37,7 @@ ### storyline《声命线》 -- voice 双性别版(male/ 与 female/ 各 36 条):旁白 + 系统音 = `help menu status1 status2 status3 p1–p8 n00_intro n01_wake n04_exit n05_signal n06_whistle n08_vent n09_dead n10_rain n11_child n13_leave n14_ignore n15_bridge n16_culvert n16b_culvert_c n17_cable n17b_cable_c n20_convoy n22_fight n23_tower e1_dawn e2_price e3_alone e4_static` +- voice 双性别版(male/ 与 female/ 各 37 条(R6 +`skip_hint`):旁白 + 系统音 = `help menu status1 status2 status3 p1–p8 n00_intro n01_wake n04_exit n05_signal n06_whistle n08_vent n09_dead n10_rain n11_child n13_leave n14_ignore n15_bridge n16_culvert n16b_culvert_c n17_cable n17b_cable_c n20_convoy n22_fight n23_tower e1_dawn e2_price e3_alone e4_static` - voice 角色台词(**不做双性别**,沿用平铺原文件): | 角色 | speaker | 文件 id | @@ -50,45 +50,52 @@ 约定:角色台词在两个性别目录下使用**同一份文件**(由开发复制/链接或引擎回退到平铺目录,见 round1-report)。 - sfx(12):`footsteps gunshot heartbeat radio_static rubble signal_beep struggle ui_confirm ui_menu water whistle wind` -- bgm(1):`dark_drone` +- bgm(2):`dark_drone`(剧情)+ R6 新增 `menu`(菜单 pad,-24 LUFS 档) ### escape《暗室》(R2 重设计版,最终清单见 `round2-voice-ids.md`) -- voice 双性别版(male/ 与 female/ 各 74 条):引导 `g0_intro g1_move g1_done g2_wall g3_left g4_down g5_inspect g5_key g6_doubletap g6_done g7_goal`、引导纠错 `guide_wrong_* guide_blunt_* guide_offpath guide_skip reopen_prompt`、三级提示 `stuck_ask stuck_{key,cabinet,box,gate}_{1,2,3}`、导航 `nav_{up,down,left,right}`、房间 `room_{storage,cabinet,power,gate}`、用时 `time_band1–4`、保留句 `wall start_room found_key inspect_* unlock_cabinet box_fixed fix_box escape use_fail inv_* replay again empty_1–3`、菜单 `help_ops menu_*` +- voice 双性别版(male/ 与 female/ 各 75 条(R6 +`skip_hint`):引导 `g0_intro g1_move g1_done g2_wall g3_left g4_down g5_inspect g5_key g6_doubletap g6_done g7_goal`、引导纠错 `guide_wrong_* guide_blunt_* guide_offpath guide_skip reopen_prompt`、三级提示 `stuck_ask stuck_{key,cabinet,box,gate}_{1,2,3}`、导航 `nav_{up,down,left,right}`、房间 `room_{storage,cabinet,power,gate}`、用时 `time_band1–4`、保留句 `wall start_room found_key inspect_* unlock_cabinet box_fixed fix_box escape use_fail inv_* replay again empty_1–3`、菜单 `help_ops menu_*` - voice 已删除(R2):`n0–n9 nshi unit_min unit_sec time_prefix intro tutorial hint_key hint_cabinet hint_box hint_gate`(整句化替代);平铺 `voice/` 旧文件全部移除 - sfx(16):`amb_drip amb_hum amb_wind`(保留)+ R2 新增 `amb_storage amb_cabinet amb_power amb_gate amb_gate_live`(Stable Audio 3,6s,统一 -25dB 均值,storage/power 偏左、cabinet 偏右 pan)+ `door_creak gate_open pickup power_on spark step unlock wall win_chime`;已删除 `ui_deny`(反馈改震动) -- bgm(1):`drone` +- bgm(2):`drone`(探索)+ R6 新增 `menu`(菜单/结算 pad) ### simon《音序回响》(R2 重设计版,最终清单见 `round2-voice-ids.md`) -- voice 双性别版(male/ 与 female/ 各 68 条):交互教学 `t0_welcome t0_goal t1_up–t6_double t_try t_ok t_wrong_* t_blunt_* t7_ready t7_retry t_skip`、轮次 `round_01–round_20 round_more`(整句)、成绩 `score_band1–6`(整句)、流程 `your_turn chances_out retry_use relisten replay rules_prompt rules gameover`、反馈 `correct wrong chance_plus new_record`、菜单 `menu_*` +- voice 双性别版(male/ 与 female/ 各 69 条(R6 +`skip_hint`):交互教学 `t0_welcome t0_goal t1_up–t6_double t_try t_ok t_wrong_* t_blunt_* t7_ready t7_retry t_skip`、轮次 `round_01–round_20 round_more`(整句)、成绩 `score_band1–6`(整句)、流程 `your_turn chances_out retry_use relisten replay rules_prompt rules gameover`、反馈 `correct wrong chance_plus new_record`、菜单 `menu_*` - voice 已删除(R2):`di lun num_shi num_0–num_9 score_pre best_pre`(拼接件)+ `intro teach_* listen`(旧连播教学);平铺 `voice/` 旧文件全部移除 - sfx(7):`gameover tone_doubletap tone_down tone_left tone_right tone_tap tone_up`;已删除 `correct wrong newrecord`(反馈改震动) -- bgm:**已于 R1 按用户要求移除**(原 `theme`,代码引用由开发删除) +- bgm:游玩**无 BGM**(用户要求);R6 新增 `menu`(菜单/结算极轻 pad,volume 0.3,10 文档 §4) ### rhythm《节拍行者》(R3 重构版,台词依据 `docs/design/03-rhythm-voice-ids.md`) -- voice 双性别版(male/ 与 female/ 各 31 条):开场流程 `welcome welcome_back tut_1 tut_2 tut_3 tut_done tut_skip mid_song`、教学纠正 `retry_early retry_late retry_gesture retry_listen retry_skip`、选曲解锁 `select_hint song1_name song2_name song3_name song_locked unlock_song2 unlock_song3`、结算 `grade_s grade_a grade_b grade_c new_best_combo restart`、菜单规则 `rules settings_hint menu_main menu_resume menu_lobby` +- voice 双性别版(male/ 与 female/ 各 32 条(R6 +`skip_hint`):开场流程 `welcome welcome_back tut_1 tut_2 tut_3 tut_done tut_skip mid_song`、教学纠正 `retry_early retry_late retry_gesture retry_listen retry_skip`、选曲解锁 `select_hint song1_name song2_name song3_name song_locked unlock_song2 unlock_song3`、结算 `grade_s grade_a grade_b grade_c new_best_combo restart`、菜单规则 `rules settings_hint menu_main menu_resume menu_lobby` - voice 已删除(R3):`tut_start`(平铺旧 id 全部移除,其余旧 id 均在 R2 已替换) - voice 修订(R5):`menu_main` 改词重录为「菜单。单击切换选项,双击确认。」(对齐 round2-review 裁决②全球菜单约定;选曲界面非模态菜单不受影响) - sfx(7,R3 补齐):`inst_kick inst_snare inst_hat_l inst_hat_r inst_tom_hi inst_tom_lo inst_accent`(演奏乐器音,详见 round3-report §7;hat_l 闭合镲极左 / hat_r 开放镲极右,accent 为 A minor 合成亮音);已删除 `judge_* tick tick_accent`(R3)与 `cue_*`(R3 收尾,重构不再使用指令音) -- bgm(3,R3 重制):`track90`(90.01 BPM,85.33s)`track105`(104.99 BPM,73.14s)`track120`(120.00 BPM,72.00s),A minor 电子乐,整数小节无缝循环,loudnorm -20 LUFS;旧 `groove` 已删除 +- bgm(3,R3 重制):`track90`(90.01 BPM,85.33s)`track105`(104.99 BPM,73.14s)`track120`(120.00 BPM,72.00s),A minor 电子乐,整数小节无缝循环,loudnorm -20 LUFS;旧 `groove` 已删除;R6 新增 `menu`(选曲/结算 pad) ### space《盲航》(R3 备产,台词依据 `docs/design/04-space-voice-ids.md`) -- voice 双性别版(male/ 与 female/ 各 52 条):演练 `drill_*`(14)、保护期 `protect_*`(5)、功能引入 `feat_* shield_ready shield_block`(6)、被撞归因 `hit_*`(5)、里程碑 `ms_10 ms_25 ms_50 ms_100`、结算 `time_1–4 dodge_1–4 new_record restart_hint`、菜单系统 `second_run_ask menu_* help_recap settings_hint paused` +- voice 双性别版(male/ 与 female/ 各 53 条(R6 +`skip_hint`):演练 `drill_*`(14)、保护期 `protect_*`(5)、功能引入 `feat_* shield_ready shield_block`(6)、被撞归因 `hit_*`(5)、里程碑 `ms_10 ms_25 ms_50 ms_100`、结算 `time_1–4 dodge_1–4 new_record restart_hint`、菜单系统 `second_run_ask menu_* help_recap settings_hint paused` - voice 修订(R4):`menu_main` 改词重录为「菜单。单击切换选项,双击确认。」(对齐 round2-review 裁决②全球菜单约定) - voice 已删除(R3):`num0–9 num_shi num_bai n_time n_sec n_dodge n_ge n_milestone intro1–4 hit1 hit2 gameover restart`(数字拼接与旧教学弃用);平铺旧文件全部移除 - sfx(12):`alarm boost crash dodge hit shield shield_block whoosh_down whoosh_left whoosh_right whoosh_up` -- bgm(1):`engine` +- bgm(2):`engine`(对局)+ R6 新增 `menu`(菜单/结算 pad) ### rpg《剑与声》(R4 新游戏第一章,台词依据 `docs/design/05-rpg-ch1-voice-ids.md` + `05-rpg-addendum.md`) -- voice 旁白双性别版(male/ 与 female/ 各 108 条,male=Uncle_Fu 说书人 / female=Serena 武侠故事):序章 `intro_1–3`、系统状态 `sys_* help_* settings_hint st_*`、场景 `desc_*`(16)、选项 `opt_*`(18)、事件 `evt_*`(6)、战斗 `btl_* cmd_* tut_*`(27,含 R5 补录 `btl_half_bandit`)、商店 `shop_*`(7)、敌方档位 `foe_hp_high/mid/low`(addendum §6.2 补录) +- voice 旁白双性别版(male/ 与 female/ 各 113 条,male=Uncle_Fu 说书人 / female=Serena 武侠故事):序章 `intro_1–3`、R6 新增首次战斗教学 `tut_battle_1–5`、系统状态 `sys_* help_* settings_hint st_*`、场景 `desc_*`(16)、选项 `opt_*`(18)、事件 `evt_*`(6)、战斗 `btl_* cmd_* tut_*`(27,含 R5 补录 `btl_half_bandit`)、商店 `shop_*`(7)、敌方档位 `foe_hp_high/mid/low`(addendum §6.2 补录) - voice NPC 单音色平铺(14 条,落 `voice/` 根,引擎平铺回退):村长 `npc_headman_1–4`(Uncle_Fu·慈祥和缓)、老猎人 `npc_hunter_1–4`(Eric·沙哑豪爽)、寨丁头目 `npc_elite_1/half/die`(Dylan·凶狠)、黑风寨主 `npc_boss1_1/2/die`(Uncle_Fu·低沉阴狠,与村长同 speaker 不同 instruct) - sfx(35):前摇 `tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2`(1.0s 格挡窗口);战斗 `atk_swing atk_hit parry skill_slash enemy_die potion` + SYN `hurt flee`;探索 `chest drink_water rest door_heavy` + SYN `step coin levelup`;环境 `amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak`(5s 无缝循环,均值 -25dB);`ui_deny` 按设计不做 -- bgm(4,Mureka):`village battle boss ending`(90s 无缝循环,loudnorm -20);探索期无音乐(addendum §3),`amb_*` 由游戏层手动 loop 接 sfx 总线 +- bgm(6,Mureka):`village battle boss ending`(R4)+ R6 新增 `menu`(菜单 pad)与 `explore`(探索轻垫乐,10 文档 §7 恢复,volume 0.35);90s 无缝循环;`amb_*` 由游戏层手动 loop 接 sfx 总线 - 再生成方法见 `web/rpg/gen/README.md` +## 响度与爆音标准(R6 起) + +- sfx one-shot:峰值归一 **-3dBFS**(真峰值经 ogg 解码回读校验,见 round6-report §1);首尾 8ms cos² 淡入淡出;去直流。 +- loop(amb_*/bgm):首尾交叉淡化保证循环连续,不加淡入淡出;仅做直流/峰值检查。 +- voice:沿用 R1 峰值 0.89 归一策略,不动。 +- bgm:loudnorm -20 LUFS(主题曲)/ -24 LUFS(menu pad)。 + ## 发音质量 ### 本轮已采取的规避措施 diff --git a/docs/audio/final-asset-check.md b/docs/audio/final-asset-check.md index 3a4eb772..67a49cb5 100644 --- a/docs/audio/final-asset-check.md +++ b/docs/audio/final-asset-check.md @@ -2,12 +2,12 @@ | 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 | |---|---|---|---|---|---| -| storyline | 133 | 133 | 0 | 0 | ✅ 各 36(平铺 48 个角色/NPC 句,豁免) | -| escape | 166 | 166 | 0 | 0 | ✅ 各 74 | -| simon | 143 | 143 | 0 | 0 | ✅ 各 68 | -| rhythm | 72 | 72 | 0 | 0 | ✅ 各 31 | -| space | 116 | 116 | 0 | 0 | ✅ 各 52 | -| rpg | 269 | 269 | 0 | 0 | ✅ 各 108(平铺 14 个角色/NPC 句,豁免) | +| storyline | 136 | 136 | 0 | 0 | ✅ 各 37(平铺 48 个角色/NPC 句,豁免) | +| escape | 169 | 169 | 0 | 0 | ✅ 各 75 | +| simon | 146 | 146 | 0 | 0 | ✅ 各 69 | +| rhythm | 75 | 75 | 0 | 0 | ✅ 各 32 | +| space | 119 | 119 | 0 | 0 | ✅ 各 53 | +| rpg | 283 | 283 | 0 | 0 | ✅ 各 114(平铺 14 个角色/NPC 句,豁免) | ## 结论 @@ -15,6 +15,7 @@ ## 附注 -- storyline 平铺 48 个 = 12 个角色台词(林晚/猎狗/小满/林晨/广播,单音色永久保留)+ 36 个 R1 旁白旧文件(与 male/ 内容重复的遗产,待引擎切换验证后清理,见 round1-report §2)。 -- rpg 平铺 14 个 = NPC 句(设计如此,单音色)。 -- 终检脚本:`tools/check_all_assets.py`(可重复执行)。 +- storyline 平铺 = 角色台词(单音色永久保留)+ R1 旁白旧文件(遗产,待清理)。 +- rpg 平铺 = NPC 句(设计如此,单音色)。 +- R6 起 sfx 峰值标准为 -3dBFS(真峰值经解码回读校验);voice 目录响度策略不变(峰值 0.89)。 +- 终检脚本:`tools/check_all_assets.py`;爆音扫描/修复:`tools/scan_clicks.py` / `tools/fix_clicks.py`。 diff --git a/docs/audio/round6-report.md b/docs/audio/round6-report.md new file mode 100644 index 00000000..05acf6d0 --- /dev/null +++ b/docs/audio/round6-report.md @@ -0,0 +1,60 @@ +# R6 音频轮次报告(2026-08-30,音乐音效总监) + +本轮主题:爆音资产排查修复 + 状态 BGM 7 首 + RPG 战斗教学/全局跳过提示语音。Mureka 本轮 $0.21 / $1.00,**累计 $0.63 / $2.00**。 + +## 1. 爆音排查与修复 + +**工具**:`tools/scan_clicks.py`(扫描)/ `tools/fix_clicks.py`(修复),全程可复跑;原件备份在 `tools/asset_fix_backup/`(71 个文件)。 + +**扫描口径**:峰值 >-1dBFS / 首尾电平突变(首末 1ms 幅值 > 局部 50ms 峰值 15%,局部低于 -40dB 的静音区不判)/ 直流偏置 |DC|>0.001。voice 目录按任务要求**不动**(峰值 0.89 归一策略保留)。 + +**初扫结果(899 个 ogg 全量)**: + +| 类别 | 数量 | 说明 | +|---|---|---| +| sfx/bgm 峰值过热 | 27 | 其中 rpg 15 个 SA3 one-shot 解码峰值高达 +0.9~+6.6dBFS(真·爆音源) | +| sfx/bgm 直流偏置 | 43 | 最大 0.076(rpg/atk_swing) | +| sfx/bgm 首尾突变 | 48 | 老资产无淡入淡出(storyline/escape/simon/space 为主) | +| voice 过热 | 340 | 不修(语音响度策略不动,见任务说明) | + +**根因发现(重要)**:R4 的 rpg SA3 one-shot 源是近满幅高密度波形(rms 0.84 / 峰值 0dBFS,>15kHz 能量 22%),MDCT 有损编码(vorbis/aac/opus 全部)重建时过冲 +4~8dB——ffmpeg `volumedetect` 走 s16 中间格式把超幅削到 0.0dB 显示,**之前的 QC 完全没发现**。本轮修复 encode 环节改为「编码→解码回读真峰值→超 -3dB 则降增益 + 首轮 18kHz 低通→重编」(最多 3 次迭代)。 + +**修复结果**:71 个 sfx/bgm 文件重处理(storyline 10 / escape 10 / simon 7 / rhythm 7 / space 11 / rpg 26),统一标准:one-shot 去直流 + 首尾 8ms cos² 淡入淡出 + 峰值 -3dBFS(真峰值校验后);loop(amb_*/bgm)只去直流(交叉淡化循环不加首尾淡入淡出,避免破坏循环)。rpg one-shot 从 staging 无损 wav 重做,其余从备份初代源修复(避免多次有损转码)。 + +**复扫(终态)**:sfx/bgm 99 个 —— 过热 0 / 直流 0 / 首尾突变 0。**全绿。** + +## 2. 状态 BGM(7 首,docs/design/10-feedback2-state-bgm.md) + +| 资产 | task id | 响度 | 成品 | +|---|---|---|---| +| storyline `bgm/menu` | 158096439705601 | -24 LUFS(menu pad 档) | 90s 循环,mean -24.4dB | +| escape `bgm/menu` | 158096580149249 | -24 | mean -26.1dB | +| simon `bgm/menu` | 158096714629126 | -24(引擎再按 volume 0.3) | mean -25.8dB | +| rhythm `bgm/menu` | 158096850812929 | -24 | mean -25.3dB | +| space `bgm/menu` | 158097039425538 | -24 | mean -25.1dB | +| rpg `bgm/menu` | 158097182294017 | -24 | mean -25.9dB | +| rpg `bgm/explore`(恢复) | 158097360420865 | -20(引擎 volume 0.35) | mean -22.3dB | + +全部一次成功选候选 0;强 onset 起点 + 90s + 3s crossfade 无缝循环 + ogg q3/m4a。既有主题曲全部不动(storyline dark_drone、escape drone、rhythm track90/105/120、space engine、rpg village/battle/boss/ending)。状态→BGM 映射与切换时机以 10 文档 §2-§7 为准。 + +## 3. 新语音(22 条,双性别) + +| id | 游戏 | 条数 | 文案 | +|---|---|---|---| +| `tut_battle_1..5` | rpg | 5×2 | 首次战斗教学:回合制说明 / 左右滑选招式单击出手 / 听前摇双击格挡 / 防御减半与金疮药 / 开场收束 | +| `skip_hint` | 全 6 游戏 | 1×2×6 | 「单击可跳过旁白。」(各游戏既有 instruct 常量) | + +均已合入 `tools/gen_voice_dual.py` 台词表并落盘 male/female 双格式。rpg 旁白现 113 id ×2(+NPC 14 平铺)。 + +## 4. 给游戏层的接线约定 + +1. **菜单/结算音乐**:所有游戏统一 `bgm/menu` id;切换时机与淡入时长按 10 文档逐游戏表(storyline 500ms、escape 400ms/结算 1500ms、simon 600ms/结算延后 1000ms、rhythm 800ms、space 300ms、rpg 战斗 400ms 快切)。 +2. simon 游玩全程不得加载任何 bgm;`bgm/menu` 仅菜单与结算,volume 0.3。 +3. rpg 双层:BGM 层报状态(menu/explore/village/battle/boss/ending),sfx 层 amb 报场景(手动 loop 接 `_sfxBus`,不变);胜利过渡 = battle 淡出 1500ms → explore 淡入 2000ms。 +4. `tut_battle_1..5`:首场战斗(enc_teach_wolf)B_START 时按序链播,二周目起跳过;`skip_hint` 为备选通用句,接入与否由游戏层定。 +5. `bgm=0` 时全部状态音乐不加载(含 menu pad 与 space engine——10 文档 §1.5 裁定)。 + +## 5. 复跑与终检 + +- `tools/check_all_assets.py` 终检已更新(`docs/audio/final-asset-check.md`):928 ogg + 928 m4a,双格式/0 字节/解码/性别集合一致性全绿。 +- 遗留:voice 层 340 个文件峰值 >-1dBFS 属 R1 既定归一(0.89 峰值),非爆音(有 TTS 天然淡入);如需统一可考虑 R7 评估,但会破坏既有响度平衡,不建议。 diff --git a/docs/design/10-feedback2-state-bgm.md b/docs/design/10-feedback2-state-bgm.md new file mode 100644 index 00000000..c3350860 --- /dev/null +++ b/docs/design/10-feedback2-state-bgm.md @@ -0,0 +1,93 @@ +# 10 · 全局"状态即音乐"规范(state-driven BGM,音频总监生产依据) + +> **给音频总监的生产摘要**:本轮新增 7 条 BGM——每游戏一条 `bgm/menu`(6 条)+ RPG 恢复 `bgm/explore`(1 条)。各游戏 prompt 草稿见 §8。所有新 BGM:60-90s 无缝循环、-20 LUFS、ogg+m4a 双格式、归一化工艺沿用现有管线。 +> **给开发的摘要**:状态→BGM 映射表见 §2-§7(逐游戏)。切换全部走引擎 `play('bgm/...')` 交叉淡入(默认 800ms),特例见各表"切换"列。`bgm=0` 设置时全部静默(含 menu pad)。RPG 的探索 amb 环境音保持在 sfx 层 loop(05-rpg-addendum §3 不变),**BGM 层专用于状态音乐**——两层叠加正是"听 BGM 辨状态、听 amb 辨场景"的设计意图。 + +--- + +## 1. 总则 + +1. **每个可感知状态都要有 BGM 身份**:菜单/结算 = `bgm/menu`(轻 pad);游玩 = 各游戏既有主题曲;战斗/Boss(RPG)= 专属曲。玩家闭上眼只听 2 秒音乐,就该知道自己在菜单里还是在游戏里。 +2. **menu pad 的共性要求**:音量基底 -24 LUFS(比主题曲低一档)、无鼓点或极弱脉冲、和声静止(pad 性质)、长度 ≥60s 无缝循环。它是"系统界面"的声音,不能抢戏。 +3. **simon 特例**:游玩/教学/序列播放**全程无 BGM**(用户明确要求,02 文档既定)。只在菜单与结算挂 `bgm/menu`,且以 volume 0.3 播放(比别的游戏更轻)。 +4. 切换时机一律在**状态进入时**(不是退出时);语音 ducking 行为不变(voice 播放时 BGM duck 到 20%)。 +5. `bgm=0`(设置关闭)时:所有状态音乐不加载不播放;RPG 场景 amb(sfx 层)不受影响;space 的 `bgm/engine` 属内容音?——**裁定:engine 是 BGM 层资产,bgm=0 时静默**,盲航的方位信息全部由 whoosh/alarm(sfx)承载,无信息损失。 + +## 2. storyline《声命线》 + +| 状态 | BGM | 切换 | +|---|---|---| +| 剧情播放(含抉择) | `bgm/dark_drone`(保留) | 开局即播,全剧不切 | +| 菜单(下滑呼出) | `bgm/menu` | 进菜单 crossfade 500ms;关菜单淡回 dark_drone 800ms | +| 结局结算 | 保持 `bgm/dark_drone` 不切 | 结局语音 duck 即可,氛围连贯 | + +## 3. escape《暗室》 + +| 状态 | BGM | 切换 | +|---|---|---| +| 引导 + 自由探索 | `bgm/drone`(保留) | 不变 | +| 菜单(ENDING 下滑呼出) | `bgm/menu` | 进菜单 crossfade 400ms | +| 逃脱结算(ENDING) | `bgm/menu`(volume 0.5) | 现有 `stopBgm(2500)` 淡出 drone 后,escape 语音播完淡入 menu pad 1500ms——结算画面从"空洞的静"变成"松一口气的亮" | + +## 4. simon《音序回响》(特例) + +| 状态 | BGM | 切换 | +|---|---|---| +| 教学 / 对局全流程 | **无**(静默) | 不加载任何 bgm | +| 菜单(结算后下滑) | `bgm/menu`(volume 0.3) | 淡入 600ms | +| 结算(gameover) | `bgm/menu`(volume 0.3) | sfx/gameover 播完 1s 后淡入 1000ms | +| 单击重开 | 停 menu pad | `stopBgm(300)`,回静默 | + +## 5. rhythm《节拍行者》 + +| 状态 | BGM | 切换 | +|---|---|---| +| 曲目游玩(含教学) | `bgm/track90/105/120`(不动) | 现有手动网格排程不变 | +| 选曲界面 | `bgm/menu` | 进选曲淡入 800ms;选定曲目后 stopBgm(400) 再按现有网格起曲目 BGM | +| 模态菜单(暂停) | 现有断点续播机制**保留**,menu pad **不叠**——暂停 = 音乐停(实现已如此,是最干净的"暂停感") | 不变 | +| 结算 | 曲目 BGM 现有 0.6s 淡出后,评级语音播完淡入 `bgm/menu` 800ms | 新增 | + +## 6. space《盲航》 + +| 状态 | BGM | 切换 | +|---|---|---| +| 演练 + 对局 | `bgm/engine`(保留) | 不变 | +| 模态菜单(暂停) | `bgm/menu` | 进菜单 crossfade 300ms(暂停要快);回对局淡回 engine 500ms | +| 坠毁结算 | 现有 stopBgm(600) 后,结算语音链播完淡入 `bgm/menu` 800ms | 新增 | + +## 7. rpg《剑与声》(重点:听觉状态语言) + +用户要求"通过背景音乐判断当前处于菜单/地图/战斗状态"。RPG 的状态语言 = **BGM 层报状态 + sfx 层 amb 报场景**(双层各司其职): + +| 状态 | BGM 层 | sfx 层 amb | 听感设计 | +|---|---|---|---| +| 菜单(模态) | `bgm/menu` | amb 继续(音量不变) | "游戏还在,界面开了" | +| 地图/探索 | `bgm/explore`(**恢复**,volume 0.35 轻垫) | `amb_` 主角 | 音乐是"底",环境音是"景" | +| 村庄(安全区特例) | `bgm/village`(保留) | amb_village(bgm=0 时的垫底) | 温暖,与探索的紧张对比 | +| 普通战斗 | `bgm/battle`(保留) | amb 停 | 进战斗 crossfade **400ms**(战斗要快切, urgency) | +| Boss 战 | `bgm/boss`(保留) | amb 停 | 同上 400ms | +| 战斗胜利 → 探索 | 关键过渡:`sfx/victory`(胜利标识音,见 11 文档 §5)→ battle/boss **淡出 1500ms** → `bgm/explore` **淡入 2000ms** + amb 恢复 | — | "从紧绷到松弛"是可听的,这是用户要的过渡 | +| 战败 → 章首 | `bgm/battle` stopBgm(2000) → `voice/btl_lose` → village:bgm/village 淡入 1500ms | — | 重生即安全区音乐 | +| 章结算/剧情(evt_ch*_end) | `bgm/ending`(保留) | amb 停 | 播完淡回 explore | + +**裁定(修订 05-rpg-addendum §3)**:addendum 当时取消了 `bgm/explore`(探索期纯 amb);按用户本轮反馈恢复 explore 作为低音量状态垫乐(volume 0.35),amb 仍在 sfx 层不受影响。BGM 需求回到 5 首(village/explore/battle/boss/ending)+ menu。 + +## 8. 新 BGM 生产清单(Mureka prompt 草稿) + +共性后缀:`", ambient pad, very sparse, no drums or minimal pulse, static harmony, calm, no vocals, seamless loop, 60-90 seconds"`。 + +| 资产 | prompt 草稿(英文) | +|---|---| +| storyline `bgm/menu` | "Dark post-apocalyptic menu pad, low warm drone with faint radio static texture, sibling mood to a dark ambient drone track" + 共性 | +| escape `bgm/menu` | "Underground facility menu pad, soft low hum with distant water drip feel, slightly hopeful resolution tone" + 共性 | +| simon `bgm/menu` | "Minimal memory-game menu pad, gentle mallet harmonics over soft sine pad, meditative, extremely light" + 共性 | +| rhythm `bgm/menu` | "Electronic music-menu pad, slowed-down synthwave chords at 90 bpm feel, airy, between-songs breather" + 共性 | +| space `bgm/menu` | "Space cockpit menu pad, soft engine-room hum with airy synth wash, weightless" + 共性 | +| rpg `bgm/menu` | "Wuxia menu pad, single sustained guzheng harmonic over warm drone, ink-painting stillness" + 共性 | +| rpg `bgm/explore` | "Wuxia exploration ambient music, sparse guzheng and dizi fragments over low forest drone, 60 bpm, mysterious calm, no vocals, seamless loop, sits quietly under ambience"(轻垫乐,混音时音量再降) | + +## 9. 验收点 + +- 每游戏闭眼测试:菜单开/关、战斗进/出(RPG)仅靠 BGM 可辨。 +- `bgm=0.3`(小声)与 `bgm=0`(关闭)两档在各状态切换处不破音、不残响(引擎 crossfade 与增益总线叠加正确)。 +- menu pad 与主题曲同调性优先(storyline/escape/rpg 的 menu 与主题曲同 key;simon/space 无强约束)。 diff --git a/docs/design/11-rpg-battle-rebalance.md b/docs/design/11-rpg-battle-rebalance.md new file mode 100644 index 00000000..8dc454f5 --- /dev/null +++ b/docs/design/11-rpg-battle-rebalance.md @@ -0,0 +1,98 @@ +# 11 · 《剑与声》战斗重构规范(rpg battle rebalance) + +> **给开发的实现要点摘要** +> 1. 首战教学化:教学战从"学格挡+学攻击"扩为**四步强制分步**(认识指令→格挡→攻击→防御),教学战敌人 ATK 降为 2 且玩家 HP 锁底 1(不死)。§2。 +> 2. 新手难度保护:按**已完成战斗场数**分两档——第 1-3 场前摇 1.5s、敌方伤害 ×0.6;第 4-5 场过渡(1.25s、×0.8);第 6 场起恢复正常(1.0s、×1.0)。判定依据 `stats.battlesWon`,与等级无关(等级通过数值自然体现)。§3 数值表。 +> 3. 逃跑新手保护:Lv<3 逃跑 100% 成功;Lv≥3 普通战斗逃跑 70%,失败代价 = 敌方一个正常回合(有前摇、可格挡,不白打)。Boss/剧情战维持不可逃。§4。 +> 4. 战斗四状态声音标识:战斗开始 / 玩家回合 / 敌方前摇 / 胜利各有专属 sfx(新增 2 个:`turn_player`、`victory`)。§5。 +> 5. 现状衔接:现版(web/rpg/game.js)前摇固定 1.0s(+听风剑 0.3s)、逃跑 flee 布尔、教学战两步。本规范是**参数化 + 步骤扩展**,不改状态机骨架(05-rpg-chapter1 §3 的 10 状态转换表不变)。 + +--- + +## 1. 现状问题(对应用户反馈) + +- "第一次进战斗必须说明清楚交互方式":现教学战只有格挡(tut_b1/b2)+ 攻击(tut_b3)两步,**防御指令和指令切换本身没教**——玩家不知道左右滑能选招式、不知道有防御。 +- "战斗太难、要循序渐进":现版所有战斗前摇恒 1.0s、敌人满伤害,第一场正式战斗(enc_wolf)和 Boss 战用同一反应标准;逃跑非 Boss 必成但没有"失败"概念,缺少梯度。 + +## 2. 首战教学化(enc_teach_wolf 重排为四步) + +教学战流水(teachStage 状态扩为 4 段,每段旁白引导 → 玩家亲手执行 → 确认后进下一段;每段 8s 无操作给一次重复提示,绝不卡死): + +| 步 | stage | 旁白(新增语音 id,台词表见 §6) | 玩家动作 | 通过条件 | +|---|---|---|---|---| +| T0 | `menu` | `tut_b0`:"战斗里,左右滑选招式,单击出手。先试试,滑一滑。" | 左右滑 ≥2 次(听到指令名切换) | 切换 2 次 | +| T1 | `attack` | `tut_b3`(现有):"轮到你了。左右滑选攻击,单击出剑。" | 选中攻击并单击 | 攻击命中一次 | +| T2 | `parry` | `tut_b1`+`tut_b2`(现有):"它要扑了,听前摇!""就是现在,双击格挡!" | 前摇内双击 | 格挡成功或受击均可通过(受击掉血,教学点已到) | +| T3 | `defend` | `tut_b6`(新增):"来不及格挡,就选防御。选防御,单击。" | 选中防御并单击 | 执行防御一次(敌方攻击减伤 50% 演示) | +| T4 | — | `tut_b5`(现有):"好剑法!接下来,靠你自己了。" | 自由击杀 | 敌亡 | + +- 教学战参数:teach_wolf **ATK 3→2**、HP 6 不变、前摇 **1.8s**(比正常宽 80%)、玩家 HP 锁底 1(不死,现版已有 clamp)、不可逃(现有)。 +- 顺序理由:先会"怎么选"(T0)再会"打"(T1)再会"保命"(T2/T3);格挡放在攻击后是让它成为"回合交换"的一部分而非孤立动作。 + +## 3. 难度曲线(新手保护分档) + +保护档位由 `stats.battlesWon`(已完成战斗场数)决定,**每场战斗开始时取档**: + +| 档 | battlesWon | 敌方前摇 | 敌方伤害系数 | 说明 | +|---|---|---|---|---| +| 教学 | 教学战 | 1.8s | ATK 2 特例 | §2 | +| 保护 | 0-2(前 3 场) | **1.5s** | **×0.6**(向下取整,min 1) | 反应窗口宽 50%,掉血痛感低 | +| 过渡 | 3-4(第 4-5 场) | 1.25s | ×0.8 | 渐回标准 | +| 标准 | ≥5(第 6 场起) | 1.0s(听风剑 +0.3s) | ×1.0 | 现版数值 | + +### 第一章遭遇战数值表(最终版,含推荐等级) + +| 遭遇战 | 敌人 | HP | ATK(标准) | 前摇 | 经验/金币 | 推荐等级 | 说明 | +|---|---|---|---|---|---|---|---| +| enc_teach_wolf | 教学野狼 | 6 | 2(教学特例) | 1.8s | 5 / 0 | Lv1 | 四步教学,锁血不死 | +| enc_wolf | 野狼 | 10 | 4(保护档实际 2) | 按档位 | 8 / 5 | Lv1 | 可逃;保护档内打它=2 伤/刀 | +| enc_bandits | 山贼 ×2 连战 | 16+16 | 6(过渡档实际 4) | 按档位 | 24 / 24 | Lv2 | 不可逃;连战不回血 | +| enc_elite | 寨丁头目 | 24 | 8 | 标准 | 20 / 25 | Lv2-3 | 强攻线;半血有狠话 | +| enc_sneak | 山贼+毒蛛 | 16+12 | 6/7 | 标准 | 24 / 20 | Lv2 | 绕行线;胜后进 Boss 前回满 | +| enc_boss1 | 黑风寨主 | 45 | 10(半血 12) | 标准 | 50 / 100 | **Lv3** | 不可逃;进 Boss 战前强制提示(§4) | + +等级节奏校验:教学狼 5 + 野狼 8 + 山贼 24 + 头目/偷袭 20-24 + 沿途 ≈ 60 exp 到 Boss 前 → Lv3(20+40=60 正好升 3)。Lv3 玩家(HP36、铁剑 atk12、皮甲 def2):保护档已过,Boss 10-2=8~11 伤/刀,可吃 4 刀;格挡成功 2-3 伤。会格挡=稳过,不格挡=紧过,符合"技巧有收益"原则。 + +**Boss 保护例外**:enc_boss1 始终标准档(battlesWon 再多也不再放宽;battlesWon 不足 5 时打 Boss 也不享受 1.5s 前摇——Boss 是毕业考)。实现:`enc.boss` 已存在,取档时 `boss ? 标准 : 按 battlesWon`。 + +## 4. 逃跑规则 + +| 玩家等级 | 普通战斗 | Boss/剧情战(flee:false) | +|---|---|---| +| Lv < 3 | **100% 成功**(新手保护) | 不可逃(btl_noflee) | +| Lv ≥ 3 | 70% 成功;失败:播 `btl_flee_fail`(新增)→ 敌方直接进入正常前摇回合(**可格挡**,不白打) | 不可逃 | + +实现:现版 `_fleeCheck` 是 flee 布尔直出;改为 `{ allowed, rate }`:`allowed=false` 播 btl_noflee;`rate = lv<3 ? 1.0 : 0.7`,失败走 ENEMY_TELEGRAPH。 + +**进 Boss 战提示**:首次进入 enc_boss1 且 Lv<3 时,门前插播 `boss1_warn`(新增):"里面就是黑风寨主。你现在回去练练再来,也来得及。"——给低等级玩家一个知情回头机会(gate 场景 back 选项本来就在)。 + +## 5. 战斗状态声音标识(听觉状态语言的战斗部分) + +| 状态 | 声音标识 | 资产 | +|---|---|---| +| 战斗开始 | `bgm/battle` 400ms 切入(10 文档 §7)+ 现有 `btl_start_` | 已有 | +| **玩家回合开始** | 新增 `sfx/turn_player`:一声轻亮的剑鸣/木鱼短音("轮到你"的听觉标点,每回合都出现,必须极短 ≤0.4s、不烦) | **新增(程序合成即可,上行双音)** | +| 敌方前摇 | 现有 `tel_` + 窗口开启 `beat` 轻震(addendum §1) | 已有 | +| **胜利** | 新增 `sfx/victory`:3 音上行短号角(≤1.2s),随后 BGM 过渡(10 文档 §7 的 1.5s 出/2s 入) | **新增(SA3:"triumphant short brass fanfare, 3 rising notes, wuxia style, under 1.5 seconds")** | +| 战败 | 现有 `hurt` + `btl_lose` + battle BGM 2s 淡出 | 已有 | + +`turn_player` 在每回合 PLAYER_SELECT 进入时播;教学战 T0 的"指令切换"教学正好以它为锚("听到这个声音,就是你的回合"——写进 `tut_b0` 台词)。 + +## 6. 新增语音(补录清单,×2 性别,交音频总监) + +| id | 台词全文 | 用途 | +|---|---|---| +| `tut_b0` | 战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。 | 教学 T0("这个声音"= turn_player,旁白落音后 0.5s 播 turn_player 示范) | +| `tut_b6` | 来不及格挡,就选防御。左右滑,选防御,单击。 | 教学 T3 | +| `btl_flee_fail` | 没甩掉!它追上来了。 | Lv≥3 逃跑失败 | +| `boss1_warn` | 里面就是黑风寨主。你现在回去练练再来,也来得及。 | Lv<3 首次到 boss1 | + +instruct 沿用 ch1 台词表常量(female=Serena / male=Uncle_Fu,讲武侠故事/说书人)。 + +## 7. 验收点 + +- 首战:不做任何正确操作也不死(锁底 1);四步每步都能被看门狗兜底推进。 +- 保护档实测:第 2 场战斗野狼每刀 ≤2 伤、前摇明显变慢(可听)。 +- Lv2 逃跑必成 ×10 次;Lv3 后逃跑出现失败且失败后可格挡。 +- Lv2 到 Boss 门前听到 `boss1_warn`;Lv3 到则不播。 +- `turn_player` 每回合出现且不与前摇音/指令语音叠放(回合切换处留 200ms 间隙)。 diff --git a/docs/design/12-skip-and-longpress-ux.md b/docs/design/12-skip-and-longpress-ux.md new file mode 100644 index 00000000..6e965f5e --- /dev/null +++ b/docs/design/12-skip-and-longpress-ux.md @@ -0,0 +1,93 @@ +# 12 · 跳过旁白 + 长按加速 · 全局 UX 规范(含男女声边界核查) + +> **给开发的实现要点摘要** +> 1. 单击跳过当前句:§1 逐游戏裁定表——核心规则是"**voice 播放中单击=跳过本句;无 voice 时单击=原语义**",语音链衔接统一"跳下一句"(不是跳到节点末)。 +> 2. 长按加速(引擎临时 1.7× voice 语速):§2 豁免表——rhythm 演奏阶段全局豁免,其余游戏默认允许。引擎层拦截 longpress,游戏不收此手势。 +> 3. 首局提示时机:§3——不进语音资产,教学自然涵盖 + 大厅 footer 文案。 +> 4. §4 男女声边界核查结论:storyline/rpg 实际行为**符合**"只有旁白跟随设置、角色音固定",无需修复;两处命名/计数勘误。 + +--- + +## 1. 单击跳过当前句 + +### 1.1 总则 + +- **语境判定**:`voice` 层有音频在播时,单击 = 跳过当前句(`stopVoice()` 截断当前句,链播自然进下一句——引擎语义"被打断不触发 onComplete",因此**实现上不是 stopVoice 而是"截断当前句但保住链"**:需引擎或游戏层支持 `skipVoice()`(截断当前句并触发 onComplete 续链)。若引擎暂无此 API,游戏层用等价实现:手动推进链。以引擎最终提供的 API 为准)。 +- **无 voice 时单击 = 各游戏原语义**(检查/确认/继续),两态有明确的听觉区分(有语音在播 vs 没有),不混淆。 +- **跳到节点末 vs 跳下一句**:统一**跳下一句**。跳到节点末会让玩家误过后续信息且实现要感知"节点"边界;"逐句跳"心智最简单:不想听就多点几下。 +- 跳过不给震动(跳过是浏览操作不是确认;双击=重听的对偶操作,保持安静)。 + +### 1.2 逐游戏裁定表 + +| 游戏 | 阶段 | 单击=跳过本句? | 说明 | +|---|---|---|---| +| storyline | 剧情对白 | ✅ 可跳 | 节点最后一句被跳 = 进下一节点(现有"单击继续"语义自然延伸) | +| storyline | 抉择提示(prompt) | ❌ 不可跳 | 左右滑抉择前的选项说明必须听完(盲玩公平性) | +| escape | 引导 G0-G7 | ❌ 不可跳(下滑=跳整个引导,已有) | 引导句即操作指令,跳句会破坏步骤机 | +| escape | PLAY 检查描述/卡关提示/背包 | ✅ 可跳 | 熟练玩家快速刷检查 | +| escape | 关键获得(found_key/unlock_cabinet/fix_box/escape) | ❌ 不可跳 | 谜题链进展的叙事回报,总长 <10s | +| simon | 教学 T0-T7 | ❌ 不可跳(下滑=跳教学,已有) | 同 escape 引导 | +| simon | 轮次播报 round_*、成绩播报 | ✅ 可跳 | 高轮次玩家最需要 | +| simon | input 阶段 | — 无语音 | 无冲突 | +| rhythm | intro/tut/结算/选曲 | ✅ 可跳 | 演奏阶段(main)无语音,无冲突 | +| rhythm | mid_song | ❌ 不可跳(仅 2s,不值得跳) | 演奏中,手势是演奏输入 | +| space | 演练 D0-D5 | ❌ 不可跳(下滑=跳演练,已有) | 教学即玩法 | +| space | feat_boost/feat_shield 引入句 | ✅ 可跳 | 二周目熟练玩家 | +| space | 归因/保护期/结算播报 | ✅ 可跳 | | +| rpg | 场景 desc、NPC 对白、事件句 | ✅ 可跳 | 刷图玩家核心诉求 | +| rpg | 战斗指令名 cmd_*、教学战 tut_b* | ❌ 不可跳 | 指令名 <1s 且正在选指令,跳过会打乱选择节奏;教学句同引导逻辑 | +| rpg | 状态播报链(st_*) | ✅ 可跳 | 逐句跳过即"快进状态查询" | + +### 1.3 与"单击 300ms 延迟"的相容性 + +跳过是非即时操作,300ms 延迟无影响。注意双击=重听与单击=跳过的对偶关系:引擎的双击窗口保证两者不误判(现有机制天然支持)。 + +## 2. 长按加速(引擎:按住 = voice 层临时 1.7×) + +**核查注记**:本规范按用户声明的既定事实("引擎已实现长按=临时 1.7 倍速")撰写;当前 `web/engine/gesture.js` 输出仍为 6 手势、未见 longpress 实现——若引擎接口与本节假设不同,**以引擎实际 API 为准**,游戏侧豁免规则不变。 + +### 2.1 总则 + +- 长按由**引擎层拦截**:按住 ≥400ms 未位移 → 当前 voice 句 playbackRate 临时 1.7(松手或句终恢复 settings.speed),**不产生手势回调**(游戏永远收不到 longpress)。BGM/sfx 不受影响。 +- 与设置语速的关系:长按倍率 = max(1.7, 当前 settings.speed) 之上的"再快一档"语义——settings.speed=1.7 时长按无效果(已是特快)。 +- 加速中的 onComplete 时序自然提前(句长缩短),语音链不断。 + +### 2.2 豁免表 + +| 游戏 | 豁免 | 理由 | +|---|---|---| +| rhythm | **main(演奏)+ tut(教学跟打)阶段全局豁免**(游戏向引擎注册关闭 rate boost) | 演奏中手指可能长按屏幕边缘蓄力;且 mid_song 加速会破坏"语音提示与节拍网格对齐"的幻觉。教学跟打同理 | +| rhythm | select/end/菜单正常允许 | 结算播报快进是刚需 | +| simon | playback(序列播放)阶段豁免 | 序列是记忆对象,虽然 tone 是 sfx 不受 rate 影响,但该阶段无 voice,豁免无成本;input/announce/结算允许 | +| space | playing 阶段豁免 | 对局中无长语音(归因句 2-3s),豁免避免长按与连续滑动的手势边界模糊;演练/结算允许 | +| escape / storyline / rpg | 不豁免(全局允许) | 叙事类游戏正是长按加速的主战场 | +| RPG 战斗 | 允许但有边界:敌方前摇是 **sfx**(tel_*)不受 rate 影响,格挡窗口时长不变——加速 voice 不影响战斗判定,安全 | 裁定:允许 | + +豁免实现:游戏在阶段切换时调引擎开关(如 `audio.setRateBoostEnabled(false)`,以引擎实际 API 为准);默认开启。 + +## 3. 首局提示时机(两个功能的发现性) + +原则:**不新增语音资产、不打断首局沉浸**。 + +1. **大厅 footer 文案**(可视化,零成本):footer 已有"单击判定有约 300ms 延迟"一行,追加一行:"播放旁白时:单击=跳过本句,按住=加速播放"。 +2. **各游戏教学内自然涵盖**: + - 有交互教学的游戏(simon/space/rhythm/rpg/escape)教学本身已教六手势;跳过/长按是**增强功能而非必需操作**,不教——玩家从大厅文案或摸索中发现即可(发现成本低:单击本来就常用)。 + - storyline(无教学)在首个抉择点 prompt 播完后追加一次现有系统提示?——**不加**。storyline 的"单击继续"已是事实上的跳过来由,长按从大厅文案发现。 +3. 若后续数据显示玩家长语音中途退出率高,再考虑在第二局长语音加一次性提示句(届时补录 `sys_skip_hint`,本规范预留 id)。 + +## 4. 附:男女声设置边界核查(用户反馈"可选男女声只是旁白,不是游戏内容") + +核查方法:读 `web/storyline/game.js`、`web/rpg/game.js` 实际播放路径 + 资产目录结构 + `tools/gen_voice_dual.py` 台词表归属。 + +### 结论:符合规范,无需修复 + +| 检查项 | storyline | rpg | +|---|---|---| +| 旁白/系统音在 `voice//` 双目录 | ✅ 36 id 双性别 | ✅ 旁白 id 双性别 | +| 角色/NPC 音平铺 `voice/`(固定音色,引擎平铺回退命中) | ✅ n02_linwan(Serena)、n07_dog(Dylan)、e1_child/n03_msg/n05_bcast(Vivian) 均只存在于平铺 | ✅ npc_* 14 id 只存在于平铺,female/male 目录均无 npc_ 文件 | +| 角色音被错误切性别的路径 | **未发现** | **未发现** | + +### 两处勘误(非 bug) + +1. **storyline `n11_child` 命名误导**:该 id 在两个性别目录都存在,初看像角色"小满"被切性别——实际它是**旁白句**(`gen_voice_dual.py:95`,speaker 组 `_SL_NARR`,文本是旁白叙述中嵌入小满的话)。行为正确,但文件名易误读;**维持不改**(改 id 要动代码+资产两端,无实际收益),此处留档说明。 +2. **RPG NPC 台词计数勘误**:05-rpg-ch1-voice-ids.md 表头写"NPC 16 句",实际表内 14 id(村长 4 + 猎人 4 + 头目 3 + 寨主 3)。以 14 为准,音频资产已按 14 落盘,文档表头笔误不另改文件,记于此。 diff --git a/docs/design/13-playtest-criteria.md b/docs/design/13-playtest-criteria.md new file mode 100644 index 00000000..5ddca539 --- /dev/null +++ b/docs/design/13-playtest-criteria.md @@ -0,0 +1,110 @@ +# 13 · 六游戏试玩通过标准(playtest criteria,无头测试 agent 断言检查单) + +> **给测试 agent 的使用说明**:每游戏分三节——**关键路径**(从启动到通关/结算必须按序达成的事件序列,附最大手势/步数预算)、**边界断言**(必须显式验证的容错/保护行为)、**失败即不通过**。测试可用测试钩子(如直接读取 game 实例字段、快进计时器)模拟时间,但手势只能通过引擎回调注入(模拟真实判定路径)。语音缺失不应导致卡死(看门狗是各游戏的既定实现,属被测项)。 + +--- + +## 1. storyline《声命线》 + +**关键路径**(单结局线,≤40 次手势): +1. 启动遮罩点按 → 音频解锁 → `n00` 旁白开始(voice 层有播放记录)。 +2. 沿任一抉择路径行进:每个抉择点断言 `pendingChoice/choiceArmed` 生效,左/右滑各至少使用一次。 +3. 到达任一结局节点(4 结局之一)→ 结局语音链 → 结算态单击重开回 `n00`。 + +**边界断言**: +- 语音播放中双击 = 重听当前节点(同一 voice id 重播)。 +- 下滑呼出菜单 → 返回剧情后节点不丢(mode 恢复,不重头)。 +- 中途选择产生剧情标记:`n09` 右滑后 `knowTrap === true`、`n11` 左滑后 `companion === true`、`n18` 左滑后 `betrayed === true`(标记设置即断言;knowTrap 无下游消费是已知项,roadmap §2.2,不作为失败项)。 +- 语音链看门狗:屏蔽某一 voice 资产时流程仍可推进到结局。 + +## 2. escape《暗室》 + +**关键路径**(引导 8 步 + 解谜链,≤80 次手势): +1. 启动 → GUIDE 进入(g0_intro 播放)→ 依次:单击(G0)→ 任意滑动(G1)→ 滑动 ×2(G2)→ 左滑(G3)→ 下滑(G4)→ 单击拾钥匙(G5,断言 `hasKey === true`)→ 双击(G6)→ 进入 PLAY(断言 `phase === 'PLAY'`,localStorage `soundgame.escape.guideDone === '1'`)。 +2. PLAY 路径:(0,2)出发 → 右滑×2 到铁柜房 (2,1) → 双击开柜(`cabinetOpen === true && hasFuse === true`)→ 左滑×2+上滑到电箱房 (0,0) → 双修复电箱(`powerOn === true`)→ 右滑×2+下滑×2 到大门 (2,2) → 双击逃脱。 +3. ENDING:断言 time_band 语音播出 + 单击重开回 PLAY。 + +**边界断言**: +- 引导期错手势:G3 右滑 → 播 guide_wrong_left;连错 3 次 → guide_blunt_left + warning 震动(监听 vibrate 调用)。 +- 撞墙:PLAY 中 (0,0) 左滑 → wall 音效 + error 震动 + 位置不变。 +- 卡关提示:计时器快进 60s/120s/180s → 依次播 stuck_*_1 / stuck_*_2 / stuck_*_3 + nav_*;任意进展事件后重置(`_stuckShown` 清空)。 +- 单击检查 = 环境音先播、描述 800ms 后播(断言播放顺序)。 +- 双击无目标兜底:播背包 + L2 明示,不崩。 + +## 3. simon《音序回响》 + +**关键路径**(教学 + 5 轮 + 复活耗尽,≤120 次手势): +1. 启动 → 0.5s 后教学 T0(t0_welcome 播放)→ T1-T6 每步:听到 tone → 做出对应手势(断言 `teachIdx` 递进)→ T7 → 进入第 1 轮。 +2. 第 1-3 轮:playback 阶段手势被忽略(注入手势断言 sequence 不受影响)→ input 阶段按 `game.sequence` 原样复现 → `round` 递增。 +3. 触发复活:第 4 轮故意答错 → 断言 `chances` 1→0、序列**不变**重播(`sequence` 引用值相等)→ 复现成功 → 第 4 轮完成时 chances 回 1(每 4 轮 +1)。 +4. 主动重听:input 阶段且期望输入非 doubletap 时双击 → 断言 chances 减少、inputIdx 清零、序列重播。 +5. 耗尽后再答错 → gameover → 断言 score_band 语音与 `round-1` 匹配 → 单击重开跳过教学直达第 1 轮。 + +**边界断言**: +- 无 BGM:全程无 bgm 层播放调用。 +- 双击冲突(裁定①):期望输入恰为 doubletap 时双击 = 答题(inputIdx 前进),不触发重听。 +- chances=0 时双击非期望手势:不判错(round 不变、inputIdx 不变)、播 chances_out。 +- 序列约束:前 5 轮无三连同手势;全程无四连。 +- 速度:第 1 轮 interval 700ms、第 12 轮起 420ms 下限。 + +## 4. rhythm《节拍行者》 + +**关键路径**(教学 + 曲目一通关 + 解锁,≤200 次手势): +1. 启动 → welcome → 教学 T1-T3:按 cues 的 expected 时刻(测试用 AudioContext 时钟对齐)做对应手势 → `tutorialDone === true` → 进曲目一 main。 +2. 曲目一:按 `_cues` 全部应答(Perfect 精度注入)→ 结算 phase 'end' → 断言评级 S(全 Perfect)→ `grades.song1 === 's'`、`unlocked === 2`、unlock_song2 播报。 +3. 结算单击 = 同曲再来一遍;右滑 = 下一首(已解锁 song2 进入 main)。 + +**边界断言**: +- tap 补偿:tap 音符以"回调时刻 -0.30s"判定(注入 tap 手势的实际回调延迟下仍判 Perfect)。 +- Miss=无乐器音:窗口内做错手势 → 无 inst_* 播放 + error 震动 + combo 清零。 +- 下滑折中:main 中无临近 down 音符时下滑 → 菜单(phase 'menu'、BGM 断点);有临近 down 应答音符时下滑 = 演奏判定,不开菜单。 +- 菜单暂停恢复:关菜单后 cue.expected 平移、BGM 断点续播(断言 `_bgmT0` 平移量 = 暂停时长)。 +- 教学失败 2 次 → retry_skip 跳过该步不卡死。 +- **结算末位**:song3 结算页右滑 → 不播 song_locked(round3-4 裁定①的回归断言;修正落地前此断言预期失败,标记 known-issue)。 + +## 5. space《盲航》 + +**关键路径**(演练 + 保护期 + 坠毁,≤150 次手势): +1. 启动 → 演练 D1-D4:按 `DRILLS[d].dir` 同向滑 → `drillIdx` 递进 → D5 后进 playing(`drillDone === '1'`)。 +2. 保护期(runStart 起 60s):等一颗陨石 → **不滑动任其撞** → 断言 `hits === 0`(训练护盾吸收)+ protect_ 播报。 +3. 第 20s:feature 阶段单击 → boost 生效(`boostUntil > now`);第 45s:双击 → 护盾(`shieldUpUntil > now`)。 +4. 60s 后:保护解除播报;之后正常闪避 3 颗(同向滑,断言 dodges=3)→ 再任撞 3 次 → 坠毁 gameover。 +5. 结算:time_/dodge_ 分档句与实际值匹配 → 单击重开。 + +**边界断言**: +- 演练做错:D1 右滑(应为左)→ drill_retry_left 重教 + 同一步重试;失败 2 次 → drill_moveon 带过。 +- 保护期撞击方位归因:撞左来陨石播 protect_left(方向句与 ob.dir 一致)。 +- 同向判定:撞击前 1.5s 内同向滑 = dodge(dodges+1);反向滑 ≠ dodge。 +- 第 2 次被撞后追加 hit_last;hits===3 才 gameover。 +- 下滑折中:下方有悬置陨石时下滑 = 闪避;无下方陨石时下滑 = 菜单(phase 'menu')。 +- 护盾充能:doubletap 后 30s 内 shieldReadyAt 锁定,到时播 shield_ready。 +- 里程碑:dodges=10 播 ms_10(测试可快进或直接设置 dodges 后触发一次 dodge)。 + +## 6. rpg《剑与声》(含 11 号文档重构项;重构未落地处标 known-issue) + +**关键路径**(第一章通关,≤300 次手势): +1. 启动 → 无存档时序章 intro_1-3 → village;断言场景进入 = amb/desc 播报 + 选项可左右滑切换。 +2. village:商店买铁剑(金币 50→断言不够 → 先调查/战斗攒钱或直接断言 shop_poor 播报路径);上滑状态播报链完整(st_lv→st_hp→st_gold→st_weapon→st_armor)。 +3. path:investigate 一次性 +15 金(再次调查播 evt_nothing)。 +4. stream:**教学战不败**——全程乱按/不动,断言玩家 HP 不低于 1(锁底);按 11 号文档重构后:四步教学(menu→attack→parry→defend)依次断言 teachStage 流转(重构落地前按现版两步 parry→attack 断言)。 +5. hunter:talk_hunter → hunter_quest flag + potions+1 + shop_discount。 +6. deepwood:山贼连战(两只依次上场,连战间不回血)→ enc_bandits_done。 +7. gate:assault → elite 战 → boss1:黑风寨主(半血二阶段 ATK 12 + npc_boss1_2)→ 胜利 → evt_ch1_end → `chapters['2'] === 'open'`。 +8. 存档断言:任一战斗结算后 PUT/localStorage 有最新 data(summary 含场景名与 Lv);杀进程重进 → sys_continue_ask → 单击继续落点正确。 + +**边界断言**: +- 格挡:前摇窗口内双击 → 伤害 ×0.3(`dmg` 断言或 HP 差值断言);窗口外双击 → error 震动不生效。 +- 防御指令:当回合伤害 ×0.5。 +- 逃跑:Lv<3 普通战 100% 成功(11 号文档;重构前现版为 flee 布尔,按现版断言 flee:true 必成);Boss 战下滑 → btl_noflee。 +- 新手保护(11 号文档,known-issue 至重构落地):前 3 场前摇 1.5s、伤害 ×0.6;enc_boss1 恒标准档。 +- 战败:HP≤0 → btl_lose → village 重生,等级/金币/flags 保留、非 Boss 遭遇重置。 +- 菜单豁免:探索态下滑=菜单(左右滑+单击确认),战斗态下滑=逃跑。 +- 看门狗:屏蔽任意 voice 资产,战斗与场景流转不卡死。 + +## 7. 全局通用断言(每游戏都跑) + +1. 启动遮罩点按前无音频播放(引擎解锁语义)。 +2. 全程无未捕获异常;资产缺失仅 console.error 不崩。 +3. 设置边界:settings.voice 切换后下一句起用对应目录(voice 缓存键含性别);settings.bgm=0 时无 bgm 层加载;speed=1.7 时下一句 playbackRate 生效。 +4. 双击=重听在其声明可用的阶段生效(escape 检查/simon 规则/rpg 场景 desc 等)。 +5. 菜单约定(裁决②):适用游戏的模态菜单 = 单击循环 / 双击确认 / 上滑帮助 / 下滑关闭(RPG 豁免)。 diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index 49dd87b6..023ec05f 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -15,9 +15,10 @@ web/ │ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈) │ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置) │ ├── settings.js # 全局用户设置(voice/speed/bgm,localStorage 持久化) -│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型) +│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型 + iOS 视觉降级) │ ├── game.js # Game / StateMachineGame 基类 -│ └── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势 +│ ├── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势 + 闪光降级样式 +│ └── vendor/soundtouch.js # SoundTouch 0.3.0(voice 变速不变调,LGPL v2.1) ├── server/ # 用户系统服务器(静态托管 + 注册/登录/进度 API) │ ├── server.js │ └── README.md # API 文档 @@ -92,7 +93,8 @@ cp -r web/template web/ iOS Safari 和 Chrome 都要求 AudioContext 必须在一次真实用户手势中创建/恢复。 引擎用启动遮罩解决: -1. 页面加载后 `showStartOverlay()` 显示全屏封面 + "轻触开始"(呼吸动画); +1. 页面加载后 `showStartOverlay()` 显示全屏封面 + "轻触开始"(呼吸动画, + 提示文字位于屏幕 30% 高度处,避开封面图底部文字区); 2. 用户第一次点按遮罩 → 引擎在手势回调里 `new AudioContext()` + `resume()`; 3. 遮罩移除,Promise resolve → 你调 `game.start()` 接管。 @@ -130,6 +132,10 @@ new WebAudioEngine(basePath = 'audio/', opts?) (与 Android 差异:Web 端**不需要**音量 0 假播预热,解码缓存即可。) - `stopBgm(fadeOutMs = 800)` — 淡出后释放 BGM。 - `stopVoice()` — 打断当前对白并清空排队,不触发 onComplete,BGM 恢复。 +- `skipVoice()` — **跳过当前对白但让语音链继续**:停掉当前 voice 并视为 + 这句自然播完——触发其 onComplete(游戏层的链播靠它推进)并继续等待队列。 + 与 stopVoice 的差异:stopVoice 作废整条链,skipVoice 只跳当前句。 + 游戏层自行绑定手势(如长按/特定滑动的"跳过这句")。 - `stopAll()` — stopBgm(200) + stopVoice()(sfx 很短,不主动停)。 三层行为(对齐 Android DefaultAudioEngine): @@ -137,8 +143,31 @@ new WebAudioEngine(basePath = 'audio/', opts?) | 层 | 播放器 | 特性 | |---|---|---| | BGM | AudioBufferSourceNode(双实例轮换) | 循环;切换时新源 0→满、旧源 →0 交叉淡入,淡出后 stop | -| voice | 单通道 | duck BGM 到 20%(300ms 渐降),播完 300ms 恢复;onComplete 驱动链播 | -| sfx | AudioBufferSourceNode 即发即弃 | 预解码 AudioBuffer 缓存,低延迟,可叠加 | +| voice | SoundTouch 时间拉伸管线(见下) | duck BGM 到 20%(300ms 渐降),播完 300ms 恢复;onComplete 驱动链播 | +| sfx | AudioBufferSourceNode 即发即弃 | 预解码 AudioBuffer 缓存,低延迟,可叠加;总线基础增益 0.8 | + +**爆音防护**:master 链末端挂 `DynamicsCompressorNode` 限幅 +(threshold -6dB / ratio 12 / attack 3ms / release 250ms),sfx 总线基础 +增益 0.8。BGM+voice+多个 sfx 并发叠加超过 0dBFS 时由限幅器压限, +不再在 destination 硬削波(爆音/突然最大音量的根治)。 + +**voice 变速不变调(SoundTouch 管线)**:voice 层播放走 +`vendor/soundtouch.js`(SoundTouch 0.3.0,LGPL v2.1,ScriptProcessorNode +实现):`WebAudioBufferSource → SimpleFilter(SoundTouch tempo) → +ScriptProcessorNode → gain → voiceBus`。`settings.speed` 只改 tempo 不改 +pitch,1.0/1.35/1.7 均不变调;自然结束由"源消耗位置 ÷ tempo"定时器判定, +onComplete/打断/排队语义与旧 BufferSource 实现完全一致。注意 voice 有 +~100ms 处理延迟(对白场景无感),且 ScriptProcessorNode 已废弃但全平台 +(含 iOS Safari)可用。 + +**长按快进(游戏零改动)**:voice 播放中长按屏幕 → 旁白临时 1.7 倍速 +(不变调),松开恢复 `settings.speed`。由 gesture.js 派发的 window 事件 +(`soundgame:longpress` / `soundgame:longpressend`)驱动,audio.js 内置监听。 + +**状态 BGM 约定**:游戏按状态(菜单/探索/战斗/结算)直接 +`play('bgm/xxx')` 切换即可——引擎 crossfade 已够用;`settings.bgm=0` 时 +切换只更新"想播"不加载不播放,用户重新打开 BGM 时自动补播最后一个状态 +BGM。游戏层无需为此写任何判断。 **用户设置自动生效(settings.js,游戏代码无需改动):** @@ -146,9 +175,9 @@ new WebAudioEngine(basePath = 'audio/', opts?) `play('voice/intro')` 依次尝试 `audio/voice//intro.{ogg,m4a,mp3,wav}`, 全部找不到再回退平铺的 `audio/voice/intro.*`(过渡期资产未分性别也能播)。 缓存键含性别,游戏中途切换音色下一句即用新性别资产。 -- `settings.speed`(`1.0|1.35|1.7`,默认 `1.0`):voice 层 - `source.playbackRate`,在创建 source 时读取,**从下一句生效** - (进行中的句子不变速)。 +- `settings.speed`(`1.0|1.35|1.7`,默认 `1.0`):voice 层 SoundTouch + tempo(**不变调**),在创建播放实例时读取,**从下一句生效** + (进行中的句子不变速——除非长按快进,见上)。 - `settings.bgm`(`1.0|0.3|0`,默认 `1.0`):BGM 层独立增益总线。 改动对**进行中的 BGM 立即生效**(150ms 渐变);调到 `0` 立即淡出停止 且此后 `play('bgm/...')` 只记意图不加载不播放;从 `0` 调回非零时, @@ -195,7 +224,11 @@ vibrate('error'); // 预定义名称 vibrate([50, 30, 50]); // 自定义震型 ``` -- 特性检测 `'vibrate' in navigator`;不支持的平台(iOS Safari)**静默 no-op**。 +- **视觉降级(iOS 等无 vibrate 平台)**:`hapticsSupported === false` 时 + `vibrate()` 自动改为全屏边框闪光(`#haptics-flash` 覆盖层,样式在 + style.css):震型 → 颜色/闪烁次数(confirm 白×1 / error 红×2 / + success 绿×1 / warning 橙×3 / heartbeat 粉×2 / pickup 青×1 / hit 强红×1 / + danger 红×3 快 / swipe、beat 极淡白×1)。游戏代码无需判断平台。 - gesture.js 已内置基础反馈:滑动→`swipe`,tap→`confirm`,doubletap→`confirm`。 **游戏逻辑层只需在语义事件(error/success/warning/heartbeat 等)处自行调用**, 不要对手势本身重复调 `vibrate('confirm')`/`vibrate('swipe')`,否则会双重震动。 @@ -204,7 +237,8 @@ vibrate([50, 30, 50]); // 自定义震型 ```js const detach = attachGestures(element, onGesture, config?) -// onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap') => void +// onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap' +// |'longpress'|'longpressend') => void // detach(): 解除绑定 ``` @@ -216,6 +250,7 @@ const detach = attachGestures(element, onGesture, config?) | `swipeMinVelocity` | 400 px/s | 平均速度阈值(位移 / 拖动时长) | | `doubleTapWindow` | 300 ms | 双击判定窗口 | | `tapSlop` | 24 px | 位移小于此才算点按 | +| `longpressMs` | 450 ms | 按住不动超过此值触发 `longpress` | - 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。 - 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。 @@ -224,8 +259,13 @@ const detach = attachGestures(element, onGesture, config?) (`style.css` 的 `#game-root` 已带)。 - 手势绑定是全屏捕获层级的:绑在 `#game-root` 上即可覆盖整个屏幕。 - **内置基础震动反馈**(haptics.js):滑动识别成功→`swipe`、tap→`confirm`、 - doubletap→`confirm`;不支持震动的平台自动静默。游戏语义反馈 - (error/success/warning 等)由游戏层调用 `vibrate()`。 + doubletap→`confirm`;不支持震动的平台自动走视觉降级(边框闪光)。 + 游戏语义反馈(error/success/warning 等)由游戏层调用 `vibrate()`。 +- **长按**:按住不动 ≥450ms 触发 `'longpress'`,松开触发 `'longpressend'`; + 长按期间不触发 tap;位移超 tapSlop 取消(已激活则先补发 `longpressend`, + 滑动照常判定)。长按同时派发 window 事件 `soundgame:longpress(end)`—— + audio.js 内置监听实现"长按=旁白临时 1.7 倍速(不变调)"。 + **游戏若把 longpress 挪作他用,注意语音快进行为会同时发生。** **单击有约 300ms 延迟**:引擎要等双击窗口关闭才能确定是单击,`tap` 回调因此延迟 ~300ms 触发(`doubletap` 和滑动是即时的)。这是引擎级行为, diff --git a/tools/asset_fix_backup/escape/audio/sfx/amb_power.m4a b/tools/asset_fix_backup/escape/audio/sfx/amb_power.m4a new file mode 100644 index 00000000..8ea8bc0a Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/amb_power.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/amb_power.ogg b/tools/asset_fix_backup/escape/audio/sfx/amb_power.ogg new file mode 100644 index 00000000..cb70dd3e Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/amb_power.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/door_creak.m4a b/tools/asset_fix_backup/escape/audio/sfx/door_creak.m4a new file mode 100644 index 00000000..f419963f Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/door_creak.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/door_creak.ogg b/tools/asset_fix_backup/escape/audio/sfx/door_creak.ogg new file mode 100644 index 00000000..76474f6d Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/door_creak.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/gate_open.m4a b/tools/asset_fix_backup/escape/audio/sfx/gate_open.m4a new file mode 100644 index 00000000..fa05301f Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/gate_open.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/gate_open.ogg b/tools/asset_fix_backup/escape/audio/sfx/gate_open.ogg new file mode 100644 index 00000000..b467550d Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/gate_open.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/pickup.m4a b/tools/asset_fix_backup/escape/audio/sfx/pickup.m4a new file mode 100644 index 00000000..3799bb51 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/pickup.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/pickup.ogg b/tools/asset_fix_backup/escape/audio/sfx/pickup.ogg new file mode 100644 index 00000000..2be329ed Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/pickup.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/power_on.m4a b/tools/asset_fix_backup/escape/audio/sfx/power_on.m4a new file mode 100644 index 00000000..4feca959 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/power_on.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/power_on.ogg b/tools/asset_fix_backup/escape/audio/sfx/power_on.ogg new file mode 100644 index 00000000..a1c4f61f Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/power_on.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/spark.m4a b/tools/asset_fix_backup/escape/audio/sfx/spark.m4a new file mode 100644 index 00000000..15298565 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/spark.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/spark.ogg b/tools/asset_fix_backup/escape/audio/sfx/spark.ogg new file mode 100644 index 00000000..91cea87a Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/spark.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/step.m4a b/tools/asset_fix_backup/escape/audio/sfx/step.m4a new file mode 100644 index 00000000..a741a500 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/step.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/step.ogg b/tools/asset_fix_backup/escape/audio/sfx/step.ogg new file mode 100644 index 00000000..2946dac2 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/step.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/unlock.m4a b/tools/asset_fix_backup/escape/audio/sfx/unlock.m4a new file mode 100644 index 00000000..2a1979dd Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/unlock.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/unlock.ogg b/tools/asset_fix_backup/escape/audio/sfx/unlock.ogg new file mode 100644 index 00000000..b86e0134 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/unlock.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/wall.m4a b/tools/asset_fix_backup/escape/audio/sfx/wall.m4a new file mode 100644 index 00000000..3bcd9fb9 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/wall.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/wall.ogg b/tools/asset_fix_backup/escape/audio/sfx/wall.ogg new file mode 100644 index 00000000..0babbe84 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/wall.ogg differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/win_chime.m4a b/tools/asset_fix_backup/escape/audio/sfx/win_chime.m4a new file mode 100644 index 00000000..77d43001 Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/win_chime.m4a differ diff --git a/tools/asset_fix_backup/escape/audio/sfx/win_chime.ogg b/tools/asset_fix_backup/escape/audio/sfx/win_chime.ogg new file mode 100644 index 00000000..54d9325e Binary files /dev/null and b/tools/asset_fix_backup/escape/audio/sfx/win_chime.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.m4a new file mode 100644 index 00000000..bfbde1e0 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.ogg new file mode 100644 index 00000000..bf74e3cd Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_accent.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.m4a new file mode 100644 index 00000000..61018df3 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.ogg new file mode 100644 index 00000000..10334270 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_l.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.m4a new file mode 100644 index 00000000..d74cc843 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.ogg new file mode 100644 index 00000000..459634ed Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_hat_r.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.m4a new file mode 100644 index 00000000..268efb14 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.ogg new file mode 100644 index 00000000..9772862e Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_kick.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.m4a new file mode 100644 index 00000000..0ddf535a Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.ogg new file mode 100644 index 00000000..443ef6d2 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_snare.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.m4a new file mode 100644 index 00000000..b6fc2167 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.ogg new file mode 100644 index 00000000..c8c05eb1 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_hi.ogg differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.m4a b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.m4a new file mode 100644 index 00000000..a5b8d5b1 Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.m4a differ diff --git a/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.ogg b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.ogg new file mode 100644 index 00000000..e97bd88c Binary files /dev/null and b/tools/asset_fix_backup/rhythm/audio/sfx/inst_tom_lo.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.m4a b/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.m4a new file mode 100644 index 00000000..52948394 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.ogg b/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.ogg new file mode 100644 index 00000000..bb6e46ae Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_fork.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.m4a b/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.m4a new file mode 100644 index 00000000..b6b9fa4b Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.ogg b/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.ogg new file mode 100644 index 00000000..1d4da17c Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_swamp.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_village.m4a b/tools/asset_fix_backup/rpg/audio/sfx/amb_village.m4a new file mode 100644 index 00000000..dc499e8e Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_village.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/amb_village.ogg b/tools/asset_fix_backup/rpg/audio/sfx/amb_village.ogg new file mode 100644 index 00000000..22d6fa93 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/amb_village.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.m4a b/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.m4a new file mode 100644 index 00000000..255ca75e Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.ogg b/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.ogg new file mode 100644 index 00000000..0e431a93 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/atk_hit.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.m4a b/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.m4a new file mode 100644 index 00000000..2aa2455d Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.ogg b/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.ogg new file mode 100644 index 00000000..4274556f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/atk_swing.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/chest.m4a b/tools/asset_fix_backup/rpg/audio/sfx/chest.m4a new file mode 100644 index 00000000..e942749f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/chest.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/chest.ogg b/tools/asset_fix_backup/rpg/audio/sfx/chest.ogg new file mode 100644 index 00000000..a5a87a86 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/chest.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/coin.m4a b/tools/asset_fix_backup/rpg/audio/sfx/coin.m4a new file mode 100644 index 00000000..769b1dda Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/coin.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/coin.ogg b/tools/asset_fix_backup/rpg/audio/sfx/coin.ogg new file mode 100644 index 00000000..e5919fd8 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/coin.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.m4a b/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.m4a new file mode 100644 index 00000000..4ee8ec89 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.ogg b/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.ogg new file mode 100644 index 00000000..561bd2aa Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/door_heavy.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/drink_water.m4a b/tools/asset_fix_backup/rpg/audio/sfx/drink_water.m4a new file mode 100644 index 00000000..e7b3a552 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/drink_water.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/drink_water.ogg b/tools/asset_fix_backup/rpg/audio/sfx/drink_water.ogg new file mode 100644 index 00000000..96938843 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/drink_water.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.m4a b/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.m4a new file mode 100644 index 00000000..1745fa47 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.ogg b/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.ogg new file mode 100644 index 00000000..4553f4d8 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/enemy_die.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/flee.m4a b/tools/asset_fix_backup/rpg/audio/sfx/flee.m4a new file mode 100644 index 00000000..2724e5ad Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/flee.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/flee.ogg b/tools/asset_fix_backup/rpg/audio/sfx/flee.ogg new file mode 100644 index 00000000..85c42aa4 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/flee.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/hurt.m4a b/tools/asset_fix_backup/rpg/audio/sfx/hurt.m4a new file mode 100644 index 00000000..07977771 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/hurt.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/hurt.ogg b/tools/asset_fix_backup/rpg/audio/sfx/hurt.ogg new file mode 100644 index 00000000..982e5905 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/hurt.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/levelup.m4a b/tools/asset_fix_backup/rpg/audio/sfx/levelup.m4a new file mode 100644 index 00000000..28d4b1de Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/levelup.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/levelup.ogg b/tools/asset_fix_backup/rpg/audio/sfx/levelup.ogg new file mode 100644 index 00000000..64d50e94 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/levelup.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/parry.m4a b/tools/asset_fix_backup/rpg/audio/sfx/parry.m4a new file mode 100644 index 00000000..7d89c31b Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/parry.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/parry.ogg b/tools/asset_fix_backup/rpg/audio/sfx/parry.ogg new file mode 100644 index 00000000..8769c103 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/parry.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/potion.m4a b/tools/asset_fix_backup/rpg/audio/sfx/potion.m4a new file mode 100644 index 00000000..d2c1fed4 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/potion.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/potion.ogg b/tools/asset_fix_backup/rpg/audio/sfx/potion.ogg new file mode 100644 index 00000000..61b88a5f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/potion.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/rest.m4a b/tools/asset_fix_backup/rpg/audio/sfx/rest.m4a new file mode 100644 index 00000000..f678fa4f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/rest.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/rest.ogg b/tools/asset_fix_backup/rpg/audio/sfx/rest.ogg new file mode 100644 index 00000000..bf73267c Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/rest.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/step.m4a b/tools/asset_fix_backup/rpg/audio/sfx/step.m4a new file mode 100644 index 00000000..303aae2e Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/step.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/step.ogg b/tools/asset_fix_backup/rpg/audio/sfx/step.ogg new file mode 100644 index 00000000..8b6ab918 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/step.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.m4a new file mode 100644 index 00000000..75dd4d6f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.ogg new file mode 100644 index 00000000..de6e65a7 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_bandit.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.m4a new file mode 100644 index 00000000..fa92e28e Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.ogg new file mode 100644 index 00000000..3780ac0d Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss1.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.m4a new file mode 100644 index 00000000..eb963b0d Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.ogg new file mode 100644 index 00000000..c28b355b Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_boss2.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.m4a new file mode 100644 index 00000000..dcbf2337 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.ogg new file mode 100644 index 00000000..3303f597 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_croc.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.m4a new file mode 100644 index 00000000..9c74d206 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.ogg new file mode 100644 index 00000000..e5151833 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_elite.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.m4a new file mode 100644 index 00000000..019083c0 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.ogg new file mode 100644 index 00000000..ce161103 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_puppet.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.m4a new file mode 100644 index 00000000..0d564eef Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.ogg new file mode 100644 index 00000000..35e740fa Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_shaman.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.m4a new file mode 100644 index 00000000..a5fa5331 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.ogg new file mode 100644 index 00000000..5af4a42f Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_spider.ogg differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.m4a b/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.m4a new file mode 100644 index 00000000..186dde46 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.m4a differ diff --git a/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.ogg b/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.ogg new file mode 100644 index 00000000..9d2fc551 Binary files /dev/null and b/tools/asset_fix_backup/rpg/audio/sfx/tel_wolf.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/gameover.m4a b/tools/asset_fix_backup/simon/audio/sfx/gameover.m4a new file mode 100644 index 00000000..386fe2b6 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/gameover.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/gameover.ogg b/tools/asset_fix_backup/simon/audio/sfx/gameover.ogg new file mode 100644 index 00000000..6fc28354 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/gameover.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.m4a new file mode 100644 index 00000000..f4308f7a Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.ogg new file mode 100644 index 00000000..9c962d52 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_doubletap.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_down.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_down.m4a new file mode 100644 index 00000000..325f05c7 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_down.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_down.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_down.ogg new file mode 100644 index 00000000..879198cc Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_down.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_left.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_left.m4a new file mode 100644 index 00000000..8ea4cc1a Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_left.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_left.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_left.ogg new file mode 100644 index 00000000..fbe2d987 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_left.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_right.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_right.m4a new file mode 100644 index 00000000..74af9e6f Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_right.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_right.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_right.ogg new file mode 100644 index 00000000..eda32102 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_right.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_tap.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_tap.m4a new file mode 100644 index 00000000..a93b4a10 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_tap.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_tap.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_tap.ogg new file mode 100644 index 00000000..033fcef5 Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_tap.ogg differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_up.m4a b/tools/asset_fix_backup/simon/audio/sfx/tone_up.m4a new file mode 100644 index 00000000..0b09952b Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_up.m4a differ diff --git a/tools/asset_fix_backup/simon/audio/sfx/tone_up.ogg b/tools/asset_fix_backup/simon/audio/sfx/tone_up.ogg new file mode 100644 index 00000000..3ef1b62b Binary files /dev/null and b/tools/asset_fix_backup/simon/audio/sfx/tone_up.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/alarm.m4a b/tools/asset_fix_backup/space/audio/sfx/alarm.m4a new file mode 100644 index 00000000..d19e5bff Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/alarm.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/alarm.ogg b/tools/asset_fix_backup/space/audio/sfx/alarm.ogg new file mode 100644 index 00000000..81df32b8 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/alarm.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/boost.m4a b/tools/asset_fix_backup/space/audio/sfx/boost.m4a new file mode 100644 index 00000000..6573d128 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/boost.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/boost.ogg b/tools/asset_fix_backup/space/audio/sfx/boost.ogg new file mode 100644 index 00000000..a87aee69 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/boost.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/crash.m4a b/tools/asset_fix_backup/space/audio/sfx/crash.m4a new file mode 100644 index 00000000..99a122f1 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/crash.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/crash.ogg b/tools/asset_fix_backup/space/audio/sfx/crash.ogg new file mode 100644 index 00000000..9efd53b7 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/crash.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/dodge.m4a b/tools/asset_fix_backup/space/audio/sfx/dodge.m4a new file mode 100644 index 00000000..284e4844 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/dodge.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/dodge.ogg b/tools/asset_fix_backup/space/audio/sfx/dodge.ogg new file mode 100644 index 00000000..06aa0cd7 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/dodge.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/hit.m4a b/tools/asset_fix_backup/space/audio/sfx/hit.m4a new file mode 100644 index 00000000..6b4715ad Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/hit.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/hit.ogg b/tools/asset_fix_backup/space/audio/sfx/hit.ogg new file mode 100644 index 00000000..643ad24c Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/hit.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/shield.m4a b/tools/asset_fix_backup/space/audio/sfx/shield.m4a new file mode 100644 index 00000000..659259f8 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/shield.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/shield.ogg b/tools/asset_fix_backup/space/audio/sfx/shield.ogg new file mode 100644 index 00000000..a5b6608d Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/shield.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/shield_block.m4a b/tools/asset_fix_backup/space/audio/sfx/shield_block.m4a new file mode 100644 index 00000000..67ddcf31 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/shield_block.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/shield_block.ogg b/tools/asset_fix_backup/space/audio/sfx/shield_block.ogg new file mode 100644 index 00000000..b3b4835b Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/shield_block.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_down.m4a b/tools/asset_fix_backup/space/audio/sfx/whoosh_down.m4a new file mode 100644 index 00000000..5452f7b0 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_down.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_down.ogg b/tools/asset_fix_backup/space/audio/sfx/whoosh_down.ogg new file mode 100644 index 00000000..76d3a305 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_down.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_left.m4a b/tools/asset_fix_backup/space/audio/sfx/whoosh_left.m4a new file mode 100644 index 00000000..673783dd Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_left.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_left.ogg b/tools/asset_fix_backup/space/audio/sfx/whoosh_left.ogg new file mode 100644 index 00000000..144bd738 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_left.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_right.m4a b/tools/asset_fix_backup/space/audio/sfx/whoosh_right.m4a new file mode 100644 index 00000000..3f2a9584 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_right.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_right.ogg b/tools/asset_fix_backup/space/audio/sfx/whoosh_right.ogg new file mode 100644 index 00000000..49de7d06 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_right.ogg differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_up.m4a b/tools/asset_fix_backup/space/audio/sfx/whoosh_up.m4a new file mode 100644 index 00000000..9e10fd2d Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_up.m4a differ diff --git a/tools/asset_fix_backup/space/audio/sfx/whoosh_up.ogg b/tools/asset_fix_backup/space/audio/sfx/whoosh_up.ogg new file mode 100644 index 00000000..9f5a56d3 Binary files /dev/null and b/tools/asset_fix_backup/space/audio/sfx/whoosh_up.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/footsteps.m4a b/tools/asset_fix_backup/storyline/audio/sfx/footsteps.m4a new file mode 100644 index 00000000..27f80948 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/footsteps.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/footsteps.ogg b/tools/asset_fix_backup/storyline/audio/sfx/footsteps.ogg new file mode 100644 index 00000000..a1ef00ed Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/footsteps.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/gunshot.m4a b/tools/asset_fix_backup/storyline/audio/sfx/gunshot.m4a new file mode 100644 index 00000000..44f33b8b Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/gunshot.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/gunshot.ogg b/tools/asset_fix_backup/storyline/audio/sfx/gunshot.ogg new file mode 100644 index 00000000..55f42b75 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/gunshot.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.m4a b/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.m4a new file mode 100644 index 00000000..850905a1 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.ogg b/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.ogg new file mode 100644 index 00000000..3b70ced4 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/heartbeat.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/radio_static.m4a b/tools/asset_fix_backup/storyline/audio/sfx/radio_static.m4a new file mode 100644 index 00000000..ed690fc2 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/radio_static.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/radio_static.ogg b/tools/asset_fix_backup/storyline/audio/sfx/radio_static.ogg new file mode 100644 index 00000000..d995ea87 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/radio_static.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/rubble.m4a b/tools/asset_fix_backup/storyline/audio/sfx/rubble.m4a new file mode 100644 index 00000000..7ed32e0f Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/rubble.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/rubble.ogg b/tools/asset_fix_backup/storyline/audio/sfx/rubble.ogg new file mode 100644 index 00000000..abecc8f2 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/rubble.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.m4a b/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.m4a new file mode 100644 index 00000000..6767af7f Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.ogg b/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.ogg new file mode 100644 index 00000000..5f0f57f4 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/signal_beep.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/struggle.m4a b/tools/asset_fix_backup/storyline/audio/sfx/struggle.m4a new file mode 100644 index 00000000..22ec66e2 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/struggle.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/struggle.ogg b/tools/asset_fix_backup/storyline/audio/sfx/struggle.ogg new file mode 100644 index 00000000..0f1b9930 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/struggle.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.m4a b/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.m4a new file mode 100644 index 00000000..9c17a09a Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.ogg b/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.ogg new file mode 100644 index 00000000..e2d50be9 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/ui_confirm.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.m4a b/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.m4a new file mode 100644 index 00000000..8f7aec50 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.ogg b/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.ogg new file mode 100644 index 00000000..fefbf9e7 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/ui_menu.ogg differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/water.m4a b/tools/asset_fix_backup/storyline/audio/sfx/water.m4a new file mode 100644 index 00000000..5d07867f Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/water.m4a differ diff --git a/tools/asset_fix_backup/storyline/audio/sfx/water.ogg b/tools/asset_fix_backup/storyline/audio/sfx/water.ogg new file mode 100644 index 00000000..ec81c786 Binary files /dev/null and b/tools/asset_fix_backup/storyline/audio/sfx/water.ogg differ diff --git a/tools/fix_clicks.py b/tools/fix_clicks.py new file mode 100644 index 00000000..a3cd8393 --- /dev/null +++ b/tools/fix_clicks.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""爆音修复(R6):处理 scan_clicks.py 标记的 sfx/bgm 资产。 + +修复策略(只修 sfx/bgm,voice 不动): + - one-shot sfx:去直流(逐声道减均值)→ 首尾 8ms cos² 淡入淡出 → 峰值归一 -3dBFS + - loop(bgm/ 与 sfx/amb_*):只去直流(首尾交叉淡化工艺保证边界连续,加淡入淡出会破坏循环) + - rpg one-shot 优先从 staging(tools/gen_rpg_sfx_wav/ 原始 SA3 wav / gen_rpg_syn.py 重出的 + /tmp/rpg_syn)重做,避免二次有损编码;其余从现有 ogg 解码修复。 +处理前原件备份到 tools/asset_fix_backup/。处理前后指标记录到 /tmp/fix_report.jsonl。 +""" +import json +import os +import shutil +import subprocess +import sys + +import numpy as np +import soundfile as sf + +ROOT = "/home/ubuntu/SoundGame" +WEB = os.path.join(ROOT, "web") +BACKUP = os.path.join(ROOT, "tools", "asset_fix_backup") +FADE_MS = 8 +PEAK_TARGET = 10 ** (-3.0 / 20) # -3dBFS + +RPG_SA3 = os.path.join(ROOT, "tools", "gen_rpg_sfx_wav") +RPG_SYN = "/tmp/rpg_syn" +# rpg one-shot 的定长裁剪参数(同 R4 工艺) +RPG_TRIM = { + "tel_wolf": 1.0, "tel_bandit": 1.0, "tel_spider": 1.0, "tel_elite": 1.0, + "tel_boss1": 1.0, "tel_croc": 1.0, "tel_shaman": 1.0, "tel_puppet": 1.0, + "tel_boss2": 1.0, "atk_swing": 0.5, "atk_hit": 0.4, "parry": 0.5, + "skill_slash": 0.8, "enemy_die": 1.0, "potion": 1.2, "chest": 1.5, + "drink_water": 1.5, "rest": 2.0, "door_heavy": 3.0, +} + + +def is_loop(rel): + return "/bgm/" in "/" + rel or os.path.basename(rel).startswith("amb_") + + +def metrics(y, sr): + mono = y.mean(axis=1) if y.ndim > 1 else y + n5 = max(1, int(sr * 5 / 1000)) + peak = float(np.max(np.abs(mono))) + return { + "peak_db": round(20 * np.log10(max(peak, 1e-9)), 2), + "dc": round(float(np.mean(mono)), 5), + "head5ms_db": round(20 * np.log10(max(float(np.max(np.abs(mono[:n5]))), 1e-9)), 2), + "tail5ms_db": round(20 * np.log10(max(float(np.max(np.abs(mono[-n5:]))), 1e-9)), 2), + } + + +def fix_array(y, sr, loop): + y = y.astype(np.float64) + if not loop: + n = int(sr * FADE_MS / 1000) + ramp = np.sin(np.linspace(0, np.pi / 2, n)) ** 2 + y[:n] *= ramp[:, None] if y.ndim > 1 else ramp + y[-n:] *= ramp[::-1][:, None] if y.ndim > 1 else ramp[::-1] + # 峰值归一 -3dBFS + peak = np.max(np.abs(y)) + if peak > 0: + y = y / peak * PEAK_TARGET + # 去直流放最后(淡入淡出/归一会重新引入微小偏置;常数平移不影响循环连续性) + y = y - y.mean(axis=0) + return y.astype(np.float32) + + +def encode(wav_path, base): + """编码 ogg+m4a;对 ogg 做解码回读真峰值校验——MDCT 重建对高密度/近 Nyquist + 内容会过冲数 dB(R6 爆音根因),过冲则按实测降增益重编(最多 3 次迭代, + 首轮附带 18kHz 低通消振铃)。返回解码后实测峰值 dB。""" + subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", wav_path, "-ar", "44100", + "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) + cur = wav_path + peak_db = None + for attempt in range(3): + subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", cur, "-ar", "44100", + "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) + dec, _sr = sf.read(base + ".ogg", always_2d=True) + peak_db = 20 * np.log10(max(float(np.max(np.abs(dec))), 1e-9)) + if peak_db <= -2.7: + break + gain_db = -3.2 - peak_db + nxt = wav_path + f".tp{attempt}.wav" + af = ("lowpass=f=18000," if attempt == 0 else "") + f"volume={gain_db:.2f}dB" + subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", cur, + "-af", af, "-c:a", "pcm_f32le", nxt], check=True) + cur = nxt + return peak_db + + +def main(): + flagged = [] + for line in open("/tmp/scan_all.jsonl"): + r = json.loads(line) + if "/voice/" in r["path"]: + continue # 语音响度策略不动 + if r["hot_peak"] or r["dc_bad"] or ((r["click_head"] or r["click_tail"]) and not r["loop"]): + flagged.append(r["path"]) + print(f"flagged sfx/bgm: {len(flagged)}") + + report = [] + for rel in flagged: + game = rel.split("/")[0] + name = os.path.basename(rel)[:-4] + ogg = os.path.join(WEB, rel) + base = ogg[:-4] + loop = is_loop(rel) + + # 选源:rpg one-shot 用 staging 重做;其余用现有 ogg + src = None + trim = None + if game == "rpg" and not loop: + if name in RPG_TRIM and os.path.exists(os.path.join(RPG_SA3, name + ".wav")): + src, trim = os.path.join(RPG_SA3, name + ".wav"), RPG_TRIM[name] + elif os.path.exists(os.path.join(RPG_SYN, name + ".wav")): + src = os.path.join(RPG_SYN, name + ".wav") + if src: + y, sr = sf.read(src, always_2d=True) + y = y.astype(np.float64) + if trim: # 去头静音 + 定长(ffmpeg silenceremove 的 python 等价:阈值 -45dB) + mono = np.abs(y.mean(axis=1)) + win = int(sr * 0.005) + env = np.convolve(mono, np.ones(win) / win, mode="same") + start = int(np.argmax(env > 10 ** (-45 / 20))) + y = y[start:start + int(sr * trim)] + else: + # 有备份读备份(初代无损源),避免多次有损转码 + bak = os.path.join(BACKUP, rel) + y, sr = sf.read(bak if os.path.exists(bak) else ogg, always_2d=True) + + before = metrics(y, sr) + # 备份原件(ogg+m4a) + for ext in (".ogg", ".m4a"): + dst = os.path.join(BACKUP, rel[:-4] + ext) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.exists(base + ext): + shutil.copy2(base + ext, dst) + + y2 = fix_array(y, sr, loop) + after = metrics(y2, sr) + tmp = f"/tmp/fix_{game}_{name}.wav" + sf.write(tmp, y2, sr) + dec_peak = encode(tmp, base) # ogg 解码回读真峰值(含过冲迭代修正) + after["decoded_peak_db"] = round(dec_peak, 2) + report.append({"path": rel, "loop": loop, "src": "staging" if src else "ogg", + "before": before, "after": after}) + print(f"[fix] {rel} ({'loop' if loop else 'shot'}, src={'staging' if src else 'ogg'}) " + f"peak {before['peak_db']}→{after['peak_db']} (decoded {round(dec_peak, 2)}) " + f"dc {before['dc']}→{after['dc']} head {before['head5ms_db']}→{after['head5ms_db']}") + + with open("/tmp/fix_report.jsonl", "w") as f: + for r in report: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + print(f"fixed {len(report)} files, backup in {BACKUP}") + + +if __name__ == "__main__": + main() diff --git a/tools/gen_voice_dual.py b/tools/gen_voice_dual.py index d3d536cd..ce449ec8 100644 --- a/tools/gen_voice_dual.py +++ b/tools/gen_voice_dual.py @@ -109,6 +109,8 @@ STORYLINE_LINES = [ ("e2_price", _SL_EPIC, "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。"), ("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"), ("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"), + ("skip_hint", _SL_NARR, "单击可跳过旁白。"), + ] # ---------------------------------------------------------------- escape @@ -198,6 +200,8 @@ ESCAPE_LINES = [ ("menu_restart", _ESC, "重新开始。"), ("menu_home", _ESC, "回大厅。"), ("menu_back", _ESC, "返回游戏。"), + ("skip_hint", _ESC, "单击可跳过旁白。"), + ] # ---------------------------------------------------------------- simon @@ -261,6 +265,8 @@ SIMON_LINES = [ ("menu_restart", _SIMON, "重新开始。"), ("menu_home", _SIMON, "回大厅。"), ("menu_back", _SIMON, "返回游戏。"), + ("skip_hint", _SIMON, "单击可跳过旁白。"), + ] # ---------------------------------------------------------------- rhythm @@ -305,6 +311,8 @@ RHYTHM_LINES = [ ("menu_main", _RHY, "菜单。单击切换选项,双击确认。"), ("menu_resume", _RHY, "继续演奏。"), ("menu_lobby", _RHY, "回大厅。"), + ("skip_hint", _RHY, "单击可跳过旁白。"), + ] # ---------------------------------------------------------------- space @@ -371,6 +379,8 @@ SPACE_LINES = [ ("help_recap", _SPC, "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。"), ("settings_hint", _SPC, "音色、语速、背景音乐,请回大厅修改。"), ("paused", _SPC, "已暂停。"), + ("skip_hint", _SPC, "单击可跳过旁白。"), + ] # ---------------------------------------------------------------- rpg @@ -495,6 +505,13 @@ RPG_LINES = [ ("foe_hp_high", _RPG, "敌人气焰正盛。"), ("foe_hp_mid", _RPG, "敌人露出疲态。"), ("foe_hp_low", _RPG, "敌人撑不住了。"), + ("skip_hint", _RPG, "单击可跳过旁白。"), + # ---- R6 补录:首次战斗教学(用户反馈:首战必须讲清交互) ---- + ("tut_battle_1", _RPG, "第一场战斗来了。别慌,战斗是回合制:你先出手,敌人再反击。"), + ("tut_battle_2", _RPG, "左右滑切换招式,攻击、破空斩、防御、金疮药。单击,出手。"), + ("tut_battle_3", _RPG, "敌人出手前有一声前摇。听到前摇,马上双击,就能格挡。"), + ("tut_battle_4", _RPG, "没把握就防御,伤害减半。金疮药可以回血。"), + ("tut_battle_5", _RPG, "记住:听清前摇,双击格挡。开始吧!"), ] # NPC 单音色平铺(id, speaker, instruct, 台词)→ web/rpg/audio/voice/.* diff --git a/tools/mureka_log.md b/tools/mureka_log.md index 500a9347..2a6d9e4d 100644 --- a/tools/mureka_log.md +++ b/tools/mureka_log.md @@ -92,3 +92,25 @@ boss=候选 1(choice 157969029332994,178.2s);ending=候选 1(choice 15 后处理:首个强 onset 起点 → 90s 裁剪 + 首尾 3s acrossfade(tri) 无缝循环 → loudnorm I=-20/TP=-2/LRA=11 → 44.1kHz → ogg q:a 3 + m4a 128k。成品均 90.00s(mean -23.0/-20.5/-22.6/-21.9dB)。 flac 原件存 tools/mureka_out/rpg_*/。 + +## 2026-08-30 R6:状态 BGM 7 首(docs/design/10-feedback2-state-bgm.md) + +预算线:本轮 $1.00;总预算 $2.00(5+轮)。 + +| # | task id | 用途 | 状态 | 花费 | +|---|---|---|---|---| +| 15 | 158096439705601 | storyline bgm/menu | succeeded | $0.03 | +| 16 | 158096580149249 | escape bgm/menu | succeeded | $0.03 | +| 17 | 158096714629126 | simon bgm/menu | succeeded | $0.03 | +| 18 | 158096850812929 | rhythm bgm/menu | succeeded | $0.03 | +| 19 | 158097039425538 | space bgm/menu | succeeded | $0.03 | +| 20 | 158097182294017 | rpg bgm/menu | succeeded | $0.03 | +| 21 | 158097360420865 | rpg bgm/explore(恢复,轻垫乐) | succeeded | $0.03 | + +**本轮花费:$0.21 / $1.00。累计:$0.63 / $2.00。** 全部一次成功无重试,均选候选 0。 + +后处理:首个强 onset 起点 → 90s 裁剪 + 首尾 3s acrossfade(tri) 无缝循环 → +menu pad 用 loudnorm **I=-24**/TP=-2(比主题曲低一档,10 文档 §1.2),rpg/explore 用 I=-20 +(引擎再按 volume 0.35 播放)→ ogg q:a 3 + m4a 128k。 +成品 mean:storyline -24.4 / escape -26.1 / simon -25.8 / rhythm -25.3 / space -25.1 / rpg menu -25.9 / rpg explore -22.3 dB。 +flac 原件存 tools/mureka_out/menu_*/ 与 rpg_explore/。 diff --git a/tools/scan_clicks.py b/tools/scan_clicks.py new file mode 100644 index 00000000..5029f262 --- /dev/null +++ b/tools/scan_clicks.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""爆音资产排查(R6):扫描 6 游戏全部 .ogg。 + +检测项: + 1. 峰值 > -1dBFS(有损编码过冲/归一不足) + 2. 首尾 5ms 内电平突变(无淡入淡出 → 爆豆声)。loop 类(bgm/ 与 sfx/amb_*)按 + 首尾交叉淡化工艺制造,边界连续是特性,只做 DC/峰值检查,不报爆豆、不修。 + 3. 直流偏置(|DC| > 0.001) +输出 JSON 行到 stdout 供汇总;--fix 模式见 fix_clicks.py 逻辑(本文件只扫)。 +""" +import os +import sys + +import numpy as np +import soundfile as sf + +ROOT = "/home/ubuntu/SoundGame/web" +GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg"] +EDGE_MS = 5 +DC_THRESH = 0.001 +PEAK_THRESH = 10 ** (-1.0 / 20) # -1dBFS + + +def is_loop(relpath): + parts = relpath.split(os.sep) + return "/bgm/" in "/" + relpath or os.path.basename(relpath).startswith("amb_") + + +def scan(path): + y, sr = sf.read(path, always_2d=True) + mono = y.mean(axis=1) + peak = float(np.max(np.abs(mono))) + dc = float(np.mean(mono)) + n5 = max(1, int(sr * EDGE_MS / 1000)) + n1 = max(1, int(sr * 1 / 1000)) + n50 = max(1, int(sr * 50 / 1000)) + head = float(np.max(np.abs(mono[:n5]))) + tail = float(np.max(np.abs(mono[-n5:]))) + # 爆豆判定(突变而非渐变):首/末 1ms 内的最大幅值 与 相邻 50ms 窗口峰值之比。 + # 正常淡入(8ms cos²)首 1ms 幅值 < 局部峰值 15%;硬边/步进则趋近 1。 + loc_h = float(np.max(np.abs(mono[:n50]))) + loc_t = float(np.max(np.abs(mono[-n50:]))) + FLOOR = 0.01 # 局部窗口低于 -40dB 视为静音区,不判爆豆(编解码底噪残余) + step_head = loc_h > FLOOR and float(np.max(np.abs(mono[:n1]))) > 0.15 * loc_h + step_tail = loc_t > FLOOR and float(np.max(np.abs(mono[-n1:]))) > 0.15 * loc_t + return { + "path": os.path.relpath(path, ROOT), + "loop": is_loop(os.path.relpath(path, ROOT)), + "peak_db": round(20 * np.log10(max(peak, 1e-9)), 2), + "dc": round(dc, 5), + "head5ms_db": round(20 * np.log10(max(head, 1e-9)), 2), + "tail5ms_db": round(20 * np.log10(max(tail, 1e-9)), 2), + "hot_peak": peak > PEAK_THRESH, + "dc_bad": abs(dc) > DC_THRESH, + "click_head": step_head, + "click_tail": step_tail, + } + + +def main(): + results = [] + for g in GAMES: + base = os.path.join(ROOT, g, "audio") + for dirpath, _d, files in os.walk(base): + for f in sorted(files): + if f.endswith(".ogg"): + results.append(scan(os.path.join(dirpath, f))) + import json + for r in results: + print(json.dumps(r, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/engine/audio.js b/web/engine/audio.js index 752c248e..0d50191e 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -32,15 +32,28 @@ * - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试 * `audio/voice//intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的 * `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。 - * - settings.speed:voice 层 source.playbackRate(语速),从下一句开始生效。 + * - settings.speed:voice 层语速,**变速不变调**——voice 走 SoundTouch 时间 + * 拉伸管线(vendor/soundtouch.js,ScriptProcessorNode),从下一句开始生效。 * - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。 * 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过; - * 调回非 0 时若此前有想播的 BGM 会自动补播)。 + * 调回非 0 时若此前有想播的 BGM 会自动补播——**状态 BGM 约定**:游戏按状态 + * (菜单/探索/战斗/结算)直接 play('bgm/xxx') 切换即可,bgm=0 时切换只更新 + * "想播",重新打开时引擎补播最后一个状态 BGM,游戏层无需特殊处理。 + * + * **爆音防护**:master 链末端挂 DynamicsCompressorNode 限幅 + * (threshold -6dB / ratio 12 / attack 3ms / release 250ms),sfx 总线基础 + * 增益 0.8——多路叠加超 0dBFS 时压限而非硬削波。 + * + * **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件 + * 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开 + * ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动。 */ import { settings } from './settings.js'; +import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js'; const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav']; +const LONGPRESS_BOOST = 1.7; // 长按快进倍速 export class WebAudioEngine { /** @@ -75,11 +88,17 @@ export class WebAudioEngine { this._cache = new Map(); // key -> Promise(voice 的 key 含性别) this._bgm = null; // { id, source, gain } 当前 BGM this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播) - this._voice = null; // { source, gain, stopped } 当前对白 token + this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice) this._voiceQueue = []; // queue:true 时的等待队列 // 设置变更实时生效 settings.subscribe((_, changed) => this._onSettingsChange(changed)); + + // 长按快进:voice 播放中长按 → 临时 1.7 倍速(不变调),松开恢复设置语速 + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('soundgame:longpress', () => this._voiceBoost(true)); + window.addEventListener('soundgame:longpressend', () => this._voiceBoost(false)); + } } /** @@ -91,8 +110,15 @@ export class WebAudioEngine { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) throw new Error('Web Audio API 不可用'); this.ctx = new AC(); + // master → 限幅器 → destination:多路叠加超 0dBFS 时压限而非硬削波 this._master = this.ctx.createGain(); - this._master.connect(this.ctx.destination); + this._limiter = this.ctx.createDynamicsCompressor(); + this._limiter.threshold.value = -6; + this._limiter.ratio.value = 12; + this._limiter.attack.value = 0.003; + this._limiter.release.value = 0.25; + this._master.connect(this._limiter); + this._limiter.connect(this.ctx.destination); this._bgmPrefBus = this.ctx.createGain(); this._bgmPrefBus.gain.value = settings.get().bgm; this._bgmPrefBus.connect(this._master); @@ -101,6 +127,7 @@ export class WebAudioEngine { this._voiceBus = this.ctx.createGain(); this._voiceBus.connect(this._master); this._sfxBus = this.ctx.createGain(); + this._sfxBus.gain.value = 0.8; // sfx 总线基础增益下调,多音叠加防过载 this._sfxBus.connect(this._master); } if (this.ctx.state !== 'running') await this.ctx.resume(); @@ -194,12 +221,28 @@ export class WebAudioEngine { this._voiceQueue.length = 0; const cur = this._voice; if (!cur) return; - cur.stopped = true; this._voice = null; - try { cur.source.stop(); } catch (_) { /* 已停止 */ } + this._killVoice(cur); this._unduckBgm(); } + /** + * 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其 + * onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异: + * stopVoice 作废整条链,skipVoice 让链继续走。 + */ + skipVoice() { + const cur = this._voice; + if (!cur) return; + this._voice = null; + this._killVoice(cur); + // 以下与 _playVoice 自然播完路径一致 + if (this._voiceQueue.length === 0) this._unduckBgm(); + if (cur.onComplete) cur.onComplete(); + const next = this._voiceQueue.shift(); + if (next) this._playVoice(next.id, next); + } + /** 停止 BGM + 对白(sfx 都很短,不主动停)。 */ stopAll() { this.stopBgm(200); @@ -322,6 +365,12 @@ export class WebAudioEngine { } } + /** + * voice 播放(SoundTouch 时间拉伸管线,变速不变调): + * WebAudioBufferSource → SimpleFilter(SoundTouch) → ScriptProcessorNode + * → gain → voiceBus。自然结束用定时器判定(源消耗位置 ÷ 当前 tempo), + * 支持中途改 tempo(长按快进)并随之修正结束时刻。 + */ async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) { if (queue && this._voice) { this._voiceQueue.push({ id, volume, onComplete }); @@ -330,35 +379,82 @@ export class WebAudioEngine { // 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义) if (this._voice) { const cur = this._voice; - cur.stopped = true; this._voice = null; - try { cur.source.stop(); } catch (_) { /* 已停止 */ } + this._killVoice(cur); } const buf = await this._load(id); if (!buf) { this._unduckBgm(); return; } - const source = this.ctx.createBufferSource(); - source.buffer = buf; - source.playbackRate.value = settings.get().speed; // 语速(下一句生效) + const st = new SoundTouch(); + st.tempo = settings.get().speed; // 语速(不变调,下一句生效) + const filter = new SimpleFilter(new WebAudioBufferSource(buf), st); + const node = getWebAudioNode(this.ctx, filter); // ScriptProcessorNode,接入即开播 const gain = this.ctx.createGain(); gain.gain.value = this.voiceVolume * volume; - source.connect(gain); + node.connect(gain); gain.connect(this._voiceBus); - const token = { source, gain, stopped: false }; + const token = { + node, gain, st, buf, + stopped: false, + onComplete, + posSec: 0, // 已消耗的源时长(秒,按 tempo 累计) + lastT: performance.now(), + endTimer: null, + }; this._voice = token; this._duckBgm(); + this._armVoiceEnd(token); + } - source.onended = () => { - if (this._voice !== token || token.stopped) return; // 被打断:不回调 - this._voice = null; - if (this._voiceQueue.length === 0) this._unduckBgm(); - if (onComplete) onComplete(); - const next = this._voiceQueue.shift(); - if (next) this._playVoice(next.id, next); - }; - source.start(); + /** 结算 voice 自然播完(onComplete 链 + 队列推进)。 */ + _finishVoice(token) { + if (this._voice !== token || token.stopped) return; // 被打断:不回调 + token.stopped = true; + this._voice = null; + this._killVoice(token); + if (this._voiceQueue.length === 0) this._unduckBgm(); + if (token.onComplete) token.onComplete(); + const next = this._voiceQueue.shift(); + if (next) this._playVoice(next.id, next); + } + + /** 按当前 tempo 排结束定时器;tempo 变化时重排。 */ + _armVoiceEnd(token) { + const remainSec = Math.max(0.05, (token.buf.duration - token.posSec) / token.st.tempo); + token.lastT = performance.now(); + token.endTimer = setTimeout(() => this._finishVoice(token), remainSec * 1000 + 120); + } + + /** 累计源消耗位置(tempo 变速前调用,保持剩余时长估算准确)。 */ + _updateVoicePos(token) { + const now = performance.now(); + token.posSec += ((now - token.lastT) / 1000) * token.st.tempo; + token.lastT = now; + } + + /** 终止 voice 播放(断开处理节点 + 清结束定时器),不触发任何回调。 */ + _killVoice(token) { + token.stopped = true; + if (token.endTimer !== null) { + clearTimeout(token.endTimer); + token.endTimer = null; + } + try { token.node.disconnect(); } catch (_) { /* 已断开 */ } + token.node.onaudioprocess = null; + } + + /** 长按快进:voice 播放中临时提速到 1.7(不变调),松开恢复设置语速。 */ + _voiceBoost(on) { + const cur = this._voice; + if (!cur || cur.stopped) return; + const target = on ? LONGPRESS_BOOST : settings.get().speed; + if (cur.st.tempo === target) return; + this._updateVoicePos(cur); + cur.st.tempo = target; + if (cur.endTimer !== null) clearTimeout(cur.endTimer); + this._armVoiceEnd(cur); } async _playSfx(id, { volume = 1 } = {}) { diff --git a/web/engine/gesture.js b/web/engine/gesture.js index e70adc7e..1b5b5426 100644 --- a/web/engine/gesture.js +++ b/web/engine/gesture.js @@ -2,6 +2,7 @@ * gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。 * * 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap' + * | 'longpress' | 'longpressend' * * 判定规则(与 Android 版 GestureConfig 一致): * - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi) @@ -10,6 +11,12 @@ * - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。 * **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击), * 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。 + * - **长按**:按住不动 >= longpressMs(默认 450ms)触发 'longpress', + * 松开触发 'longpressend';长按期间不触发 tap;位移超 tapSlop 取消 + * (已激活的长按会先补发 'longpressend',之后滑动照常判定)。 + * 长按触发/结束同时派发 window 事件 'soundgame:longpress' / + * 'soundgame:longpressend'——audio.js 内置监听:voice 播放中长按 = + * 旁白临时 1.7 倍速(不变调),松开恢复设置语速,游戏代码零改动。 * * 用法: * import { attachGestures } from '../engine/gesture.js'; @@ -32,8 +39,17 @@ export const DEFAULT_GESTURE_CONFIG = { swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长) doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调) tapSlop: 24, // px,位移小于此值才算点按 + longpressMs: 450, // ms,按住不动超过此值触发 longpress }; +/** 长按引擎事件:audio.js 监听它实现"长按=旁白临时倍速"。 */ +function _dispatchLongpress(type) { + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' + && typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent(`soundgame:${type}`)); + } +} + export function attachGestures(element, onGesture, config = {}) { const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config }; @@ -42,6 +58,10 @@ export function attachGestures(element, onGesture, config = {}) { let startX = 0, startY = 0, startTime = 0; let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按) + // 长按状态 + let longPressTimer = null; + let longPressActive = false; + // 点按状态 let lastTapTime = 0; let pendingTapTimer = null; @@ -56,22 +76,57 @@ export function attachGestures(element, onGesture, config = {}) { } } + function cancelLongPress() { + if (longPressTimer !== null) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + } + + /** 长按已激活后收尾(松开/滑动取消):补发 longpressend。 */ + function endLongPress() { + if (!longPressActive) return; + longPressActive = false; + _dispatchLongpress('longpressend'); + onGesture('longpressend'); + } + function handleDown(x, y) { tracking = true; moved = false; startX = x; startY = y; startTime = performance.now(); + cancelLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = null; + if (!tracking || moved) return; + // 触发长按:作废等待中的单击 + cancelPendingTap(); + longPressActive = true; + _dispatchLongpress('longpress'); + onGesture('longpress'); + }, cfg.longpressMs); } function handleMove(x, y) { if (!tracking) return; - if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) moved = true; + if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) { + moved = true; + cancelLongPress(); // 滑动取消长按判定 + endLongPress(); // 已激活的长按被滑动终止(之后滑动照常判定) + } } function handleUp(x, y) { if (!tracking) return; tracking = false; + cancelLongPress(); + if (longPressActive) { + endLongPress(); + lastTapTime = 0; + return; // 长按后不触发 tap/doubletap + } const dx = x - startX; const dy = y - startY; @@ -115,6 +170,8 @@ export function attachGestures(element, onGesture, config = {}) { function handleCancel() { tracking = false; + cancelLongPress(); + endLongPress(); } // ---- 触摸 ---- @@ -178,6 +235,8 @@ export function attachGestures(element, onGesture, config = {}) { return function detach() { cancelPendingTap(); + cancelLongPress(); + endLongPress(); element.removeEventListener('touchstart', onTouchStart, opts); element.removeEventListener('touchmove', onTouchMove, opts); element.removeEventListener('touchend', onTouchEnd, opts); diff --git a/web/engine/haptics.js b/web/engine/haptics.js index c2af8e06..2a868f38 100644 --- a/web/engine/haptics.js +++ b/web/engine/haptics.js @@ -15,8 +15,12 @@ * | beat | [8] | 节拍锚点(最轻) | * | danger | [25,40,25,40,25] | 危险逼近 | * - * 特性检测:'vibrate' in navigator。不支持的平台(iOS Safari 等) - * 所有调用静默 no-op,不抛异常。 + * 特性检测:'vibrate' in navigator。支持的平台正常震动; + * **不支持的平台(iOS Safari 等)降级为视觉反馈**——全屏边框闪光覆盖层 + * (#haptics-flash,样式在 style.css),不同震型对应不同颜色与闪烁次数: + * confirm 白·1 次 / error 红·2 次 / success 绿·1 次 / warning 橙·3 次 / + * swipe 白·极淡 1 次 / heartbeat 粉·2 次 / pickup 青·1 次 / hit 红·强 1 次 / + * beat 白·极淡 1 次 / danger 红·快 3 次。两者都不行的环境静默 no-op。 * * 用法: * import { vibrate } from '../engine/haptics.js'; @@ -41,26 +45,67 @@ export const HAPTIC_PATTERNS = { danger: [25, 40, 25, 40, 25], }; -/** 当前环境是否支持震动(iOS Safari 为 false)。 */ +/** 当前环境是否支持震动(iOS Safari 为 false,此时 vibrate() 走视觉降级)。 */ export const hapticsSupported = typeof navigator !== 'undefined' && 'vibrate' in navigator; +/** 震型 → 视觉降级闪光参数:[颜色, 动画 class, 亮度] */ +const FLASH_MAP = { + confirm: ['#ffffff', 'hp-once', 0.7], + error: ['#ff5252', 'hp-twice', 0.9], + success: ['#69f0ae', 'hp-once', 0.9], + warning: ['#ffb74d', 'hp-thrice', 0.9], + swipe: ['#ffffff', 'hp-once', 0.25], + heartbeat: ['#f48fb1', 'hp-twice', 0.7], + pickup: ['#80deea', 'hp-once', 0.7], + hit: ['#ff1744', 'hp-once', 1], + beat: ['#ffffff', 'hp-once', 0.15], + danger: ['#ff5252', 'hp-thrice', 0.9], +}; + +let flashEl = null; + +/** 视觉降级:全屏边框闪光(覆盖层常驻 body,改 class 重触发动画)。 */ +function _flash(patternName) { + if (typeof document === 'undefined') return; + const [color, anim, alpha] = FLASH_MAP[patternName] || FLASH_MAP.confirm; + if (!flashEl) { + flashEl = document.getElementById('haptics-flash'); + if (!flashEl) { + flashEl = document.createElement('div'); + flashEl.id = 'haptics-flash'; + document.body.appendChild(flashEl); + } + } + flashEl.style.borderColor = color; + flashEl.style.boxShadow = `inset 0 0 32px ${color}`; + flashEl.style.setProperty('--hp-alpha', alpha); + flashEl.style.opacity = ''; + flashEl.className = ''; + void flashEl.offsetWidth; // 强制 reflow,重触发动画 + flashEl.className = anim; +} + /** - * 触发震动。不支持时静默 no-op。 + * 触发震动;不支持 vibrate 的平台自动降级为边框闪光。 * @param {keyof typeof HAPTIC_PATTERNS | number[]} pattern 预定义名称或毫秒数组 */ export function vibrate(pattern) { - if (!hapticsSupported) return; - const p = typeof pattern === 'string' ? HAPTIC_PATTERNS[pattern] : pattern; + const name = typeof pattern === 'string' ? pattern : null; + const p = name ? HAPTIC_PATTERNS[name] : pattern; if (!p) { console.warn('[haptics] 未知震型:', pattern); return; } - try { - navigator.vibrate(p); - } catch (_) { - // 某些平台调用即抛(极少见)→ 静默 + if (hapticsSupported) { + try { + navigator.vibrate(p); + return; + } catch (_) { + // 某些平台调用即抛(极少见)→ 落入视觉降级 + } } + _flash(name || 'confirm'); // 自定义数组无语义名,按 confirm 闪 } export default vibrate; diff --git a/web/engine/style.css b/web/engine/style.css index f0a92fc3..67ffc5c3 100644 --- a/web/engine/style.css +++ b/web/engine/style.css @@ -56,7 +56,7 @@ html, body { position: absolute; left: 0; right: 0; - bottom: 18%; + top: 30%; /* 屏幕中间偏上,避开封面图底部文字区 */ text-align: center; color: #fff; font: 500 20px/1.5 system-ui, sans-serif; @@ -69,3 +69,24 @@ html, body { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } + +/* ---- 震动视觉降级(haptics.js,iOS 等无 vibrate 平台) ---- + 全屏边框闪光覆盖层:震型 → 颜色/闪烁次数(hp-once/twice/thrice)。 */ +#haptics-flash { + position: fixed; + inset: 0; + z-index: 90; + pointer-events: none; + opacity: 0; + border-style: solid; + border-width: 6px; + border-radius: 4px; +} +#haptics-flash.hp-once { animation: hp-flash 0.28s ease-out 1; } +#haptics-flash.hp-twice { animation: hp-flash 0.24s ease-out 2; } +#haptics-flash.hp-thrice { animation: hp-flash 0.2s ease-out 3; } +@keyframes hp-flash { + 0% { opacity: 0; } + 20% { opacity: var(--hp-alpha, 1); } + 100% { opacity: 0; } +} diff --git a/web/engine/vendor/soundtouch.js b/web/engine/vendor/soundtouch.js new file mode 100644 index 00000000..91c16583 --- /dev/null +++ b/web/engine/vendor/soundtouch.js @@ -0,0 +1,794 @@ +/* + * SoundTouch JS v0.3.0 audio processing library + * Copyright (c) Olli Parviainen + * Copyright (c) Ryan Berdeen + * Copyright (c) Jakub Fiala + * Copyright (c) Steve 'Cutter' Blades + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ + +class FifoSampleBuffer { + constructor() { + this._vector = new Float32Array(); + this._position = 0; + this._frameCount = 0; + } + get vector() { + return this._vector; + } + get position() { + return this._position; + } + get startIndex() { + return this._position * 2; + } + get frameCount() { + return this._frameCount; + } + get endIndex() { + return (this._position + this._frameCount) * 2; + } + clear() { + this._vector.fill(0); + this._position = 0; + this._frameCount = 0; + } + put(numFrames) { + this._frameCount += numFrames; + } + putSamples(samples, position, numFrames = 0) { + position = position || 0; + const sourceOffset = position * 2; + if (!(numFrames >= 0)) { + numFrames = (samples.length - sourceOffset) / 2; + } + const numSamples = numFrames * 2; + this.ensureCapacity(numFrames + this._frameCount); + const destOffset = this.endIndex; + this.vector.set(samples.subarray(sourceOffset, sourceOffset + numSamples), destOffset); + this._frameCount += numFrames; + } + putBuffer(buffer, position, numFrames = 0) { + position = position || 0; + if (!(numFrames >= 0)) { + numFrames = buffer.frameCount - position; + } + this.putSamples(buffer.vector, buffer.position + position, numFrames); + } + receive(numFrames) { + if (!(numFrames >= 0) || numFrames > this._frameCount) { + numFrames = this.frameCount; + } + this._frameCount -= numFrames; + this._position += numFrames; + } + receiveSamples(output, numFrames = 0) { + const numSamples = numFrames * 2; + const sourceOffset = this.startIndex; + output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); + this.receive(numFrames); + } + extract(output, position = 0, numFrames = 0) { + const sourceOffset = this.startIndex + position * 2; + const numSamples = numFrames * 2; + output.set(this._vector.subarray(sourceOffset, sourceOffset + numSamples)); + } + ensureCapacity(numFrames = 0) { + const minLength = parseInt(numFrames * 2); + if (this._vector.length < minLength) { + const newVector = new Float32Array(minLength); + newVector.set(this._vector.subarray(this.startIndex, this.endIndex)); + this._vector = newVector; + this._position = 0; + } else { + this.rewind(); + } + } + ensureAdditionalCapacity(numFrames = 0) { + this.ensureCapacity(this._frameCount + numFrames); + } + rewind() { + if (this._position > 0) { + this._vector.set(this._vector.subarray(this.startIndex, this.endIndex)); + this._position = 0; + } + } +} + +class AbstractFifoSamplePipe { + constructor(createBuffers) { + if (createBuffers) { + this._inputBuffer = new FifoSampleBuffer(); + this._outputBuffer = new FifoSampleBuffer(); + } else { + this._inputBuffer = this._outputBuffer = null; + } + } + get inputBuffer() { + return this._inputBuffer; + } + set inputBuffer(inputBuffer) { + this._inputBuffer = inputBuffer; + } + get outputBuffer() { + return this._outputBuffer; + } + set outputBuffer(outputBuffer) { + this._outputBuffer = outputBuffer; + } + clear() { + this._inputBuffer.clear(); + this._outputBuffer.clear(); + } +} + +class RateTransposer extends AbstractFifoSamplePipe { + constructor(createBuffers) { + super(createBuffers); + this.reset(); + this._rate = 1; + } + set rate(rate) { + this._rate = rate; + } + reset() { + this.slopeCount = 0; + this.prevSampleL = 0; + this.prevSampleR = 0; + } + clear() { + super.clear(); + this.reset(); + } + clone() { + const result = new RateTransposer(); + result.rate = this._rate; + return result; + } + process() { + const numFrames = this._inputBuffer.frameCount; + this._outputBuffer.ensureAdditionalCapacity(numFrames / this._rate + 1); + const numFramesOutput = this.transpose(numFrames); + this._inputBuffer.receive(); + this._outputBuffer.put(numFramesOutput); + } + transpose(numFrames = 0) { + if (numFrames === 0) { + return 0; + } + const src = this._inputBuffer.vector; + const srcOffset = this._inputBuffer.startIndex; + const dest = this._outputBuffer.vector; + const destOffset = this._outputBuffer.endIndex; + let used = 0; + let i = 0; + while (this.slopeCount < 1.0) { + dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * this.prevSampleL + this.slopeCount * src[srcOffset]; + dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * this.prevSampleR + this.slopeCount * src[srcOffset + 1]; + i = i + 1; + this.slopeCount += this._rate; + } + this.slopeCount -= 1.0; + if (numFrames !== 1) { + out: while (true) { + while (this.slopeCount > 1.0) { + this.slopeCount -= 1.0; + used = used + 1; + if (used >= numFrames - 1) { + break out; + } + } + const srcIndex = srcOffset + 2 * used; + dest[destOffset + 2 * i] = (1.0 - this.slopeCount) * src[srcIndex] + this.slopeCount * src[srcIndex + 2]; + dest[destOffset + 2 * i + 1] = (1.0 - this.slopeCount) * src[srcIndex + 1] + this.slopeCount * src[srcIndex + 3]; + i = i + 1; + this.slopeCount += this._rate; + } + } + this.prevSampleL = src[srcOffset + 2 * numFrames - 2]; + this.prevSampleR = src[srcOffset + 2 * numFrames - 1]; + return i; + } +} + +class FilterSupport { + constructor(pipe) { + this._pipe = pipe; + } + get pipe() { + return this._pipe; + } + get inputBuffer() { + return this._pipe.inputBuffer; + } + get outputBuffer() { + return this._pipe.outputBuffer; + } + fillInputBuffer( + ) { + throw new Error('fillInputBuffer() not overridden'); + } + fillOutputBuffer(numFrames = 0) { + while (this.outputBuffer.frameCount < numFrames) { + const numInputFrames = 8192 * 2 - this.inputBuffer.frameCount; + this.fillInputBuffer(numInputFrames); + if (this.inputBuffer.frameCount < 8192 * 2) { + break; + } + this._pipe.process(); + } + } + clear() { + this._pipe.clear(); + } +} + +const noop = function () { + return; +}; + +class SimpleFilter extends FilterSupport { + constructor(sourceSound, pipe, callback = noop) { + super(pipe); + this.callback = callback; + this.sourceSound = sourceSound; + this.historyBufferSize = 22050; + this._sourcePosition = 0; + this.outputBufferPosition = 0; + this._position = 0; + } + get position() { + return this._position; + } + set position(position) { + if (position > this._position) { + throw new RangeError('New position may not be greater than current position'); + } + const newOutputBufferPosition = this.outputBufferPosition - (this._position - position); + if (newOutputBufferPosition < 0) { + throw new RangeError('New position falls outside of history buffer'); + } + this.outputBufferPosition = newOutputBufferPosition; + this._position = position; + } + get sourcePosition() { + return this._sourcePosition; + } + set sourcePosition(sourcePosition) { + this.clear(); + this._sourcePosition = sourcePosition; + } + onEnd() { + this.callback(); + } + fillInputBuffer(numFrames = 0) { + const samples = new Float32Array(numFrames * 2); + const numFramesExtracted = this.sourceSound.extract(samples, numFrames, this._sourcePosition); + this._sourcePosition += numFramesExtracted; + this.inputBuffer.putSamples(samples, 0, numFramesExtracted); + } + extract(target, numFrames = 0) { + this.fillOutputBuffer(this.outputBufferPosition + numFrames); + const numFramesExtracted = Math.min(numFrames, this.outputBuffer.frameCount - this.outputBufferPosition); + this.outputBuffer.extract(target, this.outputBufferPosition, numFramesExtracted); + const currentFrames = this.outputBufferPosition + numFramesExtracted; + this.outputBufferPosition = Math.min(this.historyBufferSize, currentFrames); + this.outputBuffer.receive(Math.max(currentFrames - this.historyBufferSize, 0)); + this._position += numFramesExtracted; + return numFramesExtracted; + } + handleSampleData(event) { + this.extract(event.data, 4096); + } + clear() { + super.clear(); + this.outputBufferPosition = 0; + } +} + +const USE_AUTO_SEQUENCE_LEN = 0; +const DEFAULT_SEQUENCE_MS = USE_AUTO_SEQUENCE_LEN; +const USE_AUTO_SEEKWINDOW_LEN = 0; +const DEFAULT_SEEKWINDOW_MS = USE_AUTO_SEEKWINDOW_LEN; +const DEFAULT_OVERLAP_MS = 8; +const _SCAN_OFFSETS = [[124, 186, 248, 310, 372, 434, 496, 558, 620, 682, 744, 806, 868, 930, 992, 1054, 1116, 1178, 1240, 1302, 1364, 1426, 1488, 0], [-100, -75, -50, -25, 25, 50, 75, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-20, -15, -10, -5, 5, 10, 15, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [-4, -3, -2, -1, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]; +const AUTOSEQ_TEMPO_LOW = 0.25; +const AUTOSEQ_TEMPO_TOP = 4.0; +const AUTOSEQ_AT_MIN = 125.0; +const AUTOSEQ_AT_MAX = 50.0; +const AUTOSEQ_K = (AUTOSEQ_AT_MAX - AUTOSEQ_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); +const AUTOSEQ_C = AUTOSEQ_AT_MIN - AUTOSEQ_K * AUTOSEQ_TEMPO_LOW; +const AUTOSEEK_AT_MIN = 25.0; +const AUTOSEEK_AT_MAX = 15.0; +const AUTOSEEK_K = (AUTOSEEK_AT_MAX - AUTOSEEK_AT_MIN) / (AUTOSEQ_TEMPO_TOP - AUTOSEQ_TEMPO_LOW); +const AUTOSEEK_C = AUTOSEEK_AT_MIN - AUTOSEEK_K * AUTOSEQ_TEMPO_LOW; +class Stretch extends AbstractFifoSamplePipe { + constructor(createBuffers) { + super(createBuffers); + this._quickSeek = true; + this.midBufferDirty = false; + this.midBuffer = null; + this.overlapLength = 0; + this.autoSeqSetting = true; + this.autoSeekSetting = true; + this._tempo = 1; + this.setParameters(44100, DEFAULT_SEQUENCE_MS, DEFAULT_SEEKWINDOW_MS, DEFAULT_OVERLAP_MS); + } + clear() { + super.clear(); + this.clearMidBuffer(); + } + clearMidBuffer() { + this.midBufferDirty = false; + this.midBuffer = null; + if (this.refMidBuffer) { + this.refMidBuffer.fill(0); + } + this.skipFract = 0; + } + setParameters(sampleRate, sequenceMs, seekWindowMs, overlapMs) { + if (sampleRate > 0) { + this.sampleRate = sampleRate; + } + if (overlapMs > 0) { + this.overlapMs = overlapMs; + } + if (sequenceMs > 0) { + this.sequenceMs = sequenceMs; + this.autoSeqSetting = false; + } else { + this.autoSeqSetting = true; + } + if (seekWindowMs > 0) { + this.seekWindowMs = seekWindowMs; + this.autoSeekSetting = false; + } else { + this.autoSeekSetting = true; + } + this.calculateSequenceParameters(); + this.calculateOverlapLength(this.overlapMs); + this.tempo = this._tempo; + } + set tempo(newTempo) { + let intskip; + this._tempo = newTempo; + this.calculateSequenceParameters(); + this.nominalSkip = this._tempo * (this.seekWindowLength - this.overlapLength); + this.skipFract = 0; + intskip = Math.floor(this.nominalSkip + 0.5); + this.sampleReq = Math.max(intskip + this.overlapLength, this.seekWindowLength) + this.seekLength; + } + get tempo() { + return this._tempo; + } + get inputChunkSize() { + return this.sampleReq; + } + get outputChunkSize() { + return this.overlapLength + Math.max(0, this.seekWindowLength - 2 * this.overlapLength); + } + calculateOverlapLength(overlapInMsec = 0) { + let newOvl; + newOvl = this.sampleRate * overlapInMsec / 1000; + newOvl = newOvl < 16 ? 16 : newOvl; + newOvl -= newOvl % 8; + this.overlapLength = newOvl; + this.refMidBuffer = new Float32Array(this.overlapLength * 2); + this.midBuffer = new Float32Array(this.overlapLength * 2); + } + checkLimits(x, mi, ma) { + return x < mi ? mi : x > ma ? ma : x; + } + calculateSequenceParameters() { + let seq; + let seek; + if (this.autoSeqSetting) { + seq = AUTOSEQ_C + AUTOSEQ_K * this._tempo; + seq = this.checkLimits(seq, AUTOSEQ_AT_MAX, AUTOSEQ_AT_MIN); + this.sequenceMs = Math.floor(seq + 0.5); + } + if (this.autoSeekSetting) { + seek = AUTOSEEK_C + AUTOSEEK_K * this._tempo; + seek = this.checkLimits(seek, AUTOSEEK_AT_MAX, AUTOSEEK_AT_MIN); + this.seekWindowMs = Math.floor(seek + 0.5); + } + this.seekWindowLength = Math.floor(this.sampleRate * this.sequenceMs / 1000); + this.seekLength = Math.floor(this.sampleRate * this.seekWindowMs / 1000); + } + set quickSeek(enable) { + this._quickSeek = enable; + } + clone() { + const result = new Stretch(); + result.tempo = this._tempo; + result.setParameters(this.sampleRate, this.sequenceMs, this.seekWindowMs, this.overlapMs); + return result; + } + seekBestOverlapPosition() { + return this._quickSeek ? this.seekBestOverlapPositionStereoQuick() : this.seekBestOverlapPositionStereo(); + } + seekBestOverlapPositionStereo() { + let bestOffset; + let bestCorrelation; + let correlation; + let i = 0; + this.preCalculateCorrelationReferenceStereo(); + bestOffset = 0; + bestCorrelation = Number.MIN_VALUE; + for (; i < this.seekLength; i = i + 1) { + correlation = this.calculateCrossCorrelationStereo(2 * i, this.refMidBuffer); + if (correlation > bestCorrelation) { + bestCorrelation = correlation; + bestOffset = i; + } + } + return bestOffset; + } + seekBestOverlapPositionStereoQuick() { + let bestOffset; + let bestCorrelation; + let correlation; + let scanCount = 0; + let correlationOffset; + let tempOffset; + this.preCalculateCorrelationReferenceStereo(); + bestCorrelation = Number.MIN_VALUE; + bestOffset = 0; + correlationOffset = 0; + tempOffset = 0; + for (; scanCount < 4; scanCount = scanCount + 1) { + let j = 0; + while (_SCAN_OFFSETS[scanCount][j]) { + tempOffset = correlationOffset + _SCAN_OFFSETS[scanCount][j]; + if (tempOffset >= this.seekLength) { + break; + } + correlation = this.calculateCrossCorrelationStereo(2 * tempOffset, this.refMidBuffer); + if (correlation > bestCorrelation) { + bestCorrelation = correlation; + bestOffset = tempOffset; + } + j = j + 1; + } + correlationOffset = bestOffset; + } + return bestOffset; + } + preCalculateCorrelationReferenceStereo() { + let i = 0; + let context; + let temp; + for (; i < this.overlapLength; i = i + 1) { + temp = i * (this.overlapLength - i); + context = i * 2; + this.refMidBuffer[context] = this.midBuffer[context] * temp; + this.refMidBuffer[context + 1] = this.midBuffer[context + 1] * temp; + } + } + calculateCrossCorrelationStereo(mixingPosition, compare) { + const mixing = this._inputBuffer.vector; + mixingPosition += this._inputBuffer.startIndex; + let correlation = 0; + let i = 2; + const calcLength = 2 * this.overlapLength; + let mixingOffset; + for (; i < calcLength; i = i + 2) { + mixingOffset = i + mixingPosition; + correlation += mixing[mixingOffset] * compare[i] + mixing[mixingOffset + 1] * compare[i + 1]; + } + return correlation; + } + overlap(overlapPosition) { + this.overlapStereo(2 * overlapPosition); + } + overlapStereo(inputPosition) { + const input = this._inputBuffer.vector; + inputPosition += this._inputBuffer.startIndex; + const output = this._outputBuffer.vector; + const outputPosition = this._outputBuffer.endIndex; + let i = 0; + let context; + let tempFrame; + const frameScale = 1 / this.overlapLength; + let fi; + let inputOffset; + let outputOffset; + for (; i < this.overlapLength; i = i + 1) { + tempFrame = (this.overlapLength - i) * frameScale; + fi = i * frameScale; + context = 2 * i; + inputOffset = context + inputPosition; + outputOffset = context + outputPosition; + output[outputOffset + 0] = input[inputOffset + 0] * fi + this.midBuffer[context + 0] * tempFrame; + output[outputOffset + 1] = input[inputOffset + 1] * fi + this.midBuffer[context + 1] * tempFrame; + } + } + process() { + let offset; + let temp; + let overlapSkip; + if (this.midBuffer === null) { + if (this._inputBuffer.frameCount < this.overlapLength) { + return; + } + this.midBuffer = new Float32Array(this.overlapLength * 2); + this._inputBuffer.receiveSamples(this.midBuffer, this.overlapLength); + } + while (this._inputBuffer.frameCount >= this.sampleReq) { + offset = this.seekBestOverlapPosition(); + this._outputBuffer.ensureAdditionalCapacity(this.overlapLength); + this.overlap(Math.floor(offset)); + this._outputBuffer.put(this.overlapLength); + temp = this.seekWindowLength - 2 * this.overlapLength; + if (temp > 0) { + this._outputBuffer.putBuffer(this._inputBuffer, offset + this.overlapLength, temp); + } + const start = this._inputBuffer.startIndex + 2 * (offset + this.seekWindowLength - this.overlapLength); + this.midBuffer.set(this._inputBuffer.vector.subarray(start, start + 2 * this.overlapLength)); + this.skipFract += this.nominalSkip; + overlapSkip = Math.floor(this.skipFract); + this.skipFract -= overlapSkip; + this._inputBuffer.receive(overlapSkip); + } + } +} + +const testFloatEqual = function (a, b) { + return (a > b ? a - b : b - a) > 1e-10; +}; + +class SoundTouch { + constructor() { + this.transposer = new RateTransposer(false); + this.stretch = new Stretch(false); + this._inputBuffer = new FifoSampleBuffer(); + this._intermediateBuffer = new FifoSampleBuffer(); + this._outputBuffer = new FifoSampleBuffer(); + this._rate = 0; + this._tempo = 0; + this.virtualPitch = 1.0; + this.virtualRate = 1.0; + this.virtualTempo = 1.0; + this.calculateEffectiveRateAndTempo(); + } + clear() { + this.transposer.clear(); + this.stretch.clear(); + } + clone() { + const result = new SoundTouch(); + result.rate = this.rate; + result.tempo = this.tempo; + return result; + } + get rate() { + return this._rate; + } + set rate(rate) { + this.virtualRate = rate; + this.calculateEffectiveRateAndTempo(); + } + set rateChange(rateChange) { + this._rate = 1.0 + 0.01 * rateChange; + } + get tempo() { + return this._tempo; + } + set tempo(tempo) { + this.virtualTempo = tempo; + this.calculateEffectiveRateAndTempo(); + } + set tempoChange(tempoChange) { + this.tempo = 1.0 + 0.01 * tempoChange; + } + set pitch(pitch) { + this.virtualPitch = pitch; + this.calculateEffectiveRateAndTempo(); + } + set pitchOctaves(pitchOctaves) { + this.pitch = Math.exp(0.69314718056 * pitchOctaves); + this.calculateEffectiveRateAndTempo(); + } + set pitchSemitones(pitchSemitones) { + this.pitchOctaves = pitchSemitones / 12.0; + } + get inputBuffer() { + return this._inputBuffer; + } + get outputBuffer() { + return this._outputBuffer; + } + calculateEffectiveRateAndTempo() { + const previousTempo = this._tempo; + const previousRate = this._rate; + this._tempo = this.virtualTempo / this.virtualPitch; + this._rate = this.virtualRate * this.virtualPitch; + if (testFloatEqual(this._tempo, previousTempo)) { + this.stretch.tempo = this._tempo; + } + if (testFloatEqual(this._rate, previousRate)) { + this.transposer.rate = this._rate; + } + if (this._rate > 1.0) { + if (this._outputBuffer != this.transposer.outputBuffer) { + this.stretch.inputBuffer = this._inputBuffer; + this.stretch.outputBuffer = this._intermediateBuffer; + this.transposer.inputBuffer = this._intermediateBuffer; + this.transposer.outputBuffer = this._outputBuffer; + } + } else { + if (this._outputBuffer != this.stretch.outputBuffer) { + this.transposer.inputBuffer = this._inputBuffer; + this.transposer.outputBuffer = this._intermediateBuffer; + this.stretch.inputBuffer = this._intermediateBuffer; + this.stretch.outputBuffer = this._outputBuffer; + } + } + } + process() { + if (this._rate > 1.0) { + this.stretch.process(); + this.transposer.process(); + } else { + this.transposer.process(); + this.stretch.process(); + } + } +} + +class WebAudioBufferSource { + constructor(buffer) { + this.buffer = buffer; + this._position = 0; + } + get dualChannel() { + return this.buffer.numberOfChannels > 1; + } + get position() { + return this._position; + } + set position(value) { + this._position = value; + } + extract(target, numFrames = 0, position = 0) { + this.position = position; + let left = this.buffer.getChannelData(0); + let right = this.dualChannel ? this.buffer.getChannelData(1) : this.buffer.getChannelData(0); + let i = 0; + for (; i < numFrames; i++) { + target[i * 2] = left[i + position]; + target[i * 2 + 1] = right[i + position]; + } + return Math.min(numFrames, left.length - position); + } +} + +const getWebAudioNode = function (context, filter, sourcePositionCallback = noop, bufferSize = 4096) { + const node = context.createScriptProcessor(bufferSize, 2, 2); + const samples = new Float32Array(bufferSize * 2); + node.onaudioprocess = event => { + let left = event.outputBuffer.getChannelData(0); + let right = event.outputBuffer.getChannelData(1); + let framesExtracted = filter.extract(samples, bufferSize); + sourcePositionCallback(filter.sourcePosition); + if (framesExtracted === 0) { + filter.onEnd(); + } + let i = 0; + for (; i < framesExtracted; i++) { + left[i] = samples[i * 2]; + right[i] = samples[i * 2 + 1]; + } + }; + return node; +}; + +const pad = function (n, width, z) { + z = z || '0'; + n = n + ''; + return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; +}; +const minsSecs = function (secs) { + const mins = Math.floor(secs / 60); + const seconds = secs - mins * 60; + return `${mins}:${pad(parseInt(seconds), 2)}`; +}; + +const onUpdate = function (sourcePosition) { + const currentTimePlayed = this.timePlayed; + const sampleRate = this.sampleRate; + this.sourcePosition = sourcePosition; + this.timePlayed = sourcePosition / sampleRate; + if (currentTimePlayed !== this.timePlayed) { + const timePlayed = new CustomEvent('play', { + detail: { + timePlayed: this.timePlayed, + formattedTimePlayed: this.formattedTimePlayed, + percentagePlayed: this.percentagePlayed + } + }); + this._node.dispatchEvent(timePlayed); + } +}; +class PitchShifter { + constructor(context, buffer, bufferSize, onEnd = noop) { + this._soundtouch = new SoundTouch(); + const source = new WebAudioBufferSource(buffer); + this.timePlayed = 0; + this.sourcePosition = 0; + this._filter = new SimpleFilter(source, this._soundtouch, onEnd); + this._node = getWebAudioNode(context, this._filter, sourcePostion => onUpdate.call(this, sourcePostion), bufferSize); + this.tempo = 1; + this.rate = 1; + this.duration = buffer.duration; + this.sampleRate = context.sampleRate; + this.listeners = []; + } + get formattedDuration() { + return minsSecs(this.duration); + } + get formattedTimePlayed() { + return minsSecs(this.timePlayed); + } + get percentagePlayed() { + return 100 * this._filter.sourcePosition / (this.duration * this.sampleRate); + } + set percentagePlayed(perc) { + this._filter.sourcePosition = parseInt(perc * this.duration * this.sampleRate); + this.sourcePosition = this._filter.sourcePosition; + this.timePlayed = this.sourcePosition / this.sampleRate; + } + get node() { + return this._node; + } + set pitch(pitch) { + this._soundtouch.pitch = pitch; + } + set pitchSemitones(semitone) { + this._soundtouch.pitchSemitones = semitone; + } + set rate(rate) { + this._soundtouch.rate = rate; + } + set tempo(tempo) { + this._soundtouch.tempo = tempo; + } + connect(toNode) { + this._node.connect(toNode); + } + disconnect() { + this._node.disconnect(); + } + on(eventName, cb) { + this.listeners.push({ + name: eventName, + cb: cb + }); + this._node.addEventListener(eventName, event => cb(event.detail)); + } + off(eventName = null) { + let listeners = this.listeners; + if (eventName) { + listeners = listeners.filter(e => e.name === eventName); + } + listeners.forEach(e => { + this._node.removeEventListener(e.name, event => e.cb(event.detail)); + }); + } +} + +export { AbstractFifoSamplePipe, PitchShifter, RateTransposer, SimpleFilter, SoundTouch, Stretch, WebAudioBufferSource, getWebAudioNode }; +//# sourceMappingURL=soundtouch.js.map diff --git a/web/escape/audio/bgm/menu.m4a b/web/escape/audio/bgm/menu.m4a new file mode 100644 index 00000000..05bc35c0 Binary files /dev/null and b/web/escape/audio/bgm/menu.m4a differ diff --git a/web/escape/audio/bgm/menu.ogg b/web/escape/audio/bgm/menu.ogg new file mode 100644 index 00000000..7cc50c6c Binary files /dev/null and b/web/escape/audio/bgm/menu.ogg differ diff --git a/web/escape/audio/sfx/amb_power.m4a b/web/escape/audio/sfx/amb_power.m4a index 2aa121e8..8ea8bc0a 100644 Binary files a/web/escape/audio/sfx/amb_power.m4a and b/web/escape/audio/sfx/amb_power.m4a differ diff --git a/web/escape/audio/sfx/amb_power.ogg b/web/escape/audio/sfx/amb_power.ogg index d25bf2c6..52b49829 100644 Binary files a/web/escape/audio/sfx/amb_power.ogg and b/web/escape/audio/sfx/amb_power.ogg differ diff --git a/web/escape/audio/sfx/door_creak.m4a b/web/escape/audio/sfx/door_creak.m4a index 515287b3..dae0b738 100644 Binary files a/web/escape/audio/sfx/door_creak.m4a and b/web/escape/audio/sfx/door_creak.m4a differ diff --git a/web/escape/audio/sfx/door_creak.ogg b/web/escape/audio/sfx/door_creak.ogg index 87d6c6e4..8ff52334 100644 Binary files a/web/escape/audio/sfx/door_creak.ogg and b/web/escape/audio/sfx/door_creak.ogg differ diff --git a/web/escape/audio/sfx/gate_open.m4a b/web/escape/audio/sfx/gate_open.m4a index fa05301f..a52134d7 100644 Binary files a/web/escape/audio/sfx/gate_open.m4a and b/web/escape/audio/sfx/gate_open.m4a differ diff --git a/web/escape/audio/sfx/gate_open.ogg b/web/escape/audio/sfx/gate_open.ogg index b467550d..26d44c1c 100644 Binary files a/web/escape/audio/sfx/gate_open.ogg and b/web/escape/audio/sfx/gate_open.ogg differ diff --git a/web/escape/audio/sfx/pickup.m4a b/web/escape/audio/sfx/pickup.m4a index 0e984e6b..3a30f150 100644 Binary files a/web/escape/audio/sfx/pickup.m4a and b/web/escape/audio/sfx/pickup.m4a differ diff --git a/web/escape/audio/sfx/pickup.ogg b/web/escape/audio/sfx/pickup.ogg index ac04dafc..abce94d1 100644 Binary files a/web/escape/audio/sfx/pickup.ogg and b/web/escape/audio/sfx/pickup.ogg differ diff --git a/web/escape/audio/sfx/power_on.m4a b/web/escape/audio/sfx/power_on.m4a index cd681483..24bf86ce 100644 Binary files a/web/escape/audio/sfx/power_on.m4a and b/web/escape/audio/sfx/power_on.m4a differ diff --git a/web/escape/audio/sfx/power_on.ogg b/web/escape/audio/sfx/power_on.ogg index 58c77aeb..fc526db6 100644 Binary files a/web/escape/audio/sfx/power_on.ogg and b/web/escape/audio/sfx/power_on.ogg differ diff --git a/web/escape/audio/sfx/spark.m4a b/web/escape/audio/sfx/spark.m4a index 6312cb9a..f3778b3c 100644 Binary files a/web/escape/audio/sfx/spark.m4a and b/web/escape/audio/sfx/spark.m4a differ diff --git a/web/escape/audio/sfx/spark.ogg b/web/escape/audio/sfx/spark.ogg index f1bff94c..ea1c0a15 100644 Binary files a/web/escape/audio/sfx/spark.ogg and b/web/escape/audio/sfx/spark.ogg differ diff --git a/web/escape/audio/sfx/step.m4a b/web/escape/audio/sfx/step.m4a index c6ae6b48..c019df5a 100644 Binary files a/web/escape/audio/sfx/step.m4a and b/web/escape/audio/sfx/step.m4a differ diff --git a/web/escape/audio/sfx/step.ogg b/web/escape/audio/sfx/step.ogg index d34ff90e..6d7be9f1 100644 Binary files a/web/escape/audio/sfx/step.ogg and b/web/escape/audio/sfx/step.ogg differ diff --git a/web/escape/audio/sfx/unlock.m4a b/web/escape/audio/sfx/unlock.m4a index 53ae2038..5d7cdad2 100644 Binary files a/web/escape/audio/sfx/unlock.m4a and b/web/escape/audio/sfx/unlock.m4a differ diff --git a/web/escape/audio/sfx/unlock.ogg b/web/escape/audio/sfx/unlock.ogg index 0867f7df..7f0ac3ed 100644 Binary files a/web/escape/audio/sfx/unlock.ogg and b/web/escape/audio/sfx/unlock.ogg differ diff --git a/web/escape/audio/sfx/wall.m4a b/web/escape/audio/sfx/wall.m4a index fe0d4caf..a69e2d3a 100644 Binary files a/web/escape/audio/sfx/wall.m4a and b/web/escape/audio/sfx/wall.m4a differ diff --git a/web/escape/audio/sfx/wall.ogg b/web/escape/audio/sfx/wall.ogg index 34046233..adc95cf1 100644 Binary files a/web/escape/audio/sfx/wall.ogg and b/web/escape/audio/sfx/wall.ogg differ diff --git a/web/escape/audio/sfx/win_chime.m4a b/web/escape/audio/sfx/win_chime.m4a index e67ff86f..76c0392e 100644 Binary files a/web/escape/audio/sfx/win_chime.m4a and b/web/escape/audio/sfx/win_chime.m4a differ diff --git a/web/escape/audio/sfx/win_chime.ogg b/web/escape/audio/sfx/win_chime.ogg index 2df28ae2..e131ecd3 100644 Binary files a/web/escape/audio/sfx/win_chime.ogg and b/web/escape/audio/sfx/win_chime.ogg differ diff --git a/web/escape/audio/voice/female/again.ogg b/web/escape/audio/voice/female/again.ogg index 83f05ddc..609d84f7 100644 Binary files a/web/escape/audio/voice/female/again.ogg and b/web/escape/audio/voice/female/again.ogg differ diff --git a/web/escape/audio/voice/female/box_fixed.ogg b/web/escape/audio/voice/female/box_fixed.ogg index afe394a1..6678f3df 100644 Binary files a/web/escape/audio/voice/female/box_fixed.ogg and b/web/escape/audio/voice/female/box_fixed.ogg differ diff --git a/web/escape/audio/voice/female/empty_1.ogg b/web/escape/audio/voice/female/empty_1.ogg index 4c7995a8..71f27087 100644 Binary files a/web/escape/audio/voice/female/empty_1.ogg and b/web/escape/audio/voice/female/empty_1.ogg differ diff --git a/web/escape/audio/voice/female/empty_2.ogg b/web/escape/audio/voice/female/empty_2.ogg index eb2ee3a5..6f2ea5ff 100644 Binary files a/web/escape/audio/voice/female/empty_2.ogg and b/web/escape/audio/voice/female/empty_2.ogg differ diff --git a/web/escape/audio/voice/female/empty_3.ogg b/web/escape/audio/voice/female/empty_3.ogg index 8bc5eb12..fcde5b6a 100644 Binary files a/web/escape/audio/voice/female/empty_3.ogg and b/web/escape/audio/voice/female/empty_3.ogg differ diff --git a/web/escape/audio/voice/female/escape.ogg b/web/escape/audio/voice/female/escape.ogg index 4817ece3..343fcd29 100644 Binary files a/web/escape/audio/voice/female/escape.ogg and b/web/escape/audio/voice/female/escape.ogg differ diff --git a/web/escape/audio/voice/female/fix_box.ogg b/web/escape/audio/voice/female/fix_box.ogg index 2ccbaec9..64cd849a 100644 Binary files a/web/escape/audio/voice/female/fix_box.ogg and b/web/escape/audio/voice/female/fix_box.ogg differ diff --git a/web/escape/audio/voice/female/found_key.ogg b/web/escape/audio/voice/female/found_key.ogg index d81bace6..c1e8cda1 100644 Binary files a/web/escape/audio/voice/female/found_key.ogg and b/web/escape/audio/voice/female/found_key.ogg differ diff --git a/web/escape/audio/voice/female/g0_intro.ogg b/web/escape/audio/voice/female/g0_intro.ogg index 04c5d59b..ee5763f8 100644 Binary files a/web/escape/audio/voice/female/g0_intro.ogg and b/web/escape/audio/voice/female/g0_intro.ogg differ diff --git a/web/escape/audio/voice/female/g1_done.ogg b/web/escape/audio/voice/female/g1_done.ogg index d7903dc6..a079c0e6 100644 Binary files a/web/escape/audio/voice/female/g1_done.ogg and b/web/escape/audio/voice/female/g1_done.ogg differ diff --git a/web/escape/audio/voice/female/g1_move.ogg b/web/escape/audio/voice/female/g1_move.ogg index 7682feb8..bcd0db3f 100644 Binary files a/web/escape/audio/voice/female/g1_move.ogg and b/web/escape/audio/voice/female/g1_move.ogg differ diff --git a/web/escape/audio/voice/female/g2_wall.ogg b/web/escape/audio/voice/female/g2_wall.ogg index 084f0ae8..bf43aa72 100644 Binary files a/web/escape/audio/voice/female/g2_wall.ogg and b/web/escape/audio/voice/female/g2_wall.ogg differ diff --git a/web/escape/audio/voice/female/g3_left.ogg b/web/escape/audio/voice/female/g3_left.ogg index d3b71e05..fc9dc87d 100644 Binary files a/web/escape/audio/voice/female/g3_left.ogg and b/web/escape/audio/voice/female/g3_left.ogg differ diff --git a/web/escape/audio/voice/female/g4_down.ogg b/web/escape/audio/voice/female/g4_down.ogg index a38f442c..0f7e944f 100644 Binary files a/web/escape/audio/voice/female/g4_down.ogg and b/web/escape/audio/voice/female/g4_down.ogg differ diff --git a/web/escape/audio/voice/female/g5_inspect.ogg b/web/escape/audio/voice/female/g5_inspect.ogg index 2d0cb976..91c723b9 100644 Binary files a/web/escape/audio/voice/female/g5_inspect.ogg and b/web/escape/audio/voice/female/g5_inspect.ogg differ diff --git a/web/escape/audio/voice/female/g5_key.ogg b/web/escape/audio/voice/female/g5_key.ogg index 736aba85..a88e1eda 100644 Binary files a/web/escape/audio/voice/female/g5_key.ogg and b/web/escape/audio/voice/female/g5_key.ogg differ diff --git a/web/escape/audio/voice/female/g6_done.ogg b/web/escape/audio/voice/female/g6_done.ogg index 73dd47c4..3f3936c4 100644 Binary files a/web/escape/audio/voice/female/g6_done.ogg and b/web/escape/audio/voice/female/g6_done.ogg differ diff --git a/web/escape/audio/voice/female/g6_doubletap.ogg b/web/escape/audio/voice/female/g6_doubletap.ogg index cf76a816..9cc0c8f9 100644 Binary files a/web/escape/audio/voice/female/g6_doubletap.ogg and b/web/escape/audio/voice/female/g6_doubletap.ogg differ diff --git a/web/escape/audio/voice/female/g7_goal.ogg b/web/escape/audio/voice/female/g7_goal.ogg index 20276aca..62adc106 100644 Binary files a/web/escape/audio/voice/female/g7_goal.ogg and b/web/escape/audio/voice/female/g7_goal.ogg differ diff --git a/web/escape/audio/voice/female/guide_blunt_down.ogg b/web/escape/audio/voice/female/guide_blunt_down.ogg index 4014a92c..4b385f63 100644 Binary files a/web/escape/audio/voice/female/guide_blunt_down.ogg and b/web/escape/audio/voice/female/guide_blunt_down.ogg differ diff --git a/web/escape/audio/voice/female/guide_blunt_left.ogg b/web/escape/audio/voice/female/guide_blunt_left.ogg index 68bdec7e..08b12501 100644 Binary files a/web/escape/audio/voice/female/guide_blunt_left.ogg and b/web/escape/audio/voice/female/guide_blunt_left.ogg differ diff --git a/web/escape/audio/voice/female/guide_blunt_right.ogg b/web/escape/audio/voice/female/guide_blunt_right.ogg index 61ec88a5..8618ef46 100644 Binary files a/web/escape/audio/voice/female/guide_blunt_right.ogg and b/web/escape/audio/voice/female/guide_blunt_right.ogg differ diff --git a/web/escape/audio/voice/female/guide_blunt_up.ogg b/web/escape/audio/voice/female/guide_blunt_up.ogg index 3a8fa562..df9db5d9 100644 Binary files a/web/escape/audio/voice/female/guide_blunt_up.ogg and b/web/escape/audio/voice/female/guide_blunt_up.ogg differ diff --git a/web/escape/audio/voice/female/guide_offpath.ogg b/web/escape/audio/voice/female/guide_offpath.ogg index f5667950..68f83c4b 100644 Binary files a/web/escape/audio/voice/female/guide_offpath.ogg and b/web/escape/audio/voice/female/guide_offpath.ogg differ diff --git a/web/escape/audio/voice/female/guide_skip.ogg b/web/escape/audio/voice/female/guide_skip.ogg index fd576ebe..4e3e88c0 100644 Binary files a/web/escape/audio/voice/female/guide_skip.ogg and b/web/escape/audio/voice/female/guide_skip.ogg differ diff --git a/web/escape/audio/voice/female/guide_wrong_down.ogg b/web/escape/audio/voice/female/guide_wrong_down.ogg index 3a0bb234..5bb9acbe 100644 Binary files a/web/escape/audio/voice/female/guide_wrong_down.ogg and b/web/escape/audio/voice/female/guide_wrong_down.ogg differ diff --git a/web/escape/audio/voice/female/guide_wrong_gesture.ogg b/web/escape/audio/voice/female/guide_wrong_gesture.ogg index 66773a71..5f1eafad 100644 Binary files a/web/escape/audio/voice/female/guide_wrong_gesture.ogg and b/web/escape/audio/voice/female/guide_wrong_gesture.ogg differ diff --git a/web/escape/audio/voice/female/guide_wrong_left.ogg b/web/escape/audio/voice/female/guide_wrong_left.ogg index 8a36ec96..ee9ed70d 100644 Binary files a/web/escape/audio/voice/female/guide_wrong_left.ogg and b/web/escape/audio/voice/female/guide_wrong_left.ogg differ diff --git a/web/escape/audio/voice/female/guide_wrong_right.ogg b/web/escape/audio/voice/female/guide_wrong_right.ogg index c326da4d..b02f24fb 100644 Binary files a/web/escape/audio/voice/female/guide_wrong_right.ogg and b/web/escape/audio/voice/female/guide_wrong_right.ogg differ diff --git a/web/escape/audio/voice/female/guide_wrong_up.ogg b/web/escape/audio/voice/female/guide_wrong_up.ogg index 3136fffd..6ca95de7 100644 Binary files a/web/escape/audio/voice/female/guide_wrong_up.ogg and b/web/escape/audio/voice/female/guide_wrong_up.ogg differ diff --git a/web/escape/audio/voice/female/help_ops.ogg b/web/escape/audio/voice/female/help_ops.ogg index 46be8b99..d488ba72 100644 Binary files a/web/escape/audio/voice/female/help_ops.ogg and b/web/escape/audio/voice/female/help_ops.ogg differ diff --git a/web/escape/audio/voice/female/inspect_box_dead.ogg b/web/escape/audio/voice/female/inspect_box_dead.ogg index f293a6c9..582d05c2 100644 Binary files a/web/escape/audio/voice/female/inspect_box_dead.ogg and b/web/escape/audio/voice/female/inspect_box_dead.ogg differ diff --git a/web/escape/audio/voice/female/inspect_cabinet_locked.ogg b/web/escape/audio/voice/female/inspect_cabinet_locked.ogg index dbdcc29f..ed0d138a 100644 Binary files a/web/escape/audio/voice/female/inspect_cabinet_locked.ogg and b/web/escape/audio/voice/female/inspect_cabinet_locked.ogg differ diff --git a/web/escape/audio/voice/female/inspect_gate.ogg b/web/escape/audio/voice/female/inspect_gate.ogg index 6c6f7c21..d360711c 100644 Binary files a/web/escape/audio/voice/female/inspect_gate.ogg and b/web/escape/audio/voice/female/inspect_gate.ogg differ diff --git a/web/escape/audio/voice/female/inspect_gate_ready.ogg b/web/escape/audio/voice/female/inspect_gate_ready.ogg index b2cf37bc..d5371b31 100644 Binary files a/web/escape/audio/voice/female/inspect_gate_ready.ogg and b/web/escape/audio/voice/female/inspect_gate_ready.ogg differ diff --git a/web/escape/audio/voice/female/inv_both.ogg b/web/escape/audio/voice/female/inv_both.ogg index 135dbaa0..7519b4f0 100644 Binary files a/web/escape/audio/voice/female/inv_both.ogg and b/web/escape/audio/voice/female/inv_both.ogg differ diff --git a/web/escape/audio/voice/female/inv_empty.ogg b/web/escape/audio/voice/female/inv_empty.ogg index 77583a20..8de6f3c5 100644 Binary files a/web/escape/audio/voice/female/inv_empty.ogg and b/web/escape/audio/voice/female/inv_empty.ogg differ diff --git a/web/escape/audio/voice/female/inv_fuse.ogg b/web/escape/audio/voice/female/inv_fuse.ogg index 418cd17e..16cef413 100644 Binary files a/web/escape/audio/voice/female/inv_fuse.ogg and b/web/escape/audio/voice/female/inv_fuse.ogg differ diff --git a/web/escape/audio/voice/female/inv_key.ogg b/web/escape/audio/voice/female/inv_key.ogg index 15bdcb52..144a272b 100644 Binary files a/web/escape/audio/voice/female/inv_key.ogg and b/web/escape/audio/voice/female/inv_key.ogg differ diff --git a/web/escape/audio/voice/female/menu_back.ogg b/web/escape/audio/voice/female/menu_back.ogg index c48cce67..ea0cc6c5 100644 Binary files a/web/escape/audio/voice/female/menu_back.ogg and b/web/escape/audio/voice/female/menu_back.ogg differ diff --git a/web/escape/audio/voice/female/menu_home.ogg b/web/escape/audio/voice/female/menu_home.ogg index 62e0dde8..61b0e3c5 100644 Binary files a/web/escape/audio/voice/female/menu_home.ogg and b/web/escape/audio/voice/female/menu_home.ogg differ diff --git a/web/escape/audio/voice/female/menu_open.ogg b/web/escape/audio/voice/female/menu_open.ogg index 85f95a19..788420b7 100644 Binary files a/web/escape/audio/voice/female/menu_open.ogg and b/web/escape/audio/voice/female/menu_open.ogg differ diff --git a/web/escape/audio/voice/female/menu_restart.ogg b/web/escape/audio/voice/female/menu_restart.ogg index bdab2337..e3e4c860 100644 Binary files a/web/escape/audio/voice/female/menu_restart.ogg and b/web/escape/audio/voice/female/menu_restart.ogg differ diff --git a/web/escape/audio/voice/female/nav_down.ogg b/web/escape/audio/voice/female/nav_down.ogg index 39735f91..c0e475bb 100644 Binary files a/web/escape/audio/voice/female/nav_down.ogg and b/web/escape/audio/voice/female/nav_down.ogg differ diff --git a/web/escape/audio/voice/female/nav_left.ogg b/web/escape/audio/voice/female/nav_left.ogg index be2a39b1..b220388d 100644 Binary files a/web/escape/audio/voice/female/nav_left.ogg and b/web/escape/audio/voice/female/nav_left.ogg differ diff --git a/web/escape/audio/voice/female/nav_right.ogg b/web/escape/audio/voice/female/nav_right.ogg index fa5dc54c..6f1cf5f2 100644 Binary files a/web/escape/audio/voice/female/nav_right.ogg and b/web/escape/audio/voice/female/nav_right.ogg differ diff --git a/web/escape/audio/voice/female/nav_up.ogg b/web/escape/audio/voice/female/nav_up.ogg index 12d5ae46..f3969f7e 100644 Binary files a/web/escape/audio/voice/female/nav_up.ogg and b/web/escape/audio/voice/female/nav_up.ogg differ diff --git a/web/escape/audio/voice/female/reopen_prompt.ogg b/web/escape/audio/voice/female/reopen_prompt.ogg index bbafd626..81231a8f 100644 Binary files a/web/escape/audio/voice/female/reopen_prompt.ogg and b/web/escape/audio/voice/female/reopen_prompt.ogg differ diff --git a/web/escape/audio/voice/female/replay.ogg b/web/escape/audio/voice/female/replay.ogg index cfde082c..03fdb348 100644 Binary files a/web/escape/audio/voice/female/replay.ogg and b/web/escape/audio/voice/female/replay.ogg differ diff --git a/web/escape/audio/voice/female/room_cabinet.ogg b/web/escape/audio/voice/female/room_cabinet.ogg index 056cbc8d..c91eb324 100644 Binary files a/web/escape/audio/voice/female/room_cabinet.ogg and b/web/escape/audio/voice/female/room_cabinet.ogg differ diff --git a/web/escape/audio/voice/female/room_gate.ogg b/web/escape/audio/voice/female/room_gate.ogg index 473db9fb..04a901c6 100644 Binary files a/web/escape/audio/voice/female/room_gate.ogg and b/web/escape/audio/voice/female/room_gate.ogg differ diff --git a/web/escape/audio/voice/female/room_power.ogg b/web/escape/audio/voice/female/room_power.ogg index 4427180f..d6c5e9d0 100644 Binary files a/web/escape/audio/voice/female/room_power.ogg and b/web/escape/audio/voice/female/room_power.ogg differ diff --git a/web/escape/audio/voice/female/room_storage.ogg b/web/escape/audio/voice/female/room_storage.ogg index e1be7378..cff23bb8 100644 Binary files a/web/escape/audio/voice/female/room_storage.ogg and b/web/escape/audio/voice/female/room_storage.ogg differ diff --git a/web/escape/audio/voice/female/skip_hint.m4a b/web/escape/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..c52e087f Binary files /dev/null and b/web/escape/audio/voice/female/skip_hint.m4a differ diff --git a/web/escape/audio/voice/female/skip_hint.ogg b/web/escape/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..da6649d5 Binary files /dev/null and b/web/escape/audio/voice/female/skip_hint.ogg differ diff --git a/web/escape/audio/voice/female/start_room.ogg b/web/escape/audio/voice/female/start_room.ogg index 94744ccb..5911043e 100644 Binary files a/web/escape/audio/voice/female/start_room.ogg and b/web/escape/audio/voice/female/start_room.ogg differ diff --git a/web/escape/audio/voice/female/stuck_ask.ogg b/web/escape/audio/voice/female/stuck_ask.ogg index a237793c..e66f19d0 100644 Binary files a/web/escape/audio/voice/female/stuck_ask.ogg and b/web/escape/audio/voice/female/stuck_ask.ogg differ diff --git a/web/escape/audio/voice/female/stuck_box_1.ogg b/web/escape/audio/voice/female/stuck_box_1.ogg index bc98a4f3..428ab0b3 100644 Binary files a/web/escape/audio/voice/female/stuck_box_1.ogg and b/web/escape/audio/voice/female/stuck_box_1.ogg differ diff --git a/web/escape/audio/voice/female/stuck_box_2.ogg b/web/escape/audio/voice/female/stuck_box_2.ogg index 5405783c..490acfda 100644 Binary files a/web/escape/audio/voice/female/stuck_box_2.ogg and b/web/escape/audio/voice/female/stuck_box_2.ogg differ diff --git a/web/escape/audio/voice/female/stuck_box_3.ogg b/web/escape/audio/voice/female/stuck_box_3.ogg index a5258ea8..4176157e 100644 Binary files a/web/escape/audio/voice/female/stuck_box_3.ogg and b/web/escape/audio/voice/female/stuck_box_3.ogg differ diff --git a/web/escape/audio/voice/female/stuck_cabinet_1.ogg b/web/escape/audio/voice/female/stuck_cabinet_1.ogg index 089664b3..4d3c5b08 100644 Binary files a/web/escape/audio/voice/female/stuck_cabinet_1.ogg and b/web/escape/audio/voice/female/stuck_cabinet_1.ogg differ diff --git a/web/escape/audio/voice/female/stuck_cabinet_2.ogg b/web/escape/audio/voice/female/stuck_cabinet_2.ogg index 18082016..95037edd 100644 Binary files a/web/escape/audio/voice/female/stuck_cabinet_2.ogg and b/web/escape/audio/voice/female/stuck_cabinet_2.ogg differ diff --git a/web/escape/audio/voice/female/stuck_cabinet_3.ogg b/web/escape/audio/voice/female/stuck_cabinet_3.ogg index 28da650f..a71ca6e8 100644 Binary files a/web/escape/audio/voice/female/stuck_cabinet_3.ogg and b/web/escape/audio/voice/female/stuck_cabinet_3.ogg differ diff --git a/web/escape/audio/voice/female/stuck_gate_1.ogg b/web/escape/audio/voice/female/stuck_gate_1.ogg index f566cd26..e0bb89dc 100644 Binary files a/web/escape/audio/voice/female/stuck_gate_1.ogg and b/web/escape/audio/voice/female/stuck_gate_1.ogg differ diff --git a/web/escape/audio/voice/female/stuck_gate_2.ogg b/web/escape/audio/voice/female/stuck_gate_2.ogg index e9940fcb..bcc16a8b 100644 Binary files a/web/escape/audio/voice/female/stuck_gate_2.ogg and b/web/escape/audio/voice/female/stuck_gate_2.ogg differ diff --git a/web/escape/audio/voice/female/stuck_gate_3.ogg b/web/escape/audio/voice/female/stuck_gate_3.ogg index 38e2e320..d49c6c0d 100644 Binary files a/web/escape/audio/voice/female/stuck_gate_3.ogg and b/web/escape/audio/voice/female/stuck_gate_3.ogg differ diff --git a/web/escape/audio/voice/female/stuck_key_1.ogg b/web/escape/audio/voice/female/stuck_key_1.ogg index 2e6a49b0..dfbbaa7b 100644 Binary files a/web/escape/audio/voice/female/stuck_key_1.ogg and b/web/escape/audio/voice/female/stuck_key_1.ogg differ diff --git a/web/escape/audio/voice/female/stuck_key_2.ogg b/web/escape/audio/voice/female/stuck_key_2.ogg index dde50480..178896d8 100644 Binary files a/web/escape/audio/voice/female/stuck_key_2.ogg and b/web/escape/audio/voice/female/stuck_key_2.ogg differ diff --git a/web/escape/audio/voice/female/stuck_key_3.ogg b/web/escape/audio/voice/female/stuck_key_3.ogg index 0b556fc4..4df2fdd7 100644 Binary files a/web/escape/audio/voice/female/stuck_key_3.ogg and b/web/escape/audio/voice/female/stuck_key_3.ogg differ diff --git a/web/escape/audio/voice/female/time_band1.ogg b/web/escape/audio/voice/female/time_band1.ogg index f5870a36..676f8b6d 100644 Binary files a/web/escape/audio/voice/female/time_band1.ogg and b/web/escape/audio/voice/female/time_band1.ogg differ diff --git a/web/escape/audio/voice/female/time_band2.ogg b/web/escape/audio/voice/female/time_band2.ogg index 6a206ead..de205328 100644 Binary files a/web/escape/audio/voice/female/time_band2.ogg and b/web/escape/audio/voice/female/time_band2.ogg differ diff --git a/web/escape/audio/voice/female/time_band3.ogg b/web/escape/audio/voice/female/time_band3.ogg index 18b329f6..334f97f4 100644 Binary files a/web/escape/audio/voice/female/time_band3.ogg and b/web/escape/audio/voice/female/time_band3.ogg differ diff --git a/web/escape/audio/voice/female/time_band4.ogg b/web/escape/audio/voice/female/time_band4.ogg index bd66785e..a1534c54 100644 Binary files a/web/escape/audio/voice/female/time_band4.ogg and b/web/escape/audio/voice/female/time_band4.ogg differ diff --git a/web/escape/audio/voice/female/unlock_cabinet.ogg b/web/escape/audio/voice/female/unlock_cabinet.ogg index 144a69d2..199aead4 100644 Binary files a/web/escape/audio/voice/female/unlock_cabinet.ogg and b/web/escape/audio/voice/female/unlock_cabinet.ogg differ diff --git a/web/escape/audio/voice/female/use_fail.ogg b/web/escape/audio/voice/female/use_fail.ogg index 773cd670..958fc860 100644 Binary files a/web/escape/audio/voice/female/use_fail.ogg and b/web/escape/audio/voice/female/use_fail.ogg differ diff --git a/web/escape/audio/voice/female/wall.ogg b/web/escape/audio/voice/female/wall.ogg index 46a61c9e..7db3a657 100644 Binary files a/web/escape/audio/voice/female/wall.ogg and b/web/escape/audio/voice/female/wall.ogg differ diff --git a/web/escape/audio/voice/male/again.ogg b/web/escape/audio/voice/male/again.ogg index 08d2a583..d53508e1 100644 Binary files a/web/escape/audio/voice/male/again.ogg and b/web/escape/audio/voice/male/again.ogg differ diff --git a/web/escape/audio/voice/male/box_fixed.ogg b/web/escape/audio/voice/male/box_fixed.ogg index e1afe7fd..01da1ab7 100644 Binary files a/web/escape/audio/voice/male/box_fixed.ogg and b/web/escape/audio/voice/male/box_fixed.ogg differ diff --git a/web/escape/audio/voice/male/empty_1.ogg b/web/escape/audio/voice/male/empty_1.ogg index ed6ac139..e6ec2517 100644 Binary files a/web/escape/audio/voice/male/empty_1.ogg and b/web/escape/audio/voice/male/empty_1.ogg differ diff --git a/web/escape/audio/voice/male/empty_2.ogg b/web/escape/audio/voice/male/empty_2.ogg index 7769e68a..67fc5bfb 100644 Binary files a/web/escape/audio/voice/male/empty_2.ogg and b/web/escape/audio/voice/male/empty_2.ogg differ diff --git a/web/escape/audio/voice/male/empty_3.ogg b/web/escape/audio/voice/male/empty_3.ogg index 2fcdf294..9b1a3535 100644 Binary files a/web/escape/audio/voice/male/empty_3.ogg and b/web/escape/audio/voice/male/empty_3.ogg differ diff --git a/web/escape/audio/voice/male/escape.ogg b/web/escape/audio/voice/male/escape.ogg index 9eae2e7d..2cb9418a 100644 Binary files a/web/escape/audio/voice/male/escape.ogg and b/web/escape/audio/voice/male/escape.ogg differ diff --git a/web/escape/audio/voice/male/fix_box.ogg b/web/escape/audio/voice/male/fix_box.ogg index 711c00e4..2b401a6b 100644 Binary files a/web/escape/audio/voice/male/fix_box.ogg and b/web/escape/audio/voice/male/fix_box.ogg differ diff --git a/web/escape/audio/voice/male/found_key.ogg b/web/escape/audio/voice/male/found_key.ogg index adaf8a83..608a1865 100644 Binary files a/web/escape/audio/voice/male/found_key.ogg and b/web/escape/audio/voice/male/found_key.ogg differ diff --git a/web/escape/audio/voice/male/g0_intro.ogg b/web/escape/audio/voice/male/g0_intro.ogg index d7c09520..3703a082 100644 Binary files a/web/escape/audio/voice/male/g0_intro.ogg and b/web/escape/audio/voice/male/g0_intro.ogg differ diff --git a/web/escape/audio/voice/male/g1_done.ogg b/web/escape/audio/voice/male/g1_done.ogg index 493583c3..cfbcab04 100644 Binary files a/web/escape/audio/voice/male/g1_done.ogg and b/web/escape/audio/voice/male/g1_done.ogg differ diff --git a/web/escape/audio/voice/male/g1_move.ogg b/web/escape/audio/voice/male/g1_move.ogg index 8297cdde..56e7a2ba 100644 Binary files a/web/escape/audio/voice/male/g1_move.ogg and b/web/escape/audio/voice/male/g1_move.ogg differ diff --git a/web/escape/audio/voice/male/g2_wall.ogg b/web/escape/audio/voice/male/g2_wall.ogg index d3a52f9a..a1ef9c9e 100644 Binary files a/web/escape/audio/voice/male/g2_wall.ogg and b/web/escape/audio/voice/male/g2_wall.ogg differ diff --git a/web/escape/audio/voice/male/g3_left.ogg b/web/escape/audio/voice/male/g3_left.ogg index e38d13c6..4a81dc51 100644 Binary files a/web/escape/audio/voice/male/g3_left.ogg and b/web/escape/audio/voice/male/g3_left.ogg differ diff --git a/web/escape/audio/voice/male/g4_down.ogg b/web/escape/audio/voice/male/g4_down.ogg index 9f33207b..3e7151bd 100644 Binary files a/web/escape/audio/voice/male/g4_down.ogg and b/web/escape/audio/voice/male/g4_down.ogg differ diff --git a/web/escape/audio/voice/male/g5_inspect.ogg b/web/escape/audio/voice/male/g5_inspect.ogg index 658c1fea..9bc0eb2d 100644 Binary files a/web/escape/audio/voice/male/g5_inspect.ogg and b/web/escape/audio/voice/male/g5_inspect.ogg differ diff --git a/web/escape/audio/voice/male/g5_key.ogg b/web/escape/audio/voice/male/g5_key.ogg index dd19f68f..26771e8f 100644 Binary files a/web/escape/audio/voice/male/g5_key.ogg and b/web/escape/audio/voice/male/g5_key.ogg differ diff --git a/web/escape/audio/voice/male/g6_done.ogg b/web/escape/audio/voice/male/g6_done.ogg index 3f841752..307f9e8d 100644 Binary files a/web/escape/audio/voice/male/g6_done.ogg and b/web/escape/audio/voice/male/g6_done.ogg differ diff --git a/web/escape/audio/voice/male/g6_doubletap.ogg b/web/escape/audio/voice/male/g6_doubletap.ogg index c192b0c5..50047043 100644 Binary files a/web/escape/audio/voice/male/g6_doubletap.ogg and b/web/escape/audio/voice/male/g6_doubletap.ogg differ diff --git a/web/escape/audio/voice/male/g7_goal.ogg b/web/escape/audio/voice/male/g7_goal.ogg index 5cffd219..6487e3f6 100644 Binary files a/web/escape/audio/voice/male/g7_goal.ogg and b/web/escape/audio/voice/male/g7_goal.ogg differ diff --git a/web/escape/audio/voice/male/guide_blunt_down.ogg b/web/escape/audio/voice/male/guide_blunt_down.ogg index 17cefb2e..87dced83 100644 Binary files a/web/escape/audio/voice/male/guide_blunt_down.ogg and b/web/escape/audio/voice/male/guide_blunt_down.ogg differ diff --git a/web/escape/audio/voice/male/guide_blunt_left.ogg b/web/escape/audio/voice/male/guide_blunt_left.ogg index c39410c6..91b973c1 100644 Binary files a/web/escape/audio/voice/male/guide_blunt_left.ogg and b/web/escape/audio/voice/male/guide_blunt_left.ogg differ diff --git a/web/escape/audio/voice/male/guide_blunt_right.ogg b/web/escape/audio/voice/male/guide_blunt_right.ogg index c3767751..abd6ff4f 100644 Binary files a/web/escape/audio/voice/male/guide_blunt_right.ogg and b/web/escape/audio/voice/male/guide_blunt_right.ogg differ diff --git a/web/escape/audio/voice/male/guide_blunt_up.ogg b/web/escape/audio/voice/male/guide_blunt_up.ogg index 88355ce1..5d898958 100644 Binary files a/web/escape/audio/voice/male/guide_blunt_up.ogg and b/web/escape/audio/voice/male/guide_blunt_up.ogg differ diff --git a/web/escape/audio/voice/male/guide_offpath.ogg b/web/escape/audio/voice/male/guide_offpath.ogg index 5137f9cf..821b3d6b 100644 Binary files a/web/escape/audio/voice/male/guide_offpath.ogg and b/web/escape/audio/voice/male/guide_offpath.ogg differ diff --git a/web/escape/audio/voice/male/guide_skip.ogg b/web/escape/audio/voice/male/guide_skip.ogg index ad316e22..14e68690 100644 Binary files a/web/escape/audio/voice/male/guide_skip.ogg and b/web/escape/audio/voice/male/guide_skip.ogg differ diff --git a/web/escape/audio/voice/male/guide_wrong_down.ogg b/web/escape/audio/voice/male/guide_wrong_down.ogg index f48c510c..509acb96 100644 Binary files a/web/escape/audio/voice/male/guide_wrong_down.ogg and b/web/escape/audio/voice/male/guide_wrong_down.ogg differ diff --git a/web/escape/audio/voice/male/guide_wrong_gesture.ogg b/web/escape/audio/voice/male/guide_wrong_gesture.ogg index ddf462b2..b8f264a0 100644 Binary files a/web/escape/audio/voice/male/guide_wrong_gesture.ogg and b/web/escape/audio/voice/male/guide_wrong_gesture.ogg differ diff --git a/web/escape/audio/voice/male/guide_wrong_left.ogg b/web/escape/audio/voice/male/guide_wrong_left.ogg index f6b92353..a94ff9a6 100644 Binary files a/web/escape/audio/voice/male/guide_wrong_left.ogg and b/web/escape/audio/voice/male/guide_wrong_left.ogg differ diff --git a/web/escape/audio/voice/male/guide_wrong_right.ogg b/web/escape/audio/voice/male/guide_wrong_right.ogg index 25a12a18..dc8fa1f6 100644 Binary files a/web/escape/audio/voice/male/guide_wrong_right.ogg and b/web/escape/audio/voice/male/guide_wrong_right.ogg differ diff --git a/web/escape/audio/voice/male/guide_wrong_up.ogg b/web/escape/audio/voice/male/guide_wrong_up.ogg index e2c921b2..3694ab17 100644 Binary files a/web/escape/audio/voice/male/guide_wrong_up.ogg and b/web/escape/audio/voice/male/guide_wrong_up.ogg differ diff --git a/web/escape/audio/voice/male/help_ops.ogg b/web/escape/audio/voice/male/help_ops.ogg index dbd7cd4a..ba0ca485 100644 Binary files a/web/escape/audio/voice/male/help_ops.ogg and b/web/escape/audio/voice/male/help_ops.ogg differ diff --git a/web/escape/audio/voice/male/inspect_box_dead.ogg b/web/escape/audio/voice/male/inspect_box_dead.ogg index 237b738d..be932d51 100644 Binary files a/web/escape/audio/voice/male/inspect_box_dead.ogg and b/web/escape/audio/voice/male/inspect_box_dead.ogg differ diff --git a/web/escape/audio/voice/male/inspect_cabinet_locked.ogg b/web/escape/audio/voice/male/inspect_cabinet_locked.ogg index 2d952d2e..6a584fec 100644 Binary files a/web/escape/audio/voice/male/inspect_cabinet_locked.ogg and b/web/escape/audio/voice/male/inspect_cabinet_locked.ogg differ diff --git a/web/escape/audio/voice/male/inspect_gate.ogg b/web/escape/audio/voice/male/inspect_gate.ogg index b5b255a9..26b18301 100644 Binary files a/web/escape/audio/voice/male/inspect_gate.ogg and b/web/escape/audio/voice/male/inspect_gate.ogg differ diff --git a/web/escape/audio/voice/male/inspect_gate_ready.ogg b/web/escape/audio/voice/male/inspect_gate_ready.ogg index c844d429..827bbf69 100644 Binary files a/web/escape/audio/voice/male/inspect_gate_ready.ogg and b/web/escape/audio/voice/male/inspect_gate_ready.ogg differ diff --git a/web/escape/audio/voice/male/inv_both.ogg b/web/escape/audio/voice/male/inv_both.ogg index 69793dbc..1f52c985 100644 Binary files a/web/escape/audio/voice/male/inv_both.ogg and b/web/escape/audio/voice/male/inv_both.ogg differ diff --git a/web/escape/audio/voice/male/inv_empty.ogg b/web/escape/audio/voice/male/inv_empty.ogg index 7e694ed0..0829c381 100644 Binary files a/web/escape/audio/voice/male/inv_empty.ogg and b/web/escape/audio/voice/male/inv_empty.ogg differ diff --git a/web/escape/audio/voice/male/inv_fuse.ogg b/web/escape/audio/voice/male/inv_fuse.ogg index d794d693..cd0d6286 100644 Binary files a/web/escape/audio/voice/male/inv_fuse.ogg and b/web/escape/audio/voice/male/inv_fuse.ogg differ diff --git a/web/escape/audio/voice/male/inv_key.ogg b/web/escape/audio/voice/male/inv_key.ogg index d989611c..1e009e45 100644 Binary files a/web/escape/audio/voice/male/inv_key.ogg and b/web/escape/audio/voice/male/inv_key.ogg differ diff --git a/web/escape/audio/voice/male/menu_back.ogg b/web/escape/audio/voice/male/menu_back.ogg index 7b1a33af..6656fa68 100644 Binary files a/web/escape/audio/voice/male/menu_back.ogg and b/web/escape/audio/voice/male/menu_back.ogg differ diff --git a/web/escape/audio/voice/male/menu_home.ogg b/web/escape/audio/voice/male/menu_home.ogg index 4b4cd5b8..91cdfbf1 100644 Binary files a/web/escape/audio/voice/male/menu_home.ogg and b/web/escape/audio/voice/male/menu_home.ogg differ diff --git a/web/escape/audio/voice/male/menu_open.ogg b/web/escape/audio/voice/male/menu_open.ogg index 02001faa..15a32de4 100644 Binary files a/web/escape/audio/voice/male/menu_open.ogg and b/web/escape/audio/voice/male/menu_open.ogg differ diff --git a/web/escape/audio/voice/male/menu_restart.ogg b/web/escape/audio/voice/male/menu_restart.ogg index a9096c0c..39af33df 100644 Binary files a/web/escape/audio/voice/male/menu_restart.ogg and b/web/escape/audio/voice/male/menu_restart.ogg differ diff --git a/web/escape/audio/voice/male/nav_down.ogg b/web/escape/audio/voice/male/nav_down.ogg index 05b88a5b..3e8a331a 100644 Binary files a/web/escape/audio/voice/male/nav_down.ogg and b/web/escape/audio/voice/male/nav_down.ogg differ diff --git a/web/escape/audio/voice/male/nav_left.ogg b/web/escape/audio/voice/male/nav_left.ogg index a3ef2ffc..7f51fcf6 100644 Binary files a/web/escape/audio/voice/male/nav_left.ogg and b/web/escape/audio/voice/male/nav_left.ogg differ diff --git a/web/escape/audio/voice/male/nav_right.ogg b/web/escape/audio/voice/male/nav_right.ogg index e10fc9ed..8f4c6378 100644 Binary files a/web/escape/audio/voice/male/nav_right.ogg and b/web/escape/audio/voice/male/nav_right.ogg differ diff --git a/web/escape/audio/voice/male/nav_up.ogg b/web/escape/audio/voice/male/nav_up.ogg index 3a7c46c9..91679cab 100644 Binary files a/web/escape/audio/voice/male/nav_up.ogg and b/web/escape/audio/voice/male/nav_up.ogg differ diff --git a/web/escape/audio/voice/male/reopen_prompt.ogg b/web/escape/audio/voice/male/reopen_prompt.ogg index 5312a58f..f5fffd10 100644 Binary files a/web/escape/audio/voice/male/reopen_prompt.ogg and b/web/escape/audio/voice/male/reopen_prompt.ogg differ diff --git a/web/escape/audio/voice/male/replay.ogg b/web/escape/audio/voice/male/replay.ogg index bee8733a..23707e9b 100644 Binary files a/web/escape/audio/voice/male/replay.ogg and b/web/escape/audio/voice/male/replay.ogg differ diff --git a/web/escape/audio/voice/male/room_cabinet.ogg b/web/escape/audio/voice/male/room_cabinet.ogg index aac974e5..25d820f6 100644 Binary files a/web/escape/audio/voice/male/room_cabinet.ogg and b/web/escape/audio/voice/male/room_cabinet.ogg differ diff --git a/web/escape/audio/voice/male/room_gate.ogg b/web/escape/audio/voice/male/room_gate.ogg index 37cb15a9..39c196ce 100644 Binary files a/web/escape/audio/voice/male/room_gate.ogg and b/web/escape/audio/voice/male/room_gate.ogg differ diff --git a/web/escape/audio/voice/male/room_power.ogg b/web/escape/audio/voice/male/room_power.ogg index f049e473..b66d1119 100644 Binary files a/web/escape/audio/voice/male/room_power.ogg and b/web/escape/audio/voice/male/room_power.ogg differ diff --git a/web/escape/audio/voice/male/room_storage.ogg b/web/escape/audio/voice/male/room_storage.ogg index 334a1d98..5fa4c965 100644 Binary files a/web/escape/audio/voice/male/room_storage.ogg and b/web/escape/audio/voice/male/room_storage.ogg differ diff --git a/web/escape/audio/voice/male/skip_hint.m4a b/web/escape/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..d87f0683 Binary files /dev/null and b/web/escape/audio/voice/male/skip_hint.m4a differ diff --git a/web/escape/audio/voice/male/skip_hint.ogg b/web/escape/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..2c49e448 Binary files /dev/null and b/web/escape/audio/voice/male/skip_hint.ogg differ diff --git a/web/escape/audio/voice/male/start_room.ogg b/web/escape/audio/voice/male/start_room.ogg index 34694dc7..1640bb02 100644 Binary files a/web/escape/audio/voice/male/start_room.ogg and b/web/escape/audio/voice/male/start_room.ogg differ diff --git a/web/escape/audio/voice/male/stuck_ask.ogg b/web/escape/audio/voice/male/stuck_ask.ogg index 468b6924..fced4ec6 100644 Binary files a/web/escape/audio/voice/male/stuck_ask.ogg and b/web/escape/audio/voice/male/stuck_ask.ogg differ diff --git a/web/escape/audio/voice/male/stuck_box_1.ogg b/web/escape/audio/voice/male/stuck_box_1.ogg index 8c36c55e..1843c448 100644 Binary files a/web/escape/audio/voice/male/stuck_box_1.ogg and b/web/escape/audio/voice/male/stuck_box_1.ogg differ diff --git a/web/escape/audio/voice/male/stuck_box_2.ogg b/web/escape/audio/voice/male/stuck_box_2.ogg index 0d187721..c879bc0f 100644 Binary files a/web/escape/audio/voice/male/stuck_box_2.ogg and b/web/escape/audio/voice/male/stuck_box_2.ogg differ diff --git a/web/escape/audio/voice/male/stuck_box_3.ogg b/web/escape/audio/voice/male/stuck_box_3.ogg index 1f4129a9..a69e07af 100644 Binary files a/web/escape/audio/voice/male/stuck_box_3.ogg and b/web/escape/audio/voice/male/stuck_box_3.ogg differ diff --git a/web/escape/audio/voice/male/stuck_cabinet_1.ogg b/web/escape/audio/voice/male/stuck_cabinet_1.ogg index 56a49659..4f6f41e7 100644 Binary files a/web/escape/audio/voice/male/stuck_cabinet_1.ogg and b/web/escape/audio/voice/male/stuck_cabinet_1.ogg differ diff --git a/web/escape/audio/voice/male/stuck_cabinet_2.ogg b/web/escape/audio/voice/male/stuck_cabinet_2.ogg index 1f8bf1de..6dd6b281 100644 Binary files a/web/escape/audio/voice/male/stuck_cabinet_2.ogg and b/web/escape/audio/voice/male/stuck_cabinet_2.ogg differ diff --git a/web/escape/audio/voice/male/stuck_cabinet_3.ogg b/web/escape/audio/voice/male/stuck_cabinet_3.ogg index aeb2b145..2b2f3259 100644 Binary files a/web/escape/audio/voice/male/stuck_cabinet_3.ogg and b/web/escape/audio/voice/male/stuck_cabinet_3.ogg differ diff --git a/web/escape/audio/voice/male/stuck_gate_1.ogg b/web/escape/audio/voice/male/stuck_gate_1.ogg index f8e25c8c..be8c2429 100644 Binary files a/web/escape/audio/voice/male/stuck_gate_1.ogg and b/web/escape/audio/voice/male/stuck_gate_1.ogg differ diff --git a/web/escape/audio/voice/male/stuck_gate_2.ogg b/web/escape/audio/voice/male/stuck_gate_2.ogg index 2dcbe70d..b7553275 100644 Binary files a/web/escape/audio/voice/male/stuck_gate_2.ogg and b/web/escape/audio/voice/male/stuck_gate_2.ogg differ diff --git a/web/escape/audio/voice/male/stuck_gate_3.ogg b/web/escape/audio/voice/male/stuck_gate_3.ogg index a2fc2262..addd99bf 100644 Binary files a/web/escape/audio/voice/male/stuck_gate_3.ogg and b/web/escape/audio/voice/male/stuck_gate_3.ogg differ diff --git a/web/escape/audio/voice/male/stuck_key_1.ogg b/web/escape/audio/voice/male/stuck_key_1.ogg index a755e17f..8f972240 100644 Binary files a/web/escape/audio/voice/male/stuck_key_1.ogg and b/web/escape/audio/voice/male/stuck_key_1.ogg differ diff --git a/web/escape/audio/voice/male/stuck_key_2.ogg b/web/escape/audio/voice/male/stuck_key_2.ogg index 81b3c935..a5ee330b 100644 Binary files a/web/escape/audio/voice/male/stuck_key_2.ogg and b/web/escape/audio/voice/male/stuck_key_2.ogg differ diff --git a/web/escape/audio/voice/male/stuck_key_3.ogg b/web/escape/audio/voice/male/stuck_key_3.ogg index b8db7879..02f4ea04 100644 Binary files a/web/escape/audio/voice/male/stuck_key_3.ogg and b/web/escape/audio/voice/male/stuck_key_3.ogg differ diff --git a/web/escape/audio/voice/male/time_band1.ogg b/web/escape/audio/voice/male/time_band1.ogg index 7502a326..a1c10449 100644 Binary files a/web/escape/audio/voice/male/time_band1.ogg and b/web/escape/audio/voice/male/time_band1.ogg differ diff --git a/web/escape/audio/voice/male/time_band2.ogg b/web/escape/audio/voice/male/time_band2.ogg index e45a7654..613c783b 100644 Binary files a/web/escape/audio/voice/male/time_band2.ogg and b/web/escape/audio/voice/male/time_band2.ogg differ diff --git a/web/escape/audio/voice/male/time_band3.ogg b/web/escape/audio/voice/male/time_band3.ogg index 97ceffe7..21f4a920 100644 Binary files a/web/escape/audio/voice/male/time_band3.ogg and b/web/escape/audio/voice/male/time_band3.ogg differ diff --git a/web/escape/audio/voice/male/time_band4.ogg b/web/escape/audio/voice/male/time_band4.ogg index 830e030e..bd4c9a92 100644 Binary files a/web/escape/audio/voice/male/time_band4.ogg and b/web/escape/audio/voice/male/time_band4.ogg differ diff --git a/web/escape/audio/voice/male/unlock_cabinet.ogg b/web/escape/audio/voice/male/unlock_cabinet.ogg index a7778ccf..04ca1f6f 100644 Binary files a/web/escape/audio/voice/male/unlock_cabinet.ogg and b/web/escape/audio/voice/male/unlock_cabinet.ogg differ diff --git a/web/escape/audio/voice/male/use_fail.ogg b/web/escape/audio/voice/male/use_fail.ogg index a3683a4b..57487922 100644 Binary files a/web/escape/audio/voice/male/use_fail.ogg and b/web/escape/audio/voice/male/use_fail.ogg differ diff --git a/web/escape/audio/voice/male/wall.ogg b/web/escape/audio/voice/male/wall.ogg index 67f15def..a2f5e071 100644 Binary files a/web/escape/audio/voice/male/wall.ogg and b/web/escape/audio/voice/male/wall.ogg differ diff --git a/web/escape/game.js b/web/escape/game.js index 0f38d423..88303b90 100644 --- a/web/escape/game.js +++ b/web/escape/game.js @@ -6,10 +6,16 @@ * * 手势映射(偏离全局默认,引导中讲清): * - 四向滑动:在相邻房间间移动,撞墙播闷响 + ERROR 震动 + wall 语音(每次都播) - * - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙) + * - 单击:重播当前房间环境音 + 检查描述(储物间拾钥匙); + * voice 播放中单击 = 跳过本句(audio.skipVoice(),链播继续,12 号文档)。 + * 不可跳:引导 G0-G7(引导期单击是答题)、关键获得(found_key/ + * unlock_cabinet/fix_box/escape 结算链)、L3 带路(stuck_*_3 + nav_*) * - 双击:使用物品(钥匙开柜 / 保险丝修电箱 / 通电后开大门); * 无可用目标时播报背包 + L2 级明示提示 - * - 引导阶段下滑:跳过引导;ENDING 下滑:菜单 + * - 引导阶段下滑:跳过引导(G4 除外——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)、 @@ -86,6 +92,8 @@ export class EscapeGame extends Game { this.powerOn = false; this.visited = new Set(); // 首到关键房间记录(进展事件 + room_* 描述) this.startElapsedMs = 0; + // 单击跳过:当前 voice 句是否可跳(每次 voice 播放时更新,见 _playVoice) + this._voiceSkippable = false; // 引导状态 this.guideStep = 0; // 0..7 对应 G0..G7 this.guideSwipes = 0; // G2 撞墙计数 @@ -98,13 +106,14 @@ export class EscapeGame extends Game { // 菜单 this._menuReturn = Phase.ENDING; this._menuIdx = 0; + this._endingBgmStarted = false; // 结算 pad 是否已起(_escape 幂等守护) } // ---------- Game ---------- start() { this.audio.prewarm(PREWARM_SFX); - this.audio.prewarm(['voice/g0_intro', 'voice/g1_move', 'voice/reopen_prompt', 'voice/start_room']); + this.audio.prewarm(['voice/g0_intro', 'voice/g1_move', 'voice/skip_hint', 'voice/reopen_prompt', 'voice/start_room', 'bgm/menu']); this._session++; this._clearTimers(); this._startStuckTimer(); @@ -113,7 +122,7 @@ export class EscapeGame extends Game { if (guided) { // 二周目:上滑重听引导 / 单击直接开始 this.phase = Phase.REOPEN; - this.audio.play('voice/reopen_prompt'); + this._playVoice('voice/reopen_prompt', { skippable: false }); // 抉择前提示必须听完 } else { this._startGuide(); } @@ -157,7 +166,7 @@ export class EscapeGame extends Game { this._resetState(); this.phase = Phase.GUIDE; this.guideStep = 0; - this._speak(['voice/g0_intro']); + this._speak(['voice/g0_intro'], null, { skippable: false }); } /** @@ -179,17 +188,20 @@ export class EscapeGame extends Game { this._timer(() => { if (this._guideBusy && this.guideStep === fromStep) done(); }, 20000); // 看门狗 - if (ids && ids.length) this._speak(ids, done); + // 引导句不可单击跳过(12 号文档:引导句即操作指令) + if (ids && ids.length) this._speak(ids, done, { skippable: false }); else done(); } _onGuideGesture(g) { - if (g === G.DOWN) { this._skipGuide(); return; } // 任何步骤可下滑跳过 + // 任何步骤可下滑跳过——G4 除外:该步的正确答案就是下滑(01 号文档"G4=下滑" + // 与"任何步骤下滑跳过"冲突,以 13 号试玩标准为准:G4 下滑 = 作答,不可跳过) + if (g === G.DOWN && this.guideStep !== 4) { this._skipGuide(); return; } if (this._guideBusy) return; // 推进语音播放中,忽略 switch (this.guideStep) { case 0: // G0:单击继续 - if (g === G.TAP) this._advance(1, ['voice/g1_move']); - else if (this._isSwipe(g)) this.audio.play('voice/guide_offpath'); // "先跟我走" + 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 }); // "先跟我走" break; case 1: // G1:任意滑动,走一小步 if (this._isSwipe(g)) { @@ -203,7 +215,7 @@ export class EscapeGame extends Game { vibrate('error'); this.guideSwipes++; if (this.guideSwipes >= 2) this._advance(3, ['voice/wall', 'voice/g3_left']); - else this.audio.play('voice/wall'); + else this._playVoice('voice/wall', { skippable: false }); } else this._guideWrong(null); break; case 3: // G3:向左滑 → (0,1) @@ -253,7 +265,7 @@ export class EscapeGame extends Game { this._clearTimers(); this.audio.stopVoice(); try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ } - this._speak(['voice/guide_skip'], () => this._enterPlay(true)); + this._speak(['voice/guide_skip'], () => this._enterPlay(true), { skippable: false }); // 语音缺失兜底 this._timer(() => { if (this.phase === Phase.GUIDE) this._enterPlay(true); }, 5000); } @@ -267,12 +279,12 @@ export class EscapeGame extends Game { this.guideErrs++; if (this.guideErrs >= 3) { vibrate('warning'); - this.audio.play(expected ? `voice/guide_blunt_${expected}` : 'voice/guide_wrong_gesture'); + this._playVoice(expected ? `voice/guide_blunt_${expected}` : 'voice/guide_wrong_gesture', { skippable: false }); this.guideErrs = 0; return; } vibrate('error'); - this.audio.play(expected ? `voice/guide_wrong_${expected}` : 'voice/guide_wrong_gesture'); + this._playVoice(expected ? `voice/guide_wrong_${expected}` : 'voice/guide_wrong_gesture', { skippable: false }); } // ---------- 进入 / 重开 PLAY ---------- @@ -284,7 +296,7 @@ export class EscapeGame extends Game { this.phase = Phase.PLAY; this.startElapsedMs = Date.now(); this._resetStuck(); - if (announce) this.audio.play('voice/start_room'); + if (announce) this._playVoice('voice/start_room'); this._playAmbient(); } @@ -297,7 +309,7 @@ export class EscapeGame extends Game { this.startElapsedMs = Date.now(); this._resetStuck(); this.audio.play('bgm/drone'); - this.audio.play('voice/replay'); + this._playVoice('voice/replay'); this._playAmbient(); } @@ -309,7 +321,16 @@ export class EscapeGame extends Game { case G.DOWN: this._move(0, 1); break; case G.LEFT: this._move(-1, 0); break; case G.RIGHT: this._move(1, 0); break; - case G.TAP: this._inspect(); break; + case G.TAP: + // voice 播放中:可跳句单击 = 跳过本句(链播继续);不可跳句(关键获得/ + // L3 带路)单击 = 忽略——若照旧触发检查,800ms 后的描述会顶掉该句。 + // 无 voice 时单击 = 原检查语义。 + if (this.audio._voice) { + if (this._voiceSkippable) this.audio.skipVoice(); + break; + } + this._inspect(); + break; case G.DOUBLE_TAP: this._useItem(); break; } } @@ -320,7 +341,7 @@ export class EscapeGame extends Game { if (nx < 0 || nx > 2 || ny < 0 || ny > 2) { this.audio.play('sfx/wall'); vibrate('error'); - this.audio.play('voice/wall'); // 每次撞墙都播(取消一次性限制) + this._playVoice('voice/wall'); // 每次撞墙都播(取消一次性限制) return; } this.x = nx; @@ -332,7 +353,7 @@ export class EscapeGame extends Game { if (KEY_ROOMS[key] && !this.visited.has(key)) { this.visited.add(key); this._progressEvent(); - this.audio.play(KEY_ROOMS[key].room); + this._playVoice(KEY_ROOMS[key].room); } } @@ -354,8 +375,9 @@ export class EscapeGame extends Game { _inspect() { this._playAmbient(); const s = this._session; - const describe = (id) => this._timer(() => { - if (s === this._session) this.audio.play(id); + // skippable:found_key 是关键获得(不可跳),其余检查描述可跳 + const describe = (id, skippable = true) => this._timer(() => { + if (s === this._session) this._playVoice(id, { skippable }); }, 800); // 先让环境音进耳朵再播描述 const { x, y } = this; @@ -365,7 +387,7 @@ export class EscapeGame extends Game { this._progressEvent(); this.audio.play('sfx/pickup'); vibrate('pickup'); - describe('voice/found_key'); + describe('voice/found_key', false); } else { describe('voice/empty_2'); } @@ -397,7 +419,7 @@ export class EscapeGame extends Game { this.audio.play('sfx/unlock'); this.audio.play('sfx/door_creak', { volume: 0.7 }); vibrate('success'); - this.audio.play('voice/unlock_cabinet'); + this._playVoice('voice/unlock_cabinet', { skippable: false }); // 关键获得不可跳 } else if (x === 0 && y === 0 && this.hasFuse && !this.powerOn) { // 保险丝修电箱 -> 恢复供电(保险丝消耗) this.powerOn = true; @@ -406,7 +428,7 @@ export class EscapeGame extends Game { this.audio.play('sfx/spark'); this.audio.play('sfx/power_on', { volume: 0.8 }); vibrate('success'); - this.audio.play('voice/fix_box'); + this._playVoice('voice/fix_box', { skippable: false }); // 关键获得不可跳 } else if (x === 2 && y === 2 && this.powerOn) { // 通电后开大门 -> 逃脱 this._escape(); @@ -459,12 +481,12 @@ export class EscapeGame extends Game { const idleMs = Date.now() - this._lastProgressAt; const stage = this._stage(); if (idleMs >= 180000) { - // L3 带路:首次或每 60s 重复,按当前坐标重算路线 + // 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)]); + this._speak([`voice/stuck_${stage}_3`, ...this._navVoices(STAGES[stage].target)], null, { skippable: false }); } } else if (idleMs >= 120000 && !this._stuckShown.has('L2')) { this._stuckShown.add('L2'); @@ -493,22 +515,37 @@ export class EscapeGame extends Game { this._menuReturn = returnPhase; this._menuIdx = 0; this.phase = Phase.MENU; - this._speak(['voice/menu_open', 'voice/menu_restart']); + // 状态 BGM:菜单 = bgm/menu(10 号文档,400ms crossfade;与结算 pad 同音量 0.5, + // 同一首引擎不重启,关菜单回 ENDING 时无缝保持) + this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 }); + this._speak(['voice/menu_open', 'voice/menu_restart'], null, { skippable: false }); } _onMenuGesture(g) { const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back']; if (g === G.TAP) { this._menuIdx = (this._menuIdx + 1) % items.length; - this.audio.play(items[this._menuIdx]); + this._playVoice(items[this._menuIdx], { skippable: false }); } else if (g === G.UP) { - this.audio.play('voice/help_ops'); // 操作说明 + this._playVoice('voice/help_ops', { skippable: false }); // 操作说明 + } else if (g === G.DOWN) { + this._closeMenu(); // 下滑 = 关闭菜单(13 号文档 §7.5 菜单约定) } else if (g === G.DOUBLE_TAP) { const sel = this._menuIdx; - this.phase = this._menuReturn; - if (sel === 0) this._restart(); + if (sel === 0) { this.phase = this._menuReturn; this._restart(); } // 重开:_restart 内切回 bgm/drone else if (sel === 1) this._goHome(); - // sel === 2 返回游戏:phase 已恢复 + else this._closeMenu(); // "返回" + } + } + + /** 关闭菜单回到来源状态,并按来源恢复状态 BGM(ENDING 保持 menu pad)。 */ + _closeMenu() { + this.phase = this._menuReturn; + if (this._menuReturn === Phase.ENDING) { + // 返回结算:结算态 BGM 即 bgm/menu(同 id 引擎不重启,无缝保持) + this.audio.play('bgm/menu', { volume: 0.5 }); + } else { + this.audio.play('bgm/drone'); // 关闭菜单切回主题曲 } } @@ -526,9 +563,21 @@ export class EscapeGame extends Game { this.audio.play('sfx/gate_open'); this.audio.play('sfx/win_chime', { volume: 0.8 }); this.audio.stopBgm(2500); + this._endingBgmStarted = false; const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000); this._saveEscape(totalSec); - this._speak(['voice/escape', this._timeBandVoice(totalSec), 'voice/again']); + // 结算语音链(关键获得,不可跳);播完淡入 menu pad(10 号文档:volume 0.5,1500ms) + this._speak(['voice/escape', this._timeBandVoice(totalSec), 'voice/again'], + () => this._startEndingBgm(), { skippable: false }); + // 语音资产缺失时链不推进,30s 兜底起结算 pad(escape+time_band+again 正常 ~10s 内播完) + this._timer(() => this._startEndingBgm(), 30000); + } + + /** 逃脱结算 BGM:drone 淡出后淡入 bgm/menu(幂等,仅在仍是结算态时生效)。 */ + _startEndingBgm() { + if (this._endingBgmStarted || this.phase !== Phase.ENDING) return; + this._endingBgmStarted = true; + this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 1500 }); } /** 用时播报:4 段整句(time_band1..4),替代数字拼接。 */ @@ -589,14 +638,21 @@ export class EscapeGame extends Game { return g === G.UP || g === G.DOWN || g === G.LEFT || g === G.RIGHT; } + /** 对白包装:登记本句是否允许单击跳过(12 号文档裁定表,默认可跳)。 */ + _playVoice(id, { skippable = true, onComplete } = {}) { + this._voiceSkippable = skippable; + this.audio.play(id, { onComplete }); + } + /** 对白排队依次播放(onComplete 链式触发;被打断即作废整条链)。 */ - _speak(ids, onDone) { + _speak(ids, onDone, { skippable = true } = {}) { if (ids.length === 0) { if (onDone) onDone(); return; } const s = this._session; - this.audio.play(ids[0], { + this._playVoice(ids[0], { + skippable, onComplete: () => { if (s !== this._session) return; - if (ids.length > 1) this._speak(ids.slice(1), onDone); + if (ids.length > 1) this._speak(ids.slice(1), onDone, { skippable }); else if (onDone) onDone(); }, }); diff --git a/web/escape/test/playtest.mjs b/web/escape/test/playtest.mjs new file mode 100644 index 00000000..2f602a0a --- /dev/null +++ b/web/escape/test/playtest.mjs @@ -0,0 +1,643 @@ +/** + * 《暗室》无头试玩测试 —— mock 浏览器环境 + 虚拟时钟,以玩家手势跑通 + * docs/design/13-playtest-criteria.md §2 的关键路径与边界断言。 + * + * 运行:tools/node/bin/node web/escape/test/playtest.mjs + * + * 设计: + * - 真实引擎(engine/audio.js + settings.js + vendor/soundtouch.js), + * mock 的是浏览器 API 层(AudioContext/fetch/localStorage/navigator/window)。 + * - decodeAudioData 返回带 duration 的假 buffer(fetch 把时长编进 ArrayBuffer + * 头 8 字节);voice 自然结束由引擎内部定时器驱动(duration ÷ tempo), + * 虚拟时钟推进即触发 onComplete,语音链随之推进。 + * - 手势直接在 game 实例上注入 onGesture(不过 gesture.js 的触摸判定)。 + * - 全程虚拟时钟:setTimeout/setInterval/Date.now/performance.now 都走 + * 手动推进的逻辑时钟,测试毫秒级跑完、完全可重复(本游戏无随机)。 + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); // web/escape/test +const GAME_DIR = join(HERE, '..'); // web/escape + +// ---------- 虚拟时钟(必须先于引擎模块加载安装) ---------- +let now = 0; +let timerSeq = 1; +const timers = new Map(); // id -> { at, fn, interval|null } + +globalThis.setTimeout = (fn, ms = 0) => { + const id = timerSeq++; + timers.set(id, { at: now + ms, fn, interval: null }); + return id; +}; +globalThis.clearTimeout = (id) => { timers.delete(id); }; +globalThis.setInterval = (fn, ms = 0) => { + const id = timerSeq++; + timers.set(id, { at: now + ms, fn, interval: ms }); + return id; +}; +globalThis.clearInterval = (id) => { timers.delete(id); }; +Date.now = () => now; +Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true }); + +/** 推进虚拟时钟 ms,按序触发到期定时器(interval 自动重排)。 */ +function advance(ms) { + const end = now + ms; + for (;;) { + let nextId = null; + let nextAt = Infinity; + for (const [id, t] of timers) { + if (t.at <= end && t.at < nextAt) { nextAt = t.at; nextId = id; } + } + if (nextId === null) break; + const t = timers.get(nextId); + now = t.at; + if (t.interval !== null) t.at = now + t.interval; + else timers.delete(nextId); + t.fn(); + } + now = end; +} + +/** 刷新微任务队列(fetch/decode/语音链接续都是 promise 驱动)。 */ +async function flush() { + for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); +} + +// ---------- 浏览器环境 mock ---------- +const vibrateLog = []; +const fetchLog = []; +let blockedAssets = []; // 命中即 404(模拟资产缺失) + +Object.defineProperty(globalThis, 'navigator', { + value: { vibrate: (p) => vibrateLog.push(p) }, configurable: true, +}); + +const store = new Map(); +Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }, + configurable: true, +}); + +Object.defineProperty(globalThis, 'window', { + value: { + addEventListener: () => {}, + removeEventListener: () => {}, + AudioContext: null, // 下方填入 + location: { href: '' }, + }, + configurable: true, +}); + +Object.defineProperty(globalThis, 'document', { + value: { + createElement: () => ({ + style: { setProperty: () => {} }, + classList: { add: () => {}, remove: () => {} }, + appendChild: () => {}, remove: () => {}, addEventListener: () => {}, + }), + body: { appendChild: () => {} }, + getElementById: () => null, + }, + configurable: true, +}); + +const LAYER_DURATION = { voice: 1.0, sfx: 0.3, bgm: 60 }; + +Object.defineProperty(globalThis, 'fetch', { + value: async (url) => { + fetchLog.push(String(url)); + if (blockedAssets.some((b) => String(url).includes(b))) { + return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; + } + const u = String(url); + const layer = u.includes('/voice/') ? 'voice' : u.includes('/bgm/') ? 'bgm' : 'sfx'; + const buf = new ArrayBuffer(8); + new Float64Array(buf)[0] = LAYER_DURATION[layer]; + return { ok: true, arrayBuffer: async () => buf }; + }, + configurable: true, +}); + +class MockAudioParam { + constructor(v = 0) { this.value = v; } + cancelScheduledValues() {} + setValueAtTime() {} + linearRampToValueAtTime() {} +} +class MockNode { + constructor() { this.gain = new MockAudioParam(1); } + connect() {} + disconnect() {} +} +class MockAudioContext { + constructor() { + this.state = 'running'; + this.currentTime = 0; + this.destination = {}; + this.sampleRate = 44100; + } + createGain() { return new MockNode(); } + createDynamicsCompressor() { + const n = new MockNode(); + for (const k of ['threshold', 'ratio', 'attack', 'release', 'knee', 'reduction']) n[k] = new MockAudioParam(0); + return n; + } + createBufferSource() { + const n = new MockNode(); + n.buffer = null; n.loop = false; + n.start = () => {}; n.stop = () => {}; + return n; + } + createScriptProcessor() { const n = new MockNode(); n.onaudioprocess = null; return n; } + decodeAudioData(raw) { + const duration = new Float64Array(raw)[0] || 1; + return Promise.resolve({ + duration, + numberOfChannels: 1, + sampleRate: 44100, + length: Math.ceil(duration * 44100), + getChannelData: () => new Float32Array(Math.ceil(duration * 44100)), + }); + } + resume() { this.state = 'running'; return Promise.resolve(); } +} +window.AudioContext = MockAudioContext; + +// ---------- 加载引擎与游戏(globals 就绪后动态 import) ---------- +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { EscapeGame } = await import('../game.js'); + +// ---------- 断言与辅助 ---------- +let failures = 0; +let passes = 0; +function assert(cond, msg) { + if (cond) { passes++; console.log(' ✓', msg); } + else { failures++; console.error(' ✗ FAIL:', msg); } +} +function section(title) { console.log('\n== ' + title); } + +/** play 调用记录 + skipVoice 计数。 */ +function instrument(audio) { + const log = []; + const origPlay = audio.play.bind(audio); + audio.play = (id, opts = {}) => { log.push({ t: now, id, opts }); return origPlay(id, opts); }; + const origSkip = audio.skipVoice.bind(audio); + let skips = 0; + audio.skipVoice = () => { skips++; return origSkip(); }; + return { + log, + skips: () => skips, + count: (id) => log.filter((e) => e.id === id).length, + last: (id) => [...log].reverse().find((e) => e.id === id), + }; +} + +async function newGame() { + const audio = new WebAudioEngine('audio/'); + await audio.unlock(); + const game = new EscapeGame(audio, {}); + return { audio, game, rec: instrument(audio) }; +} +async function gs(game, g) { game.onGesture(g); await flush(); } +async function tick(ms) { advance(ms); await flush(); } +/** 播完 n 句 voice(每句 1s + 120ms 余量)。 */ +async function finishVoices(n = 1) { for (let i = 0; i < n; i++) await tick(1300); } +const vibrateCount = (pattern) => vibrateLog.filter((p) => JSON.stringify(p) === JSON.stringify(pattern)).length; +const ERR_PAT = [60, 40, 60]; +const WARN_PAT = [30, 50, 30, 50, 30]; + +const VOICE_END_SLACK = 1300; // 一句 voice 的虚拟时长 + +// ================================================================ +// 场景 A:关键路径 —— 首局引导 G0-G7 → PLAY 解谜链 → 逃脱结算 → 菜单 → 重开 +// ================================================================ +section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单 → 重开'); +{ + store.clear(); + const { audio, game, rec } = await newGame(); + game.start(); + await flush(); + assert(game.phase === 'GUIDE', '启动进入 GUIDE'); + assert(rec.count('bgm/drone') === 1, '开局播 bgm/drone'); + assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放'); + await tick(VOICE_END_SLACK); // g0 播完 + + // G0:单击 → G1(链播 g1_move + skip_hint 首局插播) + await gs(game, 'tap'); + assert(rec.count('voice/g1_move') === 1, 'G0 单击后播 g1_move'); + await tick(VOICE_END_SLACK); + assert(rec.count('voice/skip_hint') === 1, 'skip_hint 在 G0/G1 附近插播一次'); + await tick(VOICE_END_SLACK); + assert(game.guideStep === 1 && !game._guideBusy, '进入 G1,输入解锁'); + + // G1:任意滑动 → 脚步声 + const stepsBefore = rec.count('sfx/step'); + await gs(game, 'up'); + assert(rec.count('sfx/step') === stepsBefore + 1, 'G1 滑动播脚步声'); + await finishVoices(2); // g1_done + g2_wall + assert(game.guideStep === 2, '进入 G2'); + + // G2:滑动 ×2 撞墙反馈(避开下滑——引导期任何步骤下滑 = 跳过引导) + const wallsBefore = rec.count('sfx/wall'); + const errBefore = vibrateCount(ERR_PAT); + await gs(game, 'up'); + await gs(game, 'right'); + assert(rec.count('sfx/wall') === wallsBefore + 2, 'G2 两滑各播 sfx/wall'); + assert(vibrateCount(ERR_PAT) === errBefore + 2, 'G2 两滑各发 error 震动'); + await finishVoices(2); // wall + g3_left + assert(game.guideStep === 3, '进入 G3'); + + // G3:左滑 → (0,1) + await gs(game, 'left'); + assert(game.x === 0 && game.y === 1, 'G3 左滑移到 (0,1)'); + await finishVoices(1); // g4_down + assert(game.guideStep === 4, '进入 G4'); + + // G4:下滑 → (0,2) 储物间 + await gs(game, 'down'); + assert(game.x === 0 && game.y === 2, 'G4 下滑移到 (0,2)'); + assert(rec.count('sfx/amb_storage') >= 1, '储物间环境音'); + await finishVoices(1); // g5_inspect + assert(game.guideStep === 5, '进入 G5'); + + // G5:单击拾钥匙 + await gs(game, 'tap'); + assert(game.hasKey === true, 'G5 拾得钥匙 hasKey === true'); + await finishVoices(2); // g5_key + g6_doubletap + assert(game.guideStep === 6, '进入 G6'); + + // G6:双击 → G7 → 引导完成进 PLAY + await gs(game, 'doubletap'); + await finishVoices(3); // inv_key + g6_done + g7_goal → onDone → _finishGuide + assert(game.phase === 'PLAY', '引导完成进入 PLAY'); + assert(store.get('soundgame.escape.guideDone') === '1', "localStorage guideDone === '1'"); + + // ---- PLAY 解谜链:(0,2) → 铁柜房 (2,1) → 电箱房 (0,0) → 大门 (2,2) ---- + await gs(game, 'right'); + assert(game.x === 1 && game.y === 2, '右滑到 (1,2)'); + await gs(game, 'right'); + assert(game.x === 2 && game.y === 2, '右滑到 (2,2)'); + assert(rec.count('voice/room_gate') === 1, '首到大门播 room_gate'); + await finishVoices(1); + await gs(game, 'up'); + assert(game.x === 2 && game.y === 1, '上滑到铁柜房 (2,1)'); + assert(rec.count('voice/room_cabinet') === 1, '首到铁柜房播 room_cabinet'); + await finishVoices(1); + + // 双击开柜 → 保险丝;unlock_cabinet 为关键获得不可跳 + await gs(game, 'doubletap'); + assert(game.cabinetOpen === true && game.hasFuse === true, '开柜得保险丝 cabinetOpen && hasFuse'); + assert(game.hasKey === false, '钥匙被消耗'); + await flush(); + assert(!!audio._voice, 'unlock_cabinet 播放中'); + const skipsAtUnlock = rec.skips(); + const ambAtUnlock = rec.count('sfx/amb_cabinet'); + await gs(game, 'tap'); // 不可跳句播放中单击 = 忽略 + assert(rec.skips() === skipsAtUnlock, 'unlock_cabinet 不可单击跳过'); + assert(rec.count('sfx/amb_cabinet') === ambAtUnlock, '不可跳句播放中单击不触发检查'); + await finishVoices(1); + + // 左滑×2 + 上滑 → 电箱房 (0,0) + await gs(game, 'left'); + await gs(game, 'left'); + await gs(game, 'up'); + assert(game.x === 0 && game.y === 0, '到达电箱房 (0,0)'); + assert(rec.count('voice/room_power') === 1, '首到电箱房播 room_power'); + await finishVoices(1); + + // 双击修电箱 → 通电 + await gs(game, 'doubletap'); + assert(game.powerOn === true, '修复电箱 powerOn === true'); + assert(rec.count('sfx/spark') === 1 && rec.count('sfx/power_on') === 1, '修电箱音效 spark + power_on'); + await finishVoices(1); // fix_box + + // 右滑×2 + 下滑×2 → 大门 (2,2),通电后环境音切 amb_gate_live + await gs(game, 'right'); + await gs(game, 'right'); + await gs(game, 'down'); + await gs(game, 'down'); + assert(game.x === 2 && game.y === 2, '到达大门 (2,2)'); + assert(rec.count('sfx/amb_gate_live') >= 1, '通电后大门播 amb_gate_live'); + + // 双击逃脱 → ENDING + 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('voice/escape') === 1, 'escape 语音播出'); + assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`); + await finishVoices(1); + assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)'); + await finishVoices(2); // time_band1 + again → 链完 → 结算 pad + const menuPad = rec.last('bgm/menu'); + assert(!!menuPad && menuPad.opts.volume === 0.5 && menuPad.opts.fadeMs === 1500, + '逃脱语音链播完淡入 bgm/menu(volume 0.5 / fadeMs 1500)'); + + // ENDING 下滑 → 菜单:切 bgm/menu(400ms) + await gs(game, 'down'); + assert(game.phase === 'MENU', 'ENDING 下滑打开菜单'); + const menuOpen = rec.last('bgm/menu'); + assert(menuOpen && menuOpen.opts.fadeMs === 400, '进菜单 crossfade 400ms 播 bgm/menu'); + await finishVoices(2); // menu_open + menu_restart + + // 菜单约定:单击循环 / 上滑帮助 / 双击确认(选"返回") + await gs(game, 'tap'); + assert(rec.count('voice/menu_home') === 1, '菜单单击循环到 menu_home'); + await gs(game, 'up'); + assert(rec.count('voice/help_ops') === 1, '菜单上滑播操作说明'); + await finishVoices(1); + await gs(game, 'tap'); + assert(rec.count('voice/menu_back') === 1, '菜单单击循环到 menu_back'); + await finishVoices(1); + await gs(game, 'doubletap'); // idx=2 返回 + assert(game.phase === 'ENDING', '菜单双击"返回"回到结算'); + + // 菜单约定:下滑 = 关闭菜单 + await gs(game, 'down'); + assert(game.phase === 'MENU', '再次下滑打开菜单'); + await gs(game, 'down'); + assert(game.phase === 'ENDING', '菜单下滑关闭回到结算'); + + // 结算单击重开 → PLAY + bgm/drone + const droneBefore = rec.count('bgm/drone'); + await gs(game, 'tap'); + assert(game.phase === 'PLAY', '结算单击重开回 PLAY'); + assert(rec.count('bgm/drone') === droneBefore + 1, '重开切回 bgm/drone'); + assert(rec.count('voice/replay') === 1, '重开播 replay'); + assert(game.x === 1 && game.y === 1 && game.hasKey === false, '重开状态复位'); + await finishVoices(1); + + // ---- 边界:单击检查 = 环境音先播、描述 800ms 后播 ---- + const ambDripBefore = rec.count('sfx/amb_drip'); + await gs(game, 'tap'); + assert(rec.count('sfx/amb_drip') === ambDripBefore + 1, '检查先播环境音 amb_drip'); + assert(rec.count('voice/start_room') === 0, '描述 800ms 内未播'); + 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 播放中单击 = 跳过本句 + const skipsBefore = rec.skips(); + const ambBefore = rec.count('sfx/amb_drip'); + await gs(game, 'tap'); + assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice'); + assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查'); + await tick(200); + + // ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ---- + await gs(game, 'left'); // (0,1) + await gs(game, 'up'); // (0,0) + const stepCnt = rec.count('sfx/step'); + const wallCnt = rec.count('sfx/wall'); + const errCnt = vibrateCount(ERR_PAT); + await gs(game, 'left'); // 撞墙 + assert(game.x === 0 && game.y === 0, '撞墙位置不变'); + assert(rec.count('sfx/wall') === wallCnt + 1, '撞墙播 sfx/wall'); + assert(vibrateCount(ERR_PAT) === errCnt + 1, '撞墙发 error 震动'); + assert(rec.count('sfx/step') === stepCnt, '撞墙不播脚步声'); + assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音'); + await finishVoices(1); + + // ---- 边界:双击无目标兜底 = 背包/use_fail + L2 明示,不崩 ---- + const errCnt2 = vibrateCount(ERR_PAT); + await gs(game, 'doubletap'); // 电箱房无保险丝 + assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动'); + assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)'); + await finishVoices(1); + assert(rec.count('voice/stuck_key_2') === 1, '兜底追加 L2 明示 stuck_key_2'); + await finishVoices(1); + + game.stop(); +} + +// ================================================================ +// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导 +// ================================================================ +section('B. 引导纠错与跳过'); +{ + store.clear(); + vibrateLog.length = 0; + const { game, rec } = await newGame(); + game.start(); + await flush(); + await tick(VOICE_END_SLACK); // g0 + await gs(game, 'tap'); // G0 → G1 + await finishVoices(2); // g1_move + skip_hint + await gs(game, 'up'); // G1 + await finishVoices(2); // g1_done + g2_wall + await gs(game, 'up'); // G2 ×2 + await gs(game, 'up'); + await finishVoices(2); // wall + g3_left + assert(game.guideStep === 3, '到达 G3'); + + // G3 右滑(应为左)→ guide_wrong_left;连错 3 次 → guide_blunt_left + warning 震动 + const warnBefore = vibrateCount(WARN_PAT); + await gs(game, 'right'); + assert(rec.count('voice/guide_wrong_left') === 1, 'G3 右滑播 guide_wrong_left'); + await gs(game, 'right'); + await gs(game, 'right'); + assert(rec.count('voice/guide_blunt_left') === 1, '连错 3 次播 guide_blunt_left'); + assert(vibrateCount(WARN_PAT) === warnBefore + 1, '连错 3 次发 warning 震动'); + assert(game.guideStep === 3, '纠错不离步(仍 G3)'); + await gs(game, 'left'); + assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)'); + game.stop(); + + // 下滑跳过引导 + store.clear(); + const g2 = await newGame(); + g2.game.start(); + await flush(); + await gs(g2.game, 'down'); + assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip'); + await tick(VOICE_END_SLACK); + assert(g2.game.phase === 'PLAY', '下滑跳过引导直达 PLAY'); + assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone'); + assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room'); + g2.game.stop(); +} + +// ================================================================ +// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定) +// ================================================================ +section('C. 卡关提示计时器'); +{ + store.clear(); + store.set('soundgame.escape.guideDone', '1'); // 二周目直达 + vibrateLog.length = 0; + const { game, rec } = await newGame(); + game.start(); + await flush(); + assert(game.phase === 'REOPEN', '二周目进 REOPEN'); + assert(rec.count('voice/reopen_prompt') === 1, '播 reopen_prompt'); + await gs(game, 'tap'); + assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY'); + await finishVoices(1); // start_room + + // 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 + const skips0 = rec.skips(); + await gs(game, 'tap'); + 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); + + // 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'); + await finishVoices(1); + await tick(60000); + 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 带路不可跳:播放中单击 = 忽略 + 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 播完 + + // L3 之后每 60s 重复带路 + await tick(60000); + assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路'); + await finishVoices(3); + + // 进展事件重置:移动到关键房间 → _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'); + game.stop(); +} + +// ================================================================ +// 场景 D:语音资产缺失 → 20s 看门狗兜底推进,不卡死 +// ================================================================ +section('D. 资产缺失看门狗(屏蔽 g4_down)'); +{ + store.clear(); + blockedAssets = ['g4_down']; + const { game } = await newGame(); + game.start(); + await flush(); + await tick(VOICE_END_SLACK); + await gs(game, 'tap'); // G0 + await finishVoices(2); + await gs(game, 'up'); // G1 + await finishVoices(2); + await gs(game, 'up'); // G2 ×2 + await gs(game, 'up'); + await finishVoices(2); + await gs(game, 'left'); // G3 → _advance(4, ['voice/g4_down']) 资产缺失 + await tick(2000); + assert(game.guideStep === 3 && game._guideBusy, 'g4_down 缺失:链不推进,停在 G3 等待'); + await tick(20000); // 看门狗 + assert(game.guideStep === 4 && !game._guideBusy, '20s 看门狗兜底推进到 G4'); + // 继续走完引导,确认不卡死 + await gs(game, 'down'); // G4 + await finishVoices(1); + await gs(game, 'tap'); // G5 + await finishVoices(2); + await gs(game, 'doubletap'); // G6 + await finishVoices(3); + assert(game.phase === 'PLAY', '资产缺失下仍走完引导进 PLAY'); + blockedAssets = []; + game.stop(); +} + +// ================================================================ +// 场景 E:设置边界(bgm=0 静默/恢复、voice 性别切换、speed 1.7) +// ================================================================ +section('E. 设置边界'); +{ + store.clear(); + store.set('soundgame.escape.guideDone', '1'); + const { audio, game } = await newGame(); + game.start(); + await flush(); + await gs(game, 'tap'); // REOPEN → PLAY + await finishVoices(1); + assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'PLAY 中 BGM 为 drone'); + + settings.set({ bgm: 0 }); + await tick(300); + assert(audio._bgm === null, 'bgm=0:进行中的 BGM 停止'); + assert(audio._bgmWanted && audio._bgmWanted.id === 'bgm/drone', 'bgm=0:保留"想播"意图'); + settings.set({ bgm: 1.0 }); + await flush(); + assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'bgm 调回自动补播 drone'); + + settings.set({ voice: 'male' }); + const fetchBase = fetchLog.length; + await gs(game, 'tap'); // 检查 → 描述用男声目录 + await tick(800); + await flush(); + assert(fetchLog.slice(fetchBase).some((u) => u.includes('/voice/male/')), 'voice=male 后从 male 目录加载'); + + settings.set({ speed: 1.7 }); + await tick(VOICE_END_SLACK); // 让当前句播完 + await gs(game, 'tap'); // 再触发一句检查描述 + await tick(800); + await flush(); + assert(audio._voice && audio._voice.st.tempo === 1.7, 'speed=1.7 下一句 tempo 生效'); + + settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); // 复位共享单例 + game.stop(); +} + +// ================================================================ +// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失 +// ================================================================ +section('F. 资产引用校验'); +{ + const src = readFileSync(join(GAME_DIR, 'game.js'), 'utf8'); + const ids = new Set(); + for (const m of src.matchAll(/(voice|sfx|bgm)\/[a-z0-9_]+/g)) { + if (!m[0].endsWith('_')) ids.add(m[0]); // 模板前缀(stuck_ 等)单独展开 + } + // 模板字符串族展开 + for (const d of ['up', 'down', 'left', 'right']) { + ids.add(`voice/guide_wrong_${d}`); + ids.add(`voice/guide_blunt_${d}`); + } + for (const st of ['key', 'cabinet', 'box', 'gate']) { + for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`); + } + let missing = 0; + for (const id of [...ids].sort()) { + const [layer, name] = id.split('/'); + const bases = layer === 'voice' + ? [`audio/voice/male/${name}`, `audio/voice/female/${name}`] + : [`audio/${layer}/${name}`]; + for (const base of bases) { + for (const ext of ['.ogg', '.m4a']) { + if (!existsSync(join(GAME_DIR, base + ext))) { + missing++; + console.error(' ✗ 缺失资产:', base + ext); + } + } + } + } + assert(missing === 0, `资产引用校验:${ids.size} 个 id 全部存在(ogg+m4a${''},voice 双性别),零缺失`); +} + +// ================================================================ +console.log(`\n========== 结果:${passes} 通过 / ${failures} 失败 ==========`); +process.exit(failures === 0 ? 0 : 1); diff --git a/web/rhythm/audio/bgm/menu.m4a b/web/rhythm/audio/bgm/menu.m4a new file mode 100644 index 00000000..38223f80 Binary files /dev/null and b/web/rhythm/audio/bgm/menu.m4a differ diff --git a/web/rhythm/audio/bgm/menu.ogg b/web/rhythm/audio/bgm/menu.ogg new file mode 100644 index 00000000..6a61d658 Binary files /dev/null and b/web/rhythm/audio/bgm/menu.ogg differ diff --git a/web/rhythm/audio/sfx/inst_accent.m4a b/web/rhythm/audio/sfx/inst_accent.m4a index ff05c722..40e933fe 100644 Binary files a/web/rhythm/audio/sfx/inst_accent.m4a and b/web/rhythm/audio/sfx/inst_accent.m4a differ diff --git a/web/rhythm/audio/sfx/inst_accent.ogg b/web/rhythm/audio/sfx/inst_accent.ogg index 1525d262..89b58452 100644 Binary files a/web/rhythm/audio/sfx/inst_accent.ogg and b/web/rhythm/audio/sfx/inst_accent.ogg differ diff --git a/web/rhythm/audio/sfx/inst_hat_l.m4a b/web/rhythm/audio/sfx/inst_hat_l.m4a index daca738d..55c17fce 100644 Binary files a/web/rhythm/audio/sfx/inst_hat_l.m4a and b/web/rhythm/audio/sfx/inst_hat_l.m4a differ diff --git a/web/rhythm/audio/sfx/inst_hat_l.ogg b/web/rhythm/audio/sfx/inst_hat_l.ogg index 00b26e28..19b32b9b 100644 Binary files a/web/rhythm/audio/sfx/inst_hat_l.ogg and b/web/rhythm/audio/sfx/inst_hat_l.ogg differ diff --git a/web/rhythm/audio/sfx/inst_hat_r.m4a b/web/rhythm/audio/sfx/inst_hat_r.m4a index 95a9cfee..67edc8ba 100644 Binary files a/web/rhythm/audio/sfx/inst_hat_r.m4a and b/web/rhythm/audio/sfx/inst_hat_r.m4a differ diff --git a/web/rhythm/audio/sfx/inst_hat_r.ogg b/web/rhythm/audio/sfx/inst_hat_r.ogg index bfda621b..6c544c9e 100644 Binary files a/web/rhythm/audio/sfx/inst_hat_r.ogg and b/web/rhythm/audio/sfx/inst_hat_r.ogg differ diff --git a/web/rhythm/audio/sfx/inst_kick.m4a b/web/rhythm/audio/sfx/inst_kick.m4a index 946a01eb..2b594dff 100644 Binary files a/web/rhythm/audio/sfx/inst_kick.m4a and b/web/rhythm/audio/sfx/inst_kick.m4a differ diff --git a/web/rhythm/audio/sfx/inst_kick.ogg b/web/rhythm/audio/sfx/inst_kick.ogg index 438bc115..ffb35ba7 100644 Binary files a/web/rhythm/audio/sfx/inst_kick.ogg and b/web/rhythm/audio/sfx/inst_kick.ogg differ diff --git a/web/rhythm/audio/sfx/inst_snare.m4a b/web/rhythm/audio/sfx/inst_snare.m4a index d2fb9607..b25655a0 100644 Binary files a/web/rhythm/audio/sfx/inst_snare.m4a and b/web/rhythm/audio/sfx/inst_snare.m4a differ diff --git a/web/rhythm/audio/sfx/inst_snare.ogg b/web/rhythm/audio/sfx/inst_snare.ogg index 5feabebe..edede3f1 100644 Binary files a/web/rhythm/audio/sfx/inst_snare.ogg and b/web/rhythm/audio/sfx/inst_snare.ogg differ diff --git a/web/rhythm/audio/sfx/inst_tom_hi.m4a b/web/rhythm/audio/sfx/inst_tom_hi.m4a index c6f5886c..3a5ab4bb 100644 Binary files a/web/rhythm/audio/sfx/inst_tom_hi.m4a and b/web/rhythm/audio/sfx/inst_tom_hi.m4a differ diff --git a/web/rhythm/audio/sfx/inst_tom_hi.ogg b/web/rhythm/audio/sfx/inst_tom_hi.ogg index b22d5ea1..e0e7c343 100644 Binary files a/web/rhythm/audio/sfx/inst_tom_hi.ogg and b/web/rhythm/audio/sfx/inst_tom_hi.ogg differ diff --git a/web/rhythm/audio/sfx/inst_tom_lo.m4a b/web/rhythm/audio/sfx/inst_tom_lo.m4a index c52a4d50..35c9d558 100644 Binary files a/web/rhythm/audio/sfx/inst_tom_lo.m4a and b/web/rhythm/audio/sfx/inst_tom_lo.m4a differ diff --git a/web/rhythm/audio/sfx/inst_tom_lo.ogg b/web/rhythm/audio/sfx/inst_tom_lo.ogg index f28c4263..ba807aa6 100644 Binary files a/web/rhythm/audio/sfx/inst_tom_lo.ogg and b/web/rhythm/audio/sfx/inst_tom_lo.ogg differ diff --git a/web/rhythm/audio/voice/female/grade_a.ogg b/web/rhythm/audio/voice/female/grade_a.ogg index 2073b020..ab62e5a9 100644 Binary files a/web/rhythm/audio/voice/female/grade_a.ogg and b/web/rhythm/audio/voice/female/grade_a.ogg differ diff --git a/web/rhythm/audio/voice/female/grade_b.ogg b/web/rhythm/audio/voice/female/grade_b.ogg index b3a7467e..e1ca00e8 100644 Binary files a/web/rhythm/audio/voice/female/grade_b.ogg and b/web/rhythm/audio/voice/female/grade_b.ogg differ diff --git a/web/rhythm/audio/voice/female/grade_c.ogg b/web/rhythm/audio/voice/female/grade_c.ogg index 2db1a176..73042093 100644 Binary files a/web/rhythm/audio/voice/female/grade_c.ogg and b/web/rhythm/audio/voice/female/grade_c.ogg differ diff --git a/web/rhythm/audio/voice/female/grade_s.ogg b/web/rhythm/audio/voice/female/grade_s.ogg index ea9bc06c..ca82729a 100644 Binary files a/web/rhythm/audio/voice/female/grade_s.ogg and b/web/rhythm/audio/voice/female/grade_s.ogg differ diff --git a/web/rhythm/audio/voice/female/menu_lobby.ogg b/web/rhythm/audio/voice/female/menu_lobby.ogg index 4cb36299..ab937a9d 100644 Binary files a/web/rhythm/audio/voice/female/menu_lobby.ogg and b/web/rhythm/audio/voice/female/menu_lobby.ogg differ diff --git a/web/rhythm/audio/voice/female/menu_main.ogg b/web/rhythm/audio/voice/female/menu_main.ogg index db7d622a..11f3a76c 100644 Binary files a/web/rhythm/audio/voice/female/menu_main.ogg and b/web/rhythm/audio/voice/female/menu_main.ogg differ diff --git a/web/rhythm/audio/voice/female/menu_resume.ogg b/web/rhythm/audio/voice/female/menu_resume.ogg index eb95561f..5956079b 100644 Binary files a/web/rhythm/audio/voice/female/menu_resume.ogg and b/web/rhythm/audio/voice/female/menu_resume.ogg differ diff --git a/web/rhythm/audio/voice/female/mid_song.ogg b/web/rhythm/audio/voice/female/mid_song.ogg index 99531ca4..fd306797 100644 Binary files a/web/rhythm/audio/voice/female/mid_song.ogg and b/web/rhythm/audio/voice/female/mid_song.ogg differ diff --git a/web/rhythm/audio/voice/female/new_best_combo.ogg b/web/rhythm/audio/voice/female/new_best_combo.ogg index 1b18d28b..b23d0068 100644 Binary files a/web/rhythm/audio/voice/female/new_best_combo.ogg and b/web/rhythm/audio/voice/female/new_best_combo.ogg differ diff --git a/web/rhythm/audio/voice/female/restart.ogg b/web/rhythm/audio/voice/female/restart.ogg index a53411d2..51869350 100644 Binary files a/web/rhythm/audio/voice/female/restart.ogg and b/web/rhythm/audio/voice/female/restart.ogg differ diff --git a/web/rhythm/audio/voice/female/retry_early.ogg b/web/rhythm/audio/voice/female/retry_early.ogg index fa234cf1..24f61b10 100644 Binary files a/web/rhythm/audio/voice/female/retry_early.ogg and b/web/rhythm/audio/voice/female/retry_early.ogg differ diff --git a/web/rhythm/audio/voice/female/retry_gesture.ogg b/web/rhythm/audio/voice/female/retry_gesture.ogg index c4f49959..6ab4308b 100644 Binary files a/web/rhythm/audio/voice/female/retry_gesture.ogg and b/web/rhythm/audio/voice/female/retry_gesture.ogg differ diff --git a/web/rhythm/audio/voice/female/retry_late.ogg b/web/rhythm/audio/voice/female/retry_late.ogg index 6b704e19..b8fe1b6b 100644 Binary files a/web/rhythm/audio/voice/female/retry_late.ogg and b/web/rhythm/audio/voice/female/retry_late.ogg differ diff --git a/web/rhythm/audio/voice/female/retry_listen.ogg b/web/rhythm/audio/voice/female/retry_listen.ogg index ab2394be..01bd0be8 100644 Binary files a/web/rhythm/audio/voice/female/retry_listen.ogg and b/web/rhythm/audio/voice/female/retry_listen.ogg differ diff --git a/web/rhythm/audio/voice/female/retry_skip.ogg b/web/rhythm/audio/voice/female/retry_skip.ogg index 0140b6e1..7e81d586 100644 Binary files a/web/rhythm/audio/voice/female/retry_skip.ogg and b/web/rhythm/audio/voice/female/retry_skip.ogg differ diff --git a/web/rhythm/audio/voice/female/rules.ogg b/web/rhythm/audio/voice/female/rules.ogg index e761b53f..ce34e482 100644 Binary files a/web/rhythm/audio/voice/female/rules.ogg and b/web/rhythm/audio/voice/female/rules.ogg differ diff --git a/web/rhythm/audio/voice/female/select_hint.ogg b/web/rhythm/audio/voice/female/select_hint.ogg index 4d498a29..81bacab7 100644 Binary files a/web/rhythm/audio/voice/female/select_hint.ogg and b/web/rhythm/audio/voice/female/select_hint.ogg differ diff --git a/web/rhythm/audio/voice/female/settings_hint.ogg b/web/rhythm/audio/voice/female/settings_hint.ogg index 8cf9fbf3..45338c6d 100644 Binary files a/web/rhythm/audio/voice/female/settings_hint.ogg and b/web/rhythm/audio/voice/female/settings_hint.ogg differ diff --git a/web/rhythm/audio/voice/female/skip_hint.m4a b/web/rhythm/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..38c2ee4b Binary files /dev/null and b/web/rhythm/audio/voice/female/skip_hint.m4a differ diff --git a/web/rhythm/audio/voice/female/skip_hint.ogg b/web/rhythm/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..1593fa46 Binary files /dev/null and b/web/rhythm/audio/voice/female/skip_hint.ogg differ diff --git a/web/rhythm/audio/voice/female/song1_name.ogg b/web/rhythm/audio/voice/female/song1_name.ogg index acfe831e..0c97ae19 100644 Binary files a/web/rhythm/audio/voice/female/song1_name.ogg and b/web/rhythm/audio/voice/female/song1_name.ogg differ diff --git a/web/rhythm/audio/voice/female/song2_name.ogg b/web/rhythm/audio/voice/female/song2_name.ogg index 80f3a497..af361943 100644 Binary files a/web/rhythm/audio/voice/female/song2_name.ogg and b/web/rhythm/audio/voice/female/song2_name.ogg differ diff --git a/web/rhythm/audio/voice/female/song3_name.ogg b/web/rhythm/audio/voice/female/song3_name.ogg index 2da33c4a..a0370148 100644 Binary files a/web/rhythm/audio/voice/female/song3_name.ogg and b/web/rhythm/audio/voice/female/song3_name.ogg differ diff --git a/web/rhythm/audio/voice/female/song_locked.ogg b/web/rhythm/audio/voice/female/song_locked.ogg index f239f84c..0183d2d3 100644 Binary files a/web/rhythm/audio/voice/female/song_locked.ogg and b/web/rhythm/audio/voice/female/song_locked.ogg differ diff --git a/web/rhythm/audio/voice/female/tut_1.ogg b/web/rhythm/audio/voice/female/tut_1.ogg index 3edd6e1b..85ffe52b 100644 Binary files a/web/rhythm/audio/voice/female/tut_1.ogg and b/web/rhythm/audio/voice/female/tut_1.ogg differ diff --git a/web/rhythm/audio/voice/female/tut_2.ogg b/web/rhythm/audio/voice/female/tut_2.ogg index b26a9101..6626e3fe 100644 Binary files a/web/rhythm/audio/voice/female/tut_2.ogg and b/web/rhythm/audio/voice/female/tut_2.ogg differ diff --git a/web/rhythm/audio/voice/female/tut_3.ogg b/web/rhythm/audio/voice/female/tut_3.ogg index 26b3dd19..eb11d243 100644 Binary files a/web/rhythm/audio/voice/female/tut_3.ogg and b/web/rhythm/audio/voice/female/tut_3.ogg differ diff --git a/web/rhythm/audio/voice/female/tut_done.ogg b/web/rhythm/audio/voice/female/tut_done.ogg index 5064cc05..854b4adc 100644 Binary files a/web/rhythm/audio/voice/female/tut_done.ogg and b/web/rhythm/audio/voice/female/tut_done.ogg differ diff --git a/web/rhythm/audio/voice/female/tut_skip.ogg b/web/rhythm/audio/voice/female/tut_skip.ogg index 18732987..d99131ab 100644 Binary files a/web/rhythm/audio/voice/female/tut_skip.ogg and b/web/rhythm/audio/voice/female/tut_skip.ogg differ diff --git a/web/rhythm/audio/voice/female/unlock_song2.ogg b/web/rhythm/audio/voice/female/unlock_song2.ogg index e2a729e7..592e7dfb 100644 Binary files a/web/rhythm/audio/voice/female/unlock_song2.ogg and b/web/rhythm/audio/voice/female/unlock_song2.ogg differ diff --git a/web/rhythm/audio/voice/female/unlock_song3.ogg b/web/rhythm/audio/voice/female/unlock_song3.ogg index e4f865ec..3e16048f 100644 Binary files a/web/rhythm/audio/voice/female/unlock_song3.ogg and b/web/rhythm/audio/voice/female/unlock_song3.ogg differ diff --git a/web/rhythm/audio/voice/female/welcome.ogg b/web/rhythm/audio/voice/female/welcome.ogg index e3064ad9..825570a1 100644 Binary files a/web/rhythm/audio/voice/female/welcome.ogg and b/web/rhythm/audio/voice/female/welcome.ogg differ diff --git a/web/rhythm/audio/voice/female/welcome_back.ogg b/web/rhythm/audio/voice/female/welcome_back.ogg index 04dfccaa..bdab9883 100644 Binary files a/web/rhythm/audio/voice/female/welcome_back.ogg and b/web/rhythm/audio/voice/female/welcome_back.ogg differ diff --git a/web/rhythm/audio/voice/male/grade_a.ogg b/web/rhythm/audio/voice/male/grade_a.ogg index ec711b47..49fec51d 100644 Binary files a/web/rhythm/audio/voice/male/grade_a.ogg and b/web/rhythm/audio/voice/male/grade_a.ogg differ diff --git a/web/rhythm/audio/voice/male/grade_b.ogg b/web/rhythm/audio/voice/male/grade_b.ogg index 7d9854f7..52b0035f 100644 Binary files a/web/rhythm/audio/voice/male/grade_b.ogg and b/web/rhythm/audio/voice/male/grade_b.ogg differ diff --git a/web/rhythm/audio/voice/male/grade_c.ogg b/web/rhythm/audio/voice/male/grade_c.ogg index a2e0cb21..52b94b44 100644 Binary files a/web/rhythm/audio/voice/male/grade_c.ogg and b/web/rhythm/audio/voice/male/grade_c.ogg differ diff --git a/web/rhythm/audio/voice/male/grade_s.ogg b/web/rhythm/audio/voice/male/grade_s.ogg index 67e6a00a..48421749 100644 Binary files a/web/rhythm/audio/voice/male/grade_s.ogg and b/web/rhythm/audio/voice/male/grade_s.ogg differ diff --git a/web/rhythm/audio/voice/male/menu_lobby.ogg b/web/rhythm/audio/voice/male/menu_lobby.ogg index 973a64a7..96d66510 100644 Binary files a/web/rhythm/audio/voice/male/menu_lobby.ogg and b/web/rhythm/audio/voice/male/menu_lobby.ogg differ diff --git a/web/rhythm/audio/voice/male/menu_main.ogg b/web/rhythm/audio/voice/male/menu_main.ogg index 6fb8f3b5..8c2054c2 100644 Binary files a/web/rhythm/audio/voice/male/menu_main.ogg and b/web/rhythm/audio/voice/male/menu_main.ogg differ diff --git a/web/rhythm/audio/voice/male/menu_resume.ogg b/web/rhythm/audio/voice/male/menu_resume.ogg index 17a86c1a..296c4651 100644 Binary files a/web/rhythm/audio/voice/male/menu_resume.ogg and b/web/rhythm/audio/voice/male/menu_resume.ogg differ diff --git a/web/rhythm/audio/voice/male/mid_song.ogg b/web/rhythm/audio/voice/male/mid_song.ogg index a72a6c9b..bef065ff 100644 Binary files a/web/rhythm/audio/voice/male/mid_song.ogg and b/web/rhythm/audio/voice/male/mid_song.ogg differ diff --git a/web/rhythm/audio/voice/male/new_best_combo.ogg b/web/rhythm/audio/voice/male/new_best_combo.ogg index 87ee5f1a..a280fb70 100644 Binary files a/web/rhythm/audio/voice/male/new_best_combo.ogg and b/web/rhythm/audio/voice/male/new_best_combo.ogg differ diff --git a/web/rhythm/audio/voice/male/restart.ogg b/web/rhythm/audio/voice/male/restart.ogg index cb295556..b5b1bc1f 100644 Binary files a/web/rhythm/audio/voice/male/restart.ogg and b/web/rhythm/audio/voice/male/restart.ogg differ diff --git a/web/rhythm/audio/voice/male/retry_early.ogg b/web/rhythm/audio/voice/male/retry_early.ogg index 23e9bba8..5391328a 100644 Binary files a/web/rhythm/audio/voice/male/retry_early.ogg and b/web/rhythm/audio/voice/male/retry_early.ogg differ diff --git a/web/rhythm/audio/voice/male/retry_gesture.ogg b/web/rhythm/audio/voice/male/retry_gesture.ogg index 003d1ac3..a5076f68 100644 Binary files a/web/rhythm/audio/voice/male/retry_gesture.ogg and b/web/rhythm/audio/voice/male/retry_gesture.ogg differ diff --git a/web/rhythm/audio/voice/male/retry_late.ogg b/web/rhythm/audio/voice/male/retry_late.ogg index fdfecc15..9fc04bb2 100644 Binary files a/web/rhythm/audio/voice/male/retry_late.ogg and b/web/rhythm/audio/voice/male/retry_late.ogg differ diff --git a/web/rhythm/audio/voice/male/retry_listen.ogg b/web/rhythm/audio/voice/male/retry_listen.ogg index 447e1861..755cb5e2 100644 Binary files a/web/rhythm/audio/voice/male/retry_listen.ogg and b/web/rhythm/audio/voice/male/retry_listen.ogg differ diff --git a/web/rhythm/audio/voice/male/retry_skip.ogg b/web/rhythm/audio/voice/male/retry_skip.ogg index fdbe42d2..b494fc52 100644 Binary files a/web/rhythm/audio/voice/male/retry_skip.ogg and b/web/rhythm/audio/voice/male/retry_skip.ogg differ diff --git a/web/rhythm/audio/voice/male/rules.ogg b/web/rhythm/audio/voice/male/rules.ogg index f4f82708..3cc74346 100644 Binary files a/web/rhythm/audio/voice/male/rules.ogg and b/web/rhythm/audio/voice/male/rules.ogg differ diff --git a/web/rhythm/audio/voice/male/select_hint.ogg b/web/rhythm/audio/voice/male/select_hint.ogg index 4537cbf1..69afb7ea 100644 Binary files a/web/rhythm/audio/voice/male/select_hint.ogg and b/web/rhythm/audio/voice/male/select_hint.ogg differ diff --git a/web/rhythm/audio/voice/male/settings_hint.ogg b/web/rhythm/audio/voice/male/settings_hint.ogg index e89ee680..23ffed3f 100644 Binary files a/web/rhythm/audio/voice/male/settings_hint.ogg and b/web/rhythm/audio/voice/male/settings_hint.ogg differ diff --git a/web/rhythm/audio/voice/male/skip_hint.m4a b/web/rhythm/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..18831af8 Binary files /dev/null and b/web/rhythm/audio/voice/male/skip_hint.m4a differ diff --git a/web/rhythm/audio/voice/male/skip_hint.ogg b/web/rhythm/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..453d6edb Binary files /dev/null and b/web/rhythm/audio/voice/male/skip_hint.ogg differ diff --git a/web/rhythm/audio/voice/male/song1_name.ogg b/web/rhythm/audio/voice/male/song1_name.ogg index 5bdc83a6..2c75d33d 100644 Binary files a/web/rhythm/audio/voice/male/song1_name.ogg and b/web/rhythm/audio/voice/male/song1_name.ogg differ diff --git a/web/rhythm/audio/voice/male/song2_name.ogg b/web/rhythm/audio/voice/male/song2_name.ogg index 74cf50d7..cc0edc81 100644 Binary files a/web/rhythm/audio/voice/male/song2_name.ogg and b/web/rhythm/audio/voice/male/song2_name.ogg differ diff --git a/web/rhythm/audio/voice/male/song3_name.ogg b/web/rhythm/audio/voice/male/song3_name.ogg index 12255bdc..434d1dda 100644 Binary files a/web/rhythm/audio/voice/male/song3_name.ogg and b/web/rhythm/audio/voice/male/song3_name.ogg differ diff --git a/web/rhythm/audio/voice/male/song_locked.ogg b/web/rhythm/audio/voice/male/song_locked.ogg index 7e5099b3..8c3bf39d 100644 Binary files a/web/rhythm/audio/voice/male/song_locked.ogg and b/web/rhythm/audio/voice/male/song_locked.ogg differ diff --git a/web/rhythm/audio/voice/male/tut_1.ogg b/web/rhythm/audio/voice/male/tut_1.ogg index 9fe6e10f..2d6d55ca 100644 Binary files a/web/rhythm/audio/voice/male/tut_1.ogg and b/web/rhythm/audio/voice/male/tut_1.ogg differ diff --git a/web/rhythm/audio/voice/male/tut_2.ogg b/web/rhythm/audio/voice/male/tut_2.ogg index cffb3848..9e3cb2ca 100644 Binary files a/web/rhythm/audio/voice/male/tut_2.ogg and b/web/rhythm/audio/voice/male/tut_2.ogg differ diff --git a/web/rhythm/audio/voice/male/tut_3.ogg b/web/rhythm/audio/voice/male/tut_3.ogg index c534a8bd..eae12816 100644 Binary files a/web/rhythm/audio/voice/male/tut_3.ogg and b/web/rhythm/audio/voice/male/tut_3.ogg differ diff --git a/web/rhythm/audio/voice/male/tut_done.ogg b/web/rhythm/audio/voice/male/tut_done.ogg index ba9fff19..fbfb1c91 100644 Binary files a/web/rhythm/audio/voice/male/tut_done.ogg and b/web/rhythm/audio/voice/male/tut_done.ogg differ diff --git a/web/rhythm/audio/voice/male/tut_skip.ogg b/web/rhythm/audio/voice/male/tut_skip.ogg index 8b235baf..34c76a88 100644 Binary files a/web/rhythm/audio/voice/male/tut_skip.ogg and b/web/rhythm/audio/voice/male/tut_skip.ogg differ diff --git a/web/rhythm/audio/voice/male/unlock_song2.ogg b/web/rhythm/audio/voice/male/unlock_song2.ogg index 10dc4ae7..b9bde096 100644 Binary files a/web/rhythm/audio/voice/male/unlock_song2.ogg and b/web/rhythm/audio/voice/male/unlock_song2.ogg differ diff --git a/web/rhythm/audio/voice/male/unlock_song3.ogg b/web/rhythm/audio/voice/male/unlock_song3.ogg index 55cee6fe..8befb271 100644 Binary files a/web/rhythm/audio/voice/male/unlock_song3.ogg and b/web/rhythm/audio/voice/male/unlock_song3.ogg differ diff --git a/web/rhythm/audio/voice/male/welcome.ogg b/web/rhythm/audio/voice/male/welcome.ogg index 15ffac32..c3c52224 100644 Binary files a/web/rhythm/audio/voice/male/welcome.ogg and b/web/rhythm/audio/voice/male/welcome.ogg differ diff --git a/web/rhythm/audio/voice/male/welcome_back.ogg b/web/rhythm/audio/voice/male/welcome_back.ogg index e044cc30..36057ad9 100644 Binary files a/web/rhythm/audio/voice/male/welcome_back.ogg and b/web/rhythm/audio/voice/male/welcome_back.ogg differ diff --git a/web/rhythm/game.js b/web/rhythm/game.js index f356cc6d..827176f0 100644 --- a/web/rhythm/game.js +++ b/web/rhythm/game.js @@ -25,6 +25,16 @@ * 菜单(round2-review 全球约定):下滑呼出(模态暂停,BGM 断点续播)→ * 单击循环 → 双击确认 → 上滑帮助。下滑与"下滑=低音 tom"冲突的折中: * 仅当无悬置/临近的 down 音符时下滑才呼出菜单(见 README)。 + * + * 状态 BGM(docs/design/10-feedback2-state-bgm.md §5): + * 选曲界面 = bgm/menu(引擎层淡入 800ms);选定曲目 stopBgm(400) 后按既有 + * 网格起 track90/105/120(手动排程不变);暂停菜单保持静默(断点续播, + * 不叠 menu pad);结算评级语音链播完后淡入 bgm/menu 800ms。 + * 旁白跳过(docs/design/12-skip-and-longpress-ux.md §1.2):intro/tut/select/ + * end 阶段 voice 播放中单击 = audio.skipVoice()(跳下一句,链不断); + * main 演奏阶段单击是演奏手势不跳(mid_song 短句按裁定不跳)。 + * 长按快进豁免(12 文档 §2.2:main+tut 应豁免):引擎当前无 per-game + * 开关(audio.js 全局监听 soundgame:longpress),记为已知限制,不改引擎。 */ import { Game } from '../engine/game.js'; import { vibrate } from '../engine/haptics.js'; @@ -150,7 +160,7 @@ export class RhythmGame extends Game { start() { this.audio.prewarm([...ALL_SFX, 'voice/welcome', 'voice/welcome_back', - 'voice/tut_1', 'voice/select_hint', ...SONGS.map((s) => s.bgm)]); + 'voice/tut_1', 'voice/select_hint', 'bgm/menu', ...SONGS.map((s) => s.bgm)]); const rid = ++this._runId; this._cleanup(); if (this._progress.tutorialDone) { @@ -194,6 +204,9 @@ export class RhythmGame extends Game { const rid = this._runId; const buf = await this.audio._load(song.bgm); if (!buf || rid !== this._runId || !this.audio.ctx) return; + // 暂停发生在 BGM 起点前时 offset 为负:推迟起点而非传负偏移 + // (Web Audio 的 start(when, 负 offset) 会抛 IndexSizeError) + if (offset < 0) { t0 -= offset; offset = 0; } this._stopBgmSrc(); const src = this.audio.ctx.createBufferSource(); src.buffer = buf; @@ -299,6 +312,10 @@ export class RhythmGame extends Game { /** 教学应答评估:全部音在窗口内用对手势 → 通过;否则纠正重教。 */ _tutEvaluate(rid) { if (this._runId !== rid || this.phase !== 'tut') return; + // 评估是一次性的:先废掉触发条件——通过/失败后都要播语音, + // 语音间隙期 phase 仍是 'tut' 且 now ≥ _phaseEnd,泵会每 40ms 重进这里, + // 否则一次通过会级联"连过"剩余步骤(trace 实测:T1 通过直接跳到结算)。 + this._phaseEnd = Infinity; const pass = this._cues.every((cue) => { const a = this._tutAttempts.find((x) => x.cue === cue); return a && a.correct && Math.abs(a.dt) <= GOOD; @@ -341,6 +358,8 @@ export class RhythmGame extends Game { _enterSelect(rid) { if (this._runId !== rid) return; this.phase = 'select'; + // 10 文档 §5:选曲界面 = menu pad(引擎层淡入 800ms;bgm=0 时引擎只记意图) + this.audio.play('bgm/menu', { fadeMs: 800 }); this._speak(['voice/select_hint', SONGS[this._songIdx].nameVoice]); } @@ -369,6 +388,8 @@ export class RhythmGame extends Game { _startSong(rid, songIdx) { if (this._runId !== rid) return; this._cleanup(); + // 10 文档 §5:选定曲目后停 menu pad(选曲/结算态可能在播),再按网格起曲目 BGM + this.audio.stopBgm(400); this.phase = 'main'; this._songIdx = songIdx; this._song = SONGS[songIdx]; @@ -413,18 +434,23 @@ export class RhythmGame extends Game { this._speakGuarded(['voice/tut_skip'], () => this._startSong(rid, 0)); return; } + // 教学语音播放中单击 = 跳过本句(12 文档 §1.2);无语音时才是跟打手势 + if (this._trySkipVoice(g)) return; this._onTutGesture(g); return; case 'select': + if (this._trySkipVoice(g)) return; // 选曲播报可逐句跳过 this._onSelectGesture(rid, g); return; case 'main': + // 演奏期单击是演奏手势,永不跳过(mid_song 短句按裁定不可跳) this._onMainGesture(rid, g); return; case 'menu': this._onMenuGesture(rid, g); return; case 'end': + if (this._trySkipVoice(g)) return; // 结算播报可逐句跳过 if (g === 'tap') this._startSong(rid, this._songIdx); // 再来一遍(同曲) else if (g === 'right') { // 下一首(若已解锁) if (this._songIdx + 1 < this._progress.unlocked) this._startSong(rid, this._songIdx + 1); @@ -433,10 +459,22 @@ export class RhythmGame extends Game { } else if (g === 'down') this._goHome(); return; default: - return; // intro:不收手势 + this._trySkipVoice(g); // intro:只收"单击跳旁白",其余手势不收 + return; } } + /** + * voice 播放中单击 = 跳过当前句(12 文档 §1.2,链播继续到下一句)。 + * 引擎暂无"voice 播放中"公开查询接口,读内部 _voice 令牌判定。 + * @returns {boolean} true = 该手势已消费(跳过了一句) + */ + _trySkipVoice(g) { + if (g !== 'tap' || !this.audio._voice) return false; + this.audio.skipVoice(); + return true; + } + /** 教学阶段手势:全量记录尝试(评估用),乐器音照播(认识音色)。 */ _onTutGesture(g) { if (!INST[g] || !this.audio.ctx) return; @@ -637,7 +675,8 @@ export class RhythmGame extends Game { } ids.push('voice/restart'); console.log('[RhythmGame] 结算:', this._stats, '评级:', grade.toUpperCase()); - this._speak(ids); + // 10 文档 §5:曲目 BGM 已 0.6s 淡出,结算语音链播完后淡入 menu pad 800ms + this._speak(ids, () => this.audio.play('bgm/menu', { fadeMs: 800 })); } // ---------------- 进度存储 ---------------- diff --git a/web/rhythm/test/playtest.mjs b/web/rhythm/test/playtest.mjs new file mode 100644 index 00000000..26257c14 --- /dev/null +++ b/web/rhythm/test/playtest.mjs @@ -0,0 +1,462 @@ +/** + * 节拍行者(rhythm)无头试玩测试 —— docs/design/13-playtest-criteria.md §4 + §7。 + * + * 方法:mock 浏览器/WebAudio 最小接口(window/document/navigator/localStorage/ + * fetch/AudioContext),加载**真实** engine(audio.js/settings.js/haptics.js) + * 与 game.js;手势直接注入 game.onGesture(任务书允许的更稳路径);虚拟时钟 + * 驱动 setTimeout/setInterval/performance.now/ctx.currentTime,voice 链由引擎 + * 真实 SoundTouch 结束定时器推进,零真实等待。 + * + * 运行(仓库根):tools/node/bin/node web/rhythm/test/playtest.mjs + */ +import { readFileSync, existsSync } from 'node:fs'; + +// ================= 虚拟时钟 ================= +let nowMs = 0; +let timerSeq = 1; +const timers = new Map(); // id -> { t, fn, iv } + +globalThis.setTimeout = (fn, ms = 0, ...args) => { + const id = timerSeq++; + timers.set(id, { t: nowMs + Math.max(0, ms), fn: () => fn(...args), iv: 0 }); + return id; +}; +globalThis.setInterval = (fn, ms = 1) => { + const id = timerSeq++; + timers.set(id, { t: nowMs + ms, fn, iv: ms }); + return id; +}; +globalThis.clearTimeout = (id) => timers.delete(id); +globalThis.clearInterval = (id) => timers.delete(id); +Object.defineProperty(globalThis, 'performance', { value: { now: () => nowMs }, configurable: true }); + +async function flush() { + for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); +} +/** 推进虚拟时钟 ms 毫秒,沿途触发到期的 timeout/interval 并冲刷微任务。 */ +async function advance(ms) { + const target = nowMs + ms; + for (;;) { + await flush(); + let nid = -1; let nt = Infinity; + for (const [id, t] of timers) if (t.t < nt) { nt = t.t; nid = id; } + if (nid < 0 || nt > target) break; + const timer = timers.get(nid); + nowMs = nt; + if (timer.iv) timer.t = nt + timer.iv; else timers.delete(nid); + timer.fn(); + } + nowMs = target; + await flush(); +} +const nowSec = () => nowMs / 1000; +async function runUntil(tSec) { await advance(Math.max(0, tSec * 1000 - nowMs)); } +async function waitFor(cond, label, maxMs = 40000) { + let t = 0; + while (!cond()) { + if (t >= maxMs) throw new Error('waitFor 超时: ' + label); + await advance(100); t += 100; + } +} + +// ================= 浏览器环境 mock ================= +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), +}; + +const vibLog = []; // navigator.vibrate 调用记录(震型数组) +Object.defineProperty(globalThis, 'navigator', { + value: { vibrate: (p) => { vibLog.push(p); return true; } }, + configurable: true, +}); + +const mkEl = () => ({ + id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0, + style: { setProperty() {} }, + appendChild() {}, remove() {}, addEventListener() {}, +}); +globalThis.document = { + createElement: mkEl, getElementById: () => null, body: mkEl(), +}; +globalThis.window = { + addEventListener() {}, location: { href: '' }, AudioContext: null, // 下方填充 +}; + +// ================= Web Audio mock ================= +const VOICE_DUR = 1.2; // 假 voice 时长(秒),短时长让虚拟时钟跑得快 +const BGM_DUR = 64; +const SFX_DUR = 0.3; +const durFor = (url) => (url.includes('/voice/') ? VOICE_DUR : url.includes('/bgm/') ? BGM_DUR : SFX_DUR); + +const srcLog = []; // BufferSource.start 记录 { url, when, offset } +class MockParam { + constructor(v = 0) { this.value = v; } + cancelScheduledValues() {} + setValueAtTime(v) { this.value = v; } + linearRampToValueAtTime(v) { this.value = v; } +} +class MockAudioContext { + constructor() { this.state = 'suspended'; this.destination = {}; this.sampleRate = 44100; } + get currentTime() { return nowSec(); } + async resume() { this.state = 'running'; } + createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; } + createDynamicsCompressor() { + return { threshold: new MockParam(), ratio: new MockParam(), attack: new MockParam(), release: new MockParam(), connect() {} }; + } + createBufferSource() { + return { + buffer: null, loop: false, onended: null, + connect() {}, disconnect() {}, + start(when = 0, offset = 0) { srcLog.push({ url: this.buffer?.__url ?? null, when, offset, at: nowMs }); }, + stop() {}, + }; + } + createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } + async decodeAudioData(raw) { return { duration: durFor(raw.__url || ''), __url: raw.__url }; } +} +window.AudioContext = MockAudioContext; + +// ================= fetch mock(资产存在性由末尾 fs 校验单独保证) ================= +const fetchLog = []; +const blocked = []; // 子串命中即 404(看门狗测试用) +globalThis.fetch = async (url, _opts) => { + fetchLog.push(url); + if (typeof url === 'string' && url.startsWith('/api/')) return { ok: true, json: async () => ({}) }; + if (blocked.some((b) => String(url).includes(b))) return { ok: false, status: 404 }; + return { ok: true, arrayBuffer: async () => ({ __url: url }) }; +}; + +// ================= 断言框架 ================= +let passed = 0; let failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓', msg); } else { failed++; console.error(' ✗ 失败:', msg); } +} +const uncaught = []; +process.on('uncaughtException', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNCAUGHT', e); }); +process.on('unhandledRejection', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNHANDLED', e); }); +const origConsoleError = console.error.bind(console); +let consoleErrors = 0; +console.error = (...a) => { consoleErrors++; origConsoleError(...a); }; + +// ================= 加载真实引擎与游戏 ================= +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { RhythmGame } = await import('../game.js'); + +const PROGRESS_KEY = 'soundgame.rhythm.progress'; +let audio = null; let game = null; let playLog = []; let gestures = 0; + +async function newWorld(progress = null) { + store.clear(); + if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress)); + settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); + nowMs = 0; timers.clear(); vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0; + audio = new WebAudioEngine('audio/'); + playLog = []; + const origPlay = audio.play.bind(audio); + audio.play = (id, opts = {}) => { playLog.push({ id, t: nowMs }); return origPlay(id, opts); }; + await audio.unlock(); + game = new RhythmGame(audio, mkEl()); + if (process.env.DEBUG) { + for (const m of ['_beginTutorial', '_tutLead', '_tutEvaluate', '_startSong', '_finish', '_enterSelect', '_openMenu', '_closeMenu']) { + const orig = game[m].bind(game); + game[m] = (...a) => { console.log(` [trace ${nowMs}ms] ${m} tutIdx=${game._tutIdx} fails=${game._tutFails} phase=${game.phase}`); return orig(...a); }; + } + } + gestures = 0; + return game; +} +function G(g) { gestures++; game.onGesture(g); } +// voice 链断言用 playLog(play 调用顺序即播报顺序;fetchLog 会被 prewarm 预取污染) +const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p) => p.id); +const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; }; +const voicePlayed = (id) => voicePlays().includes(id); + +// 按 cue 期望时刻注入 Perfect 应答;tap 模拟真实回调延迟 +0.30s(补偿判定路径) +async function answerCue(cue) { + await runUntil(cue.expected + (cue.gesture === 'tap' ? 0.30 : 0)); + G(cue.gesture); + await flush(); +} + +// ================= 场景 A:首局关键路径(教学→曲目一 S→解锁→结算 BGM→重开→下一首) ================= +console.log('\n[场景 A] 首局:教学 T1-T3 → 曲目一全 Perfect → S 级解锁 → 结算'); +await newWorld(); +assert(game.phase === 'idle', '初始 phase idle'); +game.start(); +await flush(); +assert(game.phase === 'intro', 'start 后进 intro'); +assert(voicePlayed('voice/welcome'), 'welcome 旁白开始播放'); + +// —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(链推进到 tut_1,不重头)—— +assert(!!audio._voice, 'welcome 播放中(voice token 存在)'); +G('tap'); // 单击跳过 welcome +await flush(); +assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1'); + +// —— 教学 T1-T3:按 cues.expected 做手势(tap 注入在 expected+0.30 仍应判对)—— +for (let step = 0; step < 3; 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; + 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(srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length > leadNoteCount, `T${step + 1} 领奏音符已排程播放`); + await runUntil(game._phaseEnd + 0.2); // 触发 _tutEvaluate + await flush(); +} +assert(game._progress.tutorialDone === true, 'tutorialDone === true'); +assert(JSON.parse(store.get(PROGRESS_KEY)).tutorialDone === true, 'localStorage 进度已持久化 tutorialDone'); +assert(voicePlayed('voice/tut_done'), 'tut_done 播报'); + +// —— 曲目一 main:全部 Perfect —— +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'); +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 且连击不断'); + +await waitFor(() => game.phase === 'end', '曲目一结算'); +assert(game._grade() === 's', '全 Perfect 评级 S'); +assert(game._progress.grades.song1 === 's', 'grades.song1 === "s"'); +assert(game._progress.unlocked === 2, 'unlocked === 2(B 以上解锁下一首)'); +await flush(); +assert(voicePlayed('voice/grade_s'), 'grade_s 播报(结算语音链首句)'); + +// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句,不是"再来一遍" —— +await waitFor(() => !!audio._voice, '结算语音链开始'); +const phaseBeforeSkip = game.phase; +G('tap'); +await flush(); +assert(game.phase === 'end' && phaseBeforeSkip === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)'); + +// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5)—— +await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入'); +assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)'); +assert(true, '结算语音链播完后 bgm/menu 播放(引擎层)'); + +// —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 —— +G('tap'); +await flush(); +assert(game.phase === 'main' && game._songIdx === 0, '结算单击 = 同曲再来一遍'); +assert(audio._bgmWanted === null && audio._bgm === null, '选定曲目后 menu pad 停止(stopBgm(400))'); +assert(srcLog.filter((s) => s.url && s.url.includes('/bgm/track90')).length >= 2, '曲目一 BGM 重新按网格启动'); + +// —— 钩子快进结算 → 右滑 = 下一首(song2 已解锁) —— +game._finish(game._runId); +await flush(); +assert(game.phase === 'end', '钩子:曲目一二次结算'); +G('right'); +await flush(); +assert(game.phase === 'main' && game._song.id === 'song2', '结算右滑 = 下一首(song2 进入 main)'); +assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track105')), 'song2 BGM track105 启动'); + +// —— 菜单暂停恢复:下滑呼出(无临近 down 音符)→ 关菜单后时刻平移 + BGM 断点续播 —— +await waitFor(() => game._cues.length > 0 && game._bgmT0 > 0, 'song2 排程就绪'); +await runUntil(game._bgmT0 + 1.5); // BGM 已起、首个 cue 尚远(首个 cue ≈ bgmT0+5.6s) +const expBefore = game._cues.map((c) => c.expected); +const bgmT0Before = game._bgmT0; +const phaseEndBefore = game._phaseEnd; +const menuOpenAt = nowMs; +G('down'); +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 +G('down'); // 关菜单 +await flush(); +const pauseDur = (nowMs + 100 - menuOpenAt) / 1000; // resumeAt = 关菜单时刻 +0.1 +assert(game.phase === 'main', '关菜单恢复 main'); +const shift = game._bgmT0 - bgmT0Before; +assert(Math.abs(shift - pauseDur) < 1e-6, `_bgmT0 平移量 = 暂停时长(${shift.toFixed(3)}s ≈ ${pauseDur.toFixed(3)}s)`); +assert(game._cues.every((c, i) => Math.abs(c.expected - expBefore[i] - shift) < 1e-6), '全部 cue.expected 同步平移'); +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 清零 —— +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 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(vibLog.slice(vibBefore).some((p) => Array.isArray(p) && p[0] === 60), 'Miss 触发 error 震动'); +assert(game._stats.combo === 0, 'Miss 后 combo 清零'); + +// —— 下滑折中②:临近 down 应答音符时下滑 = 演奏判定,不开菜单 —— +for (const cue of game._cues) { // 把 down cue 之前的未判定音符应答掉 + if (cue.judged || cue.gesture === 'down') continue; + await answerCue(cue); + if (game._cues.some((c) => c.gesture === 'down' && !c.judged && c.expected - nowSec() < 4)) break; +} +const downCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); +assert(!!downCue, '找到 song2 的 down 应答音符'); +await runUntil(downCue.expected); +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'); + +// —— 结算末位:song3 结算页右滑 → 播 restart,不播 song_locked(round3-4 裁定①回归) —— +game._startSong(game._runId, 2); +game._finish(game._runId); +await flush(); +assert(game.phase === 'end' && game._songIdx === 2, '钩子:song3 结算页'); +const playMark = playLog.length; +G('right'); +await flush(); +const newVoices = playLog.slice(playMark).map((p) => p.id); +assert(newVoices.includes('voice/restart'), '末位曲目右滑 → 播 voice/restart'); +assert(!newVoices.includes('voice/song_locked'), '末位曲目右滑 → 不播 song_locked'); + +assert(gestures <= 200, `手势预算内(${gestures} ≤ 200)`); + +// ================= 场景 B:二周目选曲界面(menu pad + 选曲播报跳过 + 负偏移保护) ================= +console.log('\n[场景 B] 二周目:选曲界面 menu pad / 播报跳过 / 锁定曲 / 暂停负偏移保护'); +await newWorld({ tutorialDone: true, unlocked: 1, grades: {}, bestCombo: 0 }); +game.start(); +await flush(); +assert(lastVoice() === 'voice/welcome_back', '二周目播 welcome_back(缩减开场)'); +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(); +assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)'); +G('right'); // 切到 song2(未解锁) +await flush(); +assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked'); +G('tap'); // 跳过 song_locked +await flush(); +assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲'); +G('left'); // 切回 song1 +await flush(); +assert(game._songIdx === 0, '左滑切回 song1'); +await waitFor(() => !audio._voice, '选曲播报播完'); +G('tap'); // 无语音时单击 = 确认选曲 +await flush(); +assert(game.phase === 'main' && game._song.id === 'song1', '无语音时单击 = 选定曲目进 main'); +assert(audio._bgmWanted === null && audio._bgm === null, '进曲目后 menu pad 停止'); + +// 负偏移保护:BGM 起点(now+1.0)前开菜单,恢复时 offset 不得为负(真实环境负 offset 会抛异常) +await waitFor(() => game._cues.length > 0 && game._bgmT0 > 0, 'song1 排程就绪'); +G('down'); +await flush(); +assert(game.phase === 'menu', 'BGM 起点前开菜单'); +await advance(1500); +G('down'); +await flush(); +const restarted = srcLog.filter((s) => s.url && s.url.includes('/bgm/track90')).pop(); +assert(game.phase === 'main' && restarted && restarted.offset >= 0, `断点续播 offset 非负(${restarted && restarted.offset})——负偏移保护生效`); + +// ================= 场景 C:教学失败 2 次 → retry_skip 跳过该步 ================= +console.log('\n[场景 C] 教学失败重教 → 2 次失败跳过该步 → 下滑跳教学'); +await newWorld(); +game.start(); +await flush(); +G('tap'); // 跳 welcome +await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'T1 领奏'); +// 失败①:做错手势 +await runUntil(game._cues[0].expected + 0.3); +G('left'); // T1 期望 tap +await runUntil(game._phaseEnd + 0.2); +await flush(); +assert(game._tutFails === 1 && lastVoice() === 'voice/retry_gesture', '失败 1 次 → retry_gesture 纠正重教'); +await waitFor(() => game._tutAttempts.length === 0 && game._cues.length > 0 && game.phase === 'tut', 'T1 重新领奏'); +// 失败②:完全不出手 +await runUntil(game._phaseEnd + 0.2); +await flush(); +assert(game._tutFails === 2, '同一步失败 2 次'); +assert(voicePlayed('voice/retry_skip'), '播 retry_skip,跳过该步不卡死'); +await waitFor(() => game._tutIdx === 1 && game.phase === 'tut' && game._cues.length > 0, '进入 T2'); +assert(true, '流程推进到 T2'); +// 下滑 = 跳过整个教学 +G('down'); +await flush(); +assert(voicePlayed('voice/tut_skip'), '下滑跳教学播 tut_skip'); +await waitFor(() => game.phase === 'main', '跳教学后进入曲目一'); +assert(game._progress.tutorialDone === true, '跳教学同样持久化 tutorialDone'); + +// ================= 场景 D:语音资产缺失 → 看门狗推进,不卡死不崩 ================= +console.log('\n[场景 D] 看门狗:屏蔽 welcome 资产'); +await newWorld(); +blocked.push('/welcome.'); +const errBefore = consoleErrors; +game.start(); +await flush(); +await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'welcome 缺失时看门狗 8s 推进到 T1 领奏'); +assert(consoleErrors > errBefore, '缺失资产仅 console.error'); +assert(game.phase === 'tut', 'welcome 缺失不卡死(看门狗触发进教学)'); + +// ================= 场景 E:全局通用断言(§7 摘取引擎可测项) ================= +console.log('\n[场景 E] 全局:解锁前静默 / 设置边界'); +{ + const locked = new WebAudioEngine('audio/'); + const fetchMark = fetchLog.length; + locked.play('voice/tut_1'); + await flush(); + assert(fetchLog.length === fetchMark, '音频解锁前 play 静默跳过(§7.1)'); +} +await newWorld(); +settings.set({ voice: 'male' }); +audio.play('voice/rules'); +await flush(); +assert(fetchLog.some((u) => String(u).includes('/voice/male/rules.')), 'settings.voice=male 后按男性目录解析(§7.3)'); +settings.set({ speed: 1.7 }); +audio.play('voice/rules'); +await flush(); +assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 时下一句 tempo=1.7(SoundTouch 不变调)'); +settings.set({ voice: 'female', speed: 1.0 }); +const bgmFetchMark = fetchLog.length; +settings.set({ bgm: 0 }); +audio.play('bgm/menu'); +await flush(); +assert(!fetchLog.slice(bgmFetchMark).some((u) => String(u).includes('/bgm/menu')), 'bgm=0 时 BGM 不加载(§7.3)'); +assert(audio._bgm === null && audio._bgmWanted && audio._bgmWanted.id === 'bgm/menu', 'bgm=0 只记"想播"意图'); +settings.set({ bgm: 1.0 }); +await flush(); +assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM'); + +// ================= 资产引用校验(fs:ogg+m4a 双格式零缺失) ================= +console.log('\n[资产校验] game.js 引用的 id 对照 audio/ 目录'); +{ + const src = readFileSync(new URL('../game.js', import.meta.url), 'utf8'); + const ids = new Set(); + for (const m of src.matchAll(/['"`]((?:voice|sfx|bgm)\/[a-z0-9_]+)['"`]/g)) ids.add(m[1]); + if (src.includes('voice/grade_${grade}')) for (const g of ['s', 'a', 'b', 'c']) ids.add(`voice/grade_${g}`); + const missing = []; + const has = (p) => existsSync(new URL(p, import.meta.url)); + for (const id of ids) { + const [layer, name] = id.split('/'); + if (layer === 'voice') { + const dual = ['male', 'female'].every((g) => has(`../audio/voice/${g}/${name}.ogg`) && has(`../audio/voice/${g}/${name}.m4a`)); + const flat = has(`../audio/voice/${name}.ogg`) && has(`../audio/voice/${name}.m4a`); + if (!dual && !flat) missing.push(id + '(voice 需双性别或平铺 ogg+m4a)'); + } else if (!has(`../audio/${layer}/${name}.ogg`) || !has(`../audio/${layer}/${name}.m4a`)) { + missing.push(id + '(缺 ogg 或 m4a)'); + } + } + console.log(` 共引用 ${ids.size} 个资产 id`); + assert(missing.length === 0, `资产零缺失${missing.length ? '——缺失: ' + missing.join(', ') : ''}`); +} + +// ================= 汇总 ================= +console.log('\n========== 汇总 =========='); +assert(uncaught.length === 0, `全程无未捕获异常(§7.2)${uncaught.length ? '——' + uncaught.map(String).join('; ') : ''}`); +console.log(`通过 ${passed} 项,失败 ${failed} 项`); +process.exit(failed > 0 ? 1 : 0); diff --git a/web/rpg/README.md b/web/rpg/README.md index c936790b..d89350e0 100644 --- a/web/rpg/README.md +++ b/web/rpg/README.md @@ -8,9 +8,21 @@ - **探索**:节点式场景图(雾口村 → 林间小路 → 岔路口 → 溪流 → 猎人小屋 → 密林深处 → 黑风寨 → 聚义厅)。每个场景有环境音循环与语音描述, 在选项间切换、确认行动:前进 / 调查 / 商店 / 休息 / 对话 / 强攻或绕行。 -- **战斗**:明雷回合制。选指令出手;敌方攻击前会响起 1 秒的**前摇音** +- **战斗**:明雷回合制。选指令出手;敌方攻击前会响起**前摇音** (野狼低吼、山贼拔刀、寨主双刀互击……)——在前摇内**双击格挡**, 减伤 70%。这是唯一的即时操作,绑双击规避单击延迟。 + 前摇时长按新手保护分档(见下"难度曲线"),标准档 1.0s。 +- **首战教学**:第一场战斗(溪流教学狼)是四步强制教学—— + 认识指令(左右滑 2 次)→ 攻击命中 → 听前摇格挡(受击也算过)→ 防御演示, + 每步 8s 无操作重复提示、16s 看门狗兜底推进;教学狼 ATK 2、前摇 1.8s、 + 玩家 HP 锁底 1 必不死。每场战斗开始还有回合提示音 `turn_player`, + 胜利有号角 `victory`。 +- **难度曲线**(按已完成战斗场数 `battlesWon`):第 1-3 场敌方前摇 1.5s、 + 伤害 ×0.6;第 4-5 场 1.25s、×0.8;第 6 场起标准;Boss 恒标准。 +- **逃跑**:Lv<3 普通战必成;Lv≥3 70% 成功,失败则敌方追加一个正常前摇回合 + (可格挡);Boss/剧情战"避无可避"。Lv<3 首次到寨门会听到 boss1_warn 劝退提示。 +- **旁白跳过**:探索/剧情/状态语音播放中单击=跳过本句(链继续); + 战斗指令名与教学句不可跳。开局插播一次"单击可跳过旁白"。 - **成长**:战斗得经验金币 → 升级(maxHp/atk 提升)→ 商店换铁剑/皮甲 → 金疮药回血。战败从章首重生,等级金币装备保留。 - **Boss**:黑风寨主半血进二阶段(攻击提升、前摇变急促)。讨伐后章结算。 @@ -22,7 +34,7 @@ | 手势 | 作用 | |---|---| | 左 / 右滑 | 切换选项(播选项名,循环) | -| 单击 | 确认执行当前选项 | +| 单击 | 确认执行当前选项(语音播放中单击=跳过本句) | | 双击 | 重听当前场景描述 + 当前选项 | | 上滑 | 角色状态(等级/血量/金币/装备,分档整句) | | 下滑 | 菜单:继续旅程 / 保存并回大厅 / 重新听帮助 | @@ -33,9 +45,9 @@ |---|---| | 左 / 右滑 | 切换指令:攻击 → 破空斩(耗内劲 3,双倍伤害)→ 防御(本回合受伤减半)→ 金疮药(回 40% HP) | | 单击 | 执行当前指令 | -| **双击** | **格挡**:仅敌方前摇窗口(1.0s,听风剑 1.3s)内有效,减伤 70%;窗口内其他手势震动报错 | +| **双击** | **格挡**:仅敌方前摇窗口(教学 1.8s / 保护 1.5s / 过渡 1.25s / 标准 1.0s,听风剑再 +0.3s)内有效,减伤 70%;窗口内其他手势震动报错 | | 上滑 | 敌我血量档位 | -| 下滑 | 逃跑(Boss/剧情战"避无可避") | +| 下滑 | 逃跑(Lv<3 必成;Lv≥3 70%,失败吃一个可格挡的前摇回合;Boss/剧情战"避无可避") | 菜单操作(RPG 豁免,与探索同构):左右滑切换 + 单击确认;上滑=帮助;下滑=关闭。 @@ -44,10 +56,28 @@ ``` maxHp = 20 + 8×(lv-1) atk = 5 + 2×(lv-1) + 武器 def = 护甲 内劲 mp = 3 + lv(破空斩耗 3) 升级所需 exp = lv × 20 -伤害 = max(1, round((攻-防) × 0.85~1.15)) +伤害 = max(1, round((攻-防) × 0.85~1.15)) × 难度系数(保护 0.6 / 过渡 0.8 / 标准 1.0,向下取整 min 1) 格挡 ×0.3 / 防御 ×0.5(不叠加) +难度档位按 stats.battlesWon 在每场战斗开始时取档:0-2 场保护 / 3-4 场过渡 / ≥5 标准 / Boss 恒标准 ``` +## 状态音乐(docs/design/10-feedback2-state-bgm.md §7) + +BGM 层报状态 + sfx 层 amb 报场景:菜单 `bgm/menu` / 村庄 `bgm/village` / +探索 `bgm/explore`(volume 0.35 轻垫 + amb 环境音)/ 战斗 `bgm/battle`、 +Boss `bgm/boss`(400ms 快切,amb 停)/ 胜利 → 战斗 BGM 淡出 1.5s → +explore 淡入 2s / 章结算 `bgm/ending`。 + +## 无头试玩测试 + +```bash +tools/node/bin/node web/rpg/test/playtest.mjs # 139 条断言,退出码 0=全过 +``` + +mock 浏览器环境 + 虚拟时钟,覆盖 13 号文档 §6 全部关键路径与边界断言 +(四步教学/看门狗/锁血/逃跑分档/格挡防御减伤/战败重生/跳过旁白/存档续玩) ++ 资产引用校验(game.js 引用的全部 id 双性别/双格式零缺失)。 + ## 存档 - 结构:`{schema, updatedAt, summary, chapter, scene, player, flags, chapters, stats}` diff --git a/web/rpg/audio/bgm/explore.m4a b/web/rpg/audio/bgm/explore.m4a new file mode 100644 index 00000000..c850264c Binary files /dev/null and b/web/rpg/audio/bgm/explore.m4a differ diff --git a/web/rpg/audio/bgm/explore.ogg b/web/rpg/audio/bgm/explore.ogg new file mode 100644 index 00000000..e911d158 Binary files /dev/null and b/web/rpg/audio/bgm/explore.ogg differ diff --git a/web/rpg/audio/bgm/menu.m4a b/web/rpg/audio/bgm/menu.m4a new file mode 100644 index 00000000..1cc4b0c3 Binary files /dev/null and b/web/rpg/audio/bgm/menu.m4a differ diff --git a/web/rpg/audio/bgm/menu.ogg b/web/rpg/audio/bgm/menu.ogg new file mode 100644 index 00000000..4727beb1 Binary files /dev/null and b/web/rpg/audio/bgm/menu.ogg differ diff --git a/web/rpg/audio/sfx/amb_fork.m4a b/web/rpg/audio/sfx/amb_fork.m4a index ae6e57e1..52948394 100644 Binary files a/web/rpg/audio/sfx/amb_fork.m4a and b/web/rpg/audio/sfx/amb_fork.m4a differ diff --git a/web/rpg/audio/sfx/amb_fork.ogg b/web/rpg/audio/sfx/amb_fork.ogg index 954b5527..df0cbc07 100644 Binary files a/web/rpg/audio/sfx/amb_fork.ogg and b/web/rpg/audio/sfx/amb_fork.ogg differ diff --git a/web/rpg/audio/sfx/amb_swamp.m4a b/web/rpg/audio/sfx/amb_swamp.m4a index 1bfc9af3..b6b9fa4b 100644 Binary files a/web/rpg/audio/sfx/amb_swamp.m4a and b/web/rpg/audio/sfx/amb_swamp.m4a differ diff --git a/web/rpg/audio/sfx/amb_swamp.ogg b/web/rpg/audio/sfx/amb_swamp.ogg index edbcb18e..c56ebd88 100644 Binary files a/web/rpg/audio/sfx/amb_swamp.ogg and b/web/rpg/audio/sfx/amb_swamp.ogg differ diff --git a/web/rpg/audio/sfx/amb_village.m4a b/web/rpg/audio/sfx/amb_village.m4a index f7b7ffab..dc499e8e 100644 Binary files a/web/rpg/audio/sfx/amb_village.m4a and b/web/rpg/audio/sfx/amb_village.m4a differ diff --git a/web/rpg/audio/sfx/amb_village.ogg b/web/rpg/audio/sfx/amb_village.ogg index 600d1dc0..a2980b1f 100644 Binary files a/web/rpg/audio/sfx/amb_village.ogg and b/web/rpg/audio/sfx/amb_village.ogg differ diff --git a/web/rpg/audio/sfx/atk_hit.m4a b/web/rpg/audio/sfx/atk_hit.m4a index 09d63a8c..255ca75e 100644 Binary files a/web/rpg/audio/sfx/atk_hit.m4a and b/web/rpg/audio/sfx/atk_hit.m4a differ diff --git a/web/rpg/audio/sfx/atk_hit.ogg b/web/rpg/audio/sfx/atk_hit.ogg index 22f63595..c5afa27c 100644 Binary files a/web/rpg/audio/sfx/atk_hit.ogg and b/web/rpg/audio/sfx/atk_hit.ogg differ diff --git a/web/rpg/audio/sfx/atk_swing.m4a b/web/rpg/audio/sfx/atk_swing.m4a index fb12c786..2aa2455d 100644 Binary files a/web/rpg/audio/sfx/atk_swing.m4a and b/web/rpg/audio/sfx/atk_swing.m4a differ diff --git a/web/rpg/audio/sfx/atk_swing.ogg b/web/rpg/audio/sfx/atk_swing.ogg index 74ff3b9c..e030fe75 100644 Binary files a/web/rpg/audio/sfx/atk_swing.ogg and b/web/rpg/audio/sfx/atk_swing.ogg differ diff --git a/web/rpg/audio/sfx/chest.m4a b/web/rpg/audio/sfx/chest.m4a index 448bacc0..e942749f 100644 Binary files a/web/rpg/audio/sfx/chest.m4a and b/web/rpg/audio/sfx/chest.m4a differ diff --git a/web/rpg/audio/sfx/chest.ogg b/web/rpg/audio/sfx/chest.ogg index 8586b5f0..30873757 100644 Binary files a/web/rpg/audio/sfx/chest.ogg and b/web/rpg/audio/sfx/chest.ogg differ diff --git a/web/rpg/audio/sfx/coin.m4a b/web/rpg/audio/sfx/coin.m4a index 400f7385..769b1dda 100644 Binary files a/web/rpg/audio/sfx/coin.m4a and b/web/rpg/audio/sfx/coin.m4a differ diff --git a/web/rpg/audio/sfx/coin.ogg b/web/rpg/audio/sfx/coin.ogg index 7dec003a..17468da9 100644 Binary files a/web/rpg/audio/sfx/coin.ogg and b/web/rpg/audio/sfx/coin.ogg differ diff --git a/web/rpg/audio/sfx/door_heavy.m4a b/web/rpg/audio/sfx/door_heavy.m4a index 84036881..4ee8ec89 100644 Binary files a/web/rpg/audio/sfx/door_heavy.m4a and b/web/rpg/audio/sfx/door_heavy.m4a differ diff --git a/web/rpg/audio/sfx/door_heavy.ogg b/web/rpg/audio/sfx/door_heavy.ogg index becffc22..7cf5c528 100644 Binary files a/web/rpg/audio/sfx/door_heavy.ogg and b/web/rpg/audio/sfx/door_heavy.ogg differ diff --git a/web/rpg/audio/sfx/drink_water.m4a b/web/rpg/audio/sfx/drink_water.m4a index d3a5cebf..e7b3a552 100644 Binary files a/web/rpg/audio/sfx/drink_water.m4a and b/web/rpg/audio/sfx/drink_water.m4a differ diff --git a/web/rpg/audio/sfx/drink_water.ogg b/web/rpg/audio/sfx/drink_water.ogg index 17b0944f..35355619 100644 Binary files a/web/rpg/audio/sfx/drink_water.ogg and b/web/rpg/audio/sfx/drink_water.ogg differ diff --git a/web/rpg/audio/sfx/enemy_die.m4a b/web/rpg/audio/sfx/enemy_die.m4a index 1dca5c8b..1745fa47 100644 Binary files a/web/rpg/audio/sfx/enemy_die.m4a and b/web/rpg/audio/sfx/enemy_die.m4a differ diff --git a/web/rpg/audio/sfx/enemy_die.ogg b/web/rpg/audio/sfx/enemy_die.ogg index 8aac9f1d..76c4c021 100644 Binary files a/web/rpg/audio/sfx/enemy_die.ogg and b/web/rpg/audio/sfx/enemy_die.ogg differ diff --git a/web/rpg/audio/sfx/flee.m4a b/web/rpg/audio/sfx/flee.m4a index 06808633..2724e5ad 100644 Binary files a/web/rpg/audio/sfx/flee.m4a and b/web/rpg/audio/sfx/flee.m4a differ diff --git a/web/rpg/audio/sfx/flee.ogg b/web/rpg/audio/sfx/flee.ogg index 1081617d..1b4a3521 100644 Binary files a/web/rpg/audio/sfx/flee.ogg and b/web/rpg/audio/sfx/flee.ogg differ diff --git a/web/rpg/audio/sfx/hurt.m4a b/web/rpg/audio/sfx/hurt.m4a index 46287fea..07977771 100644 Binary files a/web/rpg/audio/sfx/hurt.m4a and b/web/rpg/audio/sfx/hurt.m4a differ diff --git a/web/rpg/audio/sfx/hurt.ogg b/web/rpg/audio/sfx/hurt.ogg index 325c0782..a9ed86bb 100644 Binary files a/web/rpg/audio/sfx/hurt.ogg and b/web/rpg/audio/sfx/hurt.ogg differ diff --git a/web/rpg/audio/sfx/levelup.m4a b/web/rpg/audio/sfx/levelup.m4a index 431750e5..5e844276 100644 Binary files a/web/rpg/audio/sfx/levelup.m4a and b/web/rpg/audio/sfx/levelup.m4a differ diff --git a/web/rpg/audio/sfx/levelup.ogg b/web/rpg/audio/sfx/levelup.ogg index f65219de..f1226ebe 100644 Binary files a/web/rpg/audio/sfx/levelup.ogg and b/web/rpg/audio/sfx/levelup.ogg differ diff --git a/web/rpg/audio/sfx/parry.m4a b/web/rpg/audio/sfx/parry.m4a index 767d4c81..7d89c31b 100644 Binary files a/web/rpg/audio/sfx/parry.m4a and b/web/rpg/audio/sfx/parry.m4a differ diff --git a/web/rpg/audio/sfx/parry.ogg b/web/rpg/audio/sfx/parry.ogg index 7fe03291..895ed286 100644 Binary files a/web/rpg/audio/sfx/parry.ogg and b/web/rpg/audio/sfx/parry.ogg differ diff --git a/web/rpg/audio/sfx/potion.m4a b/web/rpg/audio/sfx/potion.m4a index 857b23ef..d2c1fed4 100644 Binary files a/web/rpg/audio/sfx/potion.m4a and b/web/rpg/audio/sfx/potion.m4a differ diff --git a/web/rpg/audio/sfx/potion.ogg b/web/rpg/audio/sfx/potion.ogg index b9ae50b1..9706abff 100644 Binary files a/web/rpg/audio/sfx/potion.ogg and b/web/rpg/audio/sfx/potion.ogg differ diff --git a/web/rpg/audio/sfx/rest.m4a b/web/rpg/audio/sfx/rest.m4a index f678fa4f..a627de1a 100644 Binary files a/web/rpg/audio/sfx/rest.m4a and b/web/rpg/audio/sfx/rest.m4a differ diff --git a/web/rpg/audio/sfx/rest.ogg b/web/rpg/audio/sfx/rest.ogg index bf73267c..cf4c4835 100644 Binary files a/web/rpg/audio/sfx/rest.ogg and b/web/rpg/audio/sfx/rest.ogg differ diff --git a/web/rpg/audio/sfx/step.m4a b/web/rpg/audio/sfx/step.m4a index ef9c9038..303aae2e 100644 Binary files a/web/rpg/audio/sfx/step.m4a and b/web/rpg/audio/sfx/step.m4a differ diff --git a/web/rpg/audio/sfx/step.ogg b/web/rpg/audio/sfx/step.ogg index 5a058030..c25cec59 100644 Binary files a/web/rpg/audio/sfx/step.ogg and b/web/rpg/audio/sfx/step.ogg differ diff --git a/web/rpg/audio/sfx/tel_bandit.m4a b/web/rpg/audio/sfx/tel_bandit.m4a index fa8ca496..75dd4d6f 100644 Binary files a/web/rpg/audio/sfx/tel_bandit.m4a and b/web/rpg/audio/sfx/tel_bandit.m4a differ diff --git a/web/rpg/audio/sfx/tel_bandit.ogg b/web/rpg/audio/sfx/tel_bandit.ogg index 8ef08762..a718c1f7 100644 Binary files a/web/rpg/audio/sfx/tel_bandit.ogg and b/web/rpg/audio/sfx/tel_bandit.ogg differ diff --git a/web/rpg/audio/sfx/tel_boss1.m4a b/web/rpg/audio/sfx/tel_boss1.m4a index a6771a5f..fa92e28e 100644 Binary files a/web/rpg/audio/sfx/tel_boss1.m4a and b/web/rpg/audio/sfx/tel_boss1.m4a differ diff --git a/web/rpg/audio/sfx/tel_boss1.ogg b/web/rpg/audio/sfx/tel_boss1.ogg index ed75d2ff..3a623858 100644 Binary files a/web/rpg/audio/sfx/tel_boss1.ogg and b/web/rpg/audio/sfx/tel_boss1.ogg differ diff --git a/web/rpg/audio/sfx/tel_boss2.m4a b/web/rpg/audio/sfx/tel_boss2.m4a index 9133262a..364bc4cc 100644 Binary files a/web/rpg/audio/sfx/tel_boss2.m4a and b/web/rpg/audio/sfx/tel_boss2.m4a differ diff --git a/web/rpg/audio/sfx/tel_boss2.ogg b/web/rpg/audio/sfx/tel_boss2.ogg index ac23d7f4..70adf67e 100644 Binary files a/web/rpg/audio/sfx/tel_boss2.ogg and b/web/rpg/audio/sfx/tel_boss2.ogg differ diff --git a/web/rpg/audio/sfx/tel_croc.m4a b/web/rpg/audio/sfx/tel_croc.m4a index 5f45a7b2..dcbf2337 100644 Binary files a/web/rpg/audio/sfx/tel_croc.m4a and b/web/rpg/audio/sfx/tel_croc.m4a differ diff --git a/web/rpg/audio/sfx/tel_croc.ogg b/web/rpg/audio/sfx/tel_croc.ogg index 331e00c8..459470c9 100644 Binary files a/web/rpg/audio/sfx/tel_croc.ogg and b/web/rpg/audio/sfx/tel_croc.ogg differ diff --git a/web/rpg/audio/sfx/tel_elite.m4a b/web/rpg/audio/sfx/tel_elite.m4a index 8e4a2360..9c74d206 100644 Binary files a/web/rpg/audio/sfx/tel_elite.m4a and b/web/rpg/audio/sfx/tel_elite.m4a differ diff --git a/web/rpg/audio/sfx/tel_elite.ogg b/web/rpg/audio/sfx/tel_elite.ogg index ed0a6eb7..1bf9fa41 100644 Binary files a/web/rpg/audio/sfx/tel_elite.ogg and b/web/rpg/audio/sfx/tel_elite.ogg differ diff --git a/web/rpg/audio/sfx/tel_puppet.m4a b/web/rpg/audio/sfx/tel_puppet.m4a index dd50105e..019083c0 100644 Binary files a/web/rpg/audio/sfx/tel_puppet.m4a and b/web/rpg/audio/sfx/tel_puppet.m4a differ diff --git a/web/rpg/audio/sfx/tel_puppet.ogg b/web/rpg/audio/sfx/tel_puppet.ogg index d59ef48d..00125af4 100644 Binary files a/web/rpg/audio/sfx/tel_puppet.ogg and b/web/rpg/audio/sfx/tel_puppet.ogg differ diff --git a/web/rpg/audio/sfx/tel_shaman.m4a b/web/rpg/audio/sfx/tel_shaman.m4a index d62fba35..0d564eef 100644 Binary files a/web/rpg/audio/sfx/tel_shaman.m4a and b/web/rpg/audio/sfx/tel_shaman.m4a differ diff --git a/web/rpg/audio/sfx/tel_shaman.ogg b/web/rpg/audio/sfx/tel_shaman.ogg index aa7f911a..5d1bcca5 100644 Binary files a/web/rpg/audio/sfx/tel_shaman.ogg and b/web/rpg/audio/sfx/tel_shaman.ogg differ diff --git a/web/rpg/audio/sfx/tel_spider.m4a b/web/rpg/audio/sfx/tel_spider.m4a index 7d42aec4..a5fa5331 100644 Binary files a/web/rpg/audio/sfx/tel_spider.m4a and b/web/rpg/audio/sfx/tel_spider.m4a differ diff --git a/web/rpg/audio/sfx/tel_spider.ogg b/web/rpg/audio/sfx/tel_spider.ogg index 2bbd2c94..8fb91e25 100644 Binary files a/web/rpg/audio/sfx/tel_spider.ogg and b/web/rpg/audio/sfx/tel_spider.ogg differ diff --git a/web/rpg/audio/sfx/tel_wolf.m4a b/web/rpg/audio/sfx/tel_wolf.m4a index c5dd2d30..186dde46 100644 Binary files a/web/rpg/audio/sfx/tel_wolf.m4a and b/web/rpg/audio/sfx/tel_wolf.m4a differ diff --git a/web/rpg/audio/sfx/tel_wolf.ogg b/web/rpg/audio/sfx/tel_wolf.ogg index 4f315b7d..2a8c8a0a 100644 Binary files a/web/rpg/audio/sfx/tel_wolf.ogg and b/web/rpg/audio/sfx/tel_wolf.ogg differ diff --git a/web/rpg/audio/sfx/turn_player.m4a b/web/rpg/audio/sfx/turn_player.m4a new file mode 100644 index 00000000..9a6a572e Binary files /dev/null and b/web/rpg/audio/sfx/turn_player.m4a differ diff --git a/web/rpg/audio/sfx/turn_player.ogg b/web/rpg/audio/sfx/turn_player.ogg new file mode 100644 index 00000000..d7bcc998 Binary files /dev/null and b/web/rpg/audio/sfx/turn_player.ogg differ diff --git a/web/rpg/audio/sfx/victory.m4a b/web/rpg/audio/sfx/victory.m4a new file mode 100644 index 00000000..bfaf5323 Binary files /dev/null and b/web/rpg/audio/sfx/victory.m4a differ diff --git a/web/rpg/audio/sfx/victory.ogg b/web/rpg/audio/sfx/victory.ogg new file mode 100644 index 00000000..6a5dced0 Binary files /dev/null and b/web/rpg/audio/sfx/victory.ogg differ diff --git a/web/rpg/audio/voice/female/boss1_warn.m4a b/web/rpg/audio/voice/female/boss1_warn.m4a new file mode 100644 index 00000000..fd3ff90e Binary files /dev/null and b/web/rpg/audio/voice/female/boss1_warn.m4a differ diff --git a/web/rpg/audio/voice/female/boss1_warn.ogg b/web/rpg/audio/voice/female/boss1_warn.ogg new file mode 100644 index 00000000..20f44a81 Binary files /dev/null and b/web/rpg/audio/voice/female/boss1_warn.ogg differ diff --git a/web/rpg/audio/voice/female/btl_enemy_die.ogg b/web/rpg/audio/voice/female/btl_enemy_die.ogg index c43636bc..b1a30078 100644 Binary files a/web/rpg/audio/voice/female/btl_enemy_die.ogg and b/web/rpg/audio/voice/female/btl_enemy_die.ogg differ diff --git a/web/rpg/audio/voice/female/btl_flee.ogg b/web/rpg/audio/voice/female/btl_flee.ogg index 05e5ccca..b15a7d64 100644 Binary files a/web/rpg/audio/voice/female/btl_flee.ogg and b/web/rpg/audio/voice/female/btl_flee.ogg differ diff --git a/web/rpg/audio/voice/female/btl_flee_fail.m4a b/web/rpg/audio/voice/female/btl_flee_fail.m4a new file mode 100644 index 00000000..ea333c7f Binary files /dev/null and b/web/rpg/audio/voice/female/btl_flee_fail.m4a differ diff --git a/web/rpg/audio/voice/female/btl_flee_fail.ogg b/web/rpg/audio/voice/female/btl_flee_fail.ogg new file mode 100644 index 00000000..43abdaa4 Binary files /dev/null and b/web/rpg/audio/voice/female/btl_flee_fail.ogg differ diff --git a/web/rpg/audio/voice/female/btl_half_bandit.ogg b/web/rpg/audio/voice/female/btl_half_bandit.ogg index f8ee43ca..f90c991e 100644 Binary files a/web/rpg/audio/voice/female/btl_half_bandit.ogg and b/web/rpg/audio/voice/female/btl_half_bandit.ogg differ diff --git a/web/rpg/audio/voice/female/btl_levelup.ogg b/web/rpg/audio/voice/female/btl_levelup.ogg index 1443d9a5..fc1de7e6 100644 Binary files a/web/rpg/audio/voice/female/btl_levelup.ogg and b/web/rpg/audio/voice/female/btl_levelup.ogg differ diff --git a/web/rpg/audio/voice/female/btl_loot_big.ogg b/web/rpg/audio/voice/female/btl_loot_big.ogg index 37923c86..75b0f689 100644 Binary files a/web/rpg/audio/voice/female/btl_loot_big.ogg and b/web/rpg/audio/voice/female/btl_loot_big.ogg differ diff --git a/web/rpg/audio/voice/female/btl_loot_mid.ogg b/web/rpg/audio/voice/female/btl_loot_mid.ogg index fb9cc208..d285e77f 100644 Binary files a/web/rpg/audio/voice/female/btl_loot_mid.ogg and b/web/rpg/audio/voice/female/btl_loot_mid.ogg differ diff --git a/web/rpg/audio/voice/female/btl_loot_small.ogg b/web/rpg/audio/voice/female/btl_loot_small.ogg index df043b39..c3a24984 100644 Binary files a/web/rpg/audio/voice/female/btl_loot_small.ogg and b/web/rpg/audio/voice/female/btl_loot_small.ogg differ diff --git a/web/rpg/audio/voice/female/btl_lose.ogg b/web/rpg/audio/voice/female/btl_lose.ogg index 0043f625..2f281a27 100644 Binary files a/web/rpg/audio/voice/female/btl_lose.ogg and b/web/rpg/audio/voice/female/btl_lose.ogg differ diff --git a/web/rpg/audio/voice/female/btl_lose_hint.ogg b/web/rpg/audio/voice/female/btl_lose_hint.ogg index 7c804049..b87b6e05 100644 Binary files a/web/rpg/audio/voice/female/btl_lose_hint.ogg and b/web/rpg/audio/voice/female/btl_lose_hint.ogg differ diff --git a/web/rpg/audio/voice/female/btl_noflee.ogg b/web/rpg/audio/voice/female/btl_noflee.ogg index 811cb39f..4d69c99e 100644 Binary files a/web/rpg/audio/voice/female/btl_noflee.ogg and b/web/rpg/audio/voice/female/btl_noflee.ogg differ diff --git a/web/rpg/audio/voice/female/btl_noitem.ogg b/web/rpg/audio/voice/female/btl_noitem.ogg index 8a01f761..baef1db6 100644 Binary files a/web/rpg/audio/voice/female/btl_noitem.ogg and b/web/rpg/audio/voice/female/btl_noitem.ogg differ diff --git a/web/rpg/audio/voice/female/btl_nomp.ogg b/web/rpg/audio/voice/female/btl_nomp.ogg index b8b2845c..ff666a08 100644 Binary files a/web/rpg/audio/voice/female/btl_nomp.ogg and b/web/rpg/audio/voice/female/btl_nomp.ogg differ diff --git a/web/rpg/audio/voice/female/btl_start_bandit.ogg b/web/rpg/audio/voice/female/btl_start_bandit.ogg index 9b837a38..41ee2827 100644 Binary files a/web/rpg/audio/voice/female/btl_start_bandit.ogg and b/web/rpg/audio/voice/female/btl_start_bandit.ogg differ diff --git a/web/rpg/audio/voice/female/btl_start_boss1.ogg b/web/rpg/audio/voice/female/btl_start_boss1.ogg index fe09e19b..bda9c977 100644 Binary files a/web/rpg/audio/voice/female/btl_start_boss1.ogg and b/web/rpg/audio/voice/female/btl_start_boss1.ogg differ diff --git a/web/rpg/audio/voice/female/btl_start_elite.ogg b/web/rpg/audio/voice/female/btl_start_elite.ogg index 5267d2f4..e8cc09e4 100644 Binary files a/web/rpg/audio/voice/female/btl_start_elite.ogg and b/web/rpg/audio/voice/female/btl_start_elite.ogg differ diff --git a/web/rpg/audio/voice/female/btl_start_spider.ogg b/web/rpg/audio/voice/female/btl_start_spider.ogg index fbdb2003..facaf152 100644 Binary files a/web/rpg/audio/voice/female/btl_start_spider.ogg and b/web/rpg/audio/voice/female/btl_start_spider.ogg differ diff --git a/web/rpg/audio/voice/female/btl_start_wolf.ogg b/web/rpg/audio/voice/female/btl_start_wolf.ogg index 5b0a9f62..9b6e9bf1 100644 Binary files a/web/rpg/audio/voice/female/btl_start_wolf.ogg and b/web/rpg/audio/voice/female/btl_start_wolf.ogg differ diff --git a/web/rpg/audio/voice/female/btl_win.ogg b/web/rpg/audio/voice/female/btl_win.ogg index ebe880d0..ad74ccd3 100644 Binary files a/web/rpg/audio/voice/female/btl_win.ogg and b/web/rpg/audio/voice/female/btl_win.ogg differ diff --git a/web/rpg/audio/voice/female/cmd_attack.ogg b/web/rpg/audio/voice/female/cmd_attack.ogg index 75effa04..114b7594 100644 Binary files a/web/rpg/audio/voice/female/cmd_attack.ogg and b/web/rpg/audio/voice/female/cmd_attack.ogg differ diff --git a/web/rpg/audio/voice/female/cmd_defend.ogg b/web/rpg/audio/voice/female/cmd_defend.ogg index 4466f569..d31d00e1 100644 Binary files a/web/rpg/audio/voice/female/cmd_defend.ogg and b/web/rpg/audio/voice/female/cmd_defend.ogg differ diff --git a/web/rpg/audio/voice/female/cmd_item.ogg b/web/rpg/audio/voice/female/cmd_item.ogg index dde1361d..e60381d0 100644 Binary files a/web/rpg/audio/voice/female/cmd_item.ogg and b/web/rpg/audio/voice/female/cmd_item.ogg differ diff --git a/web/rpg/audio/voice/female/cmd_skill.ogg b/web/rpg/audio/voice/female/cmd_skill.ogg index c85b0265..5e3b459e 100644 Binary files a/web/rpg/audio/voice/female/cmd_skill.ogg and b/web/rpg/audio/voice/female/cmd_skill.ogg differ diff --git a/web/rpg/audio/voice/female/desc_chest.ogg b/web/rpg/audio/voice/female/desc_chest.ogg index 95625a5d..f0ca4346 100644 Binary files a/web/rpg/audio/voice/female/desc_chest.ogg and b/web/rpg/audio/voice/female/desc_chest.ogg differ diff --git a/web/rpg/audio/voice/female/desc_chest_re.ogg b/web/rpg/audio/voice/female/desc_chest_re.ogg index 37d17774..8f7d3fef 100644 Binary files a/web/rpg/audio/voice/female/desc_chest_re.ogg and b/web/rpg/audio/voice/female/desc_chest_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_deepwood.ogg b/web/rpg/audio/voice/female/desc_deepwood.ogg index 4a742ef5..cab6a56c 100644 Binary files a/web/rpg/audio/voice/female/desc_deepwood.ogg and b/web/rpg/audio/voice/female/desc_deepwood.ogg differ diff --git a/web/rpg/audio/voice/female/desc_deepwood_re.ogg b/web/rpg/audio/voice/female/desc_deepwood_re.ogg index 32eef444..b09335e9 100644 Binary files a/web/rpg/audio/voice/female/desc_deepwood_re.ogg and b/web/rpg/audio/voice/female/desc_deepwood_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_fork.ogg b/web/rpg/audio/voice/female/desc_fork.ogg index c5d915cc..cd7daeb3 100644 Binary files a/web/rpg/audio/voice/female/desc_fork.ogg and b/web/rpg/audio/voice/female/desc_fork.ogg differ diff --git a/web/rpg/audio/voice/female/desc_fork_re.ogg b/web/rpg/audio/voice/female/desc_fork_re.ogg index 14712c6d..ce51378c 100644 Binary files a/web/rpg/audio/voice/female/desc_fork_re.ogg and b/web/rpg/audio/voice/female/desc_fork_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_gate.ogg b/web/rpg/audio/voice/female/desc_gate.ogg index 95685e9f..256a5768 100644 Binary files a/web/rpg/audio/voice/female/desc_gate.ogg and b/web/rpg/audio/voice/female/desc_gate.ogg differ diff --git a/web/rpg/audio/voice/female/desc_hall.ogg b/web/rpg/audio/voice/female/desc_hall.ogg index 4bbbf97b..99552523 100644 Binary files a/web/rpg/audio/voice/female/desc_hall.ogg and b/web/rpg/audio/voice/female/desc_hall.ogg differ diff --git a/web/rpg/audio/voice/female/desc_hunter.ogg b/web/rpg/audio/voice/female/desc_hunter.ogg index a99ad0a8..cd123777 100644 Binary files a/web/rpg/audio/voice/female/desc_hunter.ogg and b/web/rpg/audio/voice/female/desc_hunter.ogg differ diff --git a/web/rpg/audio/voice/female/desc_hunter_re.ogg b/web/rpg/audio/voice/female/desc_hunter_re.ogg index f3611a2c..7a2a492a 100644 Binary files a/web/rpg/audio/voice/female/desc_hunter_re.ogg and b/web/rpg/audio/voice/female/desc_hunter_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_path.ogg b/web/rpg/audio/voice/female/desc_path.ogg index a9f879c9..fbbbb491 100644 Binary files a/web/rpg/audio/voice/female/desc_path.ogg and b/web/rpg/audio/voice/female/desc_path.ogg differ diff --git a/web/rpg/audio/voice/female/desc_path_re.ogg b/web/rpg/audio/voice/female/desc_path_re.ogg index 13563d5a..de36618e 100644 Binary files a/web/rpg/audio/voice/female/desc_path_re.ogg and b/web/rpg/audio/voice/female/desc_path_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_stream.ogg b/web/rpg/audio/voice/female/desc_stream.ogg index c478c488..76fe2139 100644 Binary files a/web/rpg/audio/voice/female/desc_stream.ogg and b/web/rpg/audio/voice/female/desc_stream.ogg differ diff --git a/web/rpg/audio/voice/female/desc_stream_re.ogg b/web/rpg/audio/voice/female/desc_stream_re.ogg index 665672e7..9505ed6f 100644 Binary files a/web/rpg/audio/voice/female/desc_stream_re.ogg and b/web/rpg/audio/voice/female/desc_stream_re.ogg differ diff --git a/web/rpg/audio/voice/female/desc_village.ogg b/web/rpg/audio/voice/female/desc_village.ogg index a3c9cade..61534ddb 100644 Binary files a/web/rpg/audio/voice/female/desc_village.ogg and b/web/rpg/audio/voice/female/desc_village.ogg differ diff --git a/web/rpg/audio/voice/female/desc_village_re.ogg b/web/rpg/audio/voice/female/desc_village_re.ogg index dd9a0ac5..17636686 100644 Binary files a/web/rpg/audio/voice/female/desc_village_re.ogg and b/web/rpg/audio/voice/female/desc_village_re.ogg differ diff --git a/web/rpg/audio/voice/female/evt_ch1_end.ogg b/web/rpg/audio/voice/female/evt_ch1_end.ogg index 5a685f45..3edd6a16 100644 Binary files a/web/rpg/audio/voice/female/evt_ch1_end.ogg and b/web/rpg/audio/voice/female/evt_ch1_end.ogg differ diff --git a/web/rpg/audio/voice/female/evt_chest.ogg b/web/rpg/audio/voice/female/evt_chest.ogg index 38a8487a..c4fec69b 100644 Binary files a/web/rpg/audio/voice/female/evt_chest.ogg and b/web/rpg/audio/voice/female/evt_chest.ogg differ diff --git a/web/rpg/audio/voice/female/evt_gate_open.ogg b/web/rpg/audio/voice/female/evt_gate_open.ogg index b232f0ea..65b9ebd1 100644 Binary files a/web/rpg/audio/voice/female/evt_gate_open.ogg and b/web/rpg/audio/voice/female/evt_gate_open.ogg differ diff --git a/web/rpg/audio/voice/female/evt_gold15.ogg b/web/rpg/audio/voice/female/evt_gold15.ogg index 6e61e2b7..c9dff990 100644 Binary files a/web/rpg/audio/voice/female/evt_gold15.ogg and b/web/rpg/audio/voice/female/evt_gold15.ogg differ diff --git a/web/rpg/audio/voice/female/evt_hunter_gift.ogg b/web/rpg/audio/voice/female/evt_hunter_gift.ogg index af853e04..012d99f8 100644 Binary files a/web/rpg/audio/voice/female/evt_hunter_gift.ogg and b/web/rpg/audio/voice/female/evt_hunter_gift.ogg differ diff --git a/web/rpg/audio/voice/female/evt_nothing.ogg b/web/rpg/audio/voice/female/evt_nothing.ogg index e514f2ac..96b88d31 100644 Binary files a/web/rpg/audio/voice/female/evt_nothing.ogg and b/web/rpg/audio/voice/female/evt_nothing.ogg differ diff --git a/web/rpg/audio/voice/female/foe_hp_high.ogg b/web/rpg/audio/voice/female/foe_hp_high.ogg index 4b4a33d3..1400d22e 100644 Binary files a/web/rpg/audio/voice/female/foe_hp_high.ogg and b/web/rpg/audio/voice/female/foe_hp_high.ogg differ diff --git a/web/rpg/audio/voice/female/foe_hp_low.ogg b/web/rpg/audio/voice/female/foe_hp_low.ogg index 6916e2a6..32f76db8 100644 Binary files a/web/rpg/audio/voice/female/foe_hp_low.ogg and b/web/rpg/audio/voice/female/foe_hp_low.ogg differ diff --git a/web/rpg/audio/voice/female/foe_hp_mid.ogg b/web/rpg/audio/voice/female/foe_hp_mid.ogg index 9715e100..0fa53247 100644 Binary files a/web/rpg/audio/voice/female/foe_hp_mid.ogg and b/web/rpg/audio/voice/female/foe_hp_mid.ogg differ diff --git a/web/rpg/audio/voice/female/help_battle.ogg b/web/rpg/audio/voice/female/help_battle.ogg index bc11280f..8b22b2d5 100644 Binary files a/web/rpg/audio/voice/female/help_battle.ogg and b/web/rpg/audio/voice/female/help_battle.ogg differ diff --git a/web/rpg/audio/voice/female/help_explore.ogg b/web/rpg/audio/voice/female/help_explore.ogg index 4551f928..67594db5 100644 Binary files a/web/rpg/audio/voice/female/help_explore.ogg and b/web/rpg/audio/voice/female/help_explore.ogg differ diff --git a/web/rpg/audio/voice/female/intro_1.ogg b/web/rpg/audio/voice/female/intro_1.ogg index c42b9d7b..7decc2f1 100644 Binary files a/web/rpg/audio/voice/female/intro_1.ogg and b/web/rpg/audio/voice/female/intro_1.ogg differ diff --git a/web/rpg/audio/voice/female/intro_2.ogg b/web/rpg/audio/voice/female/intro_2.ogg index 7e5ddfbf..19f97d83 100644 Binary files a/web/rpg/audio/voice/female/intro_2.ogg and b/web/rpg/audio/voice/female/intro_2.ogg differ diff --git a/web/rpg/audio/voice/female/intro_3.ogg b/web/rpg/audio/voice/female/intro_3.ogg index 532ee7f8..92cd5365 100644 Binary files a/web/rpg/audio/voice/female/intro_3.ogg and b/web/rpg/audio/voice/female/intro_3.ogg differ diff --git a/web/rpg/audio/voice/female/opt_assault.ogg b/web/rpg/audio/voice/female/opt_assault.ogg index fa438754..7d41e91f 100644 Binary files a/web/rpg/audio/voice/female/opt_assault.ogg and b/web/rpg/audio/voice/female/opt_assault.ogg differ diff --git a/web/rpg/audio/voice/female/opt_back.ogg b/web/rpg/audio/voice/female/opt_back.ogg index 4dd5b4c0..a70b29bc 100644 Binary files a/web/rpg/audio/voice/female/opt_back.ogg and b/web/rpg/audio/voice/female/opt_back.ogg differ diff --git a/web/rpg/audio/voice/female/opt_drink.ogg b/web/rpg/audio/voice/female/opt_drink.ogg index 0c128389..113ab6ec 100644 Binary files a/web/rpg/audio/voice/female/opt_drink.ogg and b/web/rpg/audio/voice/female/opt_drink.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_chest.ogg b/web/rpg/audio/voice/female/opt_go_chest.ogg index edac3e4a..c64939e5 100644 Binary files a/web/rpg/audio/voice/female/opt_go_chest.ogg and b/web/rpg/audio/voice/female/opt_go_chest.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_deepwood.ogg b/web/rpg/audio/voice/female/opt_go_deepwood.ogg index 6f12e475..af6643a7 100644 Binary files a/web/rpg/audio/voice/female/opt_go_deepwood.ogg and b/web/rpg/audio/voice/female/opt_go_deepwood.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_fork.ogg b/web/rpg/audio/voice/female/opt_go_fork.ogg index 74801bfa..b4a244c6 100644 Binary files a/web/rpg/audio/voice/female/opt_go_fork.ogg and b/web/rpg/audio/voice/female/opt_go_fork.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_gate.ogg b/web/rpg/audio/voice/female/opt_go_gate.ogg index 15fdca8d..3b8d3346 100644 Binary files a/web/rpg/audio/voice/female/opt_go_gate.ogg and b/web/rpg/audio/voice/female/opt_go_gate.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_hunter.ogg b/web/rpg/audio/voice/female/opt_go_hunter.ogg index d34cf0eb..d1da9f04 100644 Binary files a/web/rpg/audio/voice/female/opt_go_hunter.ogg and b/web/rpg/audio/voice/female/opt_go_hunter.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_path.ogg b/web/rpg/audio/voice/female/opt_go_path.ogg index 89795dc8..221a6bc9 100644 Binary files a/web/rpg/audio/voice/female/opt_go_path.ogg and b/web/rpg/audio/voice/female/opt_go_path.ogg differ diff --git a/web/rpg/audio/voice/female/opt_go_stream.ogg b/web/rpg/audio/voice/female/opt_go_stream.ogg index e96530b5..767aa9c3 100644 Binary files a/web/rpg/audio/voice/female/opt_go_stream.ogg and b/web/rpg/audio/voice/female/opt_go_stream.ogg differ diff --git a/web/rpg/audio/voice/female/opt_investigate.ogg b/web/rpg/audio/voice/female/opt_investigate.ogg index f0b461a4..a77594b7 100644 Binary files a/web/rpg/audio/voice/female/opt_investigate.ogg and b/web/rpg/audio/voice/female/opt_investigate.ogg differ diff --git a/web/rpg/audio/voice/female/opt_leave.ogg b/web/rpg/audio/voice/female/opt_leave.ogg index cf1631cc..23ffb21d 100644 Binary files a/web/rpg/audio/voice/female/opt_leave.ogg and b/web/rpg/audio/voice/female/opt_leave.ogg differ diff --git a/web/rpg/audio/voice/female/opt_open_chest.ogg b/web/rpg/audio/voice/female/opt_open_chest.ogg index 225e4a08..3a1b19b4 100644 Binary files a/web/rpg/audio/voice/female/opt_open_chest.ogg and b/web/rpg/audio/voice/female/opt_open_chest.ogg differ diff --git a/web/rpg/audio/voice/female/opt_rest.ogg b/web/rpg/audio/voice/female/opt_rest.ogg index c979a909..6487cc20 100644 Binary files a/web/rpg/audio/voice/female/opt_rest.ogg and b/web/rpg/audio/voice/female/opt_rest.ogg differ diff --git a/web/rpg/audio/voice/female/opt_shop.ogg b/web/rpg/audio/voice/female/opt_shop.ogg index eba1dbbe..464361bd 100644 Binary files a/web/rpg/audio/voice/female/opt_shop.ogg and b/web/rpg/audio/voice/female/opt_shop.ogg differ diff --git a/web/rpg/audio/voice/female/opt_sneak.ogg b/web/rpg/audio/voice/female/opt_sneak.ogg index 3cdc347c..6e16a790 100644 Binary files a/web/rpg/audio/voice/female/opt_sneak.ogg and b/web/rpg/audio/voice/female/opt_sneak.ogg differ diff --git a/web/rpg/audio/voice/female/opt_status.ogg b/web/rpg/audio/voice/female/opt_status.ogg index a7ed4366..99ed1d50 100644 Binary files a/web/rpg/audio/voice/female/opt_status.ogg and b/web/rpg/audio/voice/female/opt_status.ogg differ diff --git a/web/rpg/audio/voice/female/opt_talk_hunter.ogg b/web/rpg/audio/voice/female/opt_talk_hunter.ogg index cb8b5e45..7db9a246 100644 Binary files a/web/rpg/audio/voice/female/opt_talk_hunter.ogg and b/web/rpg/audio/voice/female/opt_talk_hunter.ogg differ diff --git a/web/rpg/audio/voice/female/settings_hint.ogg b/web/rpg/audio/voice/female/settings_hint.ogg index ccac1f18..ade2844d 100644 Binary files a/web/rpg/audio/voice/female/settings_hint.ogg and b/web/rpg/audio/voice/female/settings_hint.ogg differ diff --git a/web/rpg/audio/voice/female/shop_buy.ogg b/web/rpg/audio/voice/female/shop_buy.ogg index 87fcbba4..f3f12c0c 100644 Binary files a/web/rpg/audio/voice/female/shop_buy.ogg and b/web/rpg/audio/voice/female/shop_buy.ogg differ diff --git a/web/rpg/audio/voice/female/shop_open.ogg b/web/rpg/audio/voice/female/shop_open.ogg index 8a051cd1..f0dea31c 100644 Binary files a/web/rpg/audio/voice/female/shop_open.ogg and b/web/rpg/audio/voice/female/shop_open.ogg differ diff --git a/web/rpg/audio/voice/female/shop_owned.ogg b/web/rpg/audio/voice/female/shop_owned.ogg index 1dee1891..92cacec4 100644 Binary files a/web/rpg/audio/voice/female/shop_owned.ogg and b/web/rpg/audio/voice/female/shop_owned.ogg differ diff --git a/web/rpg/audio/voice/female/shop_poor.ogg b/web/rpg/audio/voice/female/shop_poor.ogg index 0daf8574..92691090 100644 Binary files a/web/rpg/audio/voice/female/shop_poor.ogg and b/web/rpg/audio/voice/female/shop_poor.ogg differ diff --git a/web/rpg/audio/voice/female/shop_price_armor.ogg b/web/rpg/audio/voice/female/shop_price_armor.ogg index b043b941..f13ad543 100644 Binary files a/web/rpg/audio/voice/female/shop_price_armor.ogg and b/web/rpg/audio/voice/female/shop_price_armor.ogg differ diff --git a/web/rpg/audio/voice/female/shop_price_potion.ogg b/web/rpg/audio/voice/female/shop_price_potion.ogg index 44b31315..bc4ee18c 100644 Binary files a/web/rpg/audio/voice/female/shop_price_potion.ogg and b/web/rpg/audio/voice/female/shop_price_potion.ogg differ diff --git a/web/rpg/audio/voice/female/shop_price_sword.ogg b/web/rpg/audio/voice/female/shop_price_sword.ogg index 01dabe8b..3ba9122a 100644 Binary files a/web/rpg/audio/voice/female/shop_price_sword.ogg and b/web/rpg/audio/voice/female/shop_price_sword.ogg differ diff --git a/web/rpg/audio/voice/female/skip_hint.m4a b/web/rpg/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..af3c9aba Binary files /dev/null and b/web/rpg/audio/voice/female/skip_hint.m4a differ diff --git a/web/rpg/audio/voice/female/skip_hint.ogg b/web/rpg/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..fcb76c5d Binary files /dev/null and b/web/rpg/audio/voice/female/skip_hint.ogg differ diff --git a/web/rpg/audio/voice/female/st_armor_iron.ogg b/web/rpg/audio/voice/female/st_armor_iron.ogg index d2d441f7..74ff8c62 100644 Binary files a/web/rpg/audio/voice/female/st_armor_iron.ogg and b/web/rpg/audio/voice/female/st_armor_iron.ogg differ diff --git a/web/rpg/audio/voice/female/st_armor_leather.ogg b/web/rpg/audio/voice/female/st_armor_leather.ogg index 53e512c1..762d810e 100644 Binary files a/web/rpg/audio/voice/female/st_armor_leather.ogg and b/web/rpg/audio/voice/female/st_armor_leather.ogg differ diff --git a/web/rpg/audio/voice/female/st_armor_none.ogg b/web/rpg/audio/voice/female/st_armor_none.ogg index b5fb5bfc..900d0211 100644 Binary files a/web/rpg/audio/voice/female/st_armor_none.ogg and b/web/rpg/audio/voice/female/st_armor_none.ogg differ diff --git a/web/rpg/audio/voice/female/st_gold_poor.ogg b/web/rpg/audio/voice/female/st_gold_poor.ogg index 6fba68da..e58c7377 100644 Binary files a/web/rpg/audio/voice/female/st_gold_poor.ogg and b/web/rpg/audio/voice/female/st_gold_poor.ogg differ diff --git a/web/rpg/audio/voice/female/st_gold_rich.ogg b/web/rpg/audio/voice/female/st_gold_rich.ogg index b8fd004b..b0d1032a 100644 Binary files a/web/rpg/audio/voice/female/st_gold_rich.ogg and b/web/rpg/audio/voice/female/st_gold_rich.ogg differ diff --git a/web/rpg/audio/voice/female/st_gold_some.ogg b/web/rpg/audio/voice/female/st_gold_some.ogg index 9e2df02b..61f062d8 100644 Binary files a/web/rpg/audio/voice/female/st_gold_some.ogg and b/web/rpg/audio/voice/female/st_gold_some.ogg differ diff --git a/web/rpg/audio/voice/female/st_hp_high.ogg b/web/rpg/audio/voice/female/st_hp_high.ogg index fa1ad817..f2af5215 100644 Binary files a/web/rpg/audio/voice/female/st_hp_high.ogg and b/web/rpg/audio/voice/female/st_hp_high.ogg differ diff --git a/web/rpg/audio/voice/female/st_hp_low.ogg b/web/rpg/audio/voice/female/st_hp_low.ogg index e5af131d..5accd0ac 100644 Binary files a/web/rpg/audio/voice/female/st_hp_low.ogg and b/web/rpg/audio/voice/female/st_hp_low.ogg differ diff --git a/web/rpg/audio/voice/female/st_hp_mid.ogg b/web/rpg/audio/voice/female/st_hp_mid.ogg index 685ff475..a7cf843e 100644 Binary files a/web/rpg/audio/voice/female/st_hp_mid.ogg and b/web/rpg/audio/voice/female/st_hp_mid.ogg differ diff --git a/web/rpg/audio/voice/female/st_lv_high.ogg b/web/rpg/audio/voice/female/st_lv_high.ogg index 9e5aaaad..8a3cbbbc 100644 Binary files a/web/rpg/audio/voice/female/st_lv_high.ogg and b/web/rpg/audio/voice/female/st_lv_high.ogg differ diff --git a/web/rpg/audio/voice/female/st_lv_low.ogg b/web/rpg/audio/voice/female/st_lv_low.ogg index ae34298d..ae8d7d13 100644 Binary files a/web/rpg/audio/voice/female/st_lv_low.ogg and b/web/rpg/audio/voice/female/st_lv_low.ogg differ diff --git a/web/rpg/audio/voice/female/st_lv_mid.ogg b/web/rpg/audio/voice/female/st_lv_mid.ogg index 6399a10c..8f06e330 100644 Binary files a/web/rpg/audio/voice/female/st_lv_mid.ogg and b/web/rpg/audio/voice/female/st_lv_mid.ogg differ diff --git a/web/rpg/audio/voice/female/st_weapon_iron.ogg b/web/rpg/audio/voice/female/st_weapon_iron.ogg index 7cd5ff5e..fe35e4ed 100644 Binary files a/web/rpg/audio/voice/female/st_weapon_iron.ogg and b/web/rpg/audio/voice/female/st_weapon_iron.ogg differ diff --git a/web/rpg/audio/voice/female/st_weapon_none.ogg b/web/rpg/audio/voice/female/st_weapon_none.ogg index 85eb7639..b6ac45ba 100644 Binary files a/web/rpg/audio/voice/female/st_weapon_none.ogg and b/web/rpg/audio/voice/female/st_weapon_none.ogg differ diff --git a/web/rpg/audio/voice/female/st_weapon_steel.ogg b/web/rpg/audio/voice/female/st_weapon_steel.ogg index 234b204e..6053b3e5 100644 Binary files a/web/rpg/audio/voice/female/st_weapon_steel.ogg and b/web/rpg/audio/voice/female/st_weapon_steel.ogg differ diff --git a/web/rpg/audio/voice/female/st_weapon_wind.ogg b/web/rpg/audio/voice/female/st_weapon_wind.ogg index ffa040dd..7f78665a 100644 Binary files a/web/rpg/audio/voice/female/st_weapon_wind.ogg and b/web/rpg/audio/voice/female/st_weapon_wind.ogg differ diff --git a/web/rpg/audio/voice/female/sys_continue_ask.ogg b/web/rpg/audio/voice/female/sys_continue_ask.ogg index 8d64d93e..873c017c 100644 Binary files a/web/rpg/audio/voice/female/sys_continue_ask.ogg and b/web/rpg/audio/voice/female/sys_continue_ask.ogg differ diff --git a/web/rpg/audio/voice/female/sys_drink.ogg b/web/rpg/audio/voice/female/sys_drink.ogg index 7b53f350..40c9bbbd 100644 Binary files a/web/rpg/audio/voice/female/sys_drink.ogg and b/web/rpg/audio/voice/female/sys_drink.ogg differ diff --git a/web/rpg/audio/voice/female/sys_menu.ogg b/web/rpg/audio/voice/female/sys_menu.ogg index 1fc2cfcd..183b2783 100644 Binary files a/web/rpg/audio/voice/female/sys_menu.ogg and b/web/rpg/audio/voice/female/sys_menu.ogg differ diff --git a/web/rpg/audio/voice/female/sys_menu_help.ogg b/web/rpg/audio/voice/female/sys_menu_help.ogg index 2c43e1db..fafc83d1 100644 Binary files a/web/rpg/audio/voice/female/sys_menu_help.ogg and b/web/rpg/audio/voice/female/sys_menu_help.ogg differ diff --git a/web/rpg/audio/voice/female/sys_menu_resume.ogg b/web/rpg/audio/voice/female/sys_menu_resume.ogg index c0c2b243..8dd4cd43 100644 Binary files a/web/rpg/audio/voice/female/sys_menu_resume.ogg and b/web/rpg/audio/voice/female/sys_menu_resume.ogg differ diff --git a/web/rpg/audio/voice/female/sys_menu_save.ogg b/web/rpg/audio/voice/female/sys_menu_save.ogg index 67a92df2..431f93bc 100644 Binary files a/web/rpg/audio/voice/female/sys_menu_save.ogg and b/web/rpg/audio/voice/female/sys_menu_save.ogg differ diff --git a/web/rpg/audio/voice/female/sys_new_game.ogg b/web/rpg/audio/voice/female/sys_new_game.ogg index 0d0a4059..f763f0c3 100644 Binary files a/web/rpg/audio/voice/female/sys_new_game.ogg and b/web/rpg/audio/voice/female/sys_new_game.ogg differ diff --git a/web/rpg/audio/voice/female/sys_rest.ogg b/web/rpg/audio/voice/female/sys_rest.ogg index bc02832b..5c681216 100644 Binary files a/web/rpg/audio/voice/female/sys_rest.ogg and b/web/rpg/audio/voice/female/sys_rest.ogg differ diff --git a/web/rpg/audio/voice/female/sys_saved.ogg b/web/rpg/audio/voice/female/sys_saved.ogg index 60fc45f5..e0bd5c52 100644 Binary files a/web/rpg/audio/voice/female/sys_saved.ogg and b/web/rpg/audio/voice/female/sys_saved.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b0.m4a b/web/rpg/audio/voice/female/tut_b0.m4a new file mode 100644 index 00000000..e84b32d0 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_b0.m4a differ diff --git a/web/rpg/audio/voice/female/tut_b0.ogg b/web/rpg/audio/voice/female/tut_b0.ogg new file mode 100644 index 00000000..79b46bf8 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_b0.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b1.ogg b/web/rpg/audio/voice/female/tut_b1.ogg index 09b85f95..8514c266 100644 Binary files a/web/rpg/audio/voice/female/tut_b1.ogg and b/web/rpg/audio/voice/female/tut_b1.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b2.ogg b/web/rpg/audio/voice/female/tut_b2.ogg index 16593193..a83da191 100644 Binary files a/web/rpg/audio/voice/female/tut_b2.ogg and b/web/rpg/audio/voice/female/tut_b2.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b3.ogg b/web/rpg/audio/voice/female/tut_b3.ogg index d7a0f89b..a9991d14 100644 Binary files a/web/rpg/audio/voice/female/tut_b3.ogg and b/web/rpg/audio/voice/female/tut_b3.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b4.ogg b/web/rpg/audio/voice/female/tut_b4.ogg index 894f2fc6..76dbfdbb 100644 Binary files a/web/rpg/audio/voice/female/tut_b4.ogg and b/web/rpg/audio/voice/female/tut_b4.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b5.ogg b/web/rpg/audio/voice/female/tut_b5.ogg index 19fe1219..1054b7da 100644 Binary files a/web/rpg/audio/voice/female/tut_b5.ogg and b/web/rpg/audio/voice/female/tut_b5.ogg differ diff --git a/web/rpg/audio/voice/female/tut_b6.m4a b/web/rpg/audio/voice/female/tut_b6.m4a new file mode 100644 index 00000000..754eac1f Binary files /dev/null and b/web/rpg/audio/voice/female/tut_b6.m4a differ diff --git a/web/rpg/audio/voice/female/tut_b6.ogg b/web/rpg/audio/voice/female/tut_b6.ogg new file mode 100644 index 00000000..35dfd410 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_b6.ogg differ diff --git a/web/rpg/audio/voice/female/tut_battle_1.m4a b/web/rpg/audio/voice/female/tut_battle_1.m4a new file mode 100644 index 00000000..9d370153 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_1.m4a differ diff --git a/web/rpg/audio/voice/female/tut_battle_1.ogg b/web/rpg/audio/voice/female/tut_battle_1.ogg new file mode 100644 index 00000000..c715fb63 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_1.ogg differ diff --git a/web/rpg/audio/voice/female/tut_battle_2.m4a b/web/rpg/audio/voice/female/tut_battle_2.m4a new file mode 100644 index 00000000..03b4b6c7 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_2.m4a differ diff --git a/web/rpg/audio/voice/female/tut_battle_2.ogg b/web/rpg/audio/voice/female/tut_battle_2.ogg new file mode 100644 index 00000000..9c37b0ca Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_2.ogg differ diff --git a/web/rpg/audio/voice/female/tut_battle_3.m4a b/web/rpg/audio/voice/female/tut_battle_3.m4a new file mode 100644 index 00000000..8864a5ac Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_3.m4a differ diff --git a/web/rpg/audio/voice/female/tut_battle_3.ogg b/web/rpg/audio/voice/female/tut_battle_3.ogg new file mode 100644 index 00000000..8e19b855 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_3.ogg differ diff --git a/web/rpg/audio/voice/female/tut_battle_4.m4a b/web/rpg/audio/voice/female/tut_battle_4.m4a new file mode 100644 index 00000000..edad8eb2 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_4.m4a differ diff --git a/web/rpg/audio/voice/female/tut_battle_4.ogg b/web/rpg/audio/voice/female/tut_battle_4.ogg new file mode 100644 index 00000000..3ec3825a Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_4.ogg differ diff --git a/web/rpg/audio/voice/female/tut_battle_5.m4a b/web/rpg/audio/voice/female/tut_battle_5.m4a new file mode 100644 index 00000000..aea7d3e3 Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_5.m4a differ diff --git a/web/rpg/audio/voice/female/tut_battle_5.ogg b/web/rpg/audio/voice/female/tut_battle_5.ogg new file mode 100644 index 00000000..24a2a47b Binary files /dev/null and b/web/rpg/audio/voice/female/tut_battle_5.ogg differ diff --git a/web/rpg/audio/voice/male/boss1_warn.m4a b/web/rpg/audio/voice/male/boss1_warn.m4a new file mode 100644 index 00000000..ce6c03ec Binary files /dev/null and b/web/rpg/audio/voice/male/boss1_warn.m4a differ diff --git a/web/rpg/audio/voice/male/boss1_warn.ogg b/web/rpg/audio/voice/male/boss1_warn.ogg new file mode 100644 index 00000000..d29aafdf Binary files /dev/null and b/web/rpg/audio/voice/male/boss1_warn.ogg differ diff --git a/web/rpg/audio/voice/male/btl_enemy_die.ogg b/web/rpg/audio/voice/male/btl_enemy_die.ogg index 967f36fb..f32d37fa 100644 Binary files a/web/rpg/audio/voice/male/btl_enemy_die.ogg and b/web/rpg/audio/voice/male/btl_enemy_die.ogg differ diff --git a/web/rpg/audio/voice/male/btl_flee.ogg b/web/rpg/audio/voice/male/btl_flee.ogg index ae960fa1..ce3919c8 100644 Binary files a/web/rpg/audio/voice/male/btl_flee.ogg and b/web/rpg/audio/voice/male/btl_flee.ogg differ diff --git a/web/rpg/audio/voice/male/btl_flee_fail.m4a b/web/rpg/audio/voice/male/btl_flee_fail.m4a new file mode 100644 index 00000000..b77fbeed Binary files /dev/null and b/web/rpg/audio/voice/male/btl_flee_fail.m4a differ diff --git a/web/rpg/audio/voice/male/btl_flee_fail.ogg b/web/rpg/audio/voice/male/btl_flee_fail.ogg new file mode 100644 index 00000000..d193845a Binary files /dev/null and b/web/rpg/audio/voice/male/btl_flee_fail.ogg differ diff --git a/web/rpg/audio/voice/male/btl_half_bandit.ogg b/web/rpg/audio/voice/male/btl_half_bandit.ogg index 83e694a1..798de805 100644 Binary files a/web/rpg/audio/voice/male/btl_half_bandit.ogg and b/web/rpg/audio/voice/male/btl_half_bandit.ogg differ diff --git a/web/rpg/audio/voice/male/btl_levelup.ogg b/web/rpg/audio/voice/male/btl_levelup.ogg index 29ca81f1..a59711a5 100644 Binary files a/web/rpg/audio/voice/male/btl_levelup.ogg and b/web/rpg/audio/voice/male/btl_levelup.ogg differ diff --git a/web/rpg/audio/voice/male/btl_loot_big.ogg b/web/rpg/audio/voice/male/btl_loot_big.ogg index 0b4f62c1..5a551d4c 100644 Binary files a/web/rpg/audio/voice/male/btl_loot_big.ogg and b/web/rpg/audio/voice/male/btl_loot_big.ogg differ diff --git a/web/rpg/audio/voice/male/btl_loot_mid.ogg b/web/rpg/audio/voice/male/btl_loot_mid.ogg index 16620089..da4a4ec9 100644 Binary files a/web/rpg/audio/voice/male/btl_loot_mid.ogg and b/web/rpg/audio/voice/male/btl_loot_mid.ogg differ diff --git a/web/rpg/audio/voice/male/btl_loot_small.ogg b/web/rpg/audio/voice/male/btl_loot_small.ogg index 0aff13b1..716e1962 100644 Binary files a/web/rpg/audio/voice/male/btl_loot_small.ogg and b/web/rpg/audio/voice/male/btl_loot_small.ogg differ diff --git a/web/rpg/audio/voice/male/btl_lose.ogg b/web/rpg/audio/voice/male/btl_lose.ogg index 88bde825..13034fa3 100644 Binary files a/web/rpg/audio/voice/male/btl_lose.ogg and b/web/rpg/audio/voice/male/btl_lose.ogg differ diff --git a/web/rpg/audio/voice/male/btl_lose_hint.ogg b/web/rpg/audio/voice/male/btl_lose_hint.ogg index b2a0ac68..b2d261ce 100644 Binary files a/web/rpg/audio/voice/male/btl_lose_hint.ogg and b/web/rpg/audio/voice/male/btl_lose_hint.ogg differ diff --git a/web/rpg/audio/voice/male/btl_noflee.ogg b/web/rpg/audio/voice/male/btl_noflee.ogg index 9fbad6d5..f8a07a19 100644 Binary files a/web/rpg/audio/voice/male/btl_noflee.ogg and b/web/rpg/audio/voice/male/btl_noflee.ogg differ diff --git a/web/rpg/audio/voice/male/btl_noitem.ogg b/web/rpg/audio/voice/male/btl_noitem.ogg index b2c83ef3..4e44f7b3 100644 Binary files a/web/rpg/audio/voice/male/btl_noitem.ogg and b/web/rpg/audio/voice/male/btl_noitem.ogg differ diff --git a/web/rpg/audio/voice/male/btl_nomp.ogg b/web/rpg/audio/voice/male/btl_nomp.ogg index b00e0f9b..a824bae0 100644 Binary files a/web/rpg/audio/voice/male/btl_nomp.ogg and b/web/rpg/audio/voice/male/btl_nomp.ogg differ diff --git a/web/rpg/audio/voice/male/btl_start_bandit.ogg b/web/rpg/audio/voice/male/btl_start_bandit.ogg index 2dbd2875..70c6ef15 100644 Binary files a/web/rpg/audio/voice/male/btl_start_bandit.ogg and b/web/rpg/audio/voice/male/btl_start_bandit.ogg differ diff --git a/web/rpg/audio/voice/male/btl_start_boss1.ogg b/web/rpg/audio/voice/male/btl_start_boss1.ogg index dc530832..889d7827 100644 Binary files a/web/rpg/audio/voice/male/btl_start_boss1.ogg and b/web/rpg/audio/voice/male/btl_start_boss1.ogg differ diff --git a/web/rpg/audio/voice/male/btl_start_elite.ogg b/web/rpg/audio/voice/male/btl_start_elite.ogg index 2f2ada08..5a0e8c61 100644 Binary files a/web/rpg/audio/voice/male/btl_start_elite.ogg and b/web/rpg/audio/voice/male/btl_start_elite.ogg differ diff --git a/web/rpg/audio/voice/male/btl_start_spider.ogg b/web/rpg/audio/voice/male/btl_start_spider.ogg index f316edf1..2b683db4 100644 Binary files a/web/rpg/audio/voice/male/btl_start_spider.ogg and b/web/rpg/audio/voice/male/btl_start_spider.ogg differ diff --git a/web/rpg/audio/voice/male/btl_start_wolf.ogg b/web/rpg/audio/voice/male/btl_start_wolf.ogg index 7e106143..9e1cc33f 100644 Binary files a/web/rpg/audio/voice/male/btl_start_wolf.ogg and b/web/rpg/audio/voice/male/btl_start_wolf.ogg differ diff --git a/web/rpg/audio/voice/male/btl_win.ogg b/web/rpg/audio/voice/male/btl_win.ogg index 66194444..b94cda44 100644 Binary files a/web/rpg/audio/voice/male/btl_win.ogg and b/web/rpg/audio/voice/male/btl_win.ogg differ diff --git a/web/rpg/audio/voice/male/cmd_attack.ogg b/web/rpg/audio/voice/male/cmd_attack.ogg index 9d39e806..69cb21d3 100644 Binary files a/web/rpg/audio/voice/male/cmd_attack.ogg and b/web/rpg/audio/voice/male/cmd_attack.ogg differ diff --git a/web/rpg/audio/voice/male/cmd_defend.ogg b/web/rpg/audio/voice/male/cmd_defend.ogg index ca9eb03a..c138e651 100644 Binary files a/web/rpg/audio/voice/male/cmd_defend.ogg and b/web/rpg/audio/voice/male/cmd_defend.ogg differ diff --git a/web/rpg/audio/voice/male/cmd_item.ogg b/web/rpg/audio/voice/male/cmd_item.ogg index 6ffa40d3..244b4866 100644 Binary files a/web/rpg/audio/voice/male/cmd_item.ogg and b/web/rpg/audio/voice/male/cmd_item.ogg differ diff --git a/web/rpg/audio/voice/male/cmd_skill.ogg b/web/rpg/audio/voice/male/cmd_skill.ogg index 8a79aece..6c137365 100644 Binary files a/web/rpg/audio/voice/male/cmd_skill.ogg and b/web/rpg/audio/voice/male/cmd_skill.ogg differ diff --git a/web/rpg/audio/voice/male/desc_chest.ogg b/web/rpg/audio/voice/male/desc_chest.ogg index 49e1d9af..a2cb23c5 100644 Binary files a/web/rpg/audio/voice/male/desc_chest.ogg and b/web/rpg/audio/voice/male/desc_chest.ogg differ diff --git a/web/rpg/audio/voice/male/desc_chest_re.ogg b/web/rpg/audio/voice/male/desc_chest_re.ogg index c2b255b0..d962520b 100644 Binary files a/web/rpg/audio/voice/male/desc_chest_re.ogg and b/web/rpg/audio/voice/male/desc_chest_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_deepwood.ogg b/web/rpg/audio/voice/male/desc_deepwood.ogg index ec666439..7192708e 100644 Binary files a/web/rpg/audio/voice/male/desc_deepwood.ogg and b/web/rpg/audio/voice/male/desc_deepwood.ogg differ diff --git a/web/rpg/audio/voice/male/desc_deepwood_re.ogg b/web/rpg/audio/voice/male/desc_deepwood_re.ogg index 4b138183..22065cba 100644 Binary files a/web/rpg/audio/voice/male/desc_deepwood_re.ogg and b/web/rpg/audio/voice/male/desc_deepwood_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_fork.ogg b/web/rpg/audio/voice/male/desc_fork.ogg index df629106..5184da3d 100644 Binary files a/web/rpg/audio/voice/male/desc_fork.ogg and b/web/rpg/audio/voice/male/desc_fork.ogg differ diff --git a/web/rpg/audio/voice/male/desc_fork_re.ogg b/web/rpg/audio/voice/male/desc_fork_re.ogg index 1e21fb21..5010f291 100644 Binary files a/web/rpg/audio/voice/male/desc_fork_re.ogg and b/web/rpg/audio/voice/male/desc_fork_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_gate.ogg b/web/rpg/audio/voice/male/desc_gate.ogg index 3a213250..41dad350 100644 Binary files a/web/rpg/audio/voice/male/desc_gate.ogg and b/web/rpg/audio/voice/male/desc_gate.ogg differ diff --git a/web/rpg/audio/voice/male/desc_hall.ogg b/web/rpg/audio/voice/male/desc_hall.ogg index 4a6e5867..9cdabde5 100644 Binary files a/web/rpg/audio/voice/male/desc_hall.ogg and b/web/rpg/audio/voice/male/desc_hall.ogg differ diff --git a/web/rpg/audio/voice/male/desc_hunter.ogg b/web/rpg/audio/voice/male/desc_hunter.ogg index 994fd4c0..6c2ebd49 100644 Binary files a/web/rpg/audio/voice/male/desc_hunter.ogg and b/web/rpg/audio/voice/male/desc_hunter.ogg differ diff --git a/web/rpg/audio/voice/male/desc_hunter_re.ogg b/web/rpg/audio/voice/male/desc_hunter_re.ogg index b7ad1c6c..3862b73d 100644 Binary files a/web/rpg/audio/voice/male/desc_hunter_re.ogg and b/web/rpg/audio/voice/male/desc_hunter_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_path.ogg b/web/rpg/audio/voice/male/desc_path.ogg index 1daabc6a..d5873910 100644 Binary files a/web/rpg/audio/voice/male/desc_path.ogg and b/web/rpg/audio/voice/male/desc_path.ogg differ diff --git a/web/rpg/audio/voice/male/desc_path_re.ogg b/web/rpg/audio/voice/male/desc_path_re.ogg index 2dfc6ea5..371cadf7 100644 Binary files a/web/rpg/audio/voice/male/desc_path_re.ogg and b/web/rpg/audio/voice/male/desc_path_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_stream.ogg b/web/rpg/audio/voice/male/desc_stream.ogg index a941d2c9..800fb703 100644 Binary files a/web/rpg/audio/voice/male/desc_stream.ogg and b/web/rpg/audio/voice/male/desc_stream.ogg differ diff --git a/web/rpg/audio/voice/male/desc_stream_re.ogg b/web/rpg/audio/voice/male/desc_stream_re.ogg index ddc3fede..3fb17bf8 100644 Binary files a/web/rpg/audio/voice/male/desc_stream_re.ogg and b/web/rpg/audio/voice/male/desc_stream_re.ogg differ diff --git a/web/rpg/audio/voice/male/desc_village.ogg b/web/rpg/audio/voice/male/desc_village.ogg index e415bcfb..f4749050 100644 Binary files a/web/rpg/audio/voice/male/desc_village.ogg and b/web/rpg/audio/voice/male/desc_village.ogg differ diff --git a/web/rpg/audio/voice/male/desc_village_re.ogg b/web/rpg/audio/voice/male/desc_village_re.ogg index c1b0cdba..9d070c41 100644 Binary files a/web/rpg/audio/voice/male/desc_village_re.ogg and b/web/rpg/audio/voice/male/desc_village_re.ogg differ diff --git a/web/rpg/audio/voice/male/evt_ch1_end.ogg b/web/rpg/audio/voice/male/evt_ch1_end.ogg index 27074ae9..89b3e1e6 100644 Binary files a/web/rpg/audio/voice/male/evt_ch1_end.ogg and b/web/rpg/audio/voice/male/evt_ch1_end.ogg differ diff --git a/web/rpg/audio/voice/male/evt_chest.ogg b/web/rpg/audio/voice/male/evt_chest.ogg index fda51017..c4f73554 100644 Binary files a/web/rpg/audio/voice/male/evt_chest.ogg and b/web/rpg/audio/voice/male/evt_chest.ogg differ diff --git a/web/rpg/audio/voice/male/evt_gate_open.ogg b/web/rpg/audio/voice/male/evt_gate_open.ogg index 61892c12..4abf804c 100644 Binary files a/web/rpg/audio/voice/male/evt_gate_open.ogg and b/web/rpg/audio/voice/male/evt_gate_open.ogg differ diff --git a/web/rpg/audio/voice/male/evt_gold15.ogg b/web/rpg/audio/voice/male/evt_gold15.ogg index 43f5bd41..8f28d3b7 100644 Binary files a/web/rpg/audio/voice/male/evt_gold15.ogg and b/web/rpg/audio/voice/male/evt_gold15.ogg differ diff --git a/web/rpg/audio/voice/male/evt_hunter_gift.ogg b/web/rpg/audio/voice/male/evt_hunter_gift.ogg index 88055793..f670c2ee 100644 Binary files a/web/rpg/audio/voice/male/evt_hunter_gift.ogg and b/web/rpg/audio/voice/male/evt_hunter_gift.ogg differ diff --git a/web/rpg/audio/voice/male/evt_nothing.ogg b/web/rpg/audio/voice/male/evt_nothing.ogg index a0dd8216..489f6c82 100644 Binary files a/web/rpg/audio/voice/male/evt_nothing.ogg and b/web/rpg/audio/voice/male/evt_nothing.ogg differ diff --git a/web/rpg/audio/voice/male/foe_hp_high.ogg b/web/rpg/audio/voice/male/foe_hp_high.ogg index d5326a1d..31acb889 100644 Binary files a/web/rpg/audio/voice/male/foe_hp_high.ogg and b/web/rpg/audio/voice/male/foe_hp_high.ogg differ diff --git a/web/rpg/audio/voice/male/foe_hp_low.ogg b/web/rpg/audio/voice/male/foe_hp_low.ogg index 529e9dfc..88dff30c 100644 Binary files a/web/rpg/audio/voice/male/foe_hp_low.ogg and b/web/rpg/audio/voice/male/foe_hp_low.ogg differ diff --git a/web/rpg/audio/voice/male/foe_hp_mid.ogg b/web/rpg/audio/voice/male/foe_hp_mid.ogg index e7084c79..916a45f5 100644 Binary files a/web/rpg/audio/voice/male/foe_hp_mid.ogg and b/web/rpg/audio/voice/male/foe_hp_mid.ogg differ diff --git a/web/rpg/audio/voice/male/help_battle.ogg b/web/rpg/audio/voice/male/help_battle.ogg index afb6653f..ca9ac2dd 100644 Binary files a/web/rpg/audio/voice/male/help_battle.ogg and b/web/rpg/audio/voice/male/help_battle.ogg differ diff --git a/web/rpg/audio/voice/male/help_explore.ogg b/web/rpg/audio/voice/male/help_explore.ogg index cc265e8d..192cb3a2 100644 Binary files a/web/rpg/audio/voice/male/help_explore.ogg and b/web/rpg/audio/voice/male/help_explore.ogg differ diff --git a/web/rpg/audio/voice/male/intro_1.ogg b/web/rpg/audio/voice/male/intro_1.ogg index 85e4614c..0fa092e2 100644 Binary files a/web/rpg/audio/voice/male/intro_1.ogg and b/web/rpg/audio/voice/male/intro_1.ogg differ diff --git a/web/rpg/audio/voice/male/intro_2.ogg b/web/rpg/audio/voice/male/intro_2.ogg index 9f8b1389..67979b60 100644 Binary files a/web/rpg/audio/voice/male/intro_2.ogg and b/web/rpg/audio/voice/male/intro_2.ogg differ diff --git a/web/rpg/audio/voice/male/intro_3.ogg b/web/rpg/audio/voice/male/intro_3.ogg index 7d05df99..559eb0d0 100644 Binary files a/web/rpg/audio/voice/male/intro_3.ogg and b/web/rpg/audio/voice/male/intro_3.ogg differ diff --git a/web/rpg/audio/voice/male/opt_assault.ogg b/web/rpg/audio/voice/male/opt_assault.ogg index f3150fc5..0189aa81 100644 Binary files a/web/rpg/audio/voice/male/opt_assault.ogg and b/web/rpg/audio/voice/male/opt_assault.ogg differ diff --git a/web/rpg/audio/voice/male/opt_back.ogg b/web/rpg/audio/voice/male/opt_back.ogg index 23856001..54490ee9 100644 Binary files a/web/rpg/audio/voice/male/opt_back.ogg and b/web/rpg/audio/voice/male/opt_back.ogg differ diff --git a/web/rpg/audio/voice/male/opt_drink.ogg b/web/rpg/audio/voice/male/opt_drink.ogg index a95f0434..4c8ba267 100644 Binary files a/web/rpg/audio/voice/male/opt_drink.ogg and b/web/rpg/audio/voice/male/opt_drink.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_chest.ogg b/web/rpg/audio/voice/male/opt_go_chest.ogg index 6c55592f..edf950a0 100644 Binary files a/web/rpg/audio/voice/male/opt_go_chest.ogg and b/web/rpg/audio/voice/male/opt_go_chest.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_deepwood.ogg b/web/rpg/audio/voice/male/opt_go_deepwood.ogg index 9e04d944..3673500c 100644 Binary files a/web/rpg/audio/voice/male/opt_go_deepwood.ogg and b/web/rpg/audio/voice/male/opt_go_deepwood.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_fork.ogg b/web/rpg/audio/voice/male/opt_go_fork.ogg index 5c27fb4c..ec1c6daa 100644 Binary files a/web/rpg/audio/voice/male/opt_go_fork.ogg and b/web/rpg/audio/voice/male/opt_go_fork.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_gate.ogg b/web/rpg/audio/voice/male/opt_go_gate.ogg index 33fd67cc..bca8559b 100644 Binary files a/web/rpg/audio/voice/male/opt_go_gate.ogg and b/web/rpg/audio/voice/male/opt_go_gate.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_hunter.ogg b/web/rpg/audio/voice/male/opt_go_hunter.ogg index f928e5c7..bfbbeaf5 100644 Binary files a/web/rpg/audio/voice/male/opt_go_hunter.ogg and b/web/rpg/audio/voice/male/opt_go_hunter.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_path.ogg b/web/rpg/audio/voice/male/opt_go_path.ogg index 5545e8fc..14326d8b 100644 Binary files a/web/rpg/audio/voice/male/opt_go_path.ogg and b/web/rpg/audio/voice/male/opt_go_path.ogg differ diff --git a/web/rpg/audio/voice/male/opt_go_stream.ogg b/web/rpg/audio/voice/male/opt_go_stream.ogg index 51210d75..f3cdf373 100644 Binary files a/web/rpg/audio/voice/male/opt_go_stream.ogg and b/web/rpg/audio/voice/male/opt_go_stream.ogg differ diff --git a/web/rpg/audio/voice/male/opt_investigate.ogg b/web/rpg/audio/voice/male/opt_investigate.ogg index 78dc87ec..808e79a2 100644 Binary files a/web/rpg/audio/voice/male/opt_investigate.ogg and b/web/rpg/audio/voice/male/opt_investigate.ogg differ diff --git a/web/rpg/audio/voice/male/opt_leave.ogg b/web/rpg/audio/voice/male/opt_leave.ogg index a3989d02..a6f8f35a 100644 Binary files a/web/rpg/audio/voice/male/opt_leave.ogg and b/web/rpg/audio/voice/male/opt_leave.ogg differ diff --git a/web/rpg/audio/voice/male/opt_open_chest.ogg b/web/rpg/audio/voice/male/opt_open_chest.ogg index 0b92b5f3..4bfc51fa 100644 Binary files a/web/rpg/audio/voice/male/opt_open_chest.ogg and b/web/rpg/audio/voice/male/opt_open_chest.ogg differ diff --git a/web/rpg/audio/voice/male/opt_rest.ogg b/web/rpg/audio/voice/male/opt_rest.ogg index e049d760..c7e7f90d 100644 Binary files a/web/rpg/audio/voice/male/opt_rest.ogg and b/web/rpg/audio/voice/male/opt_rest.ogg differ diff --git a/web/rpg/audio/voice/male/opt_shop.ogg b/web/rpg/audio/voice/male/opt_shop.ogg index 6c2d8606..21786e10 100644 Binary files a/web/rpg/audio/voice/male/opt_shop.ogg and b/web/rpg/audio/voice/male/opt_shop.ogg differ diff --git a/web/rpg/audio/voice/male/opt_sneak.ogg b/web/rpg/audio/voice/male/opt_sneak.ogg index d2ec41e5..7588bed4 100644 Binary files a/web/rpg/audio/voice/male/opt_sneak.ogg and b/web/rpg/audio/voice/male/opt_sneak.ogg differ diff --git a/web/rpg/audio/voice/male/opt_status.ogg b/web/rpg/audio/voice/male/opt_status.ogg index 96502be5..9cc0bb65 100644 Binary files a/web/rpg/audio/voice/male/opt_status.ogg and b/web/rpg/audio/voice/male/opt_status.ogg differ diff --git a/web/rpg/audio/voice/male/opt_talk_hunter.ogg b/web/rpg/audio/voice/male/opt_talk_hunter.ogg index f6aa6909..ec90c169 100644 Binary files a/web/rpg/audio/voice/male/opt_talk_hunter.ogg and b/web/rpg/audio/voice/male/opt_talk_hunter.ogg differ diff --git a/web/rpg/audio/voice/male/settings_hint.ogg b/web/rpg/audio/voice/male/settings_hint.ogg index d2842f1b..1a4594ab 100644 Binary files a/web/rpg/audio/voice/male/settings_hint.ogg and b/web/rpg/audio/voice/male/settings_hint.ogg differ diff --git a/web/rpg/audio/voice/male/shop_buy.ogg b/web/rpg/audio/voice/male/shop_buy.ogg index 66093816..fd7e0e2c 100644 Binary files a/web/rpg/audio/voice/male/shop_buy.ogg and b/web/rpg/audio/voice/male/shop_buy.ogg differ diff --git a/web/rpg/audio/voice/male/shop_open.ogg b/web/rpg/audio/voice/male/shop_open.ogg index 91a2a980..bdd7edf8 100644 Binary files a/web/rpg/audio/voice/male/shop_open.ogg and b/web/rpg/audio/voice/male/shop_open.ogg differ diff --git a/web/rpg/audio/voice/male/shop_owned.ogg b/web/rpg/audio/voice/male/shop_owned.ogg index ef02fbc6..0a0d04bf 100644 Binary files a/web/rpg/audio/voice/male/shop_owned.ogg and b/web/rpg/audio/voice/male/shop_owned.ogg differ diff --git a/web/rpg/audio/voice/male/shop_poor.ogg b/web/rpg/audio/voice/male/shop_poor.ogg index 38d3c086..fe215d90 100644 Binary files a/web/rpg/audio/voice/male/shop_poor.ogg and b/web/rpg/audio/voice/male/shop_poor.ogg differ diff --git a/web/rpg/audio/voice/male/shop_price_armor.ogg b/web/rpg/audio/voice/male/shop_price_armor.ogg index 4a2971af..1046505a 100644 Binary files a/web/rpg/audio/voice/male/shop_price_armor.ogg and b/web/rpg/audio/voice/male/shop_price_armor.ogg differ diff --git a/web/rpg/audio/voice/male/shop_price_potion.ogg b/web/rpg/audio/voice/male/shop_price_potion.ogg index abebd6a7..95231489 100644 Binary files a/web/rpg/audio/voice/male/shop_price_potion.ogg and b/web/rpg/audio/voice/male/shop_price_potion.ogg differ diff --git a/web/rpg/audio/voice/male/shop_price_sword.ogg b/web/rpg/audio/voice/male/shop_price_sword.ogg index bfa4d9cb..db3735b1 100644 Binary files a/web/rpg/audio/voice/male/shop_price_sword.ogg and b/web/rpg/audio/voice/male/shop_price_sword.ogg differ diff --git a/web/rpg/audio/voice/male/skip_hint.m4a b/web/rpg/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..927cedb8 Binary files /dev/null and b/web/rpg/audio/voice/male/skip_hint.m4a differ diff --git a/web/rpg/audio/voice/male/skip_hint.ogg b/web/rpg/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..7c5791c2 Binary files /dev/null and b/web/rpg/audio/voice/male/skip_hint.ogg differ diff --git a/web/rpg/audio/voice/male/st_armor_iron.ogg b/web/rpg/audio/voice/male/st_armor_iron.ogg index f3cb98ca..142cd212 100644 Binary files a/web/rpg/audio/voice/male/st_armor_iron.ogg and b/web/rpg/audio/voice/male/st_armor_iron.ogg differ diff --git a/web/rpg/audio/voice/male/st_armor_leather.ogg b/web/rpg/audio/voice/male/st_armor_leather.ogg index f40d1475..5f3d4615 100644 Binary files a/web/rpg/audio/voice/male/st_armor_leather.ogg and b/web/rpg/audio/voice/male/st_armor_leather.ogg differ diff --git a/web/rpg/audio/voice/male/st_armor_none.ogg b/web/rpg/audio/voice/male/st_armor_none.ogg index 43e5a619..7b09ae9c 100644 Binary files a/web/rpg/audio/voice/male/st_armor_none.ogg and b/web/rpg/audio/voice/male/st_armor_none.ogg differ diff --git a/web/rpg/audio/voice/male/st_gold_poor.ogg b/web/rpg/audio/voice/male/st_gold_poor.ogg index 0f433a76..dbc9795b 100644 Binary files a/web/rpg/audio/voice/male/st_gold_poor.ogg and b/web/rpg/audio/voice/male/st_gold_poor.ogg differ diff --git a/web/rpg/audio/voice/male/st_gold_rich.ogg b/web/rpg/audio/voice/male/st_gold_rich.ogg index d521ad14..836b34e4 100644 Binary files a/web/rpg/audio/voice/male/st_gold_rich.ogg and b/web/rpg/audio/voice/male/st_gold_rich.ogg differ diff --git a/web/rpg/audio/voice/male/st_gold_some.ogg b/web/rpg/audio/voice/male/st_gold_some.ogg index 20ad7159..1bcd1448 100644 Binary files a/web/rpg/audio/voice/male/st_gold_some.ogg and b/web/rpg/audio/voice/male/st_gold_some.ogg differ diff --git a/web/rpg/audio/voice/male/st_hp_high.ogg b/web/rpg/audio/voice/male/st_hp_high.ogg index b28afebb..522dd3fe 100644 Binary files a/web/rpg/audio/voice/male/st_hp_high.ogg and b/web/rpg/audio/voice/male/st_hp_high.ogg differ diff --git a/web/rpg/audio/voice/male/st_hp_low.ogg b/web/rpg/audio/voice/male/st_hp_low.ogg index 8d840c42..9fd355b4 100644 Binary files a/web/rpg/audio/voice/male/st_hp_low.ogg and b/web/rpg/audio/voice/male/st_hp_low.ogg differ diff --git a/web/rpg/audio/voice/male/st_hp_mid.ogg b/web/rpg/audio/voice/male/st_hp_mid.ogg index a7d3dcdb..5457e319 100644 Binary files a/web/rpg/audio/voice/male/st_hp_mid.ogg and b/web/rpg/audio/voice/male/st_hp_mid.ogg differ diff --git a/web/rpg/audio/voice/male/st_lv_high.ogg b/web/rpg/audio/voice/male/st_lv_high.ogg index 51efcf05..919cf6af 100644 Binary files a/web/rpg/audio/voice/male/st_lv_high.ogg and b/web/rpg/audio/voice/male/st_lv_high.ogg differ diff --git a/web/rpg/audio/voice/male/st_lv_low.ogg b/web/rpg/audio/voice/male/st_lv_low.ogg index 2c1c0553..6f970fc3 100644 Binary files a/web/rpg/audio/voice/male/st_lv_low.ogg and b/web/rpg/audio/voice/male/st_lv_low.ogg differ diff --git a/web/rpg/audio/voice/male/st_lv_mid.ogg b/web/rpg/audio/voice/male/st_lv_mid.ogg index d7f5772b..edc2022c 100644 Binary files a/web/rpg/audio/voice/male/st_lv_mid.ogg and b/web/rpg/audio/voice/male/st_lv_mid.ogg differ diff --git a/web/rpg/audio/voice/male/st_weapon_iron.ogg b/web/rpg/audio/voice/male/st_weapon_iron.ogg index be21f6fd..622079fc 100644 Binary files a/web/rpg/audio/voice/male/st_weapon_iron.ogg and b/web/rpg/audio/voice/male/st_weapon_iron.ogg differ diff --git a/web/rpg/audio/voice/male/st_weapon_none.ogg b/web/rpg/audio/voice/male/st_weapon_none.ogg index a3cc6cf6..d9d38753 100644 Binary files a/web/rpg/audio/voice/male/st_weapon_none.ogg and b/web/rpg/audio/voice/male/st_weapon_none.ogg differ diff --git a/web/rpg/audio/voice/male/st_weapon_steel.ogg b/web/rpg/audio/voice/male/st_weapon_steel.ogg index 6bbda4bf..46cceb5e 100644 Binary files a/web/rpg/audio/voice/male/st_weapon_steel.ogg and b/web/rpg/audio/voice/male/st_weapon_steel.ogg differ diff --git a/web/rpg/audio/voice/male/st_weapon_wind.ogg b/web/rpg/audio/voice/male/st_weapon_wind.ogg index 9444ea7a..4c8c7418 100644 Binary files a/web/rpg/audio/voice/male/st_weapon_wind.ogg and b/web/rpg/audio/voice/male/st_weapon_wind.ogg differ diff --git a/web/rpg/audio/voice/male/sys_continue_ask.ogg b/web/rpg/audio/voice/male/sys_continue_ask.ogg index 19ad1db7..8577a639 100644 Binary files a/web/rpg/audio/voice/male/sys_continue_ask.ogg and b/web/rpg/audio/voice/male/sys_continue_ask.ogg differ diff --git a/web/rpg/audio/voice/male/sys_drink.ogg b/web/rpg/audio/voice/male/sys_drink.ogg index 5b2f1223..039ffb7f 100644 Binary files a/web/rpg/audio/voice/male/sys_drink.ogg and b/web/rpg/audio/voice/male/sys_drink.ogg differ diff --git a/web/rpg/audio/voice/male/sys_menu.ogg b/web/rpg/audio/voice/male/sys_menu.ogg index e7eaf44c..aa44e0d8 100644 Binary files a/web/rpg/audio/voice/male/sys_menu.ogg and b/web/rpg/audio/voice/male/sys_menu.ogg differ diff --git a/web/rpg/audio/voice/male/sys_menu_help.ogg b/web/rpg/audio/voice/male/sys_menu_help.ogg index 899d3108..9c7fc911 100644 Binary files a/web/rpg/audio/voice/male/sys_menu_help.ogg and b/web/rpg/audio/voice/male/sys_menu_help.ogg differ diff --git a/web/rpg/audio/voice/male/sys_menu_resume.ogg b/web/rpg/audio/voice/male/sys_menu_resume.ogg index 38a3fe8d..eb6a38fa 100644 Binary files a/web/rpg/audio/voice/male/sys_menu_resume.ogg and b/web/rpg/audio/voice/male/sys_menu_resume.ogg differ diff --git a/web/rpg/audio/voice/male/sys_menu_save.ogg b/web/rpg/audio/voice/male/sys_menu_save.ogg index edcbe880..4fdcf715 100644 Binary files a/web/rpg/audio/voice/male/sys_menu_save.ogg and b/web/rpg/audio/voice/male/sys_menu_save.ogg differ diff --git a/web/rpg/audio/voice/male/sys_new_game.ogg b/web/rpg/audio/voice/male/sys_new_game.ogg index 249c5aa4..4a0ed3f3 100644 Binary files a/web/rpg/audio/voice/male/sys_new_game.ogg and b/web/rpg/audio/voice/male/sys_new_game.ogg differ diff --git a/web/rpg/audio/voice/male/sys_rest.ogg b/web/rpg/audio/voice/male/sys_rest.ogg index edad4d81..f72f79e9 100644 Binary files a/web/rpg/audio/voice/male/sys_rest.ogg and b/web/rpg/audio/voice/male/sys_rest.ogg differ diff --git a/web/rpg/audio/voice/male/sys_saved.ogg b/web/rpg/audio/voice/male/sys_saved.ogg index 0882e65b..4d891491 100644 Binary files a/web/rpg/audio/voice/male/sys_saved.ogg and b/web/rpg/audio/voice/male/sys_saved.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b0.m4a b/web/rpg/audio/voice/male/tut_b0.m4a new file mode 100644 index 00000000..238c94a8 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_b0.m4a differ diff --git a/web/rpg/audio/voice/male/tut_b0.ogg b/web/rpg/audio/voice/male/tut_b0.ogg new file mode 100644 index 00000000..ff90322f Binary files /dev/null and b/web/rpg/audio/voice/male/tut_b0.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b1.ogg b/web/rpg/audio/voice/male/tut_b1.ogg index 1a97ce5c..21591cf1 100644 Binary files a/web/rpg/audio/voice/male/tut_b1.ogg and b/web/rpg/audio/voice/male/tut_b1.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b2.ogg b/web/rpg/audio/voice/male/tut_b2.ogg index a46477cb..cd0d84a7 100644 Binary files a/web/rpg/audio/voice/male/tut_b2.ogg and b/web/rpg/audio/voice/male/tut_b2.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b3.ogg b/web/rpg/audio/voice/male/tut_b3.ogg index 293bea95..e8814978 100644 Binary files a/web/rpg/audio/voice/male/tut_b3.ogg and b/web/rpg/audio/voice/male/tut_b3.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b4.ogg b/web/rpg/audio/voice/male/tut_b4.ogg index a7e36eac..717055e3 100644 Binary files a/web/rpg/audio/voice/male/tut_b4.ogg and b/web/rpg/audio/voice/male/tut_b4.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b5.ogg b/web/rpg/audio/voice/male/tut_b5.ogg index e95eab04..2c8dc763 100644 Binary files a/web/rpg/audio/voice/male/tut_b5.ogg and b/web/rpg/audio/voice/male/tut_b5.ogg differ diff --git a/web/rpg/audio/voice/male/tut_b6.m4a b/web/rpg/audio/voice/male/tut_b6.m4a new file mode 100644 index 00000000..5854329a Binary files /dev/null and b/web/rpg/audio/voice/male/tut_b6.m4a differ diff --git a/web/rpg/audio/voice/male/tut_b6.ogg b/web/rpg/audio/voice/male/tut_b6.ogg new file mode 100644 index 00000000..2408279a Binary files /dev/null and b/web/rpg/audio/voice/male/tut_b6.ogg differ diff --git a/web/rpg/audio/voice/male/tut_battle_1.m4a b/web/rpg/audio/voice/male/tut_battle_1.m4a new file mode 100644 index 00000000..3e32fc29 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_1.m4a differ diff --git a/web/rpg/audio/voice/male/tut_battle_1.ogg b/web/rpg/audio/voice/male/tut_battle_1.ogg new file mode 100644 index 00000000..5d513bee Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_1.ogg differ diff --git a/web/rpg/audio/voice/male/tut_battle_2.m4a b/web/rpg/audio/voice/male/tut_battle_2.m4a new file mode 100644 index 00000000..d613ddd0 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_2.m4a differ diff --git a/web/rpg/audio/voice/male/tut_battle_2.ogg b/web/rpg/audio/voice/male/tut_battle_2.ogg new file mode 100644 index 00000000..4d22e2cf Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_2.ogg differ diff --git a/web/rpg/audio/voice/male/tut_battle_3.m4a b/web/rpg/audio/voice/male/tut_battle_3.m4a new file mode 100644 index 00000000..658d7070 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_3.m4a differ diff --git a/web/rpg/audio/voice/male/tut_battle_3.ogg b/web/rpg/audio/voice/male/tut_battle_3.ogg new file mode 100644 index 00000000..e29e1ce0 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_3.ogg differ diff --git a/web/rpg/audio/voice/male/tut_battle_4.m4a b/web/rpg/audio/voice/male/tut_battle_4.m4a new file mode 100644 index 00000000..314e3b77 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_4.m4a differ diff --git a/web/rpg/audio/voice/male/tut_battle_4.ogg b/web/rpg/audio/voice/male/tut_battle_4.ogg new file mode 100644 index 00000000..6611852d Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_4.ogg differ diff --git a/web/rpg/audio/voice/male/tut_battle_5.m4a b/web/rpg/audio/voice/male/tut_battle_5.m4a new file mode 100644 index 00000000..ce85266d Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_5.m4a differ diff --git a/web/rpg/audio/voice/male/tut_battle_5.ogg b/web/rpg/audio/voice/male/tut_battle_5.ogg new file mode 100644 index 00000000..56d0e428 Binary files /dev/null and b/web/rpg/audio/voice/male/tut_battle_5.ogg differ diff --git a/web/rpg/audio/voice/npc_boss1_1.ogg b/web/rpg/audio/voice/npc_boss1_1.ogg index 681d00f1..be45c4d0 100644 Binary files a/web/rpg/audio/voice/npc_boss1_1.ogg and b/web/rpg/audio/voice/npc_boss1_1.ogg differ diff --git a/web/rpg/audio/voice/npc_boss1_2.ogg b/web/rpg/audio/voice/npc_boss1_2.ogg index b14c2e29..5d937a5a 100644 Binary files a/web/rpg/audio/voice/npc_boss1_2.ogg and b/web/rpg/audio/voice/npc_boss1_2.ogg differ diff --git a/web/rpg/audio/voice/npc_boss1_die.ogg b/web/rpg/audio/voice/npc_boss1_die.ogg index 07eedce5..c6c1565b 100644 Binary files a/web/rpg/audio/voice/npc_boss1_die.ogg and b/web/rpg/audio/voice/npc_boss1_die.ogg differ diff --git a/web/rpg/audio/voice/npc_elite_1.ogg b/web/rpg/audio/voice/npc_elite_1.ogg index 46930397..a2f28bfb 100644 Binary files a/web/rpg/audio/voice/npc_elite_1.ogg and b/web/rpg/audio/voice/npc_elite_1.ogg differ diff --git a/web/rpg/audio/voice/npc_elite_die.ogg b/web/rpg/audio/voice/npc_elite_die.ogg index 40d66e33..594d9d19 100644 Binary files a/web/rpg/audio/voice/npc_elite_die.ogg and b/web/rpg/audio/voice/npc_elite_die.ogg differ diff --git a/web/rpg/audio/voice/npc_elite_half.ogg b/web/rpg/audio/voice/npc_elite_half.ogg index 6a28a388..7b13fe51 100644 Binary files a/web/rpg/audio/voice/npc_elite_half.ogg and b/web/rpg/audio/voice/npc_elite_half.ogg differ diff --git a/web/rpg/audio/voice/npc_headman_1.ogg b/web/rpg/audio/voice/npc_headman_1.ogg index 902f1a36..f8b5176d 100644 Binary files a/web/rpg/audio/voice/npc_headman_1.ogg and b/web/rpg/audio/voice/npc_headman_1.ogg differ diff --git a/web/rpg/audio/voice/npc_headman_2.ogg b/web/rpg/audio/voice/npc_headman_2.ogg index f19337c9..d991c6cb 100644 Binary files a/web/rpg/audio/voice/npc_headman_2.ogg and b/web/rpg/audio/voice/npc_headman_2.ogg differ diff --git a/web/rpg/audio/voice/npc_headman_3.ogg b/web/rpg/audio/voice/npc_headman_3.ogg index 2cae0889..865b9deb 100644 Binary files a/web/rpg/audio/voice/npc_headman_3.ogg and b/web/rpg/audio/voice/npc_headman_3.ogg differ diff --git a/web/rpg/audio/voice/npc_headman_4.ogg b/web/rpg/audio/voice/npc_headman_4.ogg index 126b0e02..beb23d5f 100644 Binary files a/web/rpg/audio/voice/npc_headman_4.ogg and b/web/rpg/audio/voice/npc_headman_4.ogg differ diff --git a/web/rpg/audio/voice/npc_hunter_1.ogg b/web/rpg/audio/voice/npc_hunter_1.ogg index 7152d9b4..381187a0 100644 Binary files a/web/rpg/audio/voice/npc_hunter_1.ogg and b/web/rpg/audio/voice/npc_hunter_1.ogg differ diff --git a/web/rpg/audio/voice/npc_hunter_2.ogg b/web/rpg/audio/voice/npc_hunter_2.ogg index 220e1eb4..37f43283 100644 Binary files a/web/rpg/audio/voice/npc_hunter_2.ogg and b/web/rpg/audio/voice/npc_hunter_2.ogg differ diff --git a/web/rpg/audio/voice/npc_hunter_3.ogg b/web/rpg/audio/voice/npc_hunter_3.ogg index bb1b2cbe..8dc0b42d 100644 Binary files a/web/rpg/audio/voice/npc_hunter_3.ogg and b/web/rpg/audio/voice/npc_hunter_3.ogg differ diff --git a/web/rpg/audio/voice/npc_hunter_4.ogg b/web/rpg/audio/voice/npc_hunter_4.ogg index be14f5c9..f10e34a7 100644 Binary files a/web/rpg/audio/voice/npc_hunter_4.ogg and b/web/rpg/audio/voice/npc_hunter_4.ogg differ diff --git a/web/rpg/game.js b/web/rpg/game.js index 127fff80..af3af854 100644 --- a/web/rpg/game.js +++ b/web/rpg/game.js @@ -8,12 +8,22 @@ * 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 / * 上滑=状态播报 / 下滑=菜单。 * 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 / - * 双击=格挡(敌方前摇 1.0s 窗口内,减伤 70%,即时回调规避 tap 延迟)/ - * 上滑=敌我状态 / 下滑=逃跑(Boss/剧情战无效)。 + * 双击=格挡(敌方前摇窗口内,减伤 70%,即时回调规避 tap 延迟)/ + * 上滑=敌我状态 / 下滑=逃跑(Lv<3 必成;Lv≥3 70%,失败=敌方一个正常 + * 前摇回合;Boss/剧情战无效)。 + * 战斗重构(docs/design/11-rpg-battle-rebalance.md):首战四步强制教学 + * (menu→attack→parry→defend,教学狼 ATK 2 / 前摇 1.8s / HP 锁底 1); + * 新手难度按 stats.battlesWon 分档(前 3 场 1.5s×0.6、4-5 场 1.25s×0.8、 + * 6 场起标准、Boss 恒标准);战斗四状态声音标识(turn_player/victory 等)。 * - * 音频层叠(addendum §3):village 播 bgm/village 音乐;其余探索场景**无 BGM**, - * amb_* 环境音走 sfx 层手动 BufferSource loop(不受设置 bgm=0 影响,见 - * _playAmb);战斗 crossfade 替换为 bgm/battle|bgm/boss,战后回场景 amb。 + * 音频层叠(10 文档 §7 双层:BGM 层报状态 + sfx 层 amb 报场景): + * village=bgm/village;其余探索=bgm/explore 轻垫(volume 0.35)+ amb_* 手动 + * BufferSource loop 接 sfx 总线;模态菜单=bgm/menu(amb 不停);战斗 400ms + * 快切 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 倍速(游戏零改动)。 * 震动(addendum §1 配合总表):前摇开窗 beat / 格挡 confirm / 受击 hit / * 击杀 success / 升级 success / 窗口内误触 error / 金疮药 pickup / * Boss 半血 warning / 血量告急 warning。 @@ -24,7 +34,6 @@ import { Game } from '../engine/game.js'; import { vibrate } from '../engine/haptics.js'; -import { settings } from '../engine/settings.js'; // ================================ 数据表 ================================ @@ -46,9 +55,10 @@ const GO_TARGET = { go_path: 'path', go_fork: 'fork', go_chest: 'chest', go_stre // 敌人表(chapter1 §4.2;ch1 实际使用的 5 种 + Boss1) const ENEMIES = { - teach_wolf: { hp: 6, atk: 3, def: 0, exp: 5, gold: 0, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' }, + teach_wolf: { hp: 6, atk: 2, def: 0, exp: 5, gold: 0, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' }, // ATK 3→2(11 文档 §2 教学特例) wolf: { hp: 10, atk: 4, def: 0, exp: 8, gold: 5, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' }, - bandit: { hp: 16, atk: 6, def: 1, exp: 12, gold: 12, tel: 'sfx/tel_bandit', start: 'voice/btl_start_bandit' }, + bandit: { hp: 16, atk: 6, def: 1, exp: 12, gold: 12, tel: 'sfx/tel_bandit', start: 'voice/btl_start_bandit', + half: 'voice/btl_half_bandit' }, spider: { hp: 12, atk: 7, def: 0, exp: 12, gold: 8, tel: 'sfx/tel_spider', start: 'voice/btl_start_spider' }, elite: { hp: 24, atk: 8, def: 2, exp: 20, gold: 25, tel: 'sfx/tel_elite', start: 'voice/btl_start_elite', npcStart: 'voice/npc_elite_1', half: 'voice/npc_elite_half', die: 'voice/npc_elite_die' }, @@ -96,7 +106,7 @@ const CHAPTER_NAMES = { 1: '第一章' }; const PREWARM_SFX = [ 'sfx/step', 'sfx/coin', 'sfx/chest', 'sfx/drink_water', 'sfx/rest', 'sfx/levelup', 'sfx/door_heavy', 'sfx/atk_swing', 'sfx/atk_hit', 'sfx/parry', 'sfx/skill_slash', 'sfx/enemy_die', 'sfx/potion', - 'sfx/hurt', 'sfx/flee', + 'sfx/hurt', 'sfx/flee', 'sfx/turn_player', 'sfx/victory', 'sfx/tel_wolf', 'sfx/tel_bandit', 'sfx/tel_spider', 'sfx/tel_elite', 'sfx/tel_boss1', 'sfx/amb_village', 'sfx/amb_path', 'sfx/amb_fork', 'sfx/amb_chest', 'sfx/amb_stream', 'sfx/amb_hunter', 'sfx/amb_deepwood', 'sfx/amb_gate', 'sfx/amb_hall', @@ -129,11 +139,11 @@ export class RpgGame extends Game { } _newRun(keepStats) { - const stats = keepStats && this.stats ? this.stats : { kills: 0, deaths: 0, parries: 0, playSec: 0 }; + const stats = keepStats && this.stats ? this.stats : { kills: 0, deaths: 0, parries: 0, playSec: 0, battlesWon: 0 }; this.player = { lv: 1, exp: 0, hp: 20, mp: 4, gold: 50, weapon: null, armor: null, potions: 1 }; this.flags = { tutorial_battle_done: false, chest_path: false, chest_fork: false, - hunter_quest: false, shop_discount: false, + hunter_quest: false, shop_discount: false, boss1_warned: false, enc_bandits_done: false, enc_elite_done: false, enc_sneak_done: false, boss1_done: false, }; this.chapters = { 1: 'open', 2: 'locked', 3: 'locked' }; @@ -156,7 +166,7 @@ export class RpgGame extends Game { start() { this.audio.prewarm(PREWARM_SFX); - this.audio.prewarm(['voice/intro_1', 'voice/sys_continue_ask', 'voice/desc_village', 'voice/npc_headman_1']); + this.audio.prewarm(['voice/intro_1', 'voice/sys_continue_ask', 'voice/desc_village', 'voice/npc_headman_1', 'voice/skip_hint']); this._session++; const save = this._loadSave(); if (save) { @@ -184,7 +194,8 @@ export class RpgGame extends Game { this._wolfDone = false; this.phase = 'intro'; this._segStart = performance.now(); - this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/sys_new_game'], () => { + // 开场插播一次跳过提示(12 文档 §3 预留的 sys_skip_hint 语义,rpg 用既有 skip_hint 资产) + this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/skip_hint', 'voice/sys_new_game'], () => { // 首次进村:村长送 50 金(player 默认值即剧情款)+ 商店引导 this._enterScene('village', { prepend: ['voice/npc_headman_1', 'voice/npc_headman_2'] }); }, 30000); @@ -197,7 +208,8 @@ export class RpgGame extends Game { this.player = save.player; this.flags = save.flags; this.chapters = save.chapters; - this.stats = save.stats; + // 旧存档可能缺新增字段(battlesWon 等),合并默认值兜底 + this.stats = { kills: 0, deaths: 0, parries: 0, playSec: 0, battlesWon: 0, ...save.stats }; this.visited = new Set(); // 回访描述按本轮会话计(读档后重听完整 desc 更友好) this._wolfDone = false; this.phase = 'explore'; @@ -242,9 +254,16 @@ export class RpgGame extends Game { this.scene = id; this.optIdx = 0; this.phase = 'explore'; - this._switchSceneAudio(id); + this._switchSceneAudio(id, opts.fadeMs || 800); this.audio.play('sfx/step', { volume: 0.8 }); + // 进 Boss 战前强制提示(11 文档 §4):Lv<3 首次到寨门插播 boss1_warn,给知情回头机会 + const prepend = [...(opts.prepend || [])]; + if (id === 'gate' && !this.flags.boss1_done && !this.flags.boss1_warned && this.player.lv < 3) { + prepend.push('voice/boss1_warn'); + this.flags.boss1_warned = true; + } + const first = !this.visited.has(id); this.visited.add(id); let desc = `voice/desc_${id === 'boss1' ? 'hall' : id}`; @@ -252,7 +271,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._speakGuarded([...(opts.prepend || []), desc], () => { + this._speakGuarded([...prepend, desc], () => { this._save(); if (this._checkEncounter()) return; this._speakOption(); @@ -300,6 +319,9 @@ export class RpgGame extends Game { this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n; this._speakOption(); } else if (g === 'tap') { + // voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳), + // 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。 + if (this._voicePlaying()) { this.audio.skipVoice(); return; } this._executeOption(opts[this.optIdx]); } else if (g === 'doubletap') { // 重听场景描述 + 当前选项(amb 循环持续,无需重播) @@ -473,12 +495,14 @@ export class RpgGame extends Game { this._accPlaySec(); this.phase = 'menu'; this._menuIdx = 0; + this.audio.play('bgm/menu'); // 模态菜单状态音乐(10 文档 §7:amb 继续,"游戏还在,界面开了") this._speak(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`]); } _closeMenu() { this.phase = 'explore'; this._segStart = performance.now(); + this._switchSceneAudio(this.scene); // 淡回场景状态音乐(village/explore) this._speakOption(); } @@ -528,13 +552,26 @@ export class RpgGame extends Game { totalExp: 0, totalGold: 0, onVictory: onVictory || null, - teachStage: enc.teach ? 'parry' : null, // 教学战:先学格挡再学出剑 + // 四步强制教学(11 文档 §2):menu(认识指令)→attack→parry→defend→自由击杀 + teachStage: enc.teach ? 'menu' : null, + teachSwipes: 0, + // 难度档位(11 文档 §3):教学战特例前摇 1.8s;其余按 battlesWon 取档,Boss 恒标准 + tier: enc.teach ? { telSec: 1.8, dmgMul: 1.0 } : this._battleTier(enc), telRate: 1, }; this._switchBattleAudio(enc.boss); this._nextEnemy(); } + /** 新手保护档位(11 文档 §3 数值表):每场战斗开始时按 stats.battlesWon 取档。 */ + _battleTier(enc) { + if (enc.boss) return { telSec: 1.0, dmgMul: 1.0 }; // Boss 是毕业考,恒标准 + const w = this.stats.battlesWon || 0; + if (w <= 2) return { telSec: 1.5, dmgMul: 0.6 }; // 保护档:第 1-3 场 + if (w <= 4) return { telSec: 1.25, dmgMul: 0.8 }; // 过渡档:第 4-5 场 + return { telSec: 1.0, dmgMul: 1.0 }; // 标准档:第 6 场起 + } + _nextEnemy() { const b = this._battle; const key = b.queue.shift(); @@ -545,18 +582,70 @@ export class RpgGame extends Game { b.state = 'B_START'; const ids = [base.start]; if (base.npcStart) ids.push(base.npcStart); - if (b.teachStage === 'parry') ids.push('voice/tut_b1'); // 它要扑了,听前摇! + if (b.teachStage === 'menu') { + // R6 首战教学链(tut_battle_1..5,round6-report §4.4):全周期首次才播,二周目起跳过 + if (!this._tutIntroSeen()) { + ids.push('voice/tut_battle_1', 'voice/tut_battle_2', 'voice/tut_battle_3', + 'voice/tut_battle_4', 'voice/tut_battle_5'); + this._markTutIntroSeen(); + } + ids.push('voice/tut_b0'); // T0:战斗里,左右滑选招式,单击出手…… + } this._speakGuarded(ids, () => { - if (b.teachStage === 'parry') this._enemyTelegraph(); - else this._playerSelect(); - }, 15000); + this._playerSelect(); + if (b.teachStage === 'menu') this._armTeachWatchdog('menu'); + }, 60000); } _playerSelect() { const b = this._battle; if (!b) return; b.state = 'PLAYER_SELECT'; - this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); + // 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名 + this.audio.play('sfx/turn_player'); + const cmdIdx = b.cmdIdx; + this._after(650, () => { + if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) { + this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); + } + }); + } + + /** 教学步推进(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); + 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:收尾 + if (next === 'attack' || next === 'defend') this._armTeachWatchdog(next); + } + + /** + * 教学看门狗(11 文档 §2/§7:每段 8s 无操作给一次重复提示;16s 仍无进展兜底 + * 推进,绝不卡死)。parry 步无需兜底——前摇计时器会自然结算推进。 + */ + _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]; + this._after(8000, () => { + if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint) { + this._speak(hint); // 重复提示一次 + } + }); + this._after(16000, () => { + if (this._battle !== b || b.teachStage !== stage) return; + if (b.state !== 'PLAYER_SELECT') 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); + } + }); } _onBattleGesture(g) { @@ -566,6 +655,8 @@ export class RpgGame extends Game { if (g === 'left' || g === 'right') { b.cmdIdx = (b.cmdIdx + (g === 'right' ? 1 : CMDS.length - 1)) % CMDS.length; this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); + // 教学 T0:切换 2 次(听到指令名切换)即过关 + if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack'); } else if (g === 'tap') { this._playerExecute(); } else if (g === 'up') { @@ -614,15 +705,20 @@ export class RpgGame extends Game { const dmg = cmd === 'skill' ? this._dmg(this.atk * 2, e.def) : this._dmg(this.atk, e.def); e.hp = Math.max(0, e.hp - dmg); console.log(`[RpgGame] 玩家${cmd === 'skill' ? '破空斩' : '攻击'} dmg=${dmg} 敌HP=${e.hp}/${e.maxHp}`); - // 教学战:第一剑命中后收尾教学 - if (b.teachStage === 'attack') { - b.teachStage = null; - this._speakGuarded(['voice/tut_b5'], () => this._checkEnemyDead(), 8000); + // 教学 T1:命中一次(敌未亡)→ 进入格挡教学 T2;教学步之外正常结算 + if ((b.teachStage === 'menu' || b.teachStage === 'attack') && e.hp > 0) { + this._teachAdvance('parry'); return; } this._checkEnemyDead(); } else if (cmd === 'defend') { b.defending = true; // 无播报,下回合减伤自明(addendum §1) + // 教学 T3:执行防御一次 → T4 收尾("接下来靠你自己了",自由击杀) + if (b.teachStage === 'defend') { + b.teachStage = null; + this._speakGuarded(['voice/tut_b5'], () => this._checkEnemyDead(), 8000); + return; + } this._checkEnemyDead(); } else if (cmd === 'item') { p.potions--; @@ -638,6 +734,7 @@ export class RpgGame extends Game { const e = b.enemy; b.state = 'CHECK_ENEMY_DEAD'; if (e.hp <= 0) { + b.teachStage = null; // 教学中被提前击杀:教学结束,走正常胜利(绝不卡死) this.stats.kills++; b.totalExp += e.exp; b.totalGold += e.gold; @@ -674,7 +771,8 @@ export class RpgGame extends Game { const b = this._battle; b.state = 'ENEMY_TELEGRAPH'; b.parry = false; - const windowSec = 1.0 + (this.player.weapon && WEAPONS[this.player.weapon].parryBonus || 0); + // 前摇时长按难度档位(11 文档 §3);听风剑加成照旧叠加 + const windowSec = b.tier.telSec + (this.player.weapon && WEAPONS[this.player.weapon].parryBonus || 0); b.windowEnd = performance.now() + windowSec * 1000; this._playTel(b.enemy.tel, b.telRate); vibrate('beat'); // "格挡窗口已开"的通道标识(addendum §1) @@ -704,6 +802,7 @@ export class RpgGame extends Game { const p = this.player; const e = b.enemy; 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) } else { @@ -716,11 +815,12 @@ export class RpgGame extends Game { 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) if (b.teachStage === 'parry') { - b.teachStage = 'attack'; - const ids = b.parry ? ['voice/tut_b3'] : ['voice/tut_b4']; - this._speakGuarded(ids, () => this._playerSelect(), 8000); + b.teachStage = 'defend'; + const ids = b.parry ? ['voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6']; + this._speakGuarded(ids, () => this._playerSelect(), 10000); + this._armTeachWatchdog('defend'); return; } if (p.hp <= 0) { this._defeat(); return; } @@ -737,19 +837,30 @@ export class RpgGame extends Game { _fleeCheck() { const b = this._battle; - if (b.enc.flee) { + if (!b.enc.flee) { + // Boss/剧情战不可逃 + vibrate('error'); + this._speakGuarded(['voice/btl_noflee'], () => this._playerSelect(), 5000); + return; + } + // 逃跑新手保护(11 文档 §4):Lv<3 必成;Lv≥3 70%,失败代价 = 敌方一个 + // 正常前摇回合(可格挡,不白打) + const rate = this.player.lv < 3 ? 1.0 : 0.7; + if (Math.random() < rate) { b.state = 'FLED'; this.audio.play('sfx/flee'); this._speakGuarded(['voice/btl_flee'], () => this._endBattle('fled'), 6000); } else { - vibrate('error'); - this._speakGuarded(['voice/btl_noflee'], () => this._playerSelect(), 5000); + b.state = 'FLEE_FAIL'; + this._speakGuarded(['voice/btl_flee_fail'], () => this._enemyTelegraph(), 6000); } } _victory() { const b = this._battle; b.state = 'VICTORY'; + this.stats.battlesWon = (this.stats.battlesWon || 0) + 1; // 难度档位依据(11 文档 §3) + this.audio.play('sfx/victory'); // 胜利标识音(3 音上行短号角,11 文档 §5) const p = this.player; // 战利:exp+gold 分档整句 const loot = b.totalExp + b.totalGold; @@ -776,6 +887,7 @@ export class RpgGame extends Game { const b = this._battle; b.state = 'DEFEAT'; this.stats.deaths++; + this.audio.stopBgm(2000); // 战败:战斗 BGM 2s 淡出(10 文档 §7) vibrate('hit'); this._after(300, () => vibrate('error')); this._speakGuarded(['voice/btl_lose', 'voice/btl_lose_hint'], () => { @@ -787,7 +899,7 @@ export class RpgGame extends Game { this.flags.enc_sneak_done = false; this._battle = null; this._save(); - this._enterScene('village'); + this._enterScene('village', { fadeMs: 1500 }); // 重生即安全区音乐(淡入 1.5s) }, 15000); } @@ -803,15 +915,18 @@ export class RpgGame extends Game { if (result === 'victory' && b.encId === 'enc_wolf') this._wolfDone = true; if (result === 'victory' && b.encId === 'enc_boss1') { this._chapterCleared(); return; } if (result === 'victory' && b.onVictory) { b.onVictory(); return; } - // 默认:回当前场景探索态 + // 默认:回当前场景探索态。战斗→探索的关键过渡(10 文档 §7): + // 战斗 BGM 淡出 1500ms → 探索状态音乐淡入 2000ms + amb 恢复 this.phase = 'explore'; - this._switchSceneAudio(this.scene); + this.audio.stopBgm(1500); + this._switchSceneAudio(this.scene, 2000); this._speakOption(); } - /** 章 Boss 讨伐:章节结算 → bgm/ending → 回村(ch2 未开放)。 */ + /** 章 Boss 讨伐:章节结算(开第二章)→ bgm/ending → 回村。 */ _chapterCleared() { this.chapters[1] = 'cleared'; + this.chapters[2] = 'open'; // 第一章通关解锁第二章(13 文档 §6 断言) this.flags.boss1_done = true; this._save(); this._stopAmb(); @@ -821,26 +936,22 @@ export class RpgGame extends Game { }, 15000); } - // ---------------- 音频层叠(addendum §3) ---------------- + // ---------------- 音频层叠(10 文档 §7:BGM 层报状态 + sfx 层 amb 报场景) ---------------- - /** 探索场景音频:village=bgm/village 音乐;其余=amb 环境音(sfx 层手动循环)。 */ - _switchSceneAudio(sceneId) { - if (sceneId === 'village' && settings.get().bgm > 0) { - this._stopAmb(); - this.audio.play('bgm/village'); - } else { - this.audio.stopBgm(400); - this._playAmb(`sfx/${SCENES[sceneId].amb}`); - } + /** + * 探索场景音频:village=bgm/village;其余=bgm/explore 轻垫(volume 0.35,R6 恢复); + * amb_* 环境音(sfx 层手动循环)各场景常开。bgm=0 时引擎只记意图不播放,游戏无需判断。 + */ + _switchSceneAudio(sceneId, fadeMs = 800) { + this._playAmb(`sfx/${SCENES[sceneId].amb}`); + if (sceneId === 'village') this.audio.play('bgm/village', { fadeMs }); + else this.audio.play('bgm/explore', { volume: 0.35, fadeMs }); } - /** 战斗音频:普通 bgm/battle / Boss bgm/boss;bgm=0 时保留 amb 垫底。 */ + /** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */ _switchBattleAudio(isBoss) { - if (settings.get().bgm > 0) { - this._stopAmb(); - this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle'); - } - // bgm=0:amb 照播(环境音属内容层,不受音乐设置影响) + this._stopAmb(); + this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 }); } /** @@ -939,6 +1050,18 @@ export class RpgGame extends Game { // ---------------- 工具 ---------------- + /** 当前是否有对白在播(引擎 voice token;探索态单击=skipVoice 的语境判定,12 文档 §1)。 */ + _voicePlaying() { return !!this.audio._voice; } + + /** R6 首战教学链(tut_battle_1..5)全周期只播一次:跨存档持久化,二周目起跳过。 */ + _tutIntroSeen() { + try { return localStorage.getItem('soundgame.rpg.tutIntroSeen') === '1'; } catch (_) { return false; } + } + + _markTutIntroSeen() { + try { localStorage.setItem('soundgame.rpg.tutIntroSeen', '1'); } catch (_) { /* 隐私模式 */ } + } + /** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */ _speak(ids, onDone) { if (!ids.length) { if (onDone) onDone(); return; } diff --git a/web/rpg/gen/README.md b/web/rpg/gen/README.md index b6bffd26..515d6f6f 100644 --- a/web/rpg/gen/README.md +++ b/web/rpg/gen/README.md @@ -1,34 +1,35 @@ -# web/rpg 音频资产清单与再生成方法(R4) +# web/rpg 音频资产清单与再生成方法(R4;R6 更新) -设计依据:`docs/design/05-rpg-ch1-voice-ids.md`(台词)、`05-rpg-chapter1.md`(场景/战斗/资产清单)、`05-rpg-addendum.md`(层叠修正)。 +设计依据:`docs/design/05-rpg-ch1-voice-ids.md`(台词)、`05-rpg-chapter1.md`(场景/战斗/资产清单)、`05-rpg-addendum.md`(层叠修正)、`docs/design/11-rpg-battle-rebalance.md`(R6 战斗重构缺口资产)。 ## 目录结构 ``` web/rpg/audio/ ├── voice/ -│ ├── male/ # 旁白 107 id(Uncle_Fu,说书人)×{ogg,m4a} -│ ├── female/ # 旁白 107 id(Serena,讲武侠故事)×{ogg,m4a} +│ ├── male/ # 旁白 118 id(Uncle_Fu,说书人)×{ogg,m4a} +│ ├── female/ # 旁白 118 id(Serena,讲武侠故事)×{ogg,m4a} │ └── npc_*. # NPC 14 id 单音色平铺(引擎平铺回退,同 storyline 角色模式) -├── bgm/ # village / battle / boss / ending(90s 无缝循环,loudnorm -20) -└── sfx/ # 35 件(tel_* 9 / 战斗 6+SYN2 / 探索 4+SYN3 / amb_* 11) +├── bgm/ # village / battle / boss / ending(R4)+ menu / explore(R6);90s 无缝循环 +└── sfx/ # 37 件(tel_* 9 / 战斗 7 / 探索 4 / SYN 6 / amb_* 11) ``` -## voice(107 旁白 id + 14 NPC id) +## voice(118 旁白 id + 14 NPC id) -- 旁白 id 分组:A 序章 `intro_1..3`;S 系统 `sys_* help_* settings_hint st_*`(28);D 场景 `desc_*`(16);O 选项 `opt_*`(18);E 事件 `evt_*`(6);B 战斗 `btl_* cmd_* tut_*`(26);SH 商店 `shop_*`(7);addendum 补录敌方档位 `foe_hp_high/mid/low`(3)。 +- 旁白 id 分组:A 序章 `intro_1..3`;S 系统 `sys_* help_* settings_hint st_*`(28)+ R6 `skip_hint`;D 场景 `desc_*`(16,gate/boss1 按设计无 _re 回访句);O 选项 `opt_*`(18);E 事件 `evt_*`(6);B 战斗 `btl_* cmd_* tut_*`(32,含 R5 补录 `btl_half_bandit`、R6 首战教学 `tut_battle_1..5` 与 11 号文档补录 `tut_b0 tut_b6 btl_flee_fail boss1_warn`);SH 商店 `shop_*`(7);addendum 补录敌方档位 `foe_hp_high/mid/low`(3)。 - NPC(平铺,speaker+instruct 恒定):村长 `npc_headman_1..4`(Uncle_Fu·慈祥和缓的老者);老猎人 `npc_hunter_1..4`(Eric·沙哑粗犷豪爽);寨丁头目 `npc_elite_1/half/die`(Dylan·凶狠粗鲁);黑风寨主 `npc_boss1_1/2/die`(Uncle_Fu·低沉阴狠——与村长同 speaker 不同 instruct,请试听区分度)。 - 再生成:`source env.d/tts.sh && flock .gpu.lock .venv-tts/bin/python tools/gen_voice_dual.py --game rpg --gender both`(staging 在 `tools/gen_voice_dual_wav/rpg/`,断点续跑;`--only id1,id2` 单句重录;`--check` 两性时长离群检查;`--convert` 转 ogg+m4a)。 +- R6 缺口 4 句(`tut_b0 tut_b6 btl_flee_fail boss1_warn`):`flock .gpu.lock .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py`,再 `--check` / `--convert`(staging 在 `web/rpg/gen/staging/voice/`,音色/instruct/质控同主管线)。 -## bgm(4 首,Mureka) +## bgm(6 首,Mureka) -`village`(村庄/安全区)`battle`(普通战斗)`boss`(Boss 战)`ending`(章结算)。探索期**无音乐**(addendum §3),`amb_*` 由游戏层手动 loop 接 sfx 总线。再生成:`source env.d/mureka.sh && python3 tools/mureka_gen.py --prompt ... --out tools/mureka_out/rpg_`(prompt 见 `tools/mureka_log.md` R4 节与本目录设计文档)。 +`village`(村庄/安全区)`battle`(普通战斗)`boss`(Boss 战)`ending`(章结算)+ R6 新增 `menu`(模态菜单 pad,-24 LUFS)与 `explore`(探索轻垫,引擎 volume 0.35,10 文档 §7 恢复)。`amb_*` 仍由游戏层手动 loop 接 sfx 总线(不变)。再生成:`source env.d/mureka.sh && python3 tools/mureka_gen.py --prompt ... --out tools/mureka_out/rpg_`(prompt 见 `tools/mureka_log.md`;R6 两首的 task id 见 docs/audio/round6-report.md §2)。 -## sfx(35 件) +## sfx(37 件) -- SA3 生成 29 件(`tools/gen_rpg_sfx.sh`,staging `tools/gen_rpg_sfx_wav/`):前摇 `tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2`(裁 1.0s 格挡窗口);战斗 `atk_swing atk_hit parry skill_slash enemy_die potion`;探索 `chest drink_water rest door_heavy`;环境 `amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak`(6s→1s 首尾交叉淡化成 5s 循环,均值 -25dB)。 -- 程序合成 5 件(numpy,`tools/gen_rpg_syn.py`):`hurt`(低频闷击)`flee`(渐远脚步)`step`(单步)`coin`(硬币叮当)`levelup`(A 小调五声上行钟声琶音)。 -- one-shot 统一峰值 -1dBFS;`ui_deny` 按设计不做(反馈走震动)。 +- SA3 生成 30 件(R4 批 `tools/gen_rpg_sfx.sh`,staging `tools/gen_rpg_sfx_wav/`;R6 `victory` 用 `web/rpg/gen/gen_gap_sfx.sh`,staging `web/rpg/gen/staging/sfx/`):前摇 `tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2`(裁 1.0s 格挡窗口);战斗 `atk_swing atk_hit parry skill_slash enemy_die potion victory`(胜利号角 1.2s);探索 `chest drink_water rest door_heavy`;环境 `amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak`(6s→1s 首尾交叉淡化成 5s 循环,均值 -25dB)。 +- 程序合成 6 件(numpy;R4 五件 `tools/gen_rpg_syn.py`,R6 `turn_player` 由 `web/rpg/gen/gen_gap_sfx.py` 合成):`hurt`(低频闷击)`flee`(渐远脚步)`step`(单步)`coin`(硬币叮当)`levelup`(A 小调五声上行钟声琶音)`turn_player`(E5→A5 上行双音拨弦 0.36s,"轮到你"听觉标点)。 +- one-shot 统一 R6 标准:去直流 + 首尾 8ms cos² 淡入淡出 + 峰值 -3dBFS(ogg 解码回读真峰值校验);`ui_deny` 按设计不做(反馈走震动)。 ## 双格式约定 diff --git a/web/rpg/gen/gen_gap_sfx.py b/web/rpg/gen/gen_gap_sfx.py new file mode 100644 index 00000000..650f2bbb --- /dev/null +++ b/web/rpg/gen/gen_gap_sfx.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""R6 缺口 sfx(docs/design/11-rpg-battle-rebalance.md §5):turn_player / victory。 + +- turn_player:程序合成(numpy)。上行双音(五声 E5→A5 拨弦质感),≤0.4s, + "轮到你"的听觉标点,每回合出现,必须极短不烦。 +- victory:Stable Audio 3 生成(见 gen_gap_sfx.sh,英文 prompt 照录 11 文档: + "triumphant short brass fanfare, 3 rising notes, wuxia style, under 1.5 seconds"), + 本脚本负责后处理:去头静音 + 定长 1.2s。 + +用法: + bash web/rpg/gen/gen_gap_sfx.sh # SA3 生成 victory staging(flock GPU) + .venv-tts/bin/python web/rpg/gen/gen_gap_sfx.py # 合成 turn_player + 两者后处理落盘 + +后处理标准(docs/audio/asset-manifest.md R6 起):one-shot 去直流 → 首尾 8ms cos² +淡入淡出 → 峰值 -3dBFS(ogg 解码回读真峰值校验,超 -3dB 降增益重编,首轮附带 +18kHz 低通,最多 3 次迭代)。落盘 web/rpg/audio/sfx/.{ogg,m4a}(q3 / aac 128k)。 +""" +import os +import subprocess + +import numpy as np +import soundfile as sf + +ROOT = "/home/ubuntu/SoundGame" +STAGING = os.path.join(ROOT, "web", "rpg", "gen", "staging", "sfx") +OUT = os.path.join(ROOT, "web", "rpg", "audio", "sfx") +FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") + +FADE_MS = 8 +PEAK_TARGET = 10 ** (-3.0 / 20) # -3dBFS +TRIM = {"victory": 1.2} # 去头后定长(秒);turn_player 合成即定长不裁 + + +def synth_turn_player(path, sr=44100): + """上行双音拨弦:E5(659.25) → A5(880),各 0.16s,总 0.36s。 + 基波 + 2/3 次谐波指数衰减模拟拨弦亮度,避免纯正弦的"提示音"廉价感。""" + def pluck(freq, dur, sr): + t = np.arange(int(sr * dur)) / sr + env = np.exp(-t * 18) + y = (np.sin(2 * np.pi * freq * t) + + 0.45 * np.sin(2 * np.pi * freq * 2 * t) + + 0.18 * np.sin(2 * np.pi * freq * 3 * t)) * env + return y + n1 = pluck(659.25, 0.16, sr) + n2 = pluck(880.00, 0.20, sr) + y = np.concatenate([n1, n2]).astype(np.float32) + sf.write(path, y, sr) + print(f"[synth] turn_player {len(y) / sr:.2f}s") + + +def head_trim(y, sr, thresh_db=-45): + """去头静音(5ms 包络,阈值 -45dB,同 R4/R6 工艺)。""" + mono = np.abs(y.mean(axis=1)) + win = int(sr * 0.005) + env = np.convolve(mono, np.ones(win) / win, mode="same") + start = int(np.argmax(env > 10 ** (thresh_db / 20))) + return y[start:] + + +def fix_array(y, sr): + """one-shot 标准:首尾 8ms cos² 淡入淡出 → 峰值归一 -3dBFS → 去直流。""" + y = y.astype(np.float64) + n = int(sr * FADE_MS / 1000) + ramp = np.sin(np.linspace(0, np.pi / 2, n)) ** 2 + y[:n] *= ramp[:, None] if y.ndim > 1 else ramp + y[-n:] *= ramp[::-1][:, None] if y.ndim > 1 else ramp[::-1] + peak = np.max(np.abs(y)) + if peak > 0: + y = y / peak * PEAK_TARGET + y = y - y.mean(axis=0) + return y.astype(np.float32) + + +def encode(wav_path, base): + """编码 ogg+m4a;ogg 解码回读真峰值校验(MDCT 过冲根因见 round6-report §1), + 过冲则降增益重编(首轮附带 18kHz 低通),最多 3 次。返回解码实测峰值 dB。""" + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path, "-ar", "44100", + "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) + cur = wav_path + peak_db = None + for attempt in range(3): + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", cur, "-ar", "44100", + "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) + dec, _sr = sf.read(base + ".ogg", always_2d=True) + peak_db = 20 * np.log10(max(float(np.max(np.abs(dec))), 1e-9)) + if peak_db <= -2.7: + break + gain_db = -3.2 - peak_db + nxt = wav_path + f".tp{attempt}.wav" + af = ("lowpass=f=18000," if attempt == 0 else "") + f"volume={gain_db:.2f}dB" + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", cur, + "-af", af, "-c:a", "pcm_f32le", nxt], check=True) + cur = nxt + return peak_db + + +def main(): + os.makedirs(STAGING, exist_ok=True) + os.makedirs(OUT, exist_ok=True) + + tp_staging = os.path.join(STAGING, "turn_player.wav") + if not os.path.exists(tp_staging): + synth_turn_player(tp_staging) + + for fid in ("turn_player", "victory"): + src = os.path.join(STAGING, fid + ".wav") + if not os.path.exists(src): + print(f"[WARN] 缺 staging {fid}.wav(victory 需先跑 gen_gap_sfx.sh 生成)") + continue + y, sr = sf.read(src, always_2d=True) + y = head_trim(y, sr) + if fid in TRIM: + y = y[:int(sr * TRIM[fid])] + y = fix_array(y, sr) + fixed = os.path.join(STAGING, fid + ".fixed.wav") + sf.write(fixed, y, sr) + base = os.path.join(OUT, fid) + peak_db = encode(fixed, base) + dur = len(y) / sr + mono = y.mean(axis=1) + print(f"[ok] {fid} dur={dur:.2f}s true_peak={peak_db:.2f}dBFS " + f"dc={float(np.mean(mono)):.5f} -> {base}.{{ogg,m4a}}") + assert dur <= 0.4 if fid == "turn_player" else dur <= 1.2, f"{fid} 超长 {dur:.2f}s" + assert peak_db <= -2.7, f"{fid} 真峰值超标 {peak_db:.2f}dB" + + +if __name__ == "__main__": + main() diff --git a/web/rpg/gen/gen_gap_sfx.sh b/web/rpg/gen/gen_gap_sfx.sh new file mode 100644 index 00000000..61a354b7 --- /dev/null +++ b/web/rpg/gen/gen_gap_sfx.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# R6 缺口 sfx:victory(Stable Audio 3,英文 prompt 照录 docs/design/11 §5)。 +# 用法: bash web/rpg/gen/gen_gap_sfx.sh (内部 flock 串行 GPU;输出 staging) +set -u +source /home/ubuntu/SoundGame/env.d/sfx.sh +cd /home/ubuntu/SoundGame/tools/stable-audio-3 +OUT=/home/ubuntu/SoundGame/web/rpg/gen/staging/sfx +mkdir -p "$OUT" + +flock /home/ubuntu/SoundGame/.gpu.lock bash -c ' +if [ -s "'"$OUT"'/victory.wav" ]; then echo "skip victory"; else + uv run stable-audio --model small-sfx \ + -p "triumphant short brass fanfare, 3 rising notes, wuxia style, under 1.5 seconds" \ + --duration 2 -o "'"$OUT"'/victory.wav" 2>&1 | grep -i "saved\|error" +fi' +echo "done: $(ls "$OUT")" diff --git a/web/rpg/gen/gen_gap_voice.py b/web/rpg/gen/gen_gap_voice.py new file mode 100644 index 00000000..650b4aa7 --- /dev/null +++ b/web/rpg/gen/gen_gap_voice.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""R6 缺口语音生成(docs/design/11-rpg-battle-rebalance.md §6):tut_b0 / tut_b6 / +btl_flee_fail / boss1_warn 四句,双性别(male=Uncle_Fu / female=Serena), +instruct 沿用 tools/gen_voice_dual.py 的 rpg 旁白常量。 + +用法(GPU 串行): + source env.d/tts.sh + flock .gpu.lock .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py # 生成 staging wav + .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py --convert # 转 ogg+m4a 落盘 + .venv-tts/bin/python web/rpg/gen/gen_gap_voice.py --check # 两性时长离群检查 + +staging: web/rpg/gen/staging/voice//.wav(断点续跑,删 wav 即重录该句) +落盘: web/rpg/audio/voice//.{ogg,m4a} +质量控制同主管线:时长 >0.3s、峰值 >0.05、峰值归一 0.89、>40 字拆句拼接(句间 0.12s)。 +""" +import argparse +import os +import subprocess +import sys + +os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf") +os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com") + +ROOT = "/home/ubuntu/SoundGame" +sys.path.insert(0, os.path.join(ROOT, "tools")) +from gen_voice_dual import split_text, valid_wav, PEAK_NORM, GAP_SEC # noqa: E402 只读复用主管线 + +GAME = "rpg" +STAGING = os.path.join(ROOT, "web", "rpg", "gen", "staging", "voice") +OUT = os.path.join(ROOT, "web", GAME, "audio", "voice") +FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") + +SPEAKERS = {"male": "Uncle_Fu", "female": "Serena"} +INSTRUCT = { + "male": "低沉醇厚的男声,像说书人,平静有韵味", + "female": "平静清晰的年轻女声,像在讲武侠故事", +} + +# 台词全文照录 11 号文档 §6 +LINES = [ + ("tut_b0", "战斗里,左右滑选招式,单击出手。听到这个声音,就是你的回合。先滑一滑,试试。"), + ("tut_b6", "来不及格挡,就选防御。左右滑,选防御,单击。"), + ("btl_flee_fail", "没甩掉!它追上来了。"), + ("boss1_warn", "里面就是黑风寨主。你现在回去练练再来,也来得及。"), +] + + +def staging_path(gender, fid): + return os.path.join(STAGING, gender, fid + ".wav") + + +def generate(): + import numpy as np + import soundfile as sf + import torch + from qwen_tts import Qwen3TTSModel + + model = Qwen3TTSModel.from_pretrained( + "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", + device_map="cuda:0", + dtype=torch.bfloat16, + ) + print("model loaded", flush=True) + + for gender in ("male", "female"): + speaker = SPEAKERS[gender] + instruct = INSTRUCT[gender] + os.makedirs(os.path.join(STAGING, gender), exist_ok=True) + + # 展开成 (fid, chunk_idx, text),跳过已完成 + items = [] + for fid, text in LINES: + if valid_wav(staging_path(gender, fid)): + continue + for ci, ctext in enumerate(split_text(text)): + items.append((fid, ci, ctext)) + if not items: + print(f"[{gender}] nothing to do", flush=True) + continue + + results = {} + for i in range(0, len(items), 6): + batch = items[i:i + 6] + wavs, sr = model.generate_custom_voice( + text=[b[2] for b in batch], + language=["Chinese"] * len(batch), + speaker=[speaker] * len(batch), + instruct=[instruct] * len(batch), + max_new_tokens=2048, + ) + for (fid, ci, _t), wav in zip(batch, wavs): + results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32) + print(f"[{gender}] batch {i // 6 + 1} (n={len(batch)})", flush=True) + + gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32) + for fid, text in LINES: + path = staging_path(gender, fid) + if valid_wav(path): + continue + got = results.get(fid, {}) + n_chunks = len(split_text(text)) + assert len(got) == n_chunks, f"{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}" + wav = got[0] + for ci in range(1, n_chunks): + wav = np.concatenate([wav, gap, got[ci]]) + peak = float(np.max(np.abs(wav))) + assert peak > 0.05, f"{gender}/{fid}: peak {peak:.3f}" + wav = wav / peak * PEAK_NORM + dur = len(wav) / sr + assert dur > 0.3, f"{gender}/{fid}: dur {dur:.2f}s" + sf.write(path, wav, sr) + print(f"[ok] {gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True) + + +def check(): + import numpy as np + import soundfile as sf + + problems = [] + for fid, _text in LINES: + durs = {} + for gender in ("male", "female"): + path = staging_path(gender, fid) + if not os.path.exists(path): + durs[gender] = None + continue + data, sr = sf.read(path) + peak = float(np.max(np.abs(data))) + durs[gender] = len(data) / sr + if durs[gender] <= 0.3 or peak <= 0.05: + problems.append(f"{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}") + m, f = durs["male"], durs["female"] + if m is None or f is None: + problems.append(f"{fid}: 缺 {'male' if m is None else 'female'}") + continue + ratio = max(m, f) / min(m, f) + flag = " <-- OUTLIER" if ratio > 3 else "" + if ratio > 3: + problems.append(f"{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}") + print(f" {fid:16s} m={m:6.2f}s f={f:6.2f}s{flag}") + if problems: + print("PROBLEMS:") + for p in problems: + print(" ", p) + return 1 + print("ALL OK") + return 0 + + +def convert(): + total = 0 + for gender in ("male", "female"): + dst_dir = os.path.join(OUT, gender) + os.makedirs(dst_dir, exist_ok=True) + for fid, _text in LINES: + src = staging_path(gender, fid) + if not os.path.exists(src): + print(f"[convert] WARN missing {gender}/{fid}.wav") + continue + base = os.path.join(dst_dir, fid) + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, + "-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True) + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", src, + "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) + total += 1 + print(f"converted {total} files (ogg+m4a each)") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true") + ap.add_argument("--convert", action="store_true") + args = ap.parse_args() + if args.check: + sys.exit(check()) + if args.convert: + convert() + return + generate() + + +if __name__ == "__main__": + main() diff --git a/web/rpg/gen/staging/sfx/turn_player.fixed.wav b/web/rpg/gen/staging/sfx/turn_player.fixed.wav new file mode 100644 index 00000000..bb6f234e Binary files /dev/null and b/web/rpg/gen/staging/sfx/turn_player.fixed.wav differ diff --git a/web/rpg/gen/staging/sfx/turn_player.fixed.wav.tp0.wav b/web/rpg/gen/staging/sfx/turn_player.fixed.wav.tp0.wav new file mode 100644 index 00000000..a9e4d04f Binary files /dev/null and b/web/rpg/gen/staging/sfx/turn_player.fixed.wav.tp0.wav differ diff --git a/web/rpg/gen/staging/sfx/turn_player.wav b/web/rpg/gen/staging/sfx/turn_player.wav new file mode 100644 index 00000000..05e05a35 Binary files /dev/null and b/web/rpg/gen/staging/sfx/turn_player.wav differ diff --git a/web/rpg/gen/staging/sfx/victory.fixed.wav b/web/rpg/gen/staging/sfx/victory.fixed.wav new file mode 100644 index 00000000..f07f90e5 Binary files /dev/null and b/web/rpg/gen/staging/sfx/victory.fixed.wav differ diff --git a/web/rpg/gen/staging/sfx/victory.wav b/web/rpg/gen/staging/sfx/victory.wav new file mode 100644 index 00000000..8d1739e2 Binary files /dev/null and b/web/rpg/gen/staging/sfx/victory.wav differ diff --git a/web/rpg/gen/staging/voice/female/boss1_warn.wav b/web/rpg/gen/staging/voice/female/boss1_warn.wav new file mode 100644 index 00000000..d5828b2f Binary files /dev/null and b/web/rpg/gen/staging/voice/female/boss1_warn.wav differ diff --git a/web/rpg/gen/staging/voice/female/btl_flee_fail.wav b/web/rpg/gen/staging/voice/female/btl_flee_fail.wav new file mode 100644 index 00000000..eff4398e Binary files /dev/null and b/web/rpg/gen/staging/voice/female/btl_flee_fail.wav differ diff --git a/web/rpg/gen/staging/voice/female/tut_b0.wav b/web/rpg/gen/staging/voice/female/tut_b0.wav new file mode 100644 index 00000000..a7759a7f Binary files /dev/null and b/web/rpg/gen/staging/voice/female/tut_b0.wav differ diff --git a/web/rpg/gen/staging/voice/female/tut_b6.wav b/web/rpg/gen/staging/voice/female/tut_b6.wav new file mode 100644 index 00000000..3c176fa4 Binary files /dev/null and b/web/rpg/gen/staging/voice/female/tut_b6.wav differ diff --git a/web/rpg/gen/staging/voice/male/boss1_warn.wav b/web/rpg/gen/staging/voice/male/boss1_warn.wav new file mode 100644 index 00000000..dc896af4 Binary files /dev/null and b/web/rpg/gen/staging/voice/male/boss1_warn.wav differ diff --git a/web/rpg/gen/staging/voice/male/btl_flee_fail.wav b/web/rpg/gen/staging/voice/male/btl_flee_fail.wav new file mode 100644 index 00000000..2c394bad Binary files /dev/null and b/web/rpg/gen/staging/voice/male/btl_flee_fail.wav differ diff --git a/web/rpg/gen/staging/voice/male/tut_b0.wav b/web/rpg/gen/staging/voice/male/tut_b0.wav new file mode 100644 index 00000000..7051802d Binary files /dev/null and b/web/rpg/gen/staging/voice/male/tut_b0.wav differ diff --git a/web/rpg/gen/staging/voice/male/tut_b6.wav b/web/rpg/gen/staging/voice/male/tut_b6.wav new file mode 100644 index 00000000..e7f93585 Binary files /dev/null and b/web/rpg/gen/staging/voice/male/tut_b6.wav differ diff --git a/web/rpg/test/playtest.mjs b/web/rpg/test/playtest.mjs new file mode 100644 index 00000000..1659586a --- /dev/null +++ b/web/rpg/test/playtest.mjs @@ -0,0 +1,904 @@ +/** + * 《剑与声》无头试玩测试(docs/design/13-playtest-criteria.md §6 + §7 全局断言)。 + * + * 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟) + * 与 WebAudioEngine 接口(播放日志 + voice onComplete 按虚拟时钟推进语音链, + * 资产缺失 = 不触发 onComplete,对齐引擎静默跳过语义),加载真实 game.js, + * 手势直接注入 game.onGesture(引擎回调路径)。 + * + * 运行:tools/node/bin/node web/rpg/test/playtest.mjs + * 退出码 0 = 全部断言通过。随机性由种子 PRNG(可强制排队值)保证可重复。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const RPG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// ================================ 虚拟时钟 ================================ + +let vnow = 0; // 虚拟毫秒 +const timers = new Map(); +let nextTimerId = 1; + +function schedule(ms, fn) { + const id = nextTimerId++; + timers.set(id, { at: vnow + ms, fn }); + return id; +} +function unschedule(id) { timers.delete(id); } + +async function flushMicro() { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +/** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */ +async function tick(ms) { + const target = vnow + ms; + for (let guard = 0; guard < 100000; guard++) { + await flushMicro(); + let best = null; + for (const [id, t] of timers) { + if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t }; + } + if (!best) break; + timers.delete(best.id); + vnow = best.t.at; + best.t.fn(); + } + vnow = target; + await flushMicro(); +} + +// ================================ 浏览器环境 mock ================================ + +const storageMap = new Map(); +const localStorageMock = { + getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null), + setItem: (k, v) => { storageMap.set(k, String(v)); }, + removeItem: (k) => { storageMap.delete(k); }, + clear: () => storageMap.clear(), +}; + +const vibrateLog = []; +const fetchLog = []; +const consoleErrors = []; +const consoleLogs = []; + +Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true }); +// 注意 setTimeout 参数序是 (fn, ms),内部 schedule 是 (ms, fn) —— 必须包一层 +globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn); +globalThis.clearTimeout = unschedule; +globalThis.localStorage = localStorageMock; +globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); }; +Object.defineProperty(globalThis, 'navigator', { + value: { vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, + configurable: true, +}); +globalThis.window = { + addEventListener: () => {}, + location: { href: '' }, +}; +globalThis.document = { + getElementById: () => null, + createElement: () => ({ style: { setProperty: () => {} }, className: '', offsetWidth: 0 }), + body: { appendChild: () => {} }, +}; + +const origConsoleError = console.error.bind(console); +console.error = (...a) => { consoleErrors.push(a.join(' ')); origConsoleError(...a); }; +const origConsoleLog = console.log.bind(console); +console.log = (...a) => { consoleLogs.push(a.join(' ')); origConsoleLog(...a); }; + +// 种子 PRNG(mulberry32)+ 强制值队列(遭遇/逃跑判定用) +let randState = 42; +const randQueue = []; +Math.random = () => { + if (randQueue.length) return randQueue.shift(); + randState |= 0; randState = (randState + 0x6D2B79F5) | 0; + let t = Math.imul(randState ^ (randState >>> 15), 1 | randState); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +// ================================ 资产存在性 ================================ + +function hasPair(base) { + return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a'); +} + +/** voice:双性别目录齐全,或平铺(NPC 单音色)齐全;sfx/bgm:双格式。 */ +function assetExists(id) { + const audioDir = path.join(RPG_DIR, 'audio'); + if (id.startsWith('voice/')) { + const name = id.slice(6); + if (hasPair(path.join(audioDir, 'voice', 'female', name)) + && hasPair(path.join(audioDir, 'voice', 'male', name))) return true; + return hasPair(path.join(audioDir, 'voice', name)); // 平铺回退(npc_*) + } + return hasPair(path.join(audioDir, id)); +} + +// ================================ 音频引擎 mock ================================ + +class MockParam { + constructor() { this.value = 0; } + setValueAtTime() {} + linearRampToValueAtTime() {} + cancelScheduledValues() {} +} + +class MockAudioContext { + get currentTime() { return vnow / 1000; } + createBufferSource() { + return { buffer: null, playbackRate: { value: 1 }, loop: false, onended: null, + connect() {}, start() {}, stop() {} }; + } + createGain() { return { gain: new MockParam(), connect() {} }; } +} + +class MockAudioEngine { + constructor() { + this.sfxVolume = 0.8; this.voiceVolume = 1; this.bgmVolume = 1; + this.log = []; // {t, layer, id, opts, missing} + this._voice = null; // 当前 voice token(game 侧 _voicePlaying 判定依据) + this.bgmNow = null; + this.skipCount = 0; + this.blocked = new Set(); // 被屏蔽的 voice id(看门狗测试:onComplete 永不触发) + this.ctx = new MockAudioContext(); + this._sfxBus = { kind: 'sfxBus' }; + } + _dur(id) { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进 + play(id, opts = {}) { + const layer = id.split('/')[0]; + const missing = !assetExists(id); + this.log.push({ t: vnow, layer, id, opts, missing }); + if (missing) { console.error(`[mock-audio] 资产缺失: ${id}`); return; } // 引擎静默跳过语义 + if (layer === 'voice') { + this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义) + const token = { id, onComplete: opts.onComplete }; + this._voice = token; + if (!this.blocked.has(id)) { + schedule(this._dur(id) * 1000, () => { + if (this._voice === token) { + this._voice = null; + if (token.onComplete) token.onComplete(); + } + }); + } + } else if (layer === 'bgm') { + this.bgmNow = id; + } + // sfx:即发即弃,无后续 + } + skipVoice() { + this.skipCount++; + const cur = this._voice; + if (!cur) return; + this._voice = null; + if (cur.onComplete) cur.onComplete(); // 视为自然播完:链播下一句 + } + stopVoice() { this._voice = null; } + stopBgm(fadeOutMs = 800) { + this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: { fadeOutMs } }); + this.bgmNow = null; + } + stopAll() { this.stopBgm(200); this.stopVoice(); } + prewarm() {} + async _load(id) { + const missing = !assetExists(id); + this.log.push({ t: vnow, layer: 'load', id, missing }); + if (missing) { console.error(`[mock-audio] 资产缺失: ${id}`); return null; } + return { duration: this._dur(id) }; + } +} + +// ================================ 断言设施 ================================ + +let passCount = 0; +const failures = []; +function assert(cond, msg) { + if (cond) { passCount++; } + else { failures.push(msg); origConsoleError(` ✗ ${msg}`); } +} +function ok(msg) { passCount++; origConsoleLog(` ✓ ${msg}`); } + +/** 播放日志查询(可传起始偏移只查最近段落)。 */ +function plays(audio, from = 0) { + const L = audio.log.slice(from); + return { + has: (id) => L.some((e) => e.id === id && !e.missing), + count: (id) => L.filter((e) => e.id === id && !e.missing).length, + orderOk: (...ids) => { + let pos = -1; + for (const id of ids) { + const i = L.findIndex((e, j) => j > pos && e.id === id && !e.missing); + if (i < 0) return false; + pos = i; + } + return true; + }, + lastBgm: (id) => L.filter((e) => e.id === id && !e.missing).at(-1), + }; +} + +/** 最近一次敌方攻击 dmg(console.log '[RpgGame] 敌方攻击 dmg=N')。 */ +function lastEnemyDmg() { + for (let i = consoleLogs.length - 1; i >= 0; i--) { + const m = consoleLogs[i].match(/敌方攻击 dmg=(\d+)/); + if (m) return Number(m[1]); + } + return null; +} + +const ERROR_PATTERN = JSON.stringify([60, 40, 60]); +function vibratedError(fromIdx) { + return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === ERROR_PATTERN); +} + +// ================================ 游戏装配 ================================ + +const { RpgGame } = await import('../game.js'); + +const audioRegistry = []; +function newGame() { + const audio = new MockAudioEngine(); + audioRegistry.push(audio); + const game = new RpgGame(audio, {}); + return { game, audio }; +} +const g = (game, gesture) => game.onGesture(gesture); + +/** 战斗辅助:把指令切到目标(只允许手势注入)。 */ +function selectCmd(game, cmd) { + const CMDS = ['attack', 'skill', 'defend', 'item']; + const b = game._battle; + const target = CMDS.indexOf(cmd); + const rightSteps = (target - b.cmdIdx + CMDS.length) % CMDS.length; + const leftSteps = (b.cmdIdx - target + CMDS.length) % CMDS.length; + const [dir, n] = rightSteps <= leftSteps ? ['right', rightSteps] : ['left', leftSteps]; + for (let i = 0; i < n; i++) g(game, dir); +} + +/** 语音链快进(等链播完/看门狗兜底)。 */ +async function battlePump(game, audio, ms = 30000) { await tick(ms); } + +/** 前摇钩子:记录每次 _enemyTelegraph 开窗时长(ms)。 */ +function hookTelegraph(game) { + const windows = []; + const orig = game._enemyTelegraph.bind(game); + game._enemyTelegraph = () => { orig(); windows.push(game._battle.windowEnd - vnow); }; + return windows; +} + +/** 战斗辅助:标准打法——选中攻击单击;前摇内立即双击格挡;低血量喝药。 */ +async function fightSmart(game, audio, { maxRounds = 60, potionBelow = 0 } = {}) { + let gestures = 0; + while (game.phase === 'battle' && game._battle && gestures < maxRounds * 5) { + const b = game._battle; + if (b.state === 'PLAYER_SELECT') { + const lowHp = potionBelow > 0 && game.player.hp <= potionBelow && game.player.potions > 0; + selectCmd(game, lowHp ? 'item' : 'attack'); + gestures += 4; + await tick(800); + g(game, 'tap'); // 战斗中单击=执行指令(语音不可跳,12 文档裁定) + gestures++; + await tick(200); + } else if (b.state === 'ENEMY_TELEGRAPH') { + g(game, 'doubletap'); // 即时双击格挡(窗口内) + gestures++; + await tick(200); + } else { + await tick(500); + } + } + return gestures; +} + +// ================================ 场景 A:第一章通关关键路径 ================================ + +async function scenarioA() { + origConsoleLog('\n=== 场景 A:第一章通关关键路径 ==='); + localStorageMock.clear(); + storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); // 验证 PUT 同步 + const { game, audio } = newGame(); + let mark = 0; + + // ---- 1. 启动 → 序章 → village ---- + game.start(); + assert(game.phase === 'intro', 'A1: 启动进序章 intro'); + await tick(60000); // 序章 5 句 + 进村 3 句 + assert(game.phase === 'explore' && game.scene === 'village', 'A1: 序章后进 village 探索态'); + let p = plays(audio, mark); + assert(p.orderOk('voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/skip_hint', 'voice/sys_new_game'), + 'A1: 序章语音链 intro_1-3 + skip_hint 插播 + sys_new_game 有序'); + assert(p.orderOk('voice/npc_headman_1', 'voice/npc_headman_2', 'voice/desc_village'), + 'A1: 进村村长对白 + desc_village 有序'); + assert(p.has('voice/opt_go_path'), 'A1: 进村播报首选项 opt_go_path'); + assert(p.has('bgm/village'), 'A1: village 状态音乐 bgm/village'); + assert(audio.log.some((e) => e.layer === 'load' && e.id === 'sfx/amb_village' && !e.missing), + 'A1: village 环境音 amb_village 加载(sfx 层)'); + + // ---- 2. 选项切换 / 双击重听 / 状态链 ---- + mark = audio.log.length; + g(game, 'right'); await tick(1200); + assert(game.optIdx === 1 && plays(audio, mark).has('voice/opt_shop'), 'A2: 右滑切选项播 opt_shop'); + g(game, 'left'); await tick(1200); + assert(game.optIdx === 0 && plays(audio, mark).has('voice/opt_go_path'), 'A2: 左滑切回 go_path'); + g(game, 'doubletap'); await tick(3000); + assert(plays(audio, mark).orderOk('voice/desc_village_re', 'voice/opt_go_path'), 'A2: 双击=重听场景描述+当前选项'); + mark = audio.log.length; + g(game, 'up'); await tick(8000); + assert(plays(audio, mark).orderOk('voice/st_lv_low', 'voice/st_hp_high', 'voice/st_gold_some', + 'voice/st_weapon_none', 'voice/st_armor_none'), 'A2: 上滑状态链 st_lv→st_hp→st_gold→st_weapon→st_armor 完整有序'); + + // ---- 3. 菜单(RPG 豁免:左右滑+单击确认)---- + mark = audio.log.length; + g(game, 'down'); await tick(2500); + assert(game.phase === 'menu', 'A3: 探索态下滑=菜单'); + assert(plays(audio, mark).has('bgm/menu'), 'A3: 菜单状态音乐 bgm/menu(amb 不停)'); + assert(plays(audio, mark).orderOk('voice/sys_menu', 'voice/sys_menu_resume'), 'A3: 菜单语音 + 首项 resume'); + g(game, 'right'); await tick(1200); + assert(game._menuIdx === 1 && plays(audio, mark).has('voice/sys_menu_save'), 'A3: 菜单右滑切到保存'); + g(game, 'right'); await tick(1200); + g(game, 'tap'); await tick(4000); // help + assert(plays(audio, mark).orderOk('voice/help_explore', 'voice/help_battle', 'voice/settings_hint'), 'A3: 菜单帮助链完整'); + g(game, 'down'); await tick(1500); + assert(game.phase === 'explore', 'A3: 菜单下滑关闭回探索态'); + assert(plays(audio, mark).lastBgm('bgm/village'), 'A3: 关菜单淡回 bgm/village'); + + // ---- 4. 商店:铁剑 100 金不够 → shop_poor;买金疮药 ---- + g(game, 'right'); await tick(1200); // opt_shop + g(game, 'tap'); await tick(3000); // 进商店(voice 播完再点) + assert(game.phase === 'shop', 'A4: 进商店'); + assert(plays(audio).orderOk('voice/shop_open', 'voice/shop_price_sword'), 'A4: shop_open + 铁剑报价'); + g(game, 'tap'); await tick(1500); // 买铁剑:50 < 100 + assert(plays(audio).has('voice/shop_poor') && game.player.gold === 50 && !game.player.weapon, + 'A4: 金币 50 买铁剑(100) → shop_poor,金币/装备不变'); + g(game, 'right'); await tick(1200); // armor + g(game, 'right'); await tick(1200); // potion + g(game, 'tap'); await tick(1500); // 买金疮药 30 金 + assert(game.player.gold === 20 && game.player.potions === 2, 'A4: 买金疮药成功(50→20 金,药 1→2)'); + g(game, 'right'); await tick(1200); // leave + g(game, 'tap'); await tick(1200); + assert(game.phase === 'explore', 'A4: 离开商店回探索态'); + + // ---- 5. 单击跳过旁白(12 文档:探索态 voice 播放中单击=skipVoice)---- + g(game, 'left'); await tick(1200); // 切到 go_path + const skipsBefore = audio.skipCount; + g(game, 'tap'); // 确认 go_path → _enterScene('path') 同步开播 desc_path + assert(game.scene === 'path' && audio._voice, 'A5: 进 path 后 desc 语音在播'); + g(game, 'tap'); // desc 播放中单击 = 跳过本句(不执行选项) + assert(audio.skipCount === skipsBefore + 1, 'A5: desc 播放中单击触发 skipVoice'); + assert(game.scene === 'path', 'A5: 跳过旁白不触发选项确认(场景不变)'); + await tick(3000); + assert(plays(audio).has('voice/opt_go_fork'), 'A5: 跳过本句后语音链继续推进到选项播报'); + + // ---- 6. path 调查一次性 +15 金 ---- + g(game, 'right'); await tick(1200); // investigate + g(game, 'tap'); await tick(1500); + assert(game.player.gold === 35 && plays(audio).has('voice/evt_gold15'), 'A6: investigate 一次 +15 金'); + g(game, 'tap'); await tick(1500); + assert(game.player.gold === 35 && plays(audio).has('voice/evt_nothing'), 'A6: 再次调查播 evt_nothing 不加金'); + + // ---- 7. fork → chest 开箱 ---- + g(game, 'left'); await tick(1200); // go_fork + g(game, 'tap'); await tick(3000); + assert(game.scene === 'fork', 'A7: 到岔路口'); + g(game, 'tap'); await tick(3000); // idx0 go_chest + assert(game.scene === 'chest', 'A7: 到枯树洞'); + g(game, 'tap'); await tick(1500); // open_chest + assert(game.player.potions === 4 && plays(audio).has('voice/evt_chest'), 'A7: 开箱金疮药 +2'); + + // ---- 8. stream 教学战(四步强制教学,11 文档 §2)---- + g(game, 'right'); await tick(1200); // back + g(game, 'tap'); await tick(3000); // 回 fork + g(game, 'right'); await tick(1200); // go_stream + mark = audio.log.length; + g(game, 'tap'); // 进 stream → desc 后触发教学战 + await tick(2000); + assert(game.phase === 'battle' && game._battle.encId === 'enc_teach_wolf', 'A8: stream 触发教学战'); + const bt = game._battle; + assert(bt.enemy.atk === 2, 'A8: 教学狼 ATK=2(11 文档特例)'); + assert(bt.teachStage === 'menu', 'A8: 教学 T0 stage=menu'); + assert(bt.tier.telSec === 1.8, 'A8: 教学战前摇 1.8s'); + const realDmg = game._dmg; + game._dmg = (a, d) => Math.max(1, a - d); // 测试钩子:伤害确定化(玩家 5、教学狼 2) + const teachWindows = hookTelegraph(game); // 前摇开窗时长记录(在 16s 看门狗兜底前手动推进) + await tick(8000); // 教学链 7 句播完(mock 1s/句)进 PLAYER_SELECT;看门狗 16s 兜底尚未触发 + p = plays(audio, mark); + assert(p.orderOk('voice/btl_start_wolf', 'voice/tut_battle_1', 'voice/tut_battle_2', + 'voice/tut_battle_3', 'voice/tut_battle_4', 'voice/tut_battle_5', 'voice/tut_b0'), + 'A8: 首战教学链 tut_battle_1..5 + tut_b0 有序'); + assert(p.lastBgm('bgm/battle') && p.lastBgm('bgm/battle').opts.fadeMs === 400, + 'A8: 战斗 BGM 400ms 快切 bgm/battle'); + assert(bt.state === 'PLAYER_SELECT', 'A8: 教学旁白后进 PLAYER_SELECT'); + assert(bt.teachStage === 'menu', 'A8: 旁白播完仍在 T0(看门狗未误触发)'); + assert(p.has('sfx/turn_player'), 'A8: 玩家回合播 turn_player(听觉标点)'); + { + const L = audio.log.filter((e) => !e.missing); + const t1 = L.filter((e) => e.id === 'sfx/turn_player').at(-1).t; + const t2 = L.filter((e) => e.id === 'voice/cmd_attack').at(-1).t; + assert(t2 - t1 >= 400, 'A8: turn_player 与指令名之间留间隙不叠放(≥400ms)'); + } + + // T0:左右滑 2 次 → attack 步 + 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)'); + // T1:选中攻击单击命中 → parry 步 + await tick(1200); + const wolfHpBefore = bt.enemy.hp; + g(game, 'tap'); + assert(bt.enemy.hp < wolfHpBefore, 'A8: T1 攻击命中一次'); + await tick(1500); // tut_b1(1s)→ 前摇开窗 + assert(bt.teachStage === 'parry' && plays(audio, mark).has('voice/tut_b1'), 'A8: T1 命中 → T2 parry(tut_b1)'); + assert(bt.state === 'ENEMY_TELEGRAPH', 'A8: T2 进入敌方前摇'); + assert(Math.abs(teachWindows[0] - 1800) < 50, `A8: T2 前摇窗口 1.8s(实测 ${teachWindows[0]}ms)`); + assert(plays(audio, mark).has('voice/tut_b2'), 'A8: 前摇开窗播 tut_b2'); + // T2:不格挡硬吃(受击也可通过) + const hpBeforeHit = game.player.hp; + await tick(1900); + 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'); + selectCmd(game, 'defend'); + await tick(1200); + g(game, 'tap'); + await tick(2000); + assert(bt.teachStage === null && plays(audio, mark).has('voice/tut_b5'), 'A8: T3 执行防御 → tut_b5 收尾进自由击杀'); + await tick(4000); // 敌方前摇 1.8s + 结算 + assert(lastEnemyDmg() === 1, `A8: 防御当回合伤害减半(base 2 → ${lastEnemyDmg()},期望 1)`); + assert(bt.state === 'PLAYER_SELECT', 'A8: 防御后回到玩家回合'); + // T4:自由击杀 + selectCmd(game, 'attack'); + await tick(1200); + g(game, 'tap'); + await tick(4000); + game._dmg = realDmg; + assert(game.phase === 'explore' && game.scene === 'stream', 'A8: 教学狼击杀,回 stream 探索态'); + p = plays(audio, mark); + 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(game.stats.battlesWon === 1, 'A8: battlesWon 0→1(难度档位依据)'); + { + const stop = audio.log.filter((e) => e.id === '(stop)').at(-1); + assert(stop && stop.opts.fadeOutMs === 1500, 'A8: 胜利后战斗 BGM 淡出 1500ms'); + const ex = plays(audio).lastBgm('bgm/explore'); + assert(ex && ex.opts.fadeMs === 2000 && ex.opts.volume === 0.35, + 'A8: 胜利→探索过渡:bgm/explore 淡入 2000ms、volume 0.35'); + } + + // ---- 9. hunter 支线 ---- + await tick(1500); // 等选项 voice 播完 + g(game, 'tap'); await tick(1500); // idx0 drink + assert(game.player.hp === game.maxHp && plays(audio).has('voice/sys_drink'), 'A9: 溪水回满血'); + g(game, 'right'); await tick(1200); // go_hunter + g(game, 'tap'); await tick(3000); + assert(game.scene === 'hunter', 'A9: 到猎人小屋'); + mark = audio.log.length; + g(game, 'tap'); await tick(4000); // talk_hunter + assert(game.flags.hunter_quest && game.flags.shop_discount && game.player.potions === 5, + 'A9: hunter_quest + shop_discount + 金疮药+1'); + assert(plays(audio, mark).orderOk('voice/npc_hunter_1', 'voice/npc_hunter_2', 'voice/evt_hunter_gift'), + 'A9: 猎人对白链有序'); + + // ---- 10. deepwood 山贼连战(保护档验证)---- + g(game, 'right'); await tick(1200); // go_deepwood + mark = audio.log.length; + g(game, 'tap'); + await tick(2000); // desc 后触发遭遇 + assert(game.phase === 'battle' && game._battle.encId === 'enc_bandits', 'A10: 密林触发山贼连战'); + assert(game._battle.tier.telSec === 1.5 && game._battle.tier.dmgMul === 0.6, + 'A10: battlesWon=1 → 保护档(前摇 1.5s、伤害 ×0.6)'); + const banditWindows = hookTelegraph(game); + await fightSmart(game, audio); + assert(Math.abs(banditWindows[0] - 1500) < 50, `A10: 保护档前摇窗口实测 1.5s(${banditWindows[0]}ms)`); + assert(game.phase === 'explore' && game.flags.enc_bandits_done, 'A10: 山贼连战胜利,enc_bandits_done'); + assert(game.stats.battlesWon === 2, 'A10: battlesWon 1→2(连战=1 场)'); + assert(plays(audio, mark).has('voice/btl_half_bandit'), 'A10: 山贼半血狠话 btl_half_bandit'); + assert(game.player.lv === 2, `A10: 战后升级 Lv2(实际 Lv${game.player.lv})`); + assert(plays(audio, mark).has('voice/btl_levelup'), 'A10: 升级播报 btl_levelup'); + { + const save = JSON.parse(storageMap.get('soundgame.progress.rpg')); + assert(save.summary.includes('密林深处') && save.summary.includes('Lv2'), + `A10: 存档 summary 含场景名与等级("${save.summary}")`); + assert(fetchLog.some((f) => f.url === '/api/progress' && f.opts.method === 'PUT'), + 'A10: 战斗结算后 PUT /api/progress 同步'); + } + + // ---- 11. gate:Lv2 首次到寨门 → boss1_warn;强攻 elite ---- + await tick(1500); // 等选项 voice 播完 + g(game, 'tap'); // idx0 go_gate + await tick(3500); + assert(game.scene === 'gate', 'A11: 到黑风寨门前'); + assert(plays(audio).orderOk('voice/boss1_warn', 'voice/desc_gate'), 'A11: Lv2 首次到寨门插播 boss1_warn'); + assert(game.flags.boss1_warned === true, 'A11: boss1_warned 置位(只播一次)'); + mark = audio.log.length; + g(game, 'tap'); // idx0 assault → enc_elite + await tick(1000); + assert(game.phase === 'battle' && game._battle.encId === 'enc_elite', 'A11: 强攻触发 elite 战'); + await fightSmart(game, audio, { potionBelow: 12 }); + assert(game.flags.enc_elite_done, 'A11: elite 战胜利'); + assert(plays(audio, mark).orderOk('voice/npc_elite_1', 'voice/npc_elite_half', 'voice/npc_elite_die'), + 'A11: elite 开场/半血/死亡 NPC 台词有序'); + await tick(4000); // 开门 → 聚义厅 → Boss 战 + assert(game.phase === 'battle' && game._battle.encId === 'enc_boss1', + 'A11: elite 胜利后开门进聚义厅 → Boss 战'); + + // ---- 12. Boss 战:恒标准档 + 半血二阶段 ---- + { + const b = game._battle; + assert(b.tier.telSec === 1.0 && b.tier.dmgMul === 1.0, + 'A12: battlesWon=3 时 Boss 仍恒标准档(前摇 1.0s、×1.0,毕业考不放宽)'); + } + const bossWindows = hookTelegraph(game); + await fightSmart(game, audio, { maxRounds: 30, potionBelow: 14 }); + assert(Math.abs(bossWindows[0] - 1000) < 50, `A12: Boss 前摇窗口实测 1.0s(${bossWindows[0]}ms)`); + assert(plays(audio).orderOk('voice/npc_boss1_1', 'voice/npc_boss1_2', 'voice/npc_boss1_die'), + 'A12: Boss 开场/二阶段/死亡台词有序'); + await tick(4000); + assert(game.flags.boss1_done && game.chapters[1] === 'cleared' && game.chapters[2] === 'open', + 'A12: Boss 胜利 → 第一章 cleared、第二章 open'); + assert(plays(audio).has('voice/evt_ch1_end'), 'A12: 章结算 evt_ch1_end'); + assert(plays(audio).lastBgm('bgm/ending'), 'A12: 章结算 bgm/ending'); + await tick(5000); + assert(game.phase === 'explore' && game.scene === 'village', 'A12: 结算后回村'); + ok('场景 A 通关'); + + // ---- 13. 杀进程重进 → 继续旅程落点正确 ---- + const { game: game2, audio: audio2 } = newGame(); + game2.start(); + await tick(1500); + assert(game2.phase === 'ask' && plays(audio2).has('voice/sys_continue_ask'), 'A13: 有存档重进播 sys_continue_ask'); + g(game2, 'tap'); + await tick(3000); + assert(game2.phase === 'explore' && game2.scene === 'village' && game2.player.lv === 3, + 'A13: 单击继续落点 village、Lv3 保留'); + assert(game2.stats.battlesWon === 4, 'A13: battlesWon 存档恢复(teach+bandits+elite+boss=4)'); +} + +// ================================ 场景 B:边界断言 ================================ + +/** 快速走到 stream 教学战(手势注入走真实路径)。 */ +async function walkToTeachBattle(game) { + game.start(); + await tick(60000); // 序章 + 进村 + g(game, 'tap'); // village idx0 go_path + await tick(3000); + g(game, 'tap'); // path idx0 go_fork + await tick(3000); + g(game, 'right'); // fork → go_stream + await tick(1200); + g(game, 'tap'); // stream → 教学战 + await tick(2000); +} + +/** stream → fork → stream 重进(手势路径,触发 50% 野狼遭遇)。 */ +async function reenterStream(game) { + g(game, 'right'); g(game, 'right'); await tick(2400); // stream: drink→go_hunter→back + g(game, 'tap'); await tick(3000); // fork + g(game, 'right'); await tick(1200); // go_stream + randQueue.push(0.1); // 强制遭遇命中 + g(game, 'tap'); await tick(2000); // stream + 遭遇 +} + +/** 快速布置一场可逃跑的野狼战(钩子:tutorial_battle_done + 强制遭遇随机数)。 */ +async function setupWolfBattle(game) { + game.start(); + await tick(60000); + game.flags.tutorial_battle_done = true; // 测试钩子:跳过教学战触发 + g(game, 'tap'); await tick(3000); // village → path + g(game, 'tap'); await tick(3000); // path → fork + g(game, 'right'); await tick(1200); // fork → go_stream + randQueue.push(0.1); // 强制 50% 遭遇命中 + g(game, 'tap'); await tick(2000); // stream → enc_wolf +} + +async function scenarioB1() { + origConsoleLog('\n=== 场景 B1:教学战看门狗兜底 + 锁血(全程不动) ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + await walkToTeachBattle(game); + assert(game.phase === 'battle' && game._battle.teachStage === 'menu', 'B1: 进教学战 T0'); + const bt = game._battle; + let minHp = game.player.hp; + const track = () => { minHp = Math.min(minHp, game.player.hp); }; + + // mock 语音 1s/句:教学链 7 句 → 进战约 +8s 进 PLAYER_SELECT 并 arm 看门狗。 + // 用轮询等待教学步流转(对 ±1s 的链长误差不敏感)。 + const waitStage = async (from, maxMs = 30000) => { + const t0 = vnow; + while (bt.teachStage === from && vnow - t0 < maxMs) { await tick(500); track(); } + }; + await tick(9000); // 链播完、刚进 PLAYER_SELECT 不久 + track(); + assert(bt.teachStage === 'menu', 'B1: 看门狗计时内仍在 T0'); + await waitStage('menu'); // 8s 重复提示 → 16s 兜底推进 + assert(plays(audio).count('voice/tut_b0') >= 2, 'B1: 8s 无操作重复提示一次(tut_b0 重播)'); + assert(bt.teachStage === 'attack', 'B1: 16s 无进展看门狗兜底 menu→attack'); + await waitStage('attack'); + assert(bt.teachStage === 'parry', 'B1: 看门狗兜底 attack→parry'); + await waitStage('parry'); // 前摇计时器自然结算 + assert(bt.teachStage === 'defend', 'B1: 前摇自然结算推进 parry→defend(受击不卡死)'); + await waitStage('defend'); + assert(bt.teachStage === null, 'B1: 看门狗兜底 defend→自由击杀'); + assert(plays(audio).has('voice/tut_b5'), 'B1: 兜底路径也播 tut_b5 收尾'); + assert(minHp >= 1, `B1: 全程不动 HP 锁底 1(最低 ${minHp})`); + // 补刀结束战斗 + await battlePump(game, audio); + for (let i = 0; i < 8 && game.phase === 'battle'; i++) { + selectCmd(game, 'attack'); + await tick(1200); + g(game, 'tap'); + await tick(3000); + } + assert(game.phase === 'explore', 'B1: 看门狗路径下教学战可完成'); +} + +async function scenarioB1b() { + origConsoleLog('\n=== 场景 B1b:教学战乱按不死 ==='); + localStorageMock.clear(); + const { game } = newGame(); + await walkToTeachBattle(game); + assert(game.phase === 'battle', 'B1b: 进教学战'); + const GESTURES = ['tap', 'doubletap', 'left', 'right', 'up', 'down']; + let minHp = game.player.hp; + let n = 0; + while (game.phase === 'battle' && n < 120) { + g(game, GESTURES[Math.floor(Math.random() * GESTURES.length)]); + n++; + minHp = Math.min(minHp, game.player.hp); + await tick(700); + } + assert(minHp >= 1, `B1b: 乱按 ${n} 次 HP 锁底 1(最低 ${minHp})`); + ok(`B1b: 乱按 ${n} 次无异常,phase=${game.phase}`); +} + +async function scenarioB2() { + origConsoleLog('\n=== 场景 B2:逃跑规则(Lv<3 必成 ×10 / Lv≥3 失败可格挡 / Boss 不可逃) ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + await setupWolfBattle(game); + assert(game.phase === 'battle' && game._battle.encId === 'enc_wolf', 'B2: 野狼战布置完成'); + + // Lv<3 必成 ×10(_wolfDone 仅胜利置位,重进 stream 可再遇) + let fleeOk = 0; + for (let i = 0; i < 10; i++) { + if (game.phase !== 'battle') await reenterStream(game); + if (game.phase !== 'battle') break; + await battlePump(game, audio); + g(game, 'down'); // 逃跑 + await tick(2500); + if (game.phase === 'explore') fleeOk++; + } + assert(fleeOk === 10, `B2: Lv<3 普通战逃跑 10/10 必成(实际 ${fleeOk}/10)`); + assert(plays(audio).count('voice/btl_flee') >= 10, 'B2: btl_flee 播报 10 次'); + + // Lv≥3 逃跑失败 → 敌方正常前摇回合(可格挡) + game.player.lv = 3; // 测试钩子 + await reenterStream(game); + assert(game.phase === 'battle' && game._battle.encId === 'enc_wolf', 'B2: Lv3 野狼战布置完成'); + await battlePump(game, audio); + const mark = audio.log.length; + randQueue.push(0.99); // 强制 70% 判定失败 + g(game, 'down'); + await tick(2000); + assert(plays(audio, mark).has('voice/btl_flee_fail'), 'B2: Lv3 逃跑失败播 btl_flee_fail'); + assert(game._battle && game._battle.state === 'ENEMY_TELEGRAPH', 'B2: 逃跑失败 → 敌方正常前摇回合'); + const parriesBefore = game.stats.parries; + g(game, 'doubletap'); // 前摇内双击 = 可格挡(不白打) + await tick(500); + assert(game.stats.parries === parriesBefore + 1, 'B2: 逃跑失败后的前摇可格挡(stats.parries +1)'); + // 再逃:强制成功 + await battlePump(game, audio); + randQueue.push(0.0); + g(game, 'down'); + await tick(2500); + assert(game.phase === 'explore', 'B2: Lv3 逃跑 70% 判定可成功(rand=0)'); + + // Boss 战下滑 = btl_noflee + const { game: g2, audio: a2 } = newGame(); + g2.start(); + await tick(2000); + g2._startBattle('enc_boss1'); // 测试钩子:直接进 Boss 战 + await battlePump(g2, a2); + const vibBefore = vibrateLog.length; + g(g2, 'down'); + await tick(2000); + assert(plays(a2).has('voice/btl_noflee'), 'B2: Boss 战下滑 → btl_noflee'); + assert(vibratedError(vibBefore), 'B2: 不可逃震 error'); + assert(g2.phase === 'battle' && g2._battle.state === 'PLAYER_SELECT', 'B2: btl_noflee 后回玩家回合'); +} + +async function scenarioB3() { + origConsoleLog('\n=== 场景 B3:格挡/防御减伤与窗口外双击 ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + await setupWolfBattle(game); + const b = game._battle; + game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0) + b.tier = { telSec: 1.0, dmgMul: 1.0 }; + game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → base 4 + await battlePump(game, audio); + + // 窗口外双击(PLAYER_SELECT 阶段):被忽略,不影响状态不震动 + const vib0 = vibrateLog.length; + g(game, 'doubletap'); + assert(b.state === 'PLAYER_SELECT' && vibrateLog.length === vib0, 'B3: PLAYER_SELECT 双击被忽略'); + + // 进攻一回合进前摇(_playerExecute → _checkEnemyDead → _enemyTelegraph 同步发生) + selectCmd(game, 'attack'); + await tick(1200); + g(game, 'tap'); + assert(b.state === 'ENEMY_TELEGRAPH', 'B3: 进入敌方前摇'); + assert(Math.abs(b.windowEnd - vnow - 1000) < 50, 'B3: 标准档前摇 1.0s'); + await tick(500); + + // 前摇内做错手势 → error 震动留在窗口 + const vib1 = vibrateLog.length; + g(game, 'tap'); + assert(vibratedError(vib1), 'B3: 前摇内错手势震 error'); + assert(b.state === 'ENEMY_TELEGRAPH' && !b.parry, 'B3: 错手势留在窗口'); + + // 窗口内双击格挡 → 伤害 ×0.3(base 4 → round(1.2)=1) + const hpBefore = game.player.hp; + g(game, 'doubletap'); + await tick(300); + assert(lastEnemyDmg() === 1 && game.player.hp === hpBefore - 1, + `B3: 格挡成功伤害 ×0.3(base 4 → ${lastEnemyDmg()},期望 1)`); + + // 下一回合:防御 → 伤害 ×0.5(base 4 → 2) + await battlePump(game, audio); + selectCmd(game, 'defend'); + await tick(1200); + g(game, 'tap'); + const hp2 = game.player.hp; + await tick(2500); + assert(lastEnemyDmg() === 2 && game.player.hp === hp2 - 2, + `B3: 防御当回合伤害 ×0.5(base 4 → ${lastEnemyDmg()},期望 2)`); + + // 窗口过期后双击 → error 震动不生效(不判格挡) + await battlePump(game, audio); + b.enemy.hp = 10; // 钩子:回满狼血,保证这一刀不死(前两刀已打掉 10→5) + selectCmd(game, 'attack'); + await tick(1200); + g(game, 'tap'); + assert(b.state === 'ENEMY_TELEGRAPH', 'B3: 再次进入前摇'); + b.windowEnd = vnow - 1; // 钩子:窗口已过期(结算计时器尚未触发) + const vib2 = vibrateLog.length; + g(game, 'doubletap'); + assert(!b.parry && vibratedError(vib2), 'B3: 窗口外双击 → error 震动且不判格挡'); + await tick(2500); +} + +async function scenarioB4() { + origConsoleLog('\n=== 场景 B4:战败重生 ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + await setupWolfBattle(game); + game.flags.enc_bandits_done = true; // 钩子:验证重生重置非 Boss 遭遇 + game.player.gold = 77; + game.player.hp = 1; + game._dmg = (a, d) => Math.max(1, a - d); + await battlePump(game, audio); + const mark = audio.log.length; + selectCmd(game, 'attack'); + await tick(1200); + g(game, 'tap'); + await tick(2500); // 受击 dmg≥2 > hp1 → 战败 + await tick(8000); // btl_lose + btl_lose_hint 链 → 重生 + assert(plays(audio, mark).orderOk('voice/btl_lose', 'voice/btl_lose_hint'), 'B4: 战败语音链'); + assert(game.phase === 'explore' && game.scene === 'village', 'B4: 战败回 village 重生'); + assert(game.player.hp === game.maxHp && game.player.gold === 77, 'B4: 重生满血、金币保留'); + assert(game.flags.enc_bandits_done === false, 'B4: 非 Boss 遭遇 flags 重置'); + assert(game.stats.deaths === 1, 'B4: stats.deaths +1'); + const stop = audio.log.filter((e) => e.id === '(stop)').at(-1); + assert(stop && stop.opts.fadeOutMs === 2000, 'B4: 战败战斗 BGM 2s 淡出'); +} + +async function scenarioB5() { + origConsoleLog('\n=== 场景 B5:语音屏蔽看门狗(资产缺失不卡死) ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + game.start(); + await tick(60000); + audio.blocked.add('voice/desc_path'); // 屏蔽资产:onComplete 永不触发 + g(game, 'tap'); // village → path(desc_path 被屏蔽) + await tick(25000); // 看门狗 20s 兜底 + assert(game.phase === 'explore' && game.scene === 'path', 'B5: desc 被屏蔽仍到达 path 探索态'); + assert(plays(audio).has('voice/opt_go_fork'), 'B5: 看门狗兜底后选项播报照常(流程不卡死)'); +} + +async function scenarioB6() { + origConsoleLog('\n=== 场景 B6:状态链跳过 + Lv3 到寨门无 boss1_warn ==='); + localStorageMock.clear(); + const { game, audio } = newGame(); + game.start(); + await tick(60000); + // 状态链逐句跳过 + g(game, 'up'); + const skips0 = audio.skipCount; + g(game, 'tap'); await tick(300); + g(game, 'tap'); await tick(300); + assert(audio.skipCount >= skips0 + 2, 'B6: 状态播报链 st_* 可逐句跳过(skipVoice ×2)'); + await tick(10000); // 链自然走完不卡死 + assert(plays(audio).has('voice/st_armor_none'), 'B6: 跳过后状态链走完(st_armor_none 已播)'); + // Lv3 到寨门不播 boss1_warn + game.player.lv = 3; // 钩子 + game.scene = 'deepwood'; + const mark = audio.log.length; + game._enterScene('gate'); // 钩子:直接进 gate(手势路径已在 A11 验证) + await tick(3000); + assert(!plays(audio, mark).has('voice/boss1_warn'), 'B6: Lv3 到寨门不播 boss1_warn'); +} + +// ================================ 资产引用校验 ================================ + +function assetAudit() { + origConsoleLog('\n=== 资产引用校验 ==='); + // 1) 运行期播放/加载过的全部 id 必须存在 + const missing = new Set(); + for (const audio of audioRegistry) { + for (const e of audio.log) if (e.missing) missing.add(e.id); + } + assert(missing.size === 0, `资产校验: 运行期缺失 ${[...missing].join(',') || '无'}(${audioRegistry.reduce((n, a) => n + a.log.length, 0)} 条播放日志全绿)`); + + // 2) game.js 静态引用的 id 全量展开校验(模板 id 按数据表展开) + const src = fs.readFileSync(path.join(RPG_DIR, 'game.js'), 'utf8'); + const staticIds = new Set(); + for (const m of src.matchAll(/'((?:voice|sfx|bgm)\/[^'`${]+)'/g)) staticIds.add(m[1]); + // 模板 id 展开(与 game.js 数据表一一对应) + const OPTIONS = ['go_path', 'shop', 'rest', 'status', 'go_fork', 'investigate', 'back', + 'go_chest', 'go_stream', 'drink', 'go_hunter', 'talk_hunter', 'go_deepwood', 'go_gate', + 'assault', 'sneak', 'open_chest', 'leave']; + for (const o of OPTIONS) staticIds.add(`voice/opt_${o}`); + for (const s of ['village', 'path', 'fork', 'chest', 'stream', 'hunter', 'deepwood', 'gate']) { + staticIds.add(`voice/desc_${s}`); + staticIds.add(`sfx/amb_${s}`); + // gate/boss1 按设计无 _re 回访句(game.js 回退全句 desc_gate),不列入校验 + if (s !== 'gate') staticIds.add(`voice/desc_${s}_re`); + } + staticIds.add('voice/desc_hall'); + staticIds.add('sfx/amb_hall'); + for (const band of ['high', 'mid', 'low']) { + staticIds.add(`voice/st_hp_${band}`); + staticIds.add(`voice/foe_hp_${band}`); + } + for (const c of ['attack', 'skill', 'defend', 'item']) staticIds.add(`voice/cmd_${c}`); + for (const m of ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help']) staticIds.add(`voice/${m}`); + for (const w of ['none', 'iron', 'steel', 'wind']) staticIds.add(`voice/st_weapon_${w}`); + for (const a of ['none', 'leather', 'iron']) staticIds.add(`voice/st_armor_${a}`); + for (const l of ['low', 'mid', 'high']) staticIds.add(`voice/st_lv_${l}`); + for (const gd of ['poor', 'some', 'rich']) staticIds.add(`voice/st_gold_${gd}`); + const badStatic = [...staticIds].filter((id) => !assetExists(id)); + assert(badStatic.length === 0, `资产校验: 静态引用缺失 ${badStatic.join(',') || '无'}`); + ok(`资产校验: 静态 ${staticIds.size} id + 运行期日志全量双格式(ogg+m4a)齐全`); +} + +// ================================ 主流程 ================================ + +try { + await scenarioA(); + await scenarioB1(); + await scenarioB1b(); + await scenarioB2(); + await scenarioB3(); + await scenarioB4(); + await scenarioB5(); + await scenarioB6(); +} catch (e) { + failures.push(`未捕获异常: ${e.stack}`); +} +assetAudit(); + +origConsoleLog('\n========================================'); +origConsoleLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); +if (failures.length) { + origConsoleLog('失败清单:'); + for (const f of failures) origConsoleLog(` ✗ ${f}`); +} +process.exit(failures.length ? 1 : 0); diff --git a/web/simon/README.md b/web/simon/README.md index 0c5fba8b..1cc31c68 100644 --- a/web/simon/README.md +++ b/web/simon/README.md @@ -18,16 +18,26 @@ ## 流程 -1. 启动遮罩「轻触开始」解锁音频 → BGM 起。 -2. 开场教学:语音依次教 6 个手势音(每句语音播完后示范对应音色)。 +1. 启动遮罩「轻触开始」解锁音频(游玩全程无 BGM;仅结算/菜单挂 `bgm/menu` 轻垫乐,volume 0.3)。 +2. 开场教学(t0 附近插播一次 skip_hint):语音依次教 6 个手势音,教一个练一个; + 教学期下滑=跳过整个教学(正练下滑手势时除外)。 3. 正式对局:报「第 N 轮」→ 播放序列(此阶段手势忽略)→ 「轮到你了」 (仅第 1 轮)→ 玩家复现;每个手势即时回声放对应音色。 -4. 全部复现正确:答对反馈,进入下一轮(序列尾部 +1,速度加快)。 -5. 复现错误:答错音效 → 播报成绩与新纪录 → 单击重开。 +4. 全部复现正确:答对反馈,进入下一轮(序列尾部 +1,速度加快,第 12 轮起 420ms 下限)。 +5. 复现错误:有机会则自动消耗重听(输入阶段双击=主动重听)→ 无机会则 + 播报成绩与新纪录 → 单击重开(重开跳过教学)。 +6. 旁白播放中单击=跳过当前句(skipVoice,链播继续);无语音时单击=各阶段原语义。 + +## 测试 + +`test/playtest.mjs` — Node 无头试玩仿真(mock 浏览器环境 + 虚拟时钟 + 种子随机), +覆盖 docs/design/13-playtest-criteria.md §3 全部关键路径与边界断言: +`tools/node/bin/node web/simon/test/playtest.mjs` ## 目录 - `game.js` — 游戏逻辑(继承引擎 `Game`,语音链用 onComplete 串行) +- `test/playtest.mjs` — 无头试玩测试(见上) - `cover.png` — 1080x1920 封面 - `audio/{bgm,voice,sfx}/` — 全部双格式(.ogg + .m4a,iOS 回退) - `gen/` — 资产生成脚本(SFX 合成、TTS、封面、资产校验),非运行依赖 diff --git a/web/simon/audio/bgm/menu.m4a b/web/simon/audio/bgm/menu.m4a new file mode 100644 index 00000000..b55459d9 Binary files /dev/null and b/web/simon/audio/bgm/menu.m4a differ diff --git a/web/simon/audio/bgm/menu.ogg b/web/simon/audio/bgm/menu.ogg new file mode 100644 index 00000000..c8f9f7a7 Binary files /dev/null and b/web/simon/audio/bgm/menu.ogg differ diff --git a/web/simon/audio/sfx/gameover.m4a b/web/simon/audio/sfx/gameover.m4a index cd3a5121..24aab7f3 100644 Binary files a/web/simon/audio/sfx/gameover.m4a and b/web/simon/audio/sfx/gameover.m4a differ diff --git a/web/simon/audio/sfx/gameover.ogg b/web/simon/audio/sfx/gameover.ogg index b6f125ac..cb5b3586 100644 Binary files a/web/simon/audio/sfx/gameover.ogg and b/web/simon/audio/sfx/gameover.ogg differ diff --git a/web/simon/audio/sfx/tone_doubletap.m4a b/web/simon/audio/sfx/tone_doubletap.m4a index bf33315e..cb80f387 100644 Binary files a/web/simon/audio/sfx/tone_doubletap.m4a and b/web/simon/audio/sfx/tone_doubletap.m4a differ diff --git a/web/simon/audio/sfx/tone_doubletap.ogg b/web/simon/audio/sfx/tone_doubletap.ogg index 55ecb900..e3d78e68 100644 Binary files a/web/simon/audio/sfx/tone_doubletap.ogg and b/web/simon/audio/sfx/tone_doubletap.ogg differ diff --git a/web/simon/audio/sfx/tone_down.m4a b/web/simon/audio/sfx/tone_down.m4a index c4f99c22..41ae6d2d 100644 Binary files a/web/simon/audio/sfx/tone_down.m4a and b/web/simon/audio/sfx/tone_down.m4a differ diff --git a/web/simon/audio/sfx/tone_down.ogg b/web/simon/audio/sfx/tone_down.ogg index 2e3defa9..76e65760 100644 Binary files a/web/simon/audio/sfx/tone_down.ogg and b/web/simon/audio/sfx/tone_down.ogg differ diff --git a/web/simon/audio/sfx/tone_left.m4a b/web/simon/audio/sfx/tone_left.m4a index 67b67ebe..df7e8b5f 100644 Binary files a/web/simon/audio/sfx/tone_left.m4a and b/web/simon/audio/sfx/tone_left.m4a differ diff --git a/web/simon/audio/sfx/tone_left.ogg b/web/simon/audio/sfx/tone_left.ogg index b9c64fe5..d8d03b35 100644 Binary files a/web/simon/audio/sfx/tone_left.ogg and b/web/simon/audio/sfx/tone_left.ogg differ diff --git a/web/simon/audio/sfx/tone_right.m4a b/web/simon/audio/sfx/tone_right.m4a index 90d3b24f..096c16de 100644 Binary files a/web/simon/audio/sfx/tone_right.m4a and b/web/simon/audio/sfx/tone_right.m4a differ diff --git a/web/simon/audio/sfx/tone_right.ogg b/web/simon/audio/sfx/tone_right.ogg index 10989b60..091c9301 100644 Binary files a/web/simon/audio/sfx/tone_right.ogg and b/web/simon/audio/sfx/tone_right.ogg differ diff --git a/web/simon/audio/sfx/tone_tap.m4a b/web/simon/audio/sfx/tone_tap.m4a index f517d42b..8371c0e0 100644 Binary files a/web/simon/audio/sfx/tone_tap.m4a and b/web/simon/audio/sfx/tone_tap.m4a differ diff --git a/web/simon/audio/sfx/tone_tap.ogg b/web/simon/audio/sfx/tone_tap.ogg index c13b4b28..eec006a8 100644 Binary files a/web/simon/audio/sfx/tone_tap.ogg and b/web/simon/audio/sfx/tone_tap.ogg differ diff --git a/web/simon/audio/sfx/tone_up.m4a b/web/simon/audio/sfx/tone_up.m4a index 47eb70c9..e446e673 100644 Binary files a/web/simon/audio/sfx/tone_up.m4a and b/web/simon/audio/sfx/tone_up.m4a differ diff --git a/web/simon/audio/sfx/tone_up.ogg b/web/simon/audio/sfx/tone_up.ogg index bfa52fba..0cb671ee 100644 Binary files a/web/simon/audio/sfx/tone_up.ogg and b/web/simon/audio/sfx/tone_up.ogg differ diff --git a/web/simon/audio/voice/female/chance_plus.ogg b/web/simon/audio/voice/female/chance_plus.ogg index a67494fa..1080246e 100644 Binary files a/web/simon/audio/voice/female/chance_plus.ogg and b/web/simon/audio/voice/female/chance_plus.ogg differ diff --git a/web/simon/audio/voice/female/chances_out.ogg b/web/simon/audio/voice/female/chances_out.ogg index f798fd8c..88ee3d24 100644 Binary files a/web/simon/audio/voice/female/chances_out.ogg and b/web/simon/audio/voice/female/chances_out.ogg differ diff --git a/web/simon/audio/voice/female/correct.ogg b/web/simon/audio/voice/female/correct.ogg index 29bf4eaf..bfc96079 100644 Binary files a/web/simon/audio/voice/female/correct.ogg and b/web/simon/audio/voice/female/correct.ogg differ diff --git a/web/simon/audio/voice/female/gameover.ogg b/web/simon/audio/voice/female/gameover.ogg index f1b48fc6..d64ffc9e 100644 Binary files a/web/simon/audio/voice/female/gameover.ogg and b/web/simon/audio/voice/female/gameover.ogg differ diff --git a/web/simon/audio/voice/female/menu_back.ogg b/web/simon/audio/voice/female/menu_back.ogg index 3db96619..9e7ac47f 100644 Binary files a/web/simon/audio/voice/female/menu_back.ogg and b/web/simon/audio/voice/female/menu_back.ogg differ diff --git a/web/simon/audio/voice/female/menu_home.ogg b/web/simon/audio/voice/female/menu_home.ogg index 3715ca71..992f9383 100644 Binary files a/web/simon/audio/voice/female/menu_home.ogg and b/web/simon/audio/voice/female/menu_home.ogg differ diff --git a/web/simon/audio/voice/female/menu_open.ogg b/web/simon/audio/voice/female/menu_open.ogg index 96495a39..4152646a 100644 Binary files a/web/simon/audio/voice/female/menu_open.ogg and b/web/simon/audio/voice/female/menu_open.ogg differ diff --git a/web/simon/audio/voice/female/menu_restart.ogg b/web/simon/audio/voice/female/menu_restart.ogg index 6f337eea..db7397e6 100644 Binary files a/web/simon/audio/voice/female/menu_restart.ogg and b/web/simon/audio/voice/female/menu_restart.ogg differ diff --git a/web/simon/audio/voice/female/new_record.ogg b/web/simon/audio/voice/female/new_record.ogg index f75f7c6d..4d56f345 100644 Binary files a/web/simon/audio/voice/female/new_record.ogg and b/web/simon/audio/voice/female/new_record.ogg differ diff --git a/web/simon/audio/voice/female/relisten.ogg b/web/simon/audio/voice/female/relisten.ogg index 022737f0..1776be21 100644 Binary files a/web/simon/audio/voice/female/relisten.ogg and b/web/simon/audio/voice/female/relisten.ogg differ diff --git a/web/simon/audio/voice/female/replay.ogg b/web/simon/audio/voice/female/replay.ogg index 83ea891f..1696d5ce 100644 Binary files a/web/simon/audio/voice/female/replay.ogg and b/web/simon/audio/voice/female/replay.ogg differ diff --git a/web/simon/audio/voice/female/retry_use.ogg b/web/simon/audio/voice/female/retry_use.ogg index 3140d7ff..e46ac8c5 100644 Binary files a/web/simon/audio/voice/female/retry_use.ogg and b/web/simon/audio/voice/female/retry_use.ogg differ diff --git a/web/simon/audio/voice/female/round_01.ogg b/web/simon/audio/voice/female/round_01.ogg index d894a176..dcc29bd2 100644 Binary files a/web/simon/audio/voice/female/round_01.ogg and b/web/simon/audio/voice/female/round_01.ogg differ diff --git a/web/simon/audio/voice/female/round_02.ogg b/web/simon/audio/voice/female/round_02.ogg index 4d2706d0..c26c0ccf 100644 Binary files a/web/simon/audio/voice/female/round_02.ogg and b/web/simon/audio/voice/female/round_02.ogg differ diff --git a/web/simon/audio/voice/female/round_03.ogg b/web/simon/audio/voice/female/round_03.ogg index 55337b86..36271908 100644 Binary files a/web/simon/audio/voice/female/round_03.ogg and b/web/simon/audio/voice/female/round_03.ogg differ diff --git a/web/simon/audio/voice/female/round_04.ogg b/web/simon/audio/voice/female/round_04.ogg index 6cd4b491..fb3cea5b 100644 Binary files a/web/simon/audio/voice/female/round_04.ogg and b/web/simon/audio/voice/female/round_04.ogg differ diff --git a/web/simon/audio/voice/female/round_05.ogg b/web/simon/audio/voice/female/round_05.ogg index 4a159fab..ae4aa79d 100644 Binary files a/web/simon/audio/voice/female/round_05.ogg and b/web/simon/audio/voice/female/round_05.ogg differ diff --git a/web/simon/audio/voice/female/round_06.ogg b/web/simon/audio/voice/female/round_06.ogg index 612e0e4b..3a6279a3 100644 Binary files a/web/simon/audio/voice/female/round_06.ogg and b/web/simon/audio/voice/female/round_06.ogg differ diff --git a/web/simon/audio/voice/female/round_07.ogg b/web/simon/audio/voice/female/round_07.ogg index 91095307..baa3c667 100644 Binary files a/web/simon/audio/voice/female/round_07.ogg and b/web/simon/audio/voice/female/round_07.ogg differ diff --git a/web/simon/audio/voice/female/round_08.ogg b/web/simon/audio/voice/female/round_08.ogg index 0e17710a..5ad51db8 100644 Binary files a/web/simon/audio/voice/female/round_08.ogg and b/web/simon/audio/voice/female/round_08.ogg differ diff --git a/web/simon/audio/voice/female/round_09.ogg b/web/simon/audio/voice/female/round_09.ogg index db327864..a8dc27da 100644 Binary files a/web/simon/audio/voice/female/round_09.ogg and b/web/simon/audio/voice/female/round_09.ogg differ diff --git a/web/simon/audio/voice/female/round_10.ogg b/web/simon/audio/voice/female/round_10.ogg index 1600ae5f..8602592c 100644 Binary files a/web/simon/audio/voice/female/round_10.ogg and b/web/simon/audio/voice/female/round_10.ogg differ diff --git a/web/simon/audio/voice/female/round_11.ogg b/web/simon/audio/voice/female/round_11.ogg index 2949e8e1..f918199a 100644 Binary files a/web/simon/audio/voice/female/round_11.ogg and b/web/simon/audio/voice/female/round_11.ogg differ diff --git a/web/simon/audio/voice/female/round_12.ogg b/web/simon/audio/voice/female/round_12.ogg index c71b11f1..1547f3d7 100644 Binary files a/web/simon/audio/voice/female/round_12.ogg and b/web/simon/audio/voice/female/round_12.ogg differ diff --git a/web/simon/audio/voice/female/round_13.ogg b/web/simon/audio/voice/female/round_13.ogg index 82be796b..89a0d4a1 100644 Binary files a/web/simon/audio/voice/female/round_13.ogg and b/web/simon/audio/voice/female/round_13.ogg differ diff --git a/web/simon/audio/voice/female/round_14.ogg b/web/simon/audio/voice/female/round_14.ogg index b7bc6458..c0b4e3cb 100644 Binary files a/web/simon/audio/voice/female/round_14.ogg and b/web/simon/audio/voice/female/round_14.ogg differ diff --git a/web/simon/audio/voice/female/round_15.ogg b/web/simon/audio/voice/female/round_15.ogg index 361113c3..5d01ec8b 100644 Binary files a/web/simon/audio/voice/female/round_15.ogg and b/web/simon/audio/voice/female/round_15.ogg differ diff --git a/web/simon/audio/voice/female/round_16.ogg b/web/simon/audio/voice/female/round_16.ogg index 840a91ed..e43a0329 100644 Binary files a/web/simon/audio/voice/female/round_16.ogg and b/web/simon/audio/voice/female/round_16.ogg differ diff --git a/web/simon/audio/voice/female/round_17.ogg b/web/simon/audio/voice/female/round_17.ogg index 84c2e747..627f28ed 100644 Binary files a/web/simon/audio/voice/female/round_17.ogg and b/web/simon/audio/voice/female/round_17.ogg differ diff --git a/web/simon/audio/voice/female/round_18.ogg b/web/simon/audio/voice/female/round_18.ogg index e5c0b414..f3f2dd17 100644 Binary files a/web/simon/audio/voice/female/round_18.ogg and b/web/simon/audio/voice/female/round_18.ogg differ diff --git a/web/simon/audio/voice/female/round_19.ogg b/web/simon/audio/voice/female/round_19.ogg index cff77f26..79412da7 100644 Binary files a/web/simon/audio/voice/female/round_19.ogg and b/web/simon/audio/voice/female/round_19.ogg differ diff --git a/web/simon/audio/voice/female/round_20.ogg b/web/simon/audio/voice/female/round_20.ogg index cef568ca..8bb41064 100644 Binary files a/web/simon/audio/voice/female/round_20.ogg and b/web/simon/audio/voice/female/round_20.ogg differ diff --git a/web/simon/audio/voice/female/round_more.ogg b/web/simon/audio/voice/female/round_more.ogg index 2674c49a..ee078a6f 100644 Binary files a/web/simon/audio/voice/female/round_more.ogg and b/web/simon/audio/voice/female/round_more.ogg differ diff --git a/web/simon/audio/voice/female/rules.ogg b/web/simon/audio/voice/female/rules.ogg index 9c9183a5..8246a015 100644 Binary files a/web/simon/audio/voice/female/rules.ogg and b/web/simon/audio/voice/female/rules.ogg differ diff --git a/web/simon/audio/voice/female/rules_prompt.ogg b/web/simon/audio/voice/female/rules_prompt.ogg index c70f8aaf..0d3dcbbf 100644 Binary files a/web/simon/audio/voice/female/rules_prompt.ogg and b/web/simon/audio/voice/female/rules_prompt.ogg differ diff --git a/web/simon/audio/voice/female/score_band1.ogg b/web/simon/audio/voice/female/score_band1.ogg index 3ff75704..df38ac5f 100644 Binary files a/web/simon/audio/voice/female/score_band1.ogg and b/web/simon/audio/voice/female/score_band1.ogg differ diff --git a/web/simon/audio/voice/female/score_band2.ogg b/web/simon/audio/voice/female/score_band2.ogg index 7d4639ab..b59d274d 100644 Binary files a/web/simon/audio/voice/female/score_band2.ogg and b/web/simon/audio/voice/female/score_band2.ogg differ diff --git a/web/simon/audio/voice/female/score_band3.ogg b/web/simon/audio/voice/female/score_band3.ogg index 91c1d987..d1af20df 100644 Binary files a/web/simon/audio/voice/female/score_band3.ogg and b/web/simon/audio/voice/female/score_band3.ogg differ diff --git a/web/simon/audio/voice/female/score_band4.ogg b/web/simon/audio/voice/female/score_band4.ogg index 9a0d04b0..0a83c08b 100644 Binary files a/web/simon/audio/voice/female/score_band4.ogg and b/web/simon/audio/voice/female/score_band4.ogg differ diff --git a/web/simon/audio/voice/female/score_band5.ogg b/web/simon/audio/voice/female/score_band5.ogg index 07264a60..8d52d208 100644 Binary files a/web/simon/audio/voice/female/score_band5.ogg and b/web/simon/audio/voice/female/score_band5.ogg differ diff --git a/web/simon/audio/voice/female/score_band6.ogg b/web/simon/audio/voice/female/score_band6.ogg index c8c2d7be..4b903167 100644 Binary files a/web/simon/audio/voice/female/score_band6.ogg and b/web/simon/audio/voice/female/score_band6.ogg differ diff --git a/web/simon/audio/voice/female/skip_hint.m4a b/web/simon/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..fa5a837c Binary files /dev/null and b/web/simon/audio/voice/female/skip_hint.m4a differ diff --git a/web/simon/audio/voice/female/skip_hint.ogg b/web/simon/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..a43d3658 Binary files /dev/null and b/web/simon/audio/voice/female/skip_hint.ogg differ diff --git a/web/simon/audio/voice/female/t0_goal.ogg b/web/simon/audio/voice/female/t0_goal.ogg index ea78f8eb..5e4876f4 100644 Binary files a/web/simon/audio/voice/female/t0_goal.ogg and b/web/simon/audio/voice/female/t0_goal.ogg differ diff --git a/web/simon/audio/voice/female/t0_welcome.ogg b/web/simon/audio/voice/female/t0_welcome.ogg index a6937ddd..ecce46fc 100644 Binary files a/web/simon/audio/voice/female/t0_welcome.ogg and b/web/simon/audio/voice/female/t0_welcome.ogg differ diff --git a/web/simon/audio/voice/female/t1_up.ogg b/web/simon/audio/voice/female/t1_up.ogg index ae13b60e..5ae0b352 100644 Binary files a/web/simon/audio/voice/female/t1_up.ogg and b/web/simon/audio/voice/female/t1_up.ogg differ diff --git a/web/simon/audio/voice/female/t2_down.ogg b/web/simon/audio/voice/female/t2_down.ogg index 6402016d..fa2e2a15 100644 Binary files a/web/simon/audio/voice/female/t2_down.ogg and b/web/simon/audio/voice/female/t2_down.ogg differ diff --git a/web/simon/audio/voice/female/t3_left.ogg b/web/simon/audio/voice/female/t3_left.ogg index 0a0fc6b4..28f1a285 100644 Binary files a/web/simon/audio/voice/female/t3_left.ogg and b/web/simon/audio/voice/female/t3_left.ogg differ diff --git a/web/simon/audio/voice/female/t4_right.ogg b/web/simon/audio/voice/female/t4_right.ogg index c3b725db..af9b24dc 100644 Binary files a/web/simon/audio/voice/female/t4_right.ogg and b/web/simon/audio/voice/female/t4_right.ogg differ diff --git a/web/simon/audio/voice/female/t5_tap.ogg b/web/simon/audio/voice/female/t5_tap.ogg index 2b46790c..42eccd42 100644 Binary files a/web/simon/audio/voice/female/t5_tap.ogg and b/web/simon/audio/voice/female/t5_tap.ogg differ diff --git a/web/simon/audio/voice/female/t6_double.ogg b/web/simon/audio/voice/female/t6_double.ogg index 9e54b6f3..c808a0df 100644 Binary files a/web/simon/audio/voice/female/t6_double.ogg and b/web/simon/audio/voice/female/t6_double.ogg differ diff --git a/web/simon/audio/voice/female/t7_ready.ogg b/web/simon/audio/voice/female/t7_ready.ogg index e6f65aff..3a080b9f 100644 Binary files a/web/simon/audio/voice/female/t7_ready.ogg and b/web/simon/audio/voice/female/t7_ready.ogg differ diff --git a/web/simon/audio/voice/female/t7_retry.ogg b/web/simon/audio/voice/female/t7_retry.ogg index 6e3c783f..3b78c941 100644 Binary files a/web/simon/audio/voice/female/t7_retry.ogg and b/web/simon/audio/voice/female/t7_retry.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_double.ogg b/web/simon/audio/voice/female/t_blunt_double.ogg index 3dc8850f..7d2b6d2c 100644 Binary files a/web/simon/audio/voice/female/t_blunt_double.ogg and b/web/simon/audio/voice/female/t_blunt_double.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_down.ogg b/web/simon/audio/voice/female/t_blunt_down.ogg index ad429bf7..7b592852 100644 Binary files a/web/simon/audio/voice/female/t_blunt_down.ogg and b/web/simon/audio/voice/female/t_blunt_down.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_left.ogg b/web/simon/audio/voice/female/t_blunt_left.ogg index 360fdc5c..9a60b472 100644 Binary files a/web/simon/audio/voice/female/t_blunt_left.ogg and b/web/simon/audio/voice/female/t_blunt_left.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_right.ogg b/web/simon/audio/voice/female/t_blunt_right.ogg index 826e6f30..e185b228 100644 Binary files a/web/simon/audio/voice/female/t_blunt_right.ogg and b/web/simon/audio/voice/female/t_blunt_right.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_tap.ogg b/web/simon/audio/voice/female/t_blunt_tap.ogg index daa69d74..13f2f237 100644 Binary files a/web/simon/audio/voice/female/t_blunt_tap.ogg and b/web/simon/audio/voice/female/t_blunt_tap.ogg differ diff --git a/web/simon/audio/voice/female/t_blunt_up.ogg b/web/simon/audio/voice/female/t_blunt_up.ogg index daac710f..98ab5965 100644 Binary files a/web/simon/audio/voice/female/t_blunt_up.ogg and b/web/simon/audio/voice/female/t_blunt_up.ogg differ diff --git a/web/simon/audio/voice/female/t_ok.ogg b/web/simon/audio/voice/female/t_ok.ogg index 9eeb1add..927f5c21 100644 Binary files a/web/simon/audio/voice/female/t_ok.ogg and b/web/simon/audio/voice/female/t_ok.ogg differ diff --git a/web/simon/audio/voice/female/t_skip.ogg b/web/simon/audio/voice/female/t_skip.ogg index 585799ac..80bc1d13 100644 Binary files a/web/simon/audio/voice/female/t_skip.ogg and b/web/simon/audio/voice/female/t_skip.ogg differ diff --git a/web/simon/audio/voice/female/t_try.ogg b/web/simon/audio/voice/female/t_try.ogg index f585872c..01ad1fe0 100644 Binary files a/web/simon/audio/voice/female/t_try.ogg and b/web/simon/audio/voice/female/t_try.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_double.ogg b/web/simon/audio/voice/female/t_wrong_double.ogg index 27c58021..d5c0cb1f 100644 Binary files a/web/simon/audio/voice/female/t_wrong_double.ogg and b/web/simon/audio/voice/female/t_wrong_double.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_down.ogg b/web/simon/audio/voice/female/t_wrong_down.ogg index e0863932..208c0380 100644 Binary files a/web/simon/audio/voice/female/t_wrong_down.ogg and b/web/simon/audio/voice/female/t_wrong_down.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_left.ogg b/web/simon/audio/voice/female/t_wrong_left.ogg index e7348609..fd50571a 100644 Binary files a/web/simon/audio/voice/female/t_wrong_left.ogg and b/web/simon/audio/voice/female/t_wrong_left.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_right.ogg b/web/simon/audio/voice/female/t_wrong_right.ogg index 85393866..59bb5667 100644 Binary files a/web/simon/audio/voice/female/t_wrong_right.ogg and b/web/simon/audio/voice/female/t_wrong_right.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_tap.ogg b/web/simon/audio/voice/female/t_wrong_tap.ogg index 3e4228f4..a16047c0 100644 Binary files a/web/simon/audio/voice/female/t_wrong_tap.ogg and b/web/simon/audio/voice/female/t_wrong_tap.ogg differ diff --git a/web/simon/audio/voice/female/t_wrong_up.ogg b/web/simon/audio/voice/female/t_wrong_up.ogg index 4cf6c441..ed0fb739 100644 Binary files a/web/simon/audio/voice/female/t_wrong_up.ogg and b/web/simon/audio/voice/female/t_wrong_up.ogg differ diff --git a/web/simon/audio/voice/female/wrong.ogg b/web/simon/audio/voice/female/wrong.ogg index ec809c82..0991be65 100644 Binary files a/web/simon/audio/voice/female/wrong.ogg and b/web/simon/audio/voice/female/wrong.ogg differ diff --git a/web/simon/audio/voice/female/your_turn.ogg b/web/simon/audio/voice/female/your_turn.ogg index 696aacce..7b92bfad 100644 Binary files a/web/simon/audio/voice/female/your_turn.ogg and b/web/simon/audio/voice/female/your_turn.ogg differ diff --git a/web/simon/audio/voice/male/chance_plus.ogg b/web/simon/audio/voice/male/chance_plus.ogg index 0408e5a8..72ce4424 100644 Binary files a/web/simon/audio/voice/male/chance_plus.ogg and b/web/simon/audio/voice/male/chance_plus.ogg differ diff --git a/web/simon/audio/voice/male/chances_out.ogg b/web/simon/audio/voice/male/chances_out.ogg index 6f4f9523..a464cf76 100644 Binary files a/web/simon/audio/voice/male/chances_out.ogg and b/web/simon/audio/voice/male/chances_out.ogg differ diff --git a/web/simon/audio/voice/male/correct.ogg b/web/simon/audio/voice/male/correct.ogg index 5513f773..51a1db86 100644 Binary files a/web/simon/audio/voice/male/correct.ogg and b/web/simon/audio/voice/male/correct.ogg differ diff --git a/web/simon/audio/voice/male/gameover.ogg b/web/simon/audio/voice/male/gameover.ogg index b7673880..8a32a619 100644 Binary files a/web/simon/audio/voice/male/gameover.ogg and b/web/simon/audio/voice/male/gameover.ogg differ diff --git a/web/simon/audio/voice/male/menu_back.ogg b/web/simon/audio/voice/male/menu_back.ogg index 53884eda..0873b642 100644 Binary files a/web/simon/audio/voice/male/menu_back.ogg and b/web/simon/audio/voice/male/menu_back.ogg differ diff --git a/web/simon/audio/voice/male/menu_home.ogg b/web/simon/audio/voice/male/menu_home.ogg index 59278e7d..7db24e5f 100644 Binary files a/web/simon/audio/voice/male/menu_home.ogg and b/web/simon/audio/voice/male/menu_home.ogg differ diff --git a/web/simon/audio/voice/male/menu_open.ogg b/web/simon/audio/voice/male/menu_open.ogg index a8bb1e5d..55a72c44 100644 Binary files a/web/simon/audio/voice/male/menu_open.ogg and b/web/simon/audio/voice/male/menu_open.ogg differ diff --git a/web/simon/audio/voice/male/menu_restart.ogg b/web/simon/audio/voice/male/menu_restart.ogg index 06350de0..248bf972 100644 Binary files a/web/simon/audio/voice/male/menu_restart.ogg and b/web/simon/audio/voice/male/menu_restart.ogg differ diff --git a/web/simon/audio/voice/male/new_record.ogg b/web/simon/audio/voice/male/new_record.ogg index 282d544a..c219e5aa 100644 Binary files a/web/simon/audio/voice/male/new_record.ogg and b/web/simon/audio/voice/male/new_record.ogg differ diff --git a/web/simon/audio/voice/male/relisten.ogg b/web/simon/audio/voice/male/relisten.ogg index 6117fa8c..87d92e17 100644 Binary files a/web/simon/audio/voice/male/relisten.ogg and b/web/simon/audio/voice/male/relisten.ogg differ diff --git a/web/simon/audio/voice/male/replay.ogg b/web/simon/audio/voice/male/replay.ogg index df5153f9..60a29314 100644 Binary files a/web/simon/audio/voice/male/replay.ogg and b/web/simon/audio/voice/male/replay.ogg differ diff --git a/web/simon/audio/voice/male/retry_use.ogg b/web/simon/audio/voice/male/retry_use.ogg index 2d1baa82..38caa9a0 100644 Binary files a/web/simon/audio/voice/male/retry_use.ogg and b/web/simon/audio/voice/male/retry_use.ogg differ diff --git a/web/simon/audio/voice/male/round_01.ogg b/web/simon/audio/voice/male/round_01.ogg index 4106bd03..fee020c0 100644 Binary files a/web/simon/audio/voice/male/round_01.ogg and b/web/simon/audio/voice/male/round_01.ogg differ diff --git a/web/simon/audio/voice/male/round_02.ogg b/web/simon/audio/voice/male/round_02.ogg index ca738c55..033bc970 100644 Binary files a/web/simon/audio/voice/male/round_02.ogg and b/web/simon/audio/voice/male/round_02.ogg differ diff --git a/web/simon/audio/voice/male/round_03.ogg b/web/simon/audio/voice/male/round_03.ogg index 49f74bfd..20f10eca 100644 Binary files a/web/simon/audio/voice/male/round_03.ogg and b/web/simon/audio/voice/male/round_03.ogg differ diff --git a/web/simon/audio/voice/male/round_04.ogg b/web/simon/audio/voice/male/round_04.ogg index e6f95090..2041327a 100644 Binary files a/web/simon/audio/voice/male/round_04.ogg and b/web/simon/audio/voice/male/round_04.ogg differ diff --git a/web/simon/audio/voice/male/round_05.ogg b/web/simon/audio/voice/male/round_05.ogg index 70ad5950..b1212862 100644 Binary files a/web/simon/audio/voice/male/round_05.ogg and b/web/simon/audio/voice/male/round_05.ogg differ diff --git a/web/simon/audio/voice/male/round_06.ogg b/web/simon/audio/voice/male/round_06.ogg index 27f5e90b..c8aff54c 100644 Binary files a/web/simon/audio/voice/male/round_06.ogg and b/web/simon/audio/voice/male/round_06.ogg differ diff --git a/web/simon/audio/voice/male/round_07.ogg b/web/simon/audio/voice/male/round_07.ogg index 78955e5f..6e56d886 100644 Binary files a/web/simon/audio/voice/male/round_07.ogg and b/web/simon/audio/voice/male/round_07.ogg differ diff --git a/web/simon/audio/voice/male/round_08.ogg b/web/simon/audio/voice/male/round_08.ogg index 89a8a480..b6a4966c 100644 Binary files a/web/simon/audio/voice/male/round_08.ogg and b/web/simon/audio/voice/male/round_08.ogg differ diff --git a/web/simon/audio/voice/male/round_09.ogg b/web/simon/audio/voice/male/round_09.ogg index 770265db..5e71c0b8 100644 Binary files a/web/simon/audio/voice/male/round_09.ogg and b/web/simon/audio/voice/male/round_09.ogg differ diff --git a/web/simon/audio/voice/male/round_10.ogg b/web/simon/audio/voice/male/round_10.ogg index ef75d227..3a4d35cc 100644 Binary files a/web/simon/audio/voice/male/round_10.ogg and b/web/simon/audio/voice/male/round_10.ogg differ diff --git a/web/simon/audio/voice/male/round_11.ogg b/web/simon/audio/voice/male/round_11.ogg index 2228a231..5f1f713e 100644 Binary files a/web/simon/audio/voice/male/round_11.ogg and b/web/simon/audio/voice/male/round_11.ogg differ diff --git a/web/simon/audio/voice/male/round_12.ogg b/web/simon/audio/voice/male/round_12.ogg index a4624fcf..d1137d2c 100644 Binary files a/web/simon/audio/voice/male/round_12.ogg and b/web/simon/audio/voice/male/round_12.ogg differ diff --git a/web/simon/audio/voice/male/round_13.ogg b/web/simon/audio/voice/male/round_13.ogg index fd5c0d87..382d1ed0 100644 Binary files a/web/simon/audio/voice/male/round_13.ogg and b/web/simon/audio/voice/male/round_13.ogg differ diff --git a/web/simon/audio/voice/male/round_14.ogg b/web/simon/audio/voice/male/round_14.ogg index 2765e20a..2698db17 100644 Binary files a/web/simon/audio/voice/male/round_14.ogg and b/web/simon/audio/voice/male/round_14.ogg differ diff --git a/web/simon/audio/voice/male/round_15.ogg b/web/simon/audio/voice/male/round_15.ogg index 5ead0032..7ed795d6 100644 Binary files a/web/simon/audio/voice/male/round_15.ogg and b/web/simon/audio/voice/male/round_15.ogg differ diff --git a/web/simon/audio/voice/male/round_16.ogg b/web/simon/audio/voice/male/round_16.ogg index 3e06b574..6b6a9817 100644 Binary files a/web/simon/audio/voice/male/round_16.ogg and b/web/simon/audio/voice/male/round_16.ogg differ diff --git a/web/simon/audio/voice/male/round_17.ogg b/web/simon/audio/voice/male/round_17.ogg index a62889cb..ac41bd17 100644 Binary files a/web/simon/audio/voice/male/round_17.ogg and b/web/simon/audio/voice/male/round_17.ogg differ diff --git a/web/simon/audio/voice/male/round_18.ogg b/web/simon/audio/voice/male/round_18.ogg index bc1c4013..32343bc6 100644 Binary files a/web/simon/audio/voice/male/round_18.ogg and b/web/simon/audio/voice/male/round_18.ogg differ diff --git a/web/simon/audio/voice/male/round_19.ogg b/web/simon/audio/voice/male/round_19.ogg index a4e38d80..8a92a07a 100644 Binary files a/web/simon/audio/voice/male/round_19.ogg and b/web/simon/audio/voice/male/round_19.ogg differ diff --git a/web/simon/audio/voice/male/round_20.ogg b/web/simon/audio/voice/male/round_20.ogg index 9f09573c..f988904d 100644 Binary files a/web/simon/audio/voice/male/round_20.ogg and b/web/simon/audio/voice/male/round_20.ogg differ diff --git a/web/simon/audio/voice/male/round_more.ogg b/web/simon/audio/voice/male/round_more.ogg index 14ec8dea..9af6eb6d 100644 Binary files a/web/simon/audio/voice/male/round_more.ogg and b/web/simon/audio/voice/male/round_more.ogg differ diff --git a/web/simon/audio/voice/male/rules.ogg b/web/simon/audio/voice/male/rules.ogg index dd7250e1..fee626cd 100644 Binary files a/web/simon/audio/voice/male/rules.ogg and b/web/simon/audio/voice/male/rules.ogg differ diff --git a/web/simon/audio/voice/male/rules_prompt.ogg b/web/simon/audio/voice/male/rules_prompt.ogg index ad85a4f7..82ffa506 100644 Binary files a/web/simon/audio/voice/male/rules_prompt.ogg and b/web/simon/audio/voice/male/rules_prompt.ogg differ diff --git a/web/simon/audio/voice/male/score_band1.ogg b/web/simon/audio/voice/male/score_band1.ogg index 2e022363..6dab0a5e 100644 Binary files a/web/simon/audio/voice/male/score_band1.ogg and b/web/simon/audio/voice/male/score_band1.ogg differ diff --git a/web/simon/audio/voice/male/score_band2.ogg b/web/simon/audio/voice/male/score_band2.ogg index 01d05af0..1f0abe7d 100644 Binary files a/web/simon/audio/voice/male/score_band2.ogg and b/web/simon/audio/voice/male/score_band2.ogg differ diff --git a/web/simon/audio/voice/male/score_band3.ogg b/web/simon/audio/voice/male/score_band3.ogg index 40cfa0cd..4b3fef01 100644 Binary files a/web/simon/audio/voice/male/score_band3.ogg and b/web/simon/audio/voice/male/score_band3.ogg differ diff --git a/web/simon/audio/voice/male/score_band4.ogg b/web/simon/audio/voice/male/score_band4.ogg index f8c8d1ca..aca33c52 100644 Binary files a/web/simon/audio/voice/male/score_band4.ogg and b/web/simon/audio/voice/male/score_band4.ogg differ diff --git a/web/simon/audio/voice/male/score_band5.ogg b/web/simon/audio/voice/male/score_band5.ogg index b2ff2536..8975c710 100644 Binary files a/web/simon/audio/voice/male/score_band5.ogg and b/web/simon/audio/voice/male/score_band5.ogg differ diff --git a/web/simon/audio/voice/male/score_band6.ogg b/web/simon/audio/voice/male/score_band6.ogg index 30a67e21..a1bc22de 100644 Binary files a/web/simon/audio/voice/male/score_band6.ogg and b/web/simon/audio/voice/male/score_band6.ogg differ diff --git a/web/simon/audio/voice/male/skip_hint.m4a b/web/simon/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..1a7534fc Binary files /dev/null and b/web/simon/audio/voice/male/skip_hint.m4a differ diff --git a/web/simon/audio/voice/male/skip_hint.ogg b/web/simon/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..7149fc95 Binary files /dev/null and b/web/simon/audio/voice/male/skip_hint.ogg differ diff --git a/web/simon/audio/voice/male/t0_goal.ogg b/web/simon/audio/voice/male/t0_goal.ogg index 1ab48972..3b4840f1 100644 Binary files a/web/simon/audio/voice/male/t0_goal.ogg and b/web/simon/audio/voice/male/t0_goal.ogg differ diff --git a/web/simon/audio/voice/male/t0_welcome.ogg b/web/simon/audio/voice/male/t0_welcome.ogg index 953bac28..c1584adb 100644 Binary files a/web/simon/audio/voice/male/t0_welcome.ogg and b/web/simon/audio/voice/male/t0_welcome.ogg differ diff --git a/web/simon/audio/voice/male/t1_up.ogg b/web/simon/audio/voice/male/t1_up.ogg index 9a56595b..e20da5ac 100644 Binary files a/web/simon/audio/voice/male/t1_up.ogg and b/web/simon/audio/voice/male/t1_up.ogg differ diff --git a/web/simon/audio/voice/male/t2_down.ogg b/web/simon/audio/voice/male/t2_down.ogg index 189dabde..4932af6b 100644 Binary files a/web/simon/audio/voice/male/t2_down.ogg and b/web/simon/audio/voice/male/t2_down.ogg differ diff --git a/web/simon/audio/voice/male/t3_left.ogg b/web/simon/audio/voice/male/t3_left.ogg index 746607a6..b14342ef 100644 Binary files a/web/simon/audio/voice/male/t3_left.ogg and b/web/simon/audio/voice/male/t3_left.ogg differ diff --git a/web/simon/audio/voice/male/t4_right.ogg b/web/simon/audio/voice/male/t4_right.ogg index c141b734..476ad566 100644 Binary files a/web/simon/audio/voice/male/t4_right.ogg and b/web/simon/audio/voice/male/t4_right.ogg differ diff --git a/web/simon/audio/voice/male/t5_tap.ogg b/web/simon/audio/voice/male/t5_tap.ogg index c7f3bdee..ebc24eb4 100644 Binary files a/web/simon/audio/voice/male/t5_tap.ogg and b/web/simon/audio/voice/male/t5_tap.ogg differ diff --git a/web/simon/audio/voice/male/t6_double.ogg b/web/simon/audio/voice/male/t6_double.ogg index 454390ef..dc431a26 100644 Binary files a/web/simon/audio/voice/male/t6_double.ogg and b/web/simon/audio/voice/male/t6_double.ogg differ diff --git a/web/simon/audio/voice/male/t7_ready.ogg b/web/simon/audio/voice/male/t7_ready.ogg index 3ebb659e..a7fd5330 100644 Binary files a/web/simon/audio/voice/male/t7_ready.ogg and b/web/simon/audio/voice/male/t7_ready.ogg differ diff --git a/web/simon/audio/voice/male/t7_retry.ogg b/web/simon/audio/voice/male/t7_retry.ogg index 4e112d30..8553cfe2 100644 Binary files a/web/simon/audio/voice/male/t7_retry.ogg and b/web/simon/audio/voice/male/t7_retry.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_double.ogg b/web/simon/audio/voice/male/t_blunt_double.ogg index 9c355a50..5b21303e 100644 Binary files a/web/simon/audio/voice/male/t_blunt_double.ogg and b/web/simon/audio/voice/male/t_blunt_double.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_down.ogg b/web/simon/audio/voice/male/t_blunt_down.ogg index 81298e84..d008663e 100644 Binary files a/web/simon/audio/voice/male/t_blunt_down.ogg and b/web/simon/audio/voice/male/t_blunt_down.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_left.ogg b/web/simon/audio/voice/male/t_blunt_left.ogg index cf1f10b1..60929c28 100644 Binary files a/web/simon/audio/voice/male/t_blunt_left.ogg and b/web/simon/audio/voice/male/t_blunt_left.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_right.ogg b/web/simon/audio/voice/male/t_blunt_right.ogg index 8e3f3a9a..c51609b2 100644 Binary files a/web/simon/audio/voice/male/t_blunt_right.ogg and b/web/simon/audio/voice/male/t_blunt_right.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_tap.ogg b/web/simon/audio/voice/male/t_blunt_tap.ogg index 290dbbcb..98611729 100644 Binary files a/web/simon/audio/voice/male/t_blunt_tap.ogg and b/web/simon/audio/voice/male/t_blunt_tap.ogg differ diff --git a/web/simon/audio/voice/male/t_blunt_up.ogg b/web/simon/audio/voice/male/t_blunt_up.ogg index 89dc120d..716969bd 100644 Binary files a/web/simon/audio/voice/male/t_blunt_up.ogg and b/web/simon/audio/voice/male/t_blunt_up.ogg differ diff --git a/web/simon/audio/voice/male/t_ok.ogg b/web/simon/audio/voice/male/t_ok.ogg index afa412c5..a49b4679 100644 Binary files a/web/simon/audio/voice/male/t_ok.ogg and b/web/simon/audio/voice/male/t_ok.ogg differ diff --git a/web/simon/audio/voice/male/t_skip.ogg b/web/simon/audio/voice/male/t_skip.ogg index 88b2cb27..b360034b 100644 Binary files a/web/simon/audio/voice/male/t_skip.ogg and b/web/simon/audio/voice/male/t_skip.ogg differ diff --git a/web/simon/audio/voice/male/t_try.ogg b/web/simon/audio/voice/male/t_try.ogg index 638adcf1..c27b6ba8 100644 Binary files a/web/simon/audio/voice/male/t_try.ogg and b/web/simon/audio/voice/male/t_try.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_double.ogg b/web/simon/audio/voice/male/t_wrong_double.ogg index 5408504d..25016ba7 100644 Binary files a/web/simon/audio/voice/male/t_wrong_double.ogg and b/web/simon/audio/voice/male/t_wrong_double.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_down.ogg b/web/simon/audio/voice/male/t_wrong_down.ogg index 4fbd764b..1a2a9be0 100644 Binary files a/web/simon/audio/voice/male/t_wrong_down.ogg and b/web/simon/audio/voice/male/t_wrong_down.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_left.ogg b/web/simon/audio/voice/male/t_wrong_left.ogg index c071ff30..c40f4a76 100644 Binary files a/web/simon/audio/voice/male/t_wrong_left.ogg and b/web/simon/audio/voice/male/t_wrong_left.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_right.ogg b/web/simon/audio/voice/male/t_wrong_right.ogg index bf3d2049..436a36de 100644 Binary files a/web/simon/audio/voice/male/t_wrong_right.ogg and b/web/simon/audio/voice/male/t_wrong_right.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_tap.ogg b/web/simon/audio/voice/male/t_wrong_tap.ogg index 1d95d0d2..25c9c6b3 100644 Binary files a/web/simon/audio/voice/male/t_wrong_tap.ogg and b/web/simon/audio/voice/male/t_wrong_tap.ogg differ diff --git a/web/simon/audio/voice/male/t_wrong_up.ogg b/web/simon/audio/voice/male/t_wrong_up.ogg index 6cb8fbad..71178c1c 100644 Binary files a/web/simon/audio/voice/male/t_wrong_up.ogg and b/web/simon/audio/voice/male/t_wrong_up.ogg differ diff --git a/web/simon/audio/voice/male/wrong.ogg b/web/simon/audio/voice/male/wrong.ogg index 8cfa7d0c..f5292173 100644 Binary files a/web/simon/audio/voice/male/wrong.ogg and b/web/simon/audio/voice/male/wrong.ogg differ diff --git a/web/simon/audio/voice/male/your_turn.ogg b/web/simon/audio/voice/male/your_turn.ogg index 14ae84ef..740bf0c0 100644 Binary files a/web/simon/audio/voice/male/your_turn.ogg and b/web/simon/audio/voice/male/your_turn.ogg differ diff --git a/web/simon/game.js b/web/simon/game.js index af4fc4da..e51ff679 100644 --- a/web/simon/game.js +++ b/web/simon/game.js @@ -2,7 +2,13 @@ * 音序回响(Simon)R2 —— 音频版西蒙记忆游戏(按 docs/design/02-simon-redesign.md 实现)。 * * R2 要点: - * - 无 BGM(不加载不播放,bgm/ 目录已空):只有手势音色 + 语音两条线。 + * - 游玩全程无 BGM(用户硬性要求,02 文档既定):教学/对局/序列播放静默; + * 仅结算(gameover)与菜单挂 bgm/menu(volume 0.3,比别的游戏更轻, + * 10 号文档 §4 simon 特例表);单击/菜单重开时 stopBgm(300) 回静默。 + * - 单击跳过旁白(12 号文档):voice 播放中单击 = audio.skipVoice()(截断当前句、 + * 语音链继续跳下一句);无语音时单击 = 原语义。例外:教学"教一个练一个"的 + * 等待输入阶段(teachAwait)单击仍是答题;playback 阶段手势静默忽略、菜单 + * 单击循环菜单项(均维持原裁定)。开场 t0 附近插播一次 voice/skip_hint。 * - 旁白全部整句化:轮次 round_01..round_20 + round_more 兜底;成绩 score_band1..6 * 分段整句;删除 di/lun/num_0–9/score_pre/best_pre 拼接体系与旧教学连播。 * - 交互式教学(T0-T7):教一个音色练一次该手势,做对才教下一个;教学期下滑跳过。 @@ -81,12 +87,18 @@ export class SimonGame extends Game { this.audio.prewarm([ ...GESTURES.map((g) => TONE[g]), 'sfx/gameover', - 'voice/t0_welcome', 'voice/t0_goal', 'voice/t_try', 'voice/round_01', + 'voice/t0_welcome', 'voice/t0_goal', 'voice/skip_hint', 'voice/t_try', 'voice/round_01', ]); this._timer(() => this._runTutorial(), 500); // 开场静默 0.5s 后进教学(无 BGM) } onGesture(g) { + // 单击跳过旁白(12 号文档):voice 播放中单击 = skipVoice()(截断当前句、 + // 链播自然进下一句);无语音在播时单击 = 各阶段原语义。跳过不发震动。 + if (g === 'tap' && this._tapSkipsVoice()) { + this.audio.skipVoice(); + return; + } switch (this.phase) { case 'tutorial': this._onTutorialGesture(g); @@ -108,11 +120,31 @@ export class SimonGame extends Game { } } + /** + * 单击是否应判定为"跳过当前旁白句"。规则:有 voice 在播 + 当前阶段允许跳过。 + * - tutorial:仅讲解/反馈语音播放中可跳;teachAwait(等玩家练手势)单击=答题; + * - announce/roundend/gameover:轮次/成绩/结算播报可跳(高轮次玩家刚需); + * - playback:维持"序列播放期手势静默忽略"裁定(含第 1 轮 your_turn); + * - input:答题阶段无语音;menu:单击循环菜单项是原语义,不拦截。 + * 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。 + */ + _tapSkipsVoice() { + if (!this.audio._voice) return false; + switch (this.phase) { + case 'tutorial': return !this.teachAwait; + case 'announce': + case 'roundend': + case 'gameover': return true; + default: return false; + } + } + // ---------------- 交互式教学(T0-T7) ---------------- _runTutorial() { this.phase = 'tutorial'; - this._speak(['voice/t0_welcome', 'voice/t0_goal'], () => this._teachStep(0)); + // t0 附近插播一次 skip_hint("单击可跳过旁白"),每局教学只此一次 + this._speak(['voice/t0_welcome', 'voice/skip_hint', 'voice/t0_goal'], () => this._teachStep(0)); } /** 教第 i 个手势:指令句 → 播音色 → "你试试。" → 等玩家做该手势。 */ @@ -143,7 +175,9 @@ export class SimonGame extends Game { } _onTutorialGesture(g) { - if (g === 'down') { // 下滑 = 跳过教学 + // 下滑=跳过教学 与 T2"教下滑手势"冲突,按上下文判定(同双击裁定①的思路): + // 等待输入阶段且当前教的就是下滑时,下滑=答题;其余教学时刻下滑=跳过。 + if (g === 'down' && !(this.teachAwait && GESTURES[this.teachIdx] === 'down')) { this._session++; this._clearTimers(); this.audio.stopVoice(); @@ -194,6 +228,7 @@ export class SimonGame extends Game { } _restart() { + this.audio.stopBgm(300); // 停 menu pad,回静默(10 号文档 simon 表) this.audio.play('sfx/tone_tap'); // 重开确认音 this._beginGame(); // 重开跳过教学 } @@ -247,9 +282,9 @@ export class SimonGame extends Game { return false; } - /** 播放间隔:max(420, 700 - (round-1)*25),第 12 轮才到下限(02 §6.1)。 */ + /** 播放间隔:max(420, 700 - (round-1)*26),第 12 轮起到 420ms 下限(02 §6.1 + 13 号验收)。 */ _interval() { - return Math.max(420, 700 - (this.round - 1) * 25); + return Math.max(420, 700 - (this.round - 1) * 26); } _playSequence() { @@ -377,6 +412,14 @@ export class SimonGame extends Game { this._timer(() => { if (s === this._session) this._speak(ids, null); // 链播完停在 gameover,等手势 }, 1400); + + // 状态 BGM(10 号文档 simon 表):sfx/gameover(约 0.95s,+700ms 起播) + // 播完 1s 后淡入 menu pad 1000ms,音量 0.3。若玩家已进菜单/重开则不补播。 + this._timer(() => { + if (s === this._session && this.phase === 'gameover') { + this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 1000 }); + } + }, 700 + 950 + 1000); } /** 成绩分段整句:0 / 1-3 / 4-6 / 7-9 / 10-14 / ≥15(02 §3.2)。 */ @@ -398,6 +441,8 @@ export class SimonGame extends Game { _openMenu() { this._menuIdx = 0; this.phase = 'menu'; + // 状态 BGM:菜单挂 menu pad(volume 0.3,淡入 600ms;与结算同一首,引擎不重启) + this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 600 }); this._speak(['voice/menu_open', 'voice/menu_restart'], null); } diff --git a/web/simon/test/playtest.mjs b/web/simon/test/playtest.mjs new file mode 100644 index 00000000..6add4ef3 --- /dev/null +++ b/web/simon/test/playtest.mjs @@ -0,0 +1,604 @@ +/** + * playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。 + * + * 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext), + * 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js, + * 用虚拟时钟推进所有 setTimeout(语音链结束定时器也在其列),手势直接注入 + * game.onGesture(即引擎回调路径)。Math.random 注入种子,全流程可重复。 + * + * 运行:tools/node/bin/node web/simon/test/playtest.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // web/simon + +// ---------------------------------------------------------------- 虚拟时钟 +let nowMs = 0; +const timers = new Map(); +let nextTimerId = 1; + +function vSetTimeout(fn, ms) { + const id = nextTimerId++; + timers.set(id, { at: nowMs + (ms || 0), fn }); + return id; +} +function vClearTimeout(id) { timers.delete(id); } + +async function flush(rounds = 50) { + for (let i = 0; i < rounds; i++) await Promise.resolve(); +} + +/** 推进虚拟时钟 ms,沿途触发到期定时器(含级联)。 */ +async function advance(ms) { + const target = nowMs + ms; + for (;;) { + let bestId = null; + let bestAt = Infinity; + for (const [id, t] of timers) { + if (t.at <= target && t.at < bestAt) { bestAt = t.at; bestId = id; } + } + if (bestId === null) break; + const t = timers.get(bestId); + timers.delete(bestId); + nowMs = t.at; + t.fn(); + await flush(); + } + nowMs = target; + await flush(); +} + +async function advanceUntil(cond, { step = 25, max = 60000, desc = '' } = {}) { + let t = 0; + while (!cond()) { + if (t >= max) { + (globalThis.__origError || console.error)(`[debug] phase=${game?.phase} teachIdx=${game?.teachIdx} teachAwait=${game?.teachAwait} teachErrs=${game?.teachErrs} round=${game?.round} voice=${lastVoice?.() ?? '?'} voicePlaying=${!!audio?._voice} timers=${timers.size}`); + throw new Error(`advanceUntil 超时(${max}ms):${desc}`); + } + await advance(step); + t += step; + } +} + +// ---------------------------------------------------------------- 浏览器 mock +const vibLog = []; +const fetchLog = []; +const warnLog = []; +const errorLog = []; +const blockedAssets = new Set(); // 子串匹配,命中的资产 fetch 返回 !ok +const bufDuration = new Map(); // ArrayBuffer -> 秒 + +function durFor(url) { + if (url.includes('voice/')) return 1.0; + if (url.includes('sfx/gameover')) return 0.95; + if (url.includes('sfx/tone')) return 0.3; + if (url.includes('bgm/')) return 90; + return 1.0; +} + +function fakeEl() { + return { + id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0, + style: { setProperty() {} }, + children: [], + appendChild(c) { this.children.push(c); }, + remove() {}, + addEventListener() {}, + }; +} + +const winListeners = new Map(); +const windowMock = { + addEventListener(type, fn) { + if (!winListeners.has(type)) winListeners.set(type, []); + winListeners.get(type).push(fn); + }, + removeEventListener() {}, + dispatch(type, event = {}) { + for (const fn of winListeners.get(type) || []) fn(event); + }, + location: { href: '' }, +}; + +const lsStore = new Map(); +const localStorageMock = { + getItem: (k) => (lsStore.has(k) ? lsStore.get(k) : null), + setItem: (k, v) => lsStore.set(k, String(v)), + removeItem: (k) => lsStore.delete(k), +}; + +class FakeAudioParam { + constructor(v = 0) { this.value = v; } + setValueAtTime() {} + cancelScheduledValues() {} + linearRampToValueAtTime() {} +} + +class FakeAudioContext { + constructor() { + this.state = 'suspended'; + this.currentTime = 0; + this.sampleRate = 44100; + this.destination = {}; + } + async resume() { this.state = 'running'; } + createGain() { return { gain: new FakeAudioParam(1), connect() {}, disconnect() {} }; } + createDynamicsCompressor() { + return { + threshold: new FakeAudioParam(), ratio: new FakeAudioParam(), + attack: new FakeAudioParam(), release: new FakeAudioParam(), + connect() {}, disconnect() {}, + }; + } + createBufferSource() { + return { buffer: null, loop: false, onended: null, connect() {}, disconnect() {}, start() {}, stop() {} }; + } + createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } + async decodeAudioData(ab) { + const duration = bufDuration.get(ab) ?? 1; + return { + duration, numberOfChannels: 1, sampleRate: 44100, + getChannelData: () => new Float32Array(8), + }; + } +} + +async function fetchMock(url) { + fetchLog.push(url); + const blocked = [...blockedAssets].some((b) => url.includes(b)); + const fsPath = path.join(ROOT, decodeURIComponent(url)); + const ok = !blocked && fs.existsSync(fsPath); + return { + ok, + async arrayBuffer() { + const ab = new ArrayBuffer(8); + bufDuration.set(ab, durFor(url)); + return ab; + }, + }; +} + +// 种子随机(LCG),保证序列可重复 +let seed = 20260830; +function seededRandom() { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; +} + +// 全局安装(defineProperty:Node 24 自带 fetch/performance/navigator 只读 getter) +for (const [k, v] of Object.entries({ + window: windowMock, + document: { + body: fakeEl(), + createElement: () => fakeEl(), + getElementById: () => null, + }, + navigator: { vibrate: (p) => vibLog.push(p) }, + localStorage: localStorageMock, + fetch: fetchMock, + performance: { now: () => nowMs }, + setTimeout: vSetTimeout, + clearTimeout: vClearTimeout, +})) { + Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); +} +windowMock.AudioContext = FakeAudioContext; +Math.random = seededRandom; + +const origWarn = console.warn; +const origError = console.error; +globalThis.__origError = origError; +console.warn = (...a) => { warnLog.push(a.join(' ')); }; +console.error = (...a) => { errorLog.push(a.join(' ')); }; + +// ---------------------------------------------------------------- 断言框架 +let passed = 0; +let failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(` ✓ ${msg}`); } + else { failed++; origError(` ✗ ${msg}`); } // 失败行走原始 console(console.error 被 mock 捕获) +} +function section(name) { console.log(`\n== ${name}`); } + +process.on('unhandledRejection', (e) => { + failed++; + origError(' ✗ 未处理 Promise 拒绝:', e); +}); + +// ---------------------------------------------------------------- 加载引擎与游戏 +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { SimonGame } = await import('../game.js'); + +const audio = new WebAudioEngine('audio/'); +const game = new SimonGame(audio, fakeEl()); +let gameRef = game; // 手势注入目标(第二场景换新实例时切换) + +// 间谍:记录播放/跳过/停 BGM +const playLog = []; +const skipLog = []; +const stopBgmLog = []; +const origPlay = audio.play.bind(audio); +audio.play = (id, opts) => { playLog.push({ id, opts: opts || {}, t: nowMs }); return origPlay(id, opts); }; +const origSkip = audio.skipVoice.bind(audio); +audio.skipVoice = () => { skipLog.push(nowMs); return origSkip(); }; +const origStopBgm = audio.stopBgm.bind(audio); +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 lastVoice = () => { + for (let i = playLog.length - 1; i >= 0; i--) { + if (playLog[i].id.startsWith('voice/')) return playLog[i].id; + } + return null; +}; + +let gestureCount = 0; +// 真实手势回调是独立宏任务;每次注入后 flush 微任务,模拟事件循环间隔 +//(否则同一 tick 内连续两个 voice play 会撞上引擎 _playVoice 的异步加载窗口) +async function gesture(g) { gestureCount++; gameRef.onGesture(g); await flush(); } + +const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap']; + +// ---------------------------------------------------------------- 全局断言 1:解锁前无音频 +section('启动遮罩前(引擎解锁语义)'); +audio.play('voice/t0_welcome'); +await flush(); +assert(warnLog.some((w) => w.includes('尚未解锁')), '解锁前 play 仅 console.warn'); +assert(fetchLog.length === 0, '解锁前无任何资产加载'); +await audio.unlock(); + +// ---------------------------------------------------------------- 启动 + 设置边界(全局断言 3) +section('启动 / 设置生效(voice 性别、语速)'); +settings.set({ voice: 'male', speed: 1.7 }); +game.start(); +await advanceUntil(() => !!audio._voice, { desc: 't0_welcome 开播' }); +assert(game.phase === 'tutorial', '0.5s 后进入教学(T0)'); +assert(lastVoice() === 'voice/t0_welcome', 't0_welcome 播放'); +assert(fetchLog.some((u) => u.includes('audio/voice/male/t0_welcome.ogg')), 'voice=male 时从 male 目录取材'); +assert(audio._voice.st.tempo === 1.7, 'speed=1.7 时播放实例 tempo=1.7(不变调变速)'); +settings.set({ voice: 'female', speed: 1.0 }); + +// skip_hint 插播 + 长按加速(引擎内置行为过一遍真实代码路径) +await advanceUntil(() => lastVoice() === 'voice/skip_hint' && !!audio._voice, { desc: 'skip_hint 开播' }); +assert(fetchLog.some((u) => u.includes('audio/voice/female/skip_hint.ogg')), '切回 female 后下一句从 female 目录取材'); +assert(playCount('voice/skip_hint') === 1, 'skip_hint 在 t0 附近插播一次'); +windowMock.dispatch('soundgame:longpress'); +assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速'); +windowMock.dispatch('soundgame:longpressend'); +assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed'); + +// ---------------------------------------------------------------- 教学 T1-T6 +section('交互式教学(T0-T7)'); +// T1 讲解句播放中单击 = 跳过本句(12 号文档 + 本轮裁定) +// 注意:teachIdx 构造即 0,必须以"当前在播 t1_up"为准 +await advanceUntil(() => lastVoice() === 'voice/t1_up' && !!audio._voice, { desc: 't1_up 开播' }); +assert(game.phase === 'tutorial' && game.teachIdx === 0, 'T1 教学句播放'); +{ + const skips = skipLog.length; + await gesture('tap'); + assert(skipLog.length === skips + 1, '教学讲解播放中单击 = skipVoice()'); + assert(game.phase === 'tutorial' && game.teachIdx === 0, '跳过后教学状态机不破坏'); +} +await advanceUntil(() => game.teachAwait, { desc: 'T1 等待输入' }); + +for (let i = 0; i < GESTURES.length; i++) { + await advanceUntil(() => game.teachAwait, { desc: `T${i + 1} 等待输入` }); + assert(game.teachIdx === i, `teachIdx 递进到 ${i}`); + const expected = GESTURES[i]; + if (i === 1) { + // 错手势:播 t_wrong_* + error 震动,不推进 + const wrong = GESTURES.find((g) => g !== expected && g !== 'down'); // down=跳教学,避开 + await gesture(wrong); + assert(played('voice/t_wrong_down'), '教学错手势播 t_wrong_down'); + assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '教学错手势发 error 震动'); + assert(game.teachIdx === 1 && game.teachAwait, '错手势后仍在原步骤等待'); + } + await gesture(expected); // i=4 时 expected='tap':等待输入阶段单击=答题(不跳过) + if (i < GESTURES.length - 1) { + await advanceUntil(() => game.teachIdx === i + 1, { desc: `teachIdx → ${i + 1}` }); + } +} +await advanceUntil(() => game.phase === 'announce', { desc: 'T7 收尾后进第 1 轮' }); +assert(game.round === 1 && played('voice/round_01'), '教学完成直达第 1 轮播报'); + +// ---------------------------------------------------------------- 对局辅助 +function checkSeqConstraints() { + const s = game.sequence; + if (game.round <= 5) { + for (let i = 2; i < s.length; i++) { + assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2]), `第 ${game.round} 轮序列前三连同手势(位置 ${i})`); + } + } + for (let i = 3; i < s.length; i++) { + assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2] && s[i - 2] === s[i - 3]), `全程无四连同手势(第 ${game.round} 轮位置 ${i})`); + } +} + +/** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */ +async function toInputViaSkip(round) { + await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` }); + const skips = skipLog.length; + await gesture('tap'); + assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`); + assert(game.phase === 'playback', '跳过后直接进入序列播放'); + const seqSnapshot = game.sequence.join(','); + await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳) + assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响'); + await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` }); + assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`); + checkSeqConstraints(); +} + +/** 语音缺失(round_03 被屏蔽)→ 2.5s 看门狗直接进 playback。 */ +async function toInputViaWatchdog(round) { + await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` }); + await advance(300); + assert(!audio._voice, '被屏蔽的语音确实没有播起来'); + 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} 轮进入输入` }); +} + +let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题 +/** 输入阶段按序列原样复现;途中遇到期望 doubletap 时验证"双击=答题不触发重听"。 */ +async function answerAll() { + while (game.phase === 'input' && game.inputIdx < game.sequence.length) { + const exp = game.sequence[game.inputIdx]; + if (exp === 'doubletap' && !dtAnswerTested) { + const ch = game.chances; + const idx = game.inputIdx; + const relistenCount = playCount('voice/relisten'); + await gesture('doubletap'); + assert(game.inputIdx === idx + 1 && game.chances === ch && playCount('voice/relisten') === relistenCount, + '裁定①:期望输入恰为 doubletap 时双击=答题(inputIdx 前进、不触发重听)'); + dtAnswerTested = true; + } else { + await gesture(exp); + } + } +} + +/** 把前缀里连续的 doubletap 答掉,直到期望输入非 doubletap。 */ +async function answerPastDoubletaps() { + while (game.phase === 'input' && game.sequence[game.inputIdx] === 'doubletap') { + await gesture('doubletap'); // 期望即 doubletap → 答题 + } +} + +// ---------------------------------------------------------------- 第 1-3 轮 +section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)'); +assert(game._interval() === 700, '第 1 轮播放间隔 700ms'); +await toInputViaSkip(1); +await answerAll(); +await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' }); + +await toInputViaSkip(2); +await answerAll(); +blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产 +await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' }); + +await toInputViaWatchdog(3); +await answerAll(); +await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' }); + +// ---------------------------------------------------------------- 第 4 轮:答错触发复活 +section('第 4 轮:答错消耗复活、序列不变重播'); +await toInputViaSkip(4); +assert(game.chances === 1, '初始 1 次重听机会'); +{ + const prevSeq = game.sequence; + const exp = game.sequence[0]; + const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap'); + await gesture(wrong); + assert(game.chances === 0, '答错自动消耗复活:chances 1→0'); + assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use'); + await advanceUntil(() => played('voice/chances_out'), { desc: 'chances_out 链播' }); + assert(played('voice/chances_out'), '机会归 0 追加播 chances_out'); + assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '答错发 error 震动'); + await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' }); + assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)'); + assert(game.inputIdx === 0, '复活后输入进度清零'); +} +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'); + +// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界 +section('第 5 轮:主动重听 / chances=0 双击 / 双击冲突裁定'); +await toInputViaSkip(5); +{ + await answerPastDoubletaps(); + const relistenCount = playCount('voice/relisten'); + await gesture('doubletap'); // 期望非 doubletap → 主动重听 + assert(game.chances === 0, '主动重听耗机会:chances 1→0'); + assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播'); + await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' }); + assert(game.inputIdx === 0, '重听后输入进度清零'); +} +{ + // chances=0 时双击非期望手势:不判错、播 chances_out + await answerPastDoubletaps(); + const round = game.round; + const idx = game.inputIdx; + const coCount = playCount('voice/chances_out'); + await gesture('doubletap'); + 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 不变(不为负)'); +} +// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找 +await answerAll(); +while (!dtAnswerTested && game.round < 10) { + await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' }); + const r = game.round; + await advanceUntil(() => !!audio._voice, { desc: `第 ${r} 轮播报` }); + await gesture('tap'); // 跳播报 + await advanceUntil(() => game.phase === 'input', { desc: `第 ${r} 轮输入` }); + await answerAll(); +} +assert(dtAnswerTested, '双击冲突裁定①已覆盖(序列中出现 doubletap 并验证)'); + +// ---------------------------------------------------------------- 耗尽后答错 → gameover +section('复活耗尽 → gameover → 结算'); +await advanceUntil(() => game.phase === 'input' || game.phase === 'roundend', { desc: '最后一轮输入' }); +if (game.phase === 'roundend') { + await advanceUntil(() => game.phase === 'input', { desc: '进入输入阶段' }); +} +assert(game.chances === 0, '复活已耗尽'); +const finalRound = game.round; +{ + const exp = game.sequence[game.inputIdx]; + const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap'); + await gesture(wrong); +} +assert(game.phase === 'gameover', 'chances=0 答错 → gameover'); +const gameoverT = nowMs; +const score = finalRound - 1; +const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/score_band2' + : score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4' + : score <= 14 ? 'voice/score_band5' : 'voice/score_band6'; +assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM'); +// 结算语音链播放中单击 = 跳过本句(不是重开) +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'); +assert(lsStore.get('soundgame.simon.best') === String(score), 'best 写入 localStorage'); +assert(played('sfx/gameover'), 'sfx/gameover 播出'); +// 状态 BGM:sfx/gameover 播完 1s 后淡入 menu pad(gameover 起 +2650ms,volume 0.3) +await advanceUntil(() => played('bgm/menu'), { desc: '结算 menu pad 淡入' }); +{ + const e = playLog.find((x) => x.id === 'bgm/menu'); + assert(e.opts.volume === 0.3, '结算 bgm/menu 以 volume 0.3 播放'); + assert(e.t - gameoverT >= 2500 && e.t - gameoverT <= 2800, `menu pad 在 sfx/gameover 播完 1s 后淡入(实际 +${e.t - gameoverT}ms)`); +} + +// 全程无 BGM(教学/对局):gameover 之前无任何 bgm 层播放调用 +assert(playLog.filter((e) => e.id.startsWith('bgm/') && e.t < gameoverT).length === 0, + '教学/对局/序列播放全程无 BGM(用户硬性要求)'); + +// ---------------------------------------------------------------- 速度下限(单测 _interval) +{ + const r = game.round; + game.round = 1; assert(game._interval() === 700, '第 1 轮 interval=700ms'); + game.round = 11; assert(game._interval() === 440, '第 11 轮 interval=440ms(未到下限)'); + game.round = 12; assert(game._interval() === 420, '第 12 轮起 interval=420ms 下限'); + game.round = 30; assert(game._interval() === 420, '第 30 轮仍为 420ms(不穿了下限)'); + game.round = r; +} + +// ---------------------------------------------------------------- 菜单约定 + 重开 +section('菜单(下滑呼出)与单击重开'); +await gesture('down'); +assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单'); +assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)'); +await gesture('tap'); +assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"'); +await gesture('tap'); +assert(game._menuIdx === 2 && played('voice/menu_back'), '菜单单击循环到"返回"'); +await gesture('up'); +assert(played('voice/rules'), '菜单上滑=帮助(规则播报)'); +await gesture('doubletap'); // 选中"返回" +assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页'); +await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' }); +const stopsBefore = stopBgmLog.length; +await gesture('tap'); // 结算页无语音时单击 = 重开 +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 不重播)'); +await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输入' }); +await answerAll(); +assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常'); + +// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3) +section('settings.bgm 边界'); +settings.set({ bgm: 0 }); +await flush(); +assert(audio._bgm === null, 'bgm=0 立即停播进行中 BGM'); +{ + const fetches = fetchLog.filter((u) => u.includes('bgm/menu')).length; + audio.play('bgm/menu', { volume: 0.3 }); + await flush(); + assert(fetchLog.filter((u) => u.includes('bgm/menu')).length === fetches && audio._bgm === null, + 'bgm=0 时 play(bgm) 只记意图不加载不播放'); +} +settings.set({ bgm: 1.0 }); +await advanceUntil(() => !!audio._bgm, { desc: 'bgm 恢复自动补播' }); +assert(audio._bgm.id === 'bgm/menu', 'bgm 调回非零自动补播最后的状态 BGM'); +audio.stopBgm(); +settings.set({ bgm: 1.0 }); + +// ---------------------------------------------------------------- 场景二:教学期下滑=跳教学(现有裁定回归) +section('场景二:教学期下滑=跳过教学'); +game.stop(); // 主场景实例收尾(作废旧会话计时器/语音链) +await advance(500); +{ + const game2 = new SimonGame(audio, fakeEl()); + gameRef = game2; + game2.start(); + await advanceUntil(() => game2.phase === 'tutorial' && lastVoice() === 'voice/t1_up' && !!audio._voice, + { desc: '场景二 T1 讲解开播' }); + await gesture('down'); // 非"等待练下滑"时刻:下滑=跳整个教学 + assert(played('voice/t_skip'), '教学讲解期下滑播 t_skip 确认句'); + await advanceUntil(() => game2.phase === 'announce' && game2.round === 1, { desc: '跳教学直达第 1 轮' }); + assert(game2.phase !== 'tutorial', '下滑跳过教学直达第 1 轮'); + game2.stop(); + gameRef = game; + await advance(500); +} + +// ---------------------------------------------------------------- 资产引用校验 +section('资产引用校验(ogg+m4a 双格式,voice 双性别)'); +{ + const src = fs.readFileSync(path.join(ROOT, 'game.js'), 'utf8'); + const ids = new Set(); + for (const m of src.matchAll(/'(voice|sfx|bgm)\/[a-z0-9_]+'/gi)) ids.add(m[0].slice(1, -1)); + for (let i = 1; i <= 20; i++) ids.add(`voice/round_${String(i).padStart(2, '0')}`); // 动态 id + ids.add('voice/round_more'); + const missing = []; + for (const id of ids) { + const [layer, name] = id.split('/'); + if (layer === 'voice') { + for (const gender of ['male', 'female']) { + for (const ext of ['ogg', 'm4a']) { + if (!fs.existsSync(path.join(ROOT, 'audio/voice', gender, `${name}.${ext}`))) { + missing.push(`audio/voice/${gender}/${name}.${ext}`); + } + } + } + } else { + for (const ext of ['ogg', 'm4a']) { + if (!fs.existsSync(path.join(ROOT, 'audio', layer, `${name}.${ext}`))) { + missing.push(`audio/${layer}/${name}.${ext}`); + } + } + } + } + assert(missing.length === 0, `game.js 引用 ${ids.size} 个资产 id,双格式双性别零缺失${missing.length ? ':缺 ' + missing.join(', ') : ''}`); +} + +// ---------------------------------------------------------------- 汇总 +section('汇总'); +assert(gestureCount <= 120, `手势预算达标(${gestureCount} ≤ 120)`); +console.warn = origWarn; +console.error = origError; +console.log(`\n通过 ${passed} 条断言,失败 ${failed} 条,注入手势 ${gestureCount} 次,虚拟时长 ${(nowMs / 1000).toFixed(1)}s`); +if (failed > 0) { + console.error('PLAYTEST FAILED'); + process.exit(1); +} +console.log('PLAYTEST PASSED'); diff --git a/web/space/README.md b/web/space/README.md index 726858ff..f8596f81 100644 --- a/web/space/README.md +++ b/web/space/README.md @@ -12,6 +12,9 @@ - 被撞击 **3 次**坠毁(前 60 秒保护期内撞击不掉血,训练护盾吸收并语音讲解方位); 结算播报存活时间与躲避数分档整句;保护期后陨石越快越密(120 秒爬满)。 - 躲过 10 / 25 / 50 / 100 颗有里程碑播报;破纪录有"新纪录!"播报。 +- **旁白播放中单击 = 跳过本句**(语音链跳下一句);**按住不放 = 旁白加速播放** + (引擎内置,不变调)。首局演练开场会提示一次。 +- 状态音乐:对局 = 引擎轰鸣 BGM;菜单/坠毁结算 = 轻 pad(`bgm/menu`),状态切换即换曲。 ## 教学流程(首次进入) @@ -28,10 +31,10 @@ | 手势 | 作用 | |---|---| | 上 / 下 / 左 / 右滑 | **同向闪避**:把对应方位来的陨石拨开(撞击前 1.5s 内有效) | -| 单击 | 引擎短促加速 ~1.8s,增益期间冲过空隙(规避单击 300ms 判定延迟,持续增益非即时判定);第 20s 解锁 | +| 单击 | 旁白在播 = 跳过本句;否则 = 引擎短促加速 ~1.8s,增益期间冲过空隙(规避单击 300ms 判定延迟,持续增益非即时判定);第 20s 解锁 | | 双击 | 展开护盾 6s,挡下一次撞击;之后 30s 充能冷却,充能完毕有语音提示;第 45s 解锁 | | 下滑 | **无下方陨石悬置时**呼出菜单(有下方陨石时下滑永远是闪避) | -| 单击(结算后) | 再飞一次 | +| 单击(结算后) | 跳过结算播报;播报结束后 = 再飞一次 | | 下滑(结算后) | 回大厅 | ## 菜单 @@ -46,3 +49,10 @@ python3 -m http.server 8106 --directory /home/ubuntu/SoundGame/web # 浏览器打开 http://localhost:8106/space/ ,建议戴耳机 ``` + +## 测试 + +```bash +/home/ubuntu/SoundGame/tools/node/bin/node test/playtest.mjs +# 无头试玩:虚拟时钟 + mock 浏览器环境,跑 13 号文档 §5/§7 全部关键路径与边界断言 +``` diff --git a/web/space/audio/bgm/menu.m4a b/web/space/audio/bgm/menu.m4a new file mode 100644 index 00000000..0c1c68c8 Binary files /dev/null and b/web/space/audio/bgm/menu.m4a differ diff --git a/web/space/audio/bgm/menu.ogg b/web/space/audio/bgm/menu.ogg new file mode 100644 index 00000000..fe333a93 Binary files /dev/null and b/web/space/audio/bgm/menu.ogg differ diff --git a/web/space/audio/sfx/alarm.m4a b/web/space/audio/sfx/alarm.m4a index 156b2629..3b6fe69a 100644 Binary files a/web/space/audio/sfx/alarm.m4a and b/web/space/audio/sfx/alarm.m4a differ diff --git a/web/space/audio/sfx/alarm.ogg b/web/space/audio/sfx/alarm.ogg index 6ad2811b..069746b8 100644 Binary files a/web/space/audio/sfx/alarm.ogg and b/web/space/audio/sfx/alarm.ogg differ diff --git a/web/space/audio/sfx/boost.m4a b/web/space/audio/sfx/boost.m4a index 17df17a6..8e8e6985 100644 Binary files a/web/space/audio/sfx/boost.m4a and b/web/space/audio/sfx/boost.m4a differ diff --git a/web/space/audio/sfx/boost.ogg b/web/space/audio/sfx/boost.ogg index 755acc2d..fc40612f 100644 Binary files a/web/space/audio/sfx/boost.ogg and b/web/space/audio/sfx/boost.ogg differ diff --git a/web/space/audio/sfx/crash.m4a b/web/space/audio/sfx/crash.m4a index 6cefa7a5..9e0bd4ec 100644 Binary files a/web/space/audio/sfx/crash.m4a and b/web/space/audio/sfx/crash.m4a differ diff --git a/web/space/audio/sfx/crash.ogg b/web/space/audio/sfx/crash.ogg index 54a209d5..300b233a 100644 Binary files a/web/space/audio/sfx/crash.ogg and b/web/space/audio/sfx/crash.ogg differ diff --git a/web/space/audio/sfx/dodge.m4a b/web/space/audio/sfx/dodge.m4a index 5b573654..054e1eee 100644 Binary files a/web/space/audio/sfx/dodge.m4a and b/web/space/audio/sfx/dodge.m4a differ diff --git a/web/space/audio/sfx/dodge.ogg b/web/space/audio/sfx/dodge.ogg index 48857bde..44d47418 100644 Binary files a/web/space/audio/sfx/dodge.ogg and b/web/space/audio/sfx/dodge.ogg differ diff --git a/web/space/audio/sfx/hit.m4a b/web/space/audio/sfx/hit.m4a index a73ad87e..6385be4e 100644 Binary files a/web/space/audio/sfx/hit.m4a and b/web/space/audio/sfx/hit.m4a differ diff --git a/web/space/audio/sfx/hit.ogg b/web/space/audio/sfx/hit.ogg index a1b9b8ef..be7470ce 100644 Binary files a/web/space/audio/sfx/hit.ogg and b/web/space/audio/sfx/hit.ogg differ diff --git a/web/space/audio/sfx/shield.m4a b/web/space/audio/sfx/shield.m4a index 3aae5caa..b061a5c0 100644 Binary files a/web/space/audio/sfx/shield.m4a and b/web/space/audio/sfx/shield.m4a differ diff --git a/web/space/audio/sfx/shield.ogg b/web/space/audio/sfx/shield.ogg index 6e4bf0c5..50449844 100644 Binary files a/web/space/audio/sfx/shield.ogg and b/web/space/audio/sfx/shield.ogg differ diff --git a/web/space/audio/sfx/shield_block.m4a b/web/space/audio/sfx/shield_block.m4a index fc2e037a..f245c111 100644 Binary files a/web/space/audio/sfx/shield_block.m4a and b/web/space/audio/sfx/shield_block.m4a differ diff --git a/web/space/audio/sfx/shield_block.ogg b/web/space/audio/sfx/shield_block.ogg index 0a0c8af1..ff49d3a1 100644 Binary files a/web/space/audio/sfx/shield_block.ogg and b/web/space/audio/sfx/shield_block.ogg differ diff --git a/web/space/audio/sfx/whoosh_down.m4a b/web/space/audio/sfx/whoosh_down.m4a index c976492e..a83af4b1 100644 Binary files a/web/space/audio/sfx/whoosh_down.m4a and b/web/space/audio/sfx/whoosh_down.m4a differ diff --git a/web/space/audio/sfx/whoosh_down.ogg b/web/space/audio/sfx/whoosh_down.ogg index aa6ff815..e1a4f205 100644 Binary files a/web/space/audio/sfx/whoosh_down.ogg and b/web/space/audio/sfx/whoosh_down.ogg differ diff --git a/web/space/audio/sfx/whoosh_left.m4a b/web/space/audio/sfx/whoosh_left.m4a index e85d31b2..12442a49 100644 Binary files a/web/space/audio/sfx/whoosh_left.m4a and b/web/space/audio/sfx/whoosh_left.m4a differ diff --git a/web/space/audio/sfx/whoosh_left.ogg b/web/space/audio/sfx/whoosh_left.ogg index 68fd3c6e..0e3ef25c 100644 Binary files a/web/space/audio/sfx/whoosh_left.ogg and b/web/space/audio/sfx/whoosh_left.ogg differ diff --git a/web/space/audio/sfx/whoosh_right.m4a b/web/space/audio/sfx/whoosh_right.m4a index cd660314..24e7b1f7 100644 Binary files a/web/space/audio/sfx/whoosh_right.m4a and b/web/space/audio/sfx/whoosh_right.m4a differ diff --git a/web/space/audio/sfx/whoosh_right.ogg b/web/space/audio/sfx/whoosh_right.ogg index ebd79512..b37c0073 100644 Binary files a/web/space/audio/sfx/whoosh_right.ogg and b/web/space/audio/sfx/whoosh_right.ogg differ diff --git a/web/space/audio/sfx/whoosh_up.m4a b/web/space/audio/sfx/whoosh_up.m4a index c7adaac9..8bf6dbe1 100644 Binary files a/web/space/audio/sfx/whoosh_up.m4a and b/web/space/audio/sfx/whoosh_up.m4a differ diff --git a/web/space/audio/sfx/whoosh_up.ogg b/web/space/audio/sfx/whoosh_up.ogg index bb3f9516..4d65b0bb 100644 Binary files a/web/space/audio/sfx/whoosh_up.ogg and b/web/space/audio/sfx/whoosh_up.ogg differ diff --git a/web/space/audio/voice/female/dodge_1.ogg b/web/space/audio/voice/female/dodge_1.ogg index 1763da85..3c8d808d 100644 Binary files a/web/space/audio/voice/female/dodge_1.ogg and b/web/space/audio/voice/female/dodge_1.ogg differ diff --git a/web/space/audio/voice/female/dodge_2.ogg b/web/space/audio/voice/female/dodge_2.ogg index e34c7669..fd2a7963 100644 Binary files a/web/space/audio/voice/female/dodge_2.ogg and b/web/space/audio/voice/female/dodge_2.ogg differ diff --git a/web/space/audio/voice/female/dodge_3.ogg b/web/space/audio/voice/female/dodge_3.ogg index e3de3981..9adfd7e7 100644 Binary files a/web/space/audio/voice/female/dodge_3.ogg and b/web/space/audio/voice/female/dodge_3.ogg differ diff --git a/web/space/audio/voice/female/dodge_4.ogg b/web/space/audio/voice/female/dodge_4.ogg index 0bb4b47d..3999b115 100644 Binary files a/web/space/audio/voice/female/dodge_4.ogg and b/web/space/audio/voice/female/dodge_4.ogg differ diff --git a/web/space/audio/voice/female/drill_done.ogg b/web/space/audio/voice/female/drill_done.ogg index 0bcda657..d438685d 100644 Binary files a/web/space/audio/voice/female/drill_done.ogg and b/web/space/audio/voice/female/drill_done.ogg differ diff --git a/web/space/audio/voice/female/drill_down.ogg b/web/space/audio/voice/female/drill_down.ogg index 8e9dc2f7..003f991f 100644 Binary files a/web/space/audio/voice/female/drill_down.ogg and b/web/space/audio/voice/female/drill_down.ogg differ diff --git a/web/space/audio/voice/female/drill_good.ogg b/web/space/audio/voice/female/drill_good.ogg index 5ab84c15..7226008d 100644 Binary files a/web/space/audio/voice/female/drill_good.ogg and b/web/space/audio/voice/female/drill_good.ogg differ diff --git a/web/space/audio/voice/female/drill_left.ogg b/web/space/audio/voice/female/drill_left.ogg index f51a3652..451e229b 100644 Binary files a/web/space/audio/voice/female/drill_left.ogg and b/web/space/audio/voice/female/drill_left.ogg differ diff --git a/web/space/audio/voice/female/drill_moveon.ogg b/web/space/audio/voice/female/drill_moveon.ogg index c5bcdd7f..2062de2c 100644 Binary files a/web/space/audio/voice/female/drill_moveon.ogg and b/web/space/audio/voice/female/drill_moveon.ogg differ diff --git a/web/space/audio/voice/female/drill_retry_down.ogg b/web/space/audio/voice/female/drill_retry_down.ogg index 5e28613e..ce54e446 100644 Binary files a/web/space/audio/voice/female/drill_retry_down.ogg and b/web/space/audio/voice/female/drill_retry_down.ogg differ diff --git a/web/space/audio/voice/female/drill_retry_left.ogg b/web/space/audio/voice/female/drill_retry_left.ogg index 1f54c14d..bd16a2b3 100644 Binary files a/web/space/audio/voice/female/drill_retry_left.ogg and b/web/space/audio/voice/female/drill_retry_left.ogg differ diff --git a/web/space/audio/voice/female/drill_retry_right.ogg b/web/space/audio/voice/female/drill_retry_right.ogg index d5160f45..3f621d5e 100644 Binary files a/web/space/audio/voice/female/drill_retry_right.ogg and b/web/space/audio/voice/female/drill_retry_right.ogg differ diff --git a/web/space/audio/voice/female/drill_retry_up.ogg b/web/space/audio/voice/female/drill_retry_up.ogg index a34326d6..f561d914 100644 Binary files a/web/space/audio/voice/female/drill_retry_up.ogg and b/web/space/audio/voice/female/drill_retry_up.ogg differ diff --git a/web/space/audio/voice/female/drill_right.ogg b/web/space/audio/voice/female/drill_right.ogg index 54f514a3..8fabfbd8 100644 Binary files a/web/space/audio/voice/female/drill_right.ogg and b/web/space/audio/voice/female/drill_right.ogg differ diff --git a/web/space/audio/voice/female/drill_rule.ogg b/web/space/audio/voice/female/drill_rule.ogg index 0adc260a..bfd917b0 100644 Binary files a/web/space/audio/voice/female/drill_rule.ogg and b/web/space/audio/voice/female/drill_rule.ogg differ diff --git a/web/space/audio/voice/female/drill_skip.ogg b/web/space/audio/voice/female/drill_skip.ogg index 12af19a5..1226adde 100644 Binary files a/web/space/audio/voice/female/drill_skip.ogg and b/web/space/audio/voice/female/drill_skip.ogg differ diff --git a/web/space/audio/voice/female/drill_up.ogg b/web/space/audio/voice/female/drill_up.ogg index 685df2d8..eea2746a 100644 Binary files a/web/space/audio/voice/female/drill_up.ogg and b/web/space/audio/voice/female/drill_up.ogg differ diff --git a/web/space/audio/voice/female/drill_welcome.ogg b/web/space/audio/voice/female/drill_welcome.ogg index baa88622..c6315b91 100644 Binary files a/web/space/audio/voice/female/drill_welcome.ogg and b/web/space/audio/voice/female/drill_welcome.ogg differ diff --git a/web/space/audio/voice/female/feat_boost.ogg b/web/space/audio/voice/female/feat_boost.ogg index 7ad799be..a261fe48 100644 Binary files a/web/space/audio/voice/female/feat_boost.ogg and b/web/space/audio/voice/female/feat_boost.ogg differ diff --git a/web/space/audio/voice/female/feat_boost_done.ogg b/web/space/audio/voice/female/feat_boost_done.ogg index 922c73ec..ef6c3e83 100644 Binary files a/web/space/audio/voice/female/feat_boost_done.ogg and b/web/space/audio/voice/female/feat_boost_done.ogg differ diff --git a/web/space/audio/voice/female/feat_shield.ogg b/web/space/audio/voice/female/feat_shield.ogg index 56785f31..f7c9c128 100644 Binary files a/web/space/audio/voice/female/feat_shield.ogg and b/web/space/audio/voice/female/feat_shield.ogg differ diff --git a/web/space/audio/voice/female/feat_shield_done.ogg b/web/space/audio/voice/female/feat_shield_done.ogg index e510b81d..8e9434b1 100644 Binary files a/web/space/audio/voice/female/feat_shield_done.ogg and b/web/space/audio/voice/female/feat_shield_done.ogg differ diff --git a/web/space/audio/voice/female/help_recap.ogg b/web/space/audio/voice/female/help_recap.ogg index db42719f..58fb6e56 100644 Binary files a/web/space/audio/voice/female/help_recap.ogg and b/web/space/audio/voice/female/help_recap.ogg differ diff --git a/web/space/audio/voice/female/hit_down.ogg b/web/space/audio/voice/female/hit_down.ogg index 4f2d4992..e6b7cf53 100644 Binary files a/web/space/audio/voice/female/hit_down.ogg and b/web/space/audio/voice/female/hit_down.ogg differ diff --git a/web/space/audio/voice/female/hit_last.ogg b/web/space/audio/voice/female/hit_last.ogg index 0d8ea717..787db9b7 100644 Binary files a/web/space/audio/voice/female/hit_last.ogg and b/web/space/audio/voice/female/hit_last.ogg differ diff --git a/web/space/audio/voice/female/hit_left.ogg b/web/space/audio/voice/female/hit_left.ogg index 72dfdb06..beb4ca55 100644 Binary files a/web/space/audio/voice/female/hit_left.ogg and b/web/space/audio/voice/female/hit_left.ogg differ diff --git a/web/space/audio/voice/female/hit_right.ogg b/web/space/audio/voice/female/hit_right.ogg index 5d2c29dc..9d2b0aef 100644 Binary files a/web/space/audio/voice/female/hit_right.ogg and b/web/space/audio/voice/female/hit_right.ogg differ diff --git a/web/space/audio/voice/female/hit_up.ogg b/web/space/audio/voice/female/hit_up.ogg index 5a09b4a7..2efcb3e9 100644 Binary files a/web/space/audio/voice/female/hit_up.ogg and b/web/space/audio/voice/female/hit_up.ogg differ diff --git a/web/space/audio/voice/female/menu_help.ogg b/web/space/audio/voice/female/menu_help.ogg index a76d7e1c..f26b4c7b 100644 Binary files a/web/space/audio/voice/female/menu_help.ogg and b/web/space/audio/voice/female/menu_help.ogg differ diff --git a/web/space/audio/voice/female/menu_lobby.ogg b/web/space/audio/voice/female/menu_lobby.ogg index 5f388ffa..4decea35 100644 Binary files a/web/space/audio/voice/female/menu_lobby.ogg and b/web/space/audio/voice/female/menu_lobby.ogg differ diff --git a/web/space/audio/voice/female/menu_main.ogg b/web/space/audio/voice/female/menu_main.ogg index c0e5ba86..40336488 100644 Binary files a/web/space/audio/voice/female/menu_main.ogg and b/web/space/audio/voice/female/menu_main.ogg differ diff --git a/web/space/audio/voice/female/menu_resume.ogg b/web/space/audio/voice/female/menu_resume.ogg index 4ef7b804..e486aeb1 100644 Binary files a/web/space/audio/voice/female/menu_resume.ogg and b/web/space/audio/voice/female/menu_resume.ogg differ diff --git a/web/space/audio/voice/female/ms_10.ogg b/web/space/audio/voice/female/ms_10.ogg index 5f62b985..60f01dfb 100644 Binary files a/web/space/audio/voice/female/ms_10.ogg and b/web/space/audio/voice/female/ms_10.ogg differ diff --git a/web/space/audio/voice/female/ms_100.ogg b/web/space/audio/voice/female/ms_100.ogg index 5b719461..fae5e6e3 100644 Binary files a/web/space/audio/voice/female/ms_100.ogg and b/web/space/audio/voice/female/ms_100.ogg differ diff --git a/web/space/audio/voice/female/ms_25.ogg b/web/space/audio/voice/female/ms_25.ogg index cef56cda..472456d8 100644 Binary files a/web/space/audio/voice/female/ms_25.ogg and b/web/space/audio/voice/female/ms_25.ogg differ diff --git a/web/space/audio/voice/female/ms_50.ogg b/web/space/audio/voice/female/ms_50.ogg index 6ca919da..51832307 100644 Binary files a/web/space/audio/voice/female/ms_50.ogg and b/web/space/audio/voice/female/ms_50.ogg differ diff --git a/web/space/audio/voice/female/new_record.ogg b/web/space/audio/voice/female/new_record.ogg index 30e66b48..684aca5f 100644 Binary files a/web/space/audio/voice/female/new_record.ogg and b/web/space/audio/voice/female/new_record.ogg differ diff --git a/web/space/audio/voice/female/paused.ogg b/web/space/audio/voice/female/paused.ogg index 1469af78..e6b5c25c 100644 Binary files a/web/space/audio/voice/female/paused.ogg and b/web/space/audio/voice/female/paused.ogg differ diff --git a/web/space/audio/voice/female/protect_down.ogg b/web/space/audio/voice/female/protect_down.ogg index ea5777aa..e8def423 100644 Binary files a/web/space/audio/voice/female/protect_down.ogg and b/web/space/audio/voice/female/protect_down.ogg differ diff --git a/web/space/audio/voice/female/protect_end.ogg b/web/space/audio/voice/female/protect_end.ogg index f4b5c910..610c58a5 100644 Binary files a/web/space/audio/voice/female/protect_end.ogg and b/web/space/audio/voice/female/protect_end.ogg differ diff --git a/web/space/audio/voice/female/protect_left.ogg b/web/space/audio/voice/female/protect_left.ogg index 725e90ee..98db3d18 100644 Binary files a/web/space/audio/voice/female/protect_left.ogg and b/web/space/audio/voice/female/protect_left.ogg differ diff --git a/web/space/audio/voice/female/protect_right.ogg b/web/space/audio/voice/female/protect_right.ogg index 3b8c7ab7..619ccab6 100644 Binary files a/web/space/audio/voice/female/protect_right.ogg and b/web/space/audio/voice/female/protect_right.ogg differ diff --git a/web/space/audio/voice/female/protect_up.ogg b/web/space/audio/voice/female/protect_up.ogg index b5eb679d..74c9bb2b 100644 Binary files a/web/space/audio/voice/female/protect_up.ogg and b/web/space/audio/voice/female/protect_up.ogg differ diff --git a/web/space/audio/voice/female/restart_hint.ogg b/web/space/audio/voice/female/restart_hint.ogg index 75447cc0..b654115a 100644 Binary files a/web/space/audio/voice/female/restart_hint.ogg and b/web/space/audio/voice/female/restart_hint.ogg differ diff --git a/web/space/audio/voice/female/second_run_ask.ogg b/web/space/audio/voice/female/second_run_ask.ogg index f5cb49fc..0914b5a5 100644 Binary files a/web/space/audio/voice/female/second_run_ask.ogg and b/web/space/audio/voice/female/second_run_ask.ogg differ diff --git a/web/space/audio/voice/female/settings_hint.ogg b/web/space/audio/voice/female/settings_hint.ogg index 04e81373..60957db3 100644 Binary files a/web/space/audio/voice/female/settings_hint.ogg and b/web/space/audio/voice/female/settings_hint.ogg differ diff --git a/web/space/audio/voice/female/shield_block.ogg b/web/space/audio/voice/female/shield_block.ogg index 91f17425..169b22d6 100644 Binary files a/web/space/audio/voice/female/shield_block.ogg and b/web/space/audio/voice/female/shield_block.ogg differ diff --git a/web/space/audio/voice/female/shield_ready.ogg b/web/space/audio/voice/female/shield_ready.ogg index f0fed6da..bfeedc65 100644 Binary files a/web/space/audio/voice/female/shield_ready.ogg and b/web/space/audio/voice/female/shield_ready.ogg differ diff --git a/web/space/audio/voice/female/skip_hint.m4a b/web/space/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..e3021656 Binary files /dev/null and b/web/space/audio/voice/female/skip_hint.m4a differ diff --git a/web/space/audio/voice/female/skip_hint.ogg b/web/space/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..d83298cb Binary files /dev/null and b/web/space/audio/voice/female/skip_hint.ogg differ diff --git a/web/space/audio/voice/female/time_1.ogg b/web/space/audio/voice/female/time_1.ogg index bcbf3aa6..cadd4ea2 100644 Binary files a/web/space/audio/voice/female/time_1.ogg and b/web/space/audio/voice/female/time_1.ogg differ diff --git a/web/space/audio/voice/female/time_2.ogg b/web/space/audio/voice/female/time_2.ogg index 4c4e67ff..164136bf 100644 Binary files a/web/space/audio/voice/female/time_2.ogg and b/web/space/audio/voice/female/time_2.ogg differ diff --git a/web/space/audio/voice/female/time_3.ogg b/web/space/audio/voice/female/time_3.ogg index 66c85d68..35a3dd4b 100644 Binary files a/web/space/audio/voice/female/time_3.ogg and b/web/space/audio/voice/female/time_3.ogg differ diff --git a/web/space/audio/voice/female/time_4.ogg b/web/space/audio/voice/female/time_4.ogg index 0d16ab2d..68715cfb 100644 Binary files a/web/space/audio/voice/female/time_4.ogg and b/web/space/audio/voice/female/time_4.ogg differ diff --git a/web/space/audio/voice/male/dodge_1.ogg b/web/space/audio/voice/male/dodge_1.ogg index 425829ab..508e21b8 100644 Binary files a/web/space/audio/voice/male/dodge_1.ogg and b/web/space/audio/voice/male/dodge_1.ogg differ diff --git a/web/space/audio/voice/male/dodge_2.ogg b/web/space/audio/voice/male/dodge_2.ogg index 0c3de298..8165a201 100644 Binary files a/web/space/audio/voice/male/dodge_2.ogg and b/web/space/audio/voice/male/dodge_2.ogg differ diff --git a/web/space/audio/voice/male/dodge_3.ogg b/web/space/audio/voice/male/dodge_3.ogg index a07fd174..963d0c73 100644 Binary files a/web/space/audio/voice/male/dodge_3.ogg and b/web/space/audio/voice/male/dodge_3.ogg differ diff --git a/web/space/audio/voice/male/dodge_4.ogg b/web/space/audio/voice/male/dodge_4.ogg index 318d4148..60f77a8d 100644 Binary files a/web/space/audio/voice/male/dodge_4.ogg and b/web/space/audio/voice/male/dodge_4.ogg differ diff --git a/web/space/audio/voice/male/drill_done.ogg b/web/space/audio/voice/male/drill_done.ogg index 510a1549..c48ec145 100644 Binary files a/web/space/audio/voice/male/drill_done.ogg and b/web/space/audio/voice/male/drill_done.ogg differ diff --git a/web/space/audio/voice/male/drill_down.ogg b/web/space/audio/voice/male/drill_down.ogg index 590cb1fd..65562882 100644 Binary files a/web/space/audio/voice/male/drill_down.ogg and b/web/space/audio/voice/male/drill_down.ogg differ diff --git a/web/space/audio/voice/male/drill_good.ogg b/web/space/audio/voice/male/drill_good.ogg index c3f4bdba..1ebb8adb 100644 Binary files a/web/space/audio/voice/male/drill_good.ogg and b/web/space/audio/voice/male/drill_good.ogg differ diff --git a/web/space/audio/voice/male/drill_left.ogg b/web/space/audio/voice/male/drill_left.ogg index 2cd64e3e..910e6fae 100644 Binary files a/web/space/audio/voice/male/drill_left.ogg and b/web/space/audio/voice/male/drill_left.ogg differ diff --git a/web/space/audio/voice/male/drill_moveon.ogg b/web/space/audio/voice/male/drill_moveon.ogg index 9b479afe..a00dca76 100644 Binary files a/web/space/audio/voice/male/drill_moveon.ogg and b/web/space/audio/voice/male/drill_moveon.ogg differ diff --git a/web/space/audio/voice/male/drill_retry_down.ogg b/web/space/audio/voice/male/drill_retry_down.ogg index 1cd43141..81628924 100644 Binary files a/web/space/audio/voice/male/drill_retry_down.ogg and b/web/space/audio/voice/male/drill_retry_down.ogg differ diff --git a/web/space/audio/voice/male/drill_retry_left.ogg b/web/space/audio/voice/male/drill_retry_left.ogg index cdb91214..be57cf6b 100644 Binary files a/web/space/audio/voice/male/drill_retry_left.ogg and b/web/space/audio/voice/male/drill_retry_left.ogg differ diff --git a/web/space/audio/voice/male/drill_retry_right.ogg b/web/space/audio/voice/male/drill_retry_right.ogg index 80694435..7403e3e6 100644 Binary files a/web/space/audio/voice/male/drill_retry_right.ogg and b/web/space/audio/voice/male/drill_retry_right.ogg differ diff --git a/web/space/audio/voice/male/drill_retry_up.ogg b/web/space/audio/voice/male/drill_retry_up.ogg index 09423b9a..e1274df3 100644 Binary files a/web/space/audio/voice/male/drill_retry_up.ogg and b/web/space/audio/voice/male/drill_retry_up.ogg differ diff --git a/web/space/audio/voice/male/drill_right.ogg b/web/space/audio/voice/male/drill_right.ogg index 7f2a429f..2432dc92 100644 Binary files a/web/space/audio/voice/male/drill_right.ogg and b/web/space/audio/voice/male/drill_right.ogg differ diff --git a/web/space/audio/voice/male/drill_rule.ogg b/web/space/audio/voice/male/drill_rule.ogg index ad769e6c..eb180b73 100644 Binary files a/web/space/audio/voice/male/drill_rule.ogg and b/web/space/audio/voice/male/drill_rule.ogg differ diff --git a/web/space/audio/voice/male/drill_skip.ogg b/web/space/audio/voice/male/drill_skip.ogg index f2cf25cb..f31b7417 100644 Binary files a/web/space/audio/voice/male/drill_skip.ogg and b/web/space/audio/voice/male/drill_skip.ogg differ diff --git a/web/space/audio/voice/male/drill_up.ogg b/web/space/audio/voice/male/drill_up.ogg index 32561569..a69bdc18 100644 Binary files a/web/space/audio/voice/male/drill_up.ogg and b/web/space/audio/voice/male/drill_up.ogg differ diff --git a/web/space/audio/voice/male/drill_welcome.ogg b/web/space/audio/voice/male/drill_welcome.ogg index c9ea8ebb..f2cc3d0f 100644 Binary files a/web/space/audio/voice/male/drill_welcome.ogg and b/web/space/audio/voice/male/drill_welcome.ogg differ diff --git a/web/space/audio/voice/male/feat_boost.ogg b/web/space/audio/voice/male/feat_boost.ogg index 7c98e058..ca853946 100644 Binary files a/web/space/audio/voice/male/feat_boost.ogg and b/web/space/audio/voice/male/feat_boost.ogg differ diff --git a/web/space/audio/voice/male/feat_boost_done.ogg b/web/space/audio/voice/male/feat_boost_done.ogg index b588b477..8ea1f830 100644 Binary files a/web/space/audio/voice/male/feat_boost_done.ogg and b/web/space/audio/voice/male/feat_boost_done.ogg differ diff --git a/web/space/audio/voice/male/feat_shield.ogg b/web/space/audio/voice/male/feat_shield.ogg index 1b131de1..cb4fdc3b 100644 Binary files a/web/space/audio/voice/male/feat_shield.ogg and b/web/space/audio/voice/male/feat_shield.ogg differ diff --git a/web/space/audio/voice/male/feat_shield_done.ogg b/web/space/audio/voice/male/feat_shield_done.ogg index 73a66644..6e615885 100644 Binary files a/web/space/audio/voice/male/feat_shield_done.ogg and b/web/space/audio/voice/male/feat_shield_done.ogg differ diff --git a/web/space/audio/voice/male/help_recap.ogg b/web/space/audio/voice/male/help_recap.ogg index ba9560b3..eb13ed7c 100644 Binary files a/web/space/audio/voice/male/help_recap.ogg and b/web/space/audio/voice/male/help_recap.ogg differ diff --git a/web/space/audio/voice/male/hit_down.ogg b/web/space/audio/voice/male/hit_down.ogg index b554ea1a..f26c03e6 100644 Binary files a/web/space/audio/voice/male/hit_down.ogg and b/web/space/audio/voice/male/hit_down.ogg differ diff --git a/web/space/audio/voice/male/hit_last.ogg b/web/space/audio/voice/male/hit_last.ogg index 61560361..e3608264 100644 Binary files a/web/space/audio/voice/male/hit_last.ogg and b/web/space/audio/voice/male/hit_last.ogg differ diff --git a/web/space/audio/voice/male/hit_left.ogg b/web/space/audio/voice/male/hit_left.ogg index 6c66b2ba..d3fa8520 100644 Binary files a/web/space/audio/voice/male/hit_left.ogg and b/web/space/audio/voice/male/hit_left.ogg differ diff --git a/web/space/audio/voice/male/hit_right.ogg b/web/space/audio/voice/male/hit_right.ogg index 499e361f..751e4813 100644 Binary files a/web/space/audio/voice/male/hit_right.ogg and b/web/space/audio/voice/male/hit_right.ogg differ diff --git a/web/space/audio/voice/male/hit_up.ogg b/web/space/audio/voice/male/hit_up.ogg index fd7ac3c4..2b093264 100644 Binary files a/web/space/audio/voice/male/hit_up.ogg and b/web/space/audio/voice/male/hit_up.ogg differ diff --git a/web/space/audio/voice/male/menu_help.ogg b/web/space/audio/voice/male/menu_help.ogg index b2b01f37..4f6046f5 100644 Binary files a/web/space/audio/voice/male/menu_help.ogg and b/web/space/audio/voice/male/menu_help.ogg differ diff --git a/web/space/audio/voice/male/menu_lobby.ogg b/web/space/audio/voice/male/menu_lobby.ogg index b58dbc4a..2a02eb9b 100644 Binary files a/web/space/audio/voice/male/menu_lobby.ogg and b/web/space/audio/voice/male/menu_lobby.ogg differ diff --git a/web/space/audio/voice/male/menu_main.ogg b/web/space/audio/voice/male/menu_main.ogg index f7a3e7c2..b65c374e 100644 Binary files a/web/space/audio/voice/male/menu_main.ogg and b/web/space/audio/voice/male/menu_main.ogg differ diff --git a/web/space/audio/voice/male/menu_resume.ogg b/web/space/audio/voice/male/menu_resume.ogg index 2ed4bd6a..d5ffeda5 100644 Binary files a/web/space/audio/voice/male/menu_resume.ogg and b/web/space/audio/voice/male/menu_resume.ogg differ diff --git a/web/space/audio/voice/male/ms_10.ogg b/web/space/audio/voice/male/ms_10.ogg index 74f969a4..d33513fa 100644 Binary files a/web/space/audio/voice/male/ms_10.ogg and b/web/space/audio/voice/male/ms_10.ogg differ diff --git a/web/space/audio/voice/male/ms_100.ogg b/web/space/audio/voice/male/ms_100.ogg index 219cafd8..1737162d 100644 Binary files a/web/space/audio/voice/male/ms_100.ogg and b/web/space/audio/voice/male/ms_100.ogg differ diff --git a/web/space/audio/voice/male/ms_25.ogg b/web/space/audio/voice/male/ms_25.ogg index 64934e54..11f462a2 100644 Binary files a/web/space/audio/voice/male/ms_25.ogg and b/web/space/audio/voice/male/ms_25.ogg differ diff --git a/web/space/audio/voice/male/ms_50.ogg b/web/space/audio/voice/male/ms_50.ogg index bd8b8ddc..44efbd00 100644 Binary files a/web/space/audio/voice/male/ms_50.ogg and b/web/space/audio/voice/male/ms_50.ogg differ diff --git a/web/space/audio/voice/male/new_record.ogg b/web/space/audio/voice/male/new_record.ogg index 583bd2e7..cf4b7ded 100644 Binary files a/web/space/audio/voice/male/new_record.ogg and b/web/space/audio/voice/male/new_record.ogg differ diff --git a/web/space/audio/voice/male/paused.ogg b/web/space/audio/voice/male/paused.ogg index 6933198d..7e00000c 100644 Binary files a/web/space/audio/voice/male/paused.ogg and b/web/space/audio/voice/male/paused.ogg differ diff --git a/web/space/audio/voice/male/protect_down.ogg b/web/space/audio/voice/male/protect_down.ogg index cf2a5bca..5d07eed0 100644 Binary files a/web/space/audio/voice/male/protect_down.ogg and b/web/space/audio/voice/male/protect_down.ogg differ diff --git a/web/space/audio/voice/male/protect_end.ogg b/web/space/audio/voice/male/protect_end.ogg index c1e091f2..37ce4d49 100644 Binary files a/web/space/audio/voice/male/protect_end.ogg and b/web/space/audio/voice/male/protect_end.ogg differ diff --git a/web/space/audio/voice/male/protect_left.ogg b/web/space/audio/voice/male/protect_left.ogg index 00790280..bdda8cdd 100644 Binary files a/web/space/audio/voice/male/protect_left.ogg and b/web/space/audio/voice/male/protect_left.ogg differ diff --git a/web/space/audio/voice/male/protect_right.ogg b/web/space/audio/voice/male/protect_right.ogg index f0b3f1fe..c1903a7d 100644 Binary files a/web/space/audio/voice/male/protect_right.ogg and b/web/space/audio/voice/male/protect_right.ogg differ diff --git a/web/space/audio/voice/male/protect_up.ogg b/web/space/audio/voice/male/protect_up.ogg index 1e1c2347..9ba3eb76 100644 Binary files a/web/space/audio/voice/male/protect_up.ogg and b/web/space/audio/voice/male/protect_up.ogg differ diff --git a/web/space/audio/voice/male/restart_hint.ogg b/web/space/audio/voice/male/restart_hint.ogg index 1b3c3941..84fcaf66 100644 Binary files a/web/space/audio/voice/male/restart_hint.ogg and b/web/space/audio/voice/male/restart_hint.ogg differ diff --git a/web/space/audio/voice/male/second_run_ask.ogg b/web/space/audio/voice/male/second_run_ask.ogg index ae1c0f89..6a0cdd62 100644 Binary files a/web/space/audio/voice/male/second_run_ask.ogg and b/web/space/audio/voice/male/second_run_ask.ogg differ diff --git a/web/space/audio/voice/male/settings_hint.ogg b/web/space/audio/voice/male/settings_hint.ogg index cd98070e..14e209bc 100644 Binary files a/web/space/audio/voice/male/settings_hint.ogg and b/web/space/audio/voice/male/settings_hint.ogg differ diff --git a/web/space/audio/voice/male/shield_block.ogg b/web/space/audio/voice/male/shield_block.ogg index a580724c..fb44e807 100644 Binary files a/web/space/audio/voice/male/shield_block.ogg and b/web/space/audio/voice/male/shield_block.ogg differ diff --git a/web/space/audio/voice/male/shield_ready.ogg b/web/space/audio/voice/male/shield_ready.ogg index 0a8d7949..949b82bf 100644 Binary files a/web/space/audio/voice/male/shield_ready.ogg and b/web/space/audio/voice/male/shield_ready.ogg differ diff --git a/web/space/audio/voice/male/skip_hint.m4a b/web/space/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..62efcfef Binary files /dev/null and b/web/space/audio/voice/male/skip_hint.m4a differ diff --git a/web/space/audio/voice/male/skip_hint.ogg b/web/space/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..57c6a401 Binary files /dev/null and b/web/space/audio/voice/male/skip_hint.ogg differ diff --git a/web/space/audio/voice/male/time_1.ogg b/web/space/audio/voice/male/time_1.ogg index 6688ab52..53bbf3a3 100644 Binary files a/web/space/audio/voice/male/time_1.ogg and b/web/space/audio/voice/male/time_1.ogg differ diff --git a/web/space/audio/voice/male/time_2.ogg b/web/space/audio/voice/male/time_2.ogg index 4054ce60..7e0b88be 100644 Binary files a/web/space/audio/voice/male/time_2.ogg and b/web/space/audio/voice/male/time_2.ogg differ diff --git a/web/space/audio/voice/male/time_3.ogg b/web/space/audio/voice/male/time_3.ogg index 71bc8a85..11c26009 100644 Binary files a/web/space/audio/voice/male/time_3.ogg and b/web/space/audio/voice/male/time_3.ogg differ diff --git a/web/space/audio/voice/male/time_4.ogg b/web/space/audio/voice/male/time_4.ogg index 68dec4cb..b47e5fc0 100644 Binary files a/web/space/audio/voice/male/time_4.ogg and b/web/space/audio/voice/male/time_4.ogg differ diff --git a/web/space/game.js b/web/space/game.js index 4ed46159..3d470183 100644 --- a/web/space/game.js +++ b/web/space/game.js @@ -18,6 +18,11 @@ * - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 → * 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时** * 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。 + * - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回 + * `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。 + * - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报 + * 播放中单击 = `skipVoice()` 跳下一句;无语音在播时单击维持原义(加速/重开)。 + * 首局 D0 在 drill_welcome 后插播一次 skip_hint。 * * 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件—— * 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm / @@ -118,6 +123,7 @@ export class SpaceGame extends Game { 'sfx/alarm', 'sfx/boost', 'sfx/shield', 'sfx/shield_block', 'sfx/dodge', 'sfx/hit', 'sfx/crash', 'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask', + 'voice/skip_hint', ]); this._session++; this._clearTimers(); @@ -127,7 +133,7 @@ export class SpaceGame extends Game { this.phase = 'ask'; this.audio.play('voice/second_run_ask'); } else { - this._startDrill(); + this._startDrill(true); // 首局:D0 插播一次 skip_hint } } @@ -158,22 +164,28 @@ export class SpaceGame extends Game { this._onMenuGesture(g); break; case 'over': - if (g === 'tap') this._startRun(); // 单击再飞一次 - else if (g === 'down') this._goHome(); // 下滑回大厅(restart_hint 口径) + // 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次 + if (g === 'tap') { + if (this._voiceBusy()) this.audio.skipVoice(); + else this._startRun(); + } else if (g === 'down') this._goHome(); // 下滑回大厅(restart_hint 口径) break; } } // ---------------- D0-D5 交互式演练 ---------------- - _startDrill() { + _startDrill(withSkipHint = false) { this._session++; this._clearTimers(); this.audio.stopVoice(); this._resetRunState(); this.phase = 'drill'; - // D0:建立预期(无陨石) - this._speakGuarded(['voice/drill_welcome', 'voice/drill_rule'], () => this._nextDrill()); + // D0:建立预期(无陨石)。首局在 welcome 后插播一次 skip_hint(12 号文档 §3 预留 id)。 + const d0 = withSkipHint + ? ['voice/drill_welcome', 'voice/skip_hint', 'voice/drill_rule'] + : ['voice/drill_welcome', 'voice/drill_rule']; + this._speakGuarded(d0, () => this._nextDrill()); } _nextDrill() { @@ -203,8 +215,10 @@ export class SpaceGame extends Game { _onDrillGesture(g) { if (!this.drillOb) { - // 无演练陨石悬置(旁白/反馈播放中):下滑 = 跳过演练,其余忽略 + // 无演练陨石悬置(旁白/反馈播放中):下滑 = 跳过演练; + // 旁白在播时单击 = 跳过本句(语音链跳下一句),其余忽略 if (g === 'down') this._skipDrill(); + else if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice(); return; } const d = DRILLS[this.drillIdx]; @@ -287,6 +301,8 @@ export class SpaceGame extends Game { } break; case 'tap': + // 归因/里程碑等播报在播时单击 = 跳过本句(12 号文档裁定:space 对局语音可跳) + if (this._voiceBusy()) { this.audio.skipVoice(); break; } // 加速是持续型增益(~1.8s),期间的撞击一律算冲过空隙, // 因此 tap 的 ~300ms 判定延迟不影响手感。第 20s 引入后才解锁。 if (this.boostIntroDone && now >= this.boostReadyAt) { @@ -336,6 +352,9 @@ export class SpaceGame extends Game { } _onFeatureGesture(g) { + // 引入旁白在播时单击 = 跳过本句(12 号文档:feat_* 引入句可跳); + // 单击加速教学需旁白播完(或被跳过)后再单击一次触发 + if (g === 'tap' && this._voiceBusy()) { this.audio.skipVoice(); return; } const want = this.featureKind === 'boost' ? 'tap' : 'doubletap'; if (g !== want) return; // 其他手势忽略,等超时自动继续 const now = performance.now(); @@ -350,8 +369,7 @@ export class SpaceGame extends Game { this.shieldReadyAt = now + SHIELD_COOLDOWN_MS; this.audio.play('sfx/shield'); this._speak(['voice/feat_shield_done']); - this._endFeature(); - this._scheduleShieldReady(); // _endFeature 清过 timers,充能播报在其后重排 + this._endFeature(); // _endFeature → _rearmRunTimers 会按 shieldReadyAt 重排充能播报 } } @@ -501,6 +519,7 @@ export class SpaceGame extends Game { this.obstacles.clear(); this.swipes = []; this._menuIdx = 0; + this.audio.play('bgm/menu', { fadeMs: 300 }); // 状态 BGM:进菜单快切(10 号文档 §6) this._speak(['voice/paused', 'voice/menu_main', MENU_ITEMS[0]]); } @@ -522,6 +541,7 @@ export class SpaceGame extends Game { _closeMenu() { this.phase = 'playing'; + this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine this._scheduleSpawn(800); this._rearmRunTimers(); } @@ -549,7 +569,10 @@ export class SpaceGame extends Game { const ids = [this._timeBandVoice(aliveSec)]; if (isRecord) ids.push('voice/new_record'); ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint'); - this._speak(ids); + // 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链 + this._speak(ids, () => { + if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 }); + }); }); } @@ -619,6 +642,11 @@ export class SpaceGame extends Game { // ---------------- 工具 ---------------- + /** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */ + _voiceBusy() { + return !!this.audio._voice; + } + /** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */ _speak(ids, onDone) { if (!ids.length) { if (onDone) onDone(); return; } diff --git a/web/space/test/playtest.mjs b/web/space/test/playtest.mjs new file mode 100644 index 00000000..20b4064f --- /dev/null +++ b/web/space/test/playtest.mjs @@ -0,0 +1,583 @@ +/** + * 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径 + * + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)。 + * + * 用法:tools/node/bin/node web/space/test/playtest.mjs + * + * 设计: + * - 真实加载 engine/{audio,settings,haptics,game}.js 与 vendor/soundtouch.js(纯 JS), + * 只 mock 浏览器环境:window/document/navigator/localStorage/fetch/AudioContext。 + * - fetch 映射到 web/space/audio/ 真实文件系统:资产缺失 → ok:false(顺带做资产校验)。 + * - decodeAudioData 返回带 duration 的假 buffer(voice 1.0s / bgm 60s / sfx 0.5s); + * ScriptProcessorNode 永不触发 onaudioprocess,voice 自然结束由引擎内部定时器驱动。 + * - 虚拟时钟:setTimeout/clearTimeout/performance.now 全部替换,测试零真实等待。 + * - 手势直接注入 game.onGesture(引擎判定路径之外的稳定注入点,13 号文档允许); + * 刷怪方向用 Math.random 队列控制,陨石投放/快进用游戏实例钩子(文档明示允许)。 + */ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const GAME_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// ---------------- 虚拟时钟 ---------------- +const clock = { + now: 0, + _timers: new Map(), + _nextId: 1, + setTimeout(fn, ms = 0) { + const id = this._nextId++; + this._timers.set(id, { at: this.now + Math.max(0, ms), fn }); + return id; + }, + clearTimeout(id) { this._timers.delete(id); }, + /** 推进虚拟时钟,按到期顺序触发所有 ≤ target 的定时器。 */ + advance(ms) { + const target = this.now + ms; + for (;;) { + let bestId = -1, bestAt = Infinity; + for (const [id, t] of this._timers) { + if (t.at < bestAt || (t.at === bestAt && id < bestId)) { bestAt = t.at; bestId = id; } + } + if (bestId < 0 || bestAt > target) break; + const { fn } = this._timers.get(bestId); + this._timers.delete(bestId); + this.now = bestAt; + fn(); + } + this.now = target; + }, +}; + +// ---------------- 浏览器环境 mock(必须先于引擎 import) ---------------- +globalThis.setTimeout = (fn, ms) => clock.setTimeout(fn, ms); +globalThis.clearTimeout = (id) => clock.clearTimeout(id); +Object.defineProperty(globalThis, 'performance', { + value: { now: () => clock.now }, configurable: true, +}); +Object.defineProperty(globalThis, 'navigator', { + value: { vibrate: (p) => { vibrations.push(p); return true; } }, configurable: true, +}); +const vibrations = []; + +function makeLocalStorage() { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + clear: () => m.clear(), + }; +} +Object.defineProperty(globalThis, 'localStorage', { + value: makeLocalStorage(), writable: true, configurable: true, +}); +globalThis.window = globalThis; +window.addEventListener = () => {}; +window.location = { href: '' }; +globalThis.document = { + createElement: () => ({ style: { setProperty() {} }, appendChild() {}, addEventListener() {}, remove() {} }), + getElementById: () => null, + body: { appendChild() {} }, +}; + +/** 置为缺失的资产 id(看门狗测试用),如 'voice/drill_good'。 */ +const missingAssets = new Set(); +const fetchLog = []; +globalThis.fetch = async (url, opts) => { + const u = String(url); + if (u.startsWith('audio/')) { + // url 形如 audio/voice/female/x.ogg → 归一化 id(voice/x)用于缺失注入 + const noExt = u.replace(/\.[a-z0-9]+$/i, ''); + const id = noExt.replace(/^audio\//, '').replace(/^voice\/(male|female)\//, 'voice/'); + if (missingAssets.has(id) || !existsSync(path.join(GAME_DIR, u))) { + fetchLog.push({ url: u, opts, t: clock.now, ok: false }); + return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; + } + fetchLog.push({ url: u, opts, t: clock.now, ok: true }); + return { ok: true, status: 200, arrayBuffer: async () => ({ __url: u }) }; + } + fetchLog.push({ url: u, opts, t: clock.now, ok: true }); + return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0), json: async () => ({}) }; +}; + +class MockParam { + constructor(v = 0) { this.value = v; } + cancelScheduledValues() {} + setValueAtTime() {} + linearRampToValueAtTime() {} +} +class MockAudioContext { + constructor() { this.state = 'suspended'; this.destination = {}; } + get currentTime() { return clock.now / 1000; } + async resume() { this.state = 'running'; } + createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; } + createDynamicsCompressor() { + return { + threshold: new MockParam(), ratio: new MockParam(), + attack: new MockParam(), release: new MockParam(), + connect() {}, disconnect() {}, + }; + } + createBufferSource() { + return { buffer: null, loop: false, connect() {}, disconnect() {}, start() {}, stop() {} }; + } + createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } + async decodeAudioData(raw) { + const u = (raw && raw.__url) || ''; + let duration = 0.5; + if (u.includes('/voice/')) duration = 1.0; + else if (u.includes('/bgm/')) duration = 60; + return { + duration, numberOfChannels: 1, sampleRate: 44100, + getChannelData: () => new Float32Array(16), + }; + } +} +globalThis.AudioContext = MockAudioContext; + +// ---------------- 受控随机 ---------------- +const randQueue = []; +Math.random = () => (randQueue.length ? randQueue.shift() : 0); // 默认 0 = left + +// ---------------- 加载引擎与游戏(globals 就绪后动态 import) ---------------- +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { SpaceGame } = await import('../game.js'); + +// ---------------- 断言与工具 ---------------- +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓', msg); } + else { failed++; console.error(' ✗ FAIL:', msg); } +} +function section(name) { console.log('\n== ' + name); } +/** 冲刷微任务(fetch/decode/play 链是 async 的)。 */ +const tick = async (n = 10) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); }; +async function advance(ms) { clock.advance(ms); await tick(); } + +function makeHarness() { + const audio = new WebAudioEngine('audio/'); + const plays = [], skips = []; + const origPlay = audio.play.bind(audio); + audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(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 }; +} +const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id); +const lastVoice = (plays) => voices(plays).at(-1); +const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat); + +try { + // ====================================================================== + section('S1 关键路径:演练 → 保护期 → 分批引入 → 坠毁结算 → 重开'); + // ====================================================================== + const h = makeHarness(); + const { game, audio, plays, skips } = h; + + // -- §7.1 解锁前无音频播放 -- + audio.play('sfx/dodge'); + assert(fetchLog.length === 0, '音频解锁前 play 不加载不播放(§7.1)'); + await audio.unlock(); + + game.start(); + await tick(); + assert(game.phase === 'drill', '首局进入演练 phase'); + assert(lastVoice(plays) === 'voice/drill_welcome', 'D0 drill_welcome 开播'); + assert(!!audio._voice, 'voice 层播放中'); + + // -- ② 演练旁白单击跳过 + ③ skip_hint 插播 -- + game.onGesture('tap'); await tick(); + assert(skips.length === 1, '演练旁白中单击 = skipVoice()'); + assert(lastVoice(plays) === 'voice/skip_hint', 'skip_hint 紧随 drill_welcome 插播(③)'); + game.onGesture('tap'); await tick(); + assert(lastVoice(plays) === 'voice/drill_rule', '再单击跳到 D0 下一句 drill_rule'); + + await advance(1300); // drill_rule 播完 → D1 旁白 + assert(game.drillIdx === 0 && lastVoice(plays) === 'voice/drill_left', 'D1 旁白 drill_left'); + assert(!game.drillOb, '旁白播完前不出现演练陨石(语音先、音效后)'); + game.onGesture('tap'); await tick(); // 跳过 drill_left → 立即 spawn + assert(game.drillOb && game.drillOb.dir === 'left', '跳句后 D1 演练陨石(左)出现'); + assert(plays.some((p) => p.id === 'sfx/whoosh_left'), 'whoosh_left 方位音'); + + // -- 边界:演练做错重教 + 失败 2 次带过 -- + game.onGesture('right'); await tick(); + assert(game.drillFails === 1 && game.drillIdx === 0, 'D1 右滑(应左)→ 同一步重试'); + assert(lastVoice(plays) === 'voice/drill_retry_left', 'drill_retry_left 当场重教'); + assert(vibrated('60,40,60'), '演练做错 ERROR 震动'); + await advance(1300); + assert(game.drillOb && game.drillOb.dir === 'left', '重教后同一颗重来'); + game.onGesture('up'); await tick(); + assert(game.drillFails === 2, 'D1 连错第 2 次'); + assert(lastVoice(plays) === 'voice/drill_moveon', '失败 2 次 drill_moveon 带过'); + await advance(1300); + assert(game.drillIdx === 1 && lastVoice(plays) === 'voice/drill_right', '自动进入 D2'); + + // -- 边界回归:演练等待输入时单击维持原义(算做错) -- + await advance(1300); // drill_right 播完 → spawn + assert(game.drillOb && game.drillOb.dir === 'right', 'D2 演练陨石(右)出现'); + game.onGesture('tap'); await tick(); + assert(game.drillFails === 1 && lastVoice(plays) === 'voice/drill_retry_right', + '演练等待输入时单击维持原义(判错重教),不被跳过逻辑劫持'); + await advance(1300); + + // D2 做对(good=false → 直接下一步) + game.onGesture('right'); await tick(); + assert(game.drillIdx === 2 && lastVoice(plays) === 'voice/drill_up', 'D2 做对进 D3'); + // D3 做对(good=true → drill_good 短肯定) + await advance(1300); + assert(game.drillOb && game.drillOb.dir === 'up', 'D3 演练陨石(上)出现'); + game.onGesture('up'); await tick(); + assert(lastVoice(plays) === 'voice/drill_good', 'D3 做对播 drill_good'); + await advance(1300); + assert(game.drillIdx === 3 && lastVoice(plays) === 'voice/drill_down', '进 D4'); + // D4 做对 → D5 收尾 + await advance(1300); + assert(game.drillOb && game.drillOb.dir === 'down', 'D4 演练陨石(下)出现'); + game.onGesture('down'); await tick(); + assert(lastVoice(plays) === 'voice/drill_done', 'D5 收尾句'); + await advance(1300); + assert(localStorage.getItem('soundgame.space.drillDone') === '1', 'drillDone 落盘(§5.1)'); + assert(game.phase === 'playing', 'D5 后进入 playing'); + assert(voices(plays).filter((id) => id === 'voice/skip_hint').length === 1, + 'skip_hint 全程仅插播一次(③)'); + await tick(); + assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局 BGM = bgm/engine'); + + // -- §5.2 保护期:任其撞不掉血 + 方位归因 -- + const t0 = game.runStart; + await advance(1500); // 首颗(左,Math.random 默认 0) + assert(game.obstacles.size >= 1, '保护期首颗陨石刷出'); + await advance(4100); // 到撞击点 + assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)'); + assert(lastVoice(plays) === 'voice/protect_left', 'protect_ 与来袭方向一致(左)'); + assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动'); + + // -- §5.3 第 20s 单击引入(feat_boost 可跳) -- + await advance(t0 + 20000 - clock.now); + assert(game.phase === 'feature' && game.featureKind === 'boost', '第 20s 暂停刷怪教单击'); + assert(lastVoice(plays) === 'voice/feat_boost', 'feat_boost 引入句'); + const skipsBefore = skips.length; + game.onGesture('tap'); await tick(); + assert(skips.length === skipsBefore + 1 && game.phase === 'feature', + 'feat_boost 在播时单击 = 跳过本句(不直接触发加速)'); + game.onGesture('tap'); await tick(); + assert(game.boostUntil > clock.now, '旁白已跳后单击 → boost 生效(§5.3)'); + assert(game.phase === 'playing', '分批引入结束恢复 playing'); + assert(lastVoice(plays) === 'voice/feat_boost_done', 'feat_boost_done 确认句'); + + // 加速增益期间的撞击算冲过空隙 + game.obstacles.clear(); + const obBoost = { dir: 'right', impactAt: clock.now + 100 }; + game.obstacles.add(obBoost); + game._after(100, () => game._resolve(obBoost)); + await advance(150); + assert(game.dodges === 1 && game.hits === 0, '加速期间撞击 = 冲过空隙(dodge)'); + + // -- §5.3 第 45s 双击引入 -- + await advance(t0 + 45000 - clock.now); + 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.phase === 'playing', '护盾引入结束恢复 playing'); + + // 护盾格挡一颗(消耗护盾,不掉血) + game.obstacles.clear(); + const obShield = { dir: 'left', impactAt: clock.now + 100 }; + game.obstacles.add(obShield); + game._after(100, () => game._resolve(obShield)); + await advance(150); + assert(game.hits === 0 && game.shieldUpUntil === 0, '护盾挡下一颗后消耗'); + assert(plays.some((p) => p.id === 'sfx/shield_block'), 'shield_block 格挡音'); + await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音 + assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出'); + + // -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 -- + const naturalSpawn = game._spawnObstacle.bind(game); + game._spawnObstacle = () => {}; + + // -- §5.4 保护解除播报 -- + await advance(t0 + 60050 - clock.now); + assert(game._protectAnnounced && voices(plays).includes('voice/protect_end'), '60s 保护解除播报'); + + // -- 边界:护盾充能完毕播报,且只播一次(回归:曾双重调度播两次) -- + await advance(game.shieldReadyAt - clock.now + 50); + assert(voices(plays).filter((id) => id === 'voice/shield_ready').length === 1, + 'shield_ready 充能完毕播报恰好一次'); + assert(vibrated('15'), '护盾就绪 PICKUP 震动'); + + // -- §5.4 正常闪避 3 颗(同向滑,须在撞击前 1.5s 判定窗口内出手) -- + let expectDodges = game.dodges; + for (const rv of [0, 0.25, 0.5]) { // left / right / up + randQueue.push(rv); + game.obstacles.clear(); game.swipes = []; + naturalSpawn(); + const ob = [...game.obstacles].at(-1); + 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, '同向闪避不掉血'); + } + + // -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) -- + randQueue.push(0); // left + game.obstacles.clear(); game.swipes = []; + naturalSpawn(); + const obH1 = [...game.obstacles].at(-1); + game.onGesture('right'); // 反向 + await advance(obH1.impactAt - clock.now + 50); + assert(game.hits === 1, '反向滑不判闪避,撞击 hits=1'); + assert(lastVoice(plays) === 'voice/hit_left', 'hit_ 归因方向与来袭一致'); + assert(vibrated('80'), '受击 HIT 震动'); + + // -- ② 对局归因语音可跳 -- + const skipsB2 = skips.length; + game.onGesture('tap'); await tick(); + assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()'); + + // -- 边界:第 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'); + + // -- 边界:里程碑 dodges=10 播 ms_10 -- + game.dodges = 9; // 13 号文档允许的测试钩子 + randQueue.push(0.5); // up + game.obstacles.clear(); game.swipes = []; + naturalSpawn(); + const obM = [...game.obstacles].at(-1); + await advance(obM.impactAt - clock.now - 800); + game.onGesture('up'); + await advance(900); + assert(game.dodges === 10, 'dodges=10'); + assert(voices(plays).includes('voice/ms_10'), '里程碑 ms_10 播报'); + assert(vibrated('20'), '里程碑 CONFIRM 震动'); + await advance(1300); + + // -- §5.4 第 3 次撞击坠毁 -- + 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); + assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover'); + assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash'); + assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)'); + assert(vibrated('60,40,60'), '坠毁 ERROR 震动'); + await advance(350); + assert(vibrations.filter((v) => Array.isArray(v) && v.join() === '80').length >= 2, '坠毁追加 HIT 震动'); + + // -- §5.5 结算分档 + 状态 BGM(10 号文档 §6) -- + const aliveSec = Math.floor((crashAt - t0) / 1000); + const expectTime = aliveSec < 30 ? 'voice/time_1' : aliveSec < 60 ? 'voice/time_2' + : aliveSec < 120 ? 'voice/time_3' : 'voice/time_4'; + const expectDodge = game.dodges < 5 ? 'voice/dodge_1' : game.dodges < 15 ? 'voice/dodge_2' + : game.dodges < 50 ? 'voice/dodge_3' : 'voice/dodge_4'; + await advance(1300); // 爆炸声 1.2s 后开口 + assert(lastVoice(plays) === expectTime, `结算时间段句 ${expectTime}(存活 ${aliveSec}s)`); + game.onGesture('tap'); await tick(); // 跳过时间段句 + assert(lastVoice(plays) === 'voice/new_record', '结算句可跳;首局必破纪录 new_record'); + await advance(1300); + assert(lastVoice(plays) === expectDodge, `结算躲避段句 ${expectDodge}(dodges=${game.dodges})`); + await advance(1300); + assert(lastVoice(plays) === 'voice/restart_hint', 'restart_hint 收尾'); + await advance(1300); // 链播完 + assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 800), + '结算语音链播完淡入 bgm/menu 800ms(10 号文档 §6)'); + await tick(); + assert(audio._bgm && audio._bgm.id === 'bgm/menu', '结算态 BGM = bgm/menu'); + assert(vibrated('100'), '破纪录 SUCCESS 震动'); + + // -- §5.5 单击重开 -- + game.onGesture('tap'); await tick(); + assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局'); + assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine'); + + // ====================================================================== + section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)'); + // ====================================================================== + game.obstacles.clear(); + game.onGesture('down'); await tick(); + assert(game.phase === 'menu', '无下方陨石时下滑 = 菜单(下滑折中)'); + assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300), + '进菜单 crossfade 300ms 切 bgm/menu'); + await tick(); + assert(audio._bgm && audio._bgm.id === 'bgm/menu', '菜单态 BGM = bgm/menu'); + game.onGesture('tap'); await tick(); + assert(lastVoice(plays) === 'voice/menu_lobby', '菜单单击循环 → menu_lobby'); + game.onGesture('tap'); await tick(); + assert(lastVoice(plays) === 'voice/menu_help', '菜单单击循环 → menu_help'); + game.onGesture('tap'); await tick(); + assert(lastVoice(plays) === 'voice/menu_resume', '菜单单击循环回卷 → menu_resume'); + game.onGesture('up'); await tick(); + assert(lastVoice(plays) === 'voice/help_recap', '菜单上滑 = 帮助'); + game.onGesture('doubletap'); await tick(); // _menuIdx=0 → 恢复 + assert(game.phase === 'playing', '菜单双击确认 menu_resume = 回对局'); + assert(plays.some((p) => p.id === 'bgm/engine' && p.opts.fadeMs === 500), + '回对局淡回 bgm/engine 500ms'); + await tick(); + assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine'); + + // 下滑折中另一侧:有下方陨石时下滑 = 闪避 + randQueue.push(0.75); // down + game.obstacles.clear(); game.swipes = []; + naturalSpawn(); + const obD = [...game.obstacles].at(-1); + assert(obD.dir === 'down', '投放下方陨石'); + await advance(obD.impactAt - clock.now - 800); + game.onGesture('down'); + assert(game.phase === 'playing', '有下方陨石时下滑 = 闪避,不开菜单'); + await advance(900); + assert(game.dodges === 1, '下滑闪避生效 dodges=1'); + + // 菜单"回大厅" + game.obstacles.clear(); + game.onGesture('down'); await tick(); + game.onGesture('tap'); await tick(); // → menu_lobby + game.onGesture('doubletap'); await tick(); + assert(window.location.href === '../', '菜单双击确认 menu_lobby = 回大厅'); + window.location.href = ''; + + // ====================================================================== + section('S3 二周目:ask 流程 + skip_hint 不重复插播'); + // ====================================================================== + const h2 = makeHarness(); + await h2.audio.unlock(); + h2.game.start(); await tick(); + assert(h2.game.phase === 'ask' && lastVoice(h2.plays) === 'voice/second_run_ask', + '二周目 second_run_ask 询问'); + h2.game.onGesture('up'); await tick(); // 上滑重听演练 + assert(h2.game.phase === 'drill' && lastVoice(h2.plays) === 'voice/drill_welcome', + '上滑重听演练'); + await advance(1300); + assert(lastVoice(h2.plays) === 'voice/drill_rule', '重听演练 D0 无 skip_hint(仅首局一次)'); + assert(!voices(h2.plays).includes('voice/skip_hint'), 'skip_hint 未重复插播'); + h2.game.stop(); + + const h2b = makeHarness(); + await h2b.audio.unlock(); + h2b.game.start(); await tick(); + h2b.game.onGesture('tap'); await tick(); // 单击直接出发 + assert(h2b.game.phase === 'playing', '二周目单击直接出发'); + h2b.game.stop(); + + // ====================================================================== + section('S4 看门狗:屏蔽 voice 资产不卡死(§7.2 / §5 说明)'); + // ====================================================================== + globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练 + missingAssets.add('voice/drill_good'); + const h3 = makeHarness(); + await h3.audio.unlock(); + h3.game.start(); await tick(); + for (let i = 0; i < 3; i++) await advance(1300); // D0 三句逐句播完(welcome/skip_hint/rule) + assert(lastVoice(h3.plays) === 'voice/drill_left', '看门狗局:D1 旁白'); + await advance(1300); // spawn D1 + h3.game.onGesture('left'); await tick(); // 做对 → drill_good(缺失) + assert(fetchLog.some((f) => f.url.includes('drill_good') && !f.ok) && !h3.audio._voice, + '缺失资产 404 且未起播(静默降级)'); + await advance(10100); // 看门狗 10s + assert(h3.game.drillIdx === 1 && lastVoice(h3.plays) === 'voice/drill_right', + '语音缺失时看门狗推进到 D2'); + await advance(1300); + h3.game.onGesture('right'); await tick(); // D2 做对(无 drill_good) + assert(h3.game.drillIdx === 2, 'D2 无短肯定直接进 D3'); + await advance(1300); + h3.game.onGesture('up'); await tick(); // D3 做对 → drill_good(缺失)→ 看门狗 + await advance(10100); + assert(h3.game.drillIdx === 3, '看门狗再次推进到 D4'); + await advance(1300); + h3.game.onGesture('down'); await tick(); // D4 做对 → drill_done + await advance(1400); + assert(h3.game.phase === 'playing', '屏蔽资产全流程不卡死,进 playing'); + h3.game.stop(); + missingAssets.clear(); + + // ====================================================================== + section('S5 设置边界(§7.3)'); + // ====================================================================== + const h4 = makeHarness(); + await h4.audio.unlock(); + globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练(S4 已写过 drillDone) + settings.set({ speed: 1.7 }); + h4.game.start(); await tick(); + assert(h4.game.phase === 'drill', 'S5 首局进演练'); + assert(h4.audio._voice && h4.audio._voice.st.tempo === 1.7, 'speed=1.7 时 voice tempo 生效'); + settings.set({ voice: 'male' }); + const f0 = fetchLog.length; + h4.game.onGesture('tap'); await tick(); // 跳过 welcome → 下一句走 male 目录 + const maleFetches = fetchLog.slice(f0).filter((f) => f.url.includes('/voice/')); + assert(maleFetches.length > 0 && maleFetches.every((f) => f.url.includes('/voice/male/')), + 'settings.voice=male 后下一句起用 male 目录'); + h4.game.stop(); + + settings.set({ bgm: 0 }); + localStorage.setItem('soundgame.space.drillDone', '1'); + const f1 = fetchLog.length; + h4.game.start(); await tick(); // ask + h4.game.onGesture('tap'); await tick(); // → _startRun → play('bgm/engine') + assert(h4.game.phase === 'playing', 'bgm=0 局进入 playing'); + assert(!fetchLog.slice(f1).some((f) => f.url.startsWith('audio/bgm/')), + 'bgm=0 时不加载 BGM 资产'); + assert(!h4.audio._bgm && h4.audio._bgmWanted && h4.audio._bgmWanted.id === 'bgm/engine', + 'bgm=0 时只记"想播"意图'); + settings.set({ bgm: 1 }); + await tick(); + assert(h4.audio._bgm && h4.audio._bgm.id === 'bgm/engine', 'bgm 调回非零自动补播最后状态 BGM'); + h4.game.stop(); + settings.set({ voice: 'female', speed: 1.0 }); + + // ====================================================================== + section('S6 资产引用校验(game.js 引用 id × ogg+m4a × 双性别)'); + // ====================================================================== + const src = readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') // 去块注释 + .replace(/\/\/[^\n]*/g, ''); // 去行注释 + const ids = new Set(); + for (const m of src.matchAll(/['"`]((?:bgm|sfx|voice)\/[A-Za-z0-9_]+)['"`]/g)) { + if (!m[1].endsWith('_')) ids.add(m[1]); // 滤掉模板前缀(voice/protect_ 等) + } + // 动态拼接件展开 + for (const d of ['left', 'right', 'up', 'down']) { + ids.add(`voice/protect_${d}`); + ids.add(`voice/hit_${d}`); + } + const missing = []; + for (const id of ids) { + if (id.startsWith('voice/')) { + for (const g of ['female', 'male']) { + for (const ext of ['ogg', 'm4a']) { + const rel = `audio/voice/${g}/${id.slice(6)}.${ext}`; + if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel); + } + } + } else { + for (const ext of ['ogg', 'm4a']) { + const rel = `audio/${id}.${ext}`; + if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel); + } + } + } + console.log(` 引用音频 id ${ids.size} 个`); + assert(missing.length === 0, '资产零缺失' + (missing.length ? `(缺:${missing.join(', ')})` : '')); + +} catch (e) { + failed++; + console.error('\n未捕获异常(§7.2 失败):', e); +} + +console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========`); +process.exit(failed ? 1 : 0); diff --git a/web/storyline/audio/bgm/menu.m4a b/web/storyline/audio/bgm/menu.m4a new file mode 100644 index 00000000..0d0a4ff6 Binary files /dev/null and b/web/storyline/audio/bgm/menu.m4a differ diff --git a/web/storyline/audio/bgm/menu.ogg b/web/storyline/audio/bgm/menu.ogg new file mode 100644 index 00000000..d08d05de Binary files /dev/null and b/web/storyline/audio/bgm/menu.ogg differ diff --git a/web/storyline/audio/sfx/footsteps.m4a b/web/storyline/audio/sfx/footsteps.m4a index ae813318..83706178 100644 Binary files a/web/storyline/audio/sfx/footsteps.m4a and b/web/storyline/audio/sfx/footsteps.m4a differ diff --git a/web/storyline/audio/sfx/footsteps.ogg b/web/storyline/audio/sfx/footsteps.ogg index 81efd3a3..358e0dcf 100644 Binary files a/web/storyline/audio/sfx/footsteps.ogg and b/web/storyline/audio/sfx/footsteps.ogg differ diff --git a/web/storyline/audio/sfx/gunshot.m4a b/web/storyline/audio/sfx/gunshot.m4a index 8e880b96..84736424 100644 Binary files a/web/storyline/audio/sfx/gunshot.m4a and b/web/storyline/audio/sfx/gunshot.m4a differ diff --git a/web/storyline/audio/sfx/gunshot.ogg b/web/storyline/audio/sfx/gunshot.ogg index abdd18c6..2e4fd666 100644 Binary files a/web/storyline/audio/sfx/gunshot.ogg and b/web/storyline/audio/sfx/gunshot.ogg differ diff --git a/web/storyline/audio/sfx/heartbeat.m4a b/web/storyline/audio/sfx/heartbeat.m4a index fedeed50..87eb1d2d 100644 Binary files a/web/storyline/audio/sfx/heartbeat.m4a and b/web/storyline/audio/sfx/heartbeat.m4a differ diff --git a/web/storyline/audio/sfx/heartbeat.ogg b/web/storyline/audio/sfx/heartbeat.ogg index c3c516ae..adf7b041 100644 Binary files a/web/storyline/audio/sfx/heartbeat.ogg and b/web/storyline/audio/sfx/heartbeat.ogg differ diff --git a/web/storyline/audio/sfx/radio_static.m4a b/web/storyline/audio/sfx/radio_static.m4a index ed690fc2..ed4cc71a 100644 Binary files a/web/storyline/audio/sfx/radio_static.m4a and b/web/storyline/audio/sfx/radio_static.m4a differ diff --git a/web/storyline/audio/sfx/radio_static.ogg b/web/storyline/audio/sfx/radio_static.ogg index d995ea87..68db1aa9 100644 Binary files a/web/storyline/audio/sfx/radio_static.ogg and b/web/storyline/audio/sfx/radio_static.ogg differ diff --git a/web/storyline/audio/sfx/rubble.m4a b/web/storyline/audio/sfx/rubble.m4a index 85a7c91d..dc304a3a 100644 Binary files a/web/storyline/audio/sfx/rubble.m4a and b/web/storyline/audio/sfx/rubble.m4a differ diff --git a/web/storyline/audio/sfx/rubble.ogg b/web/storyline/audio/sfx/rubble.ogg index 94ddc59a..a40545a7 100644 Binary files a/web/storyline/audio/sfx/rubble.ogg and b/web/storyline/audio/sfx/rubble.ogg differ diff --git a/web/storyline/audio/sfx/signal_beep.m4a b/web/storyline/audio/sfx/signal_beep.m4a index 21a2c599..a847f662 100644 Binary files a/web/storyline/audio/sfx/signal_beep.m4a and b/web/storyline/audio/sfx/signal_beep.m4a differ diff --git a/web/storyline/audio/sfx/signal_beep.ogg b/web/storyline/audio/sfx/signal_beep.ogg index ef5b9748..41c7e700 100644 Binary files a/web/storyline/audio/sfx/signal_beep.ogg and b/web/storyline/audio/sfx/signal_beep.ogg differ diff --git a/web/storyline/audio/sfx/struggle.m4a b/web/storyline/audio/sfx/struggle.m4a index ce2f3ce0..4cf8e03a 100644 Binary files a/web/storyline/audio/sfx/struggle.m4a and b/web/storyline/audio/sfx/struggle.m4a differ diff --git a/web/storyline/audio/sfx/struggle.ogg b/web/storyline/audio/sfx/struggle.ogg index 2976bfa5..8c75226b 100644 Binary files a/web/storyline/audio/sfx/struggle.ogg and b/web/storyline/audio/sfx/struggle.ogg differ diff --git a/web/storyline/audio/sfx/ui_confirm.m4a b/web/storyline/audio/sfx/ui_confirm.m4a index 5554db30..b742131f 100644 Binary files a/web/storyline/audio/sfx/ui_confirm.m4a and b/web/storyline/audio/sfx/ui_confirm.m4a differ diff --git a/web/storyline/audio/sfx/ui_confirm.ogg b/web/storyline/audio/sfx/ui_confirm.ogg index bf0f9593..9df3010c 100644 Binary files a/web/storyline/audio/sfx/ui_confirm.ogg and b/web/storyline/audio/sfx/ui_confirm.ogg differ diff --git a/web/storyline/audio/sfx/ui_menu.m4a b/web/storyline/audio/sfx/ui_menu.m4a index 8f7aec50..8f7e168b 100644 Binary files a/web/storyline/audio/sfx/ui_menu.m4a and b/web/storyline/audio/sfx/ui_menu.m4a differ diff --git a/web/storyline/audio/sfx/ui_menu.ogg b/web/storyline/audio/sfx/ui_menu.ogg index fefbf9e7..50716193 100644 Binary files a/web/storyline/audio/sfx/ui_menu.ogg and b/web/storyline/audio/sfx/ui_menu.ogg differ diff --git a/web/storyline/audio/sfx/water.m4a b/web/storyline/audio/sfx/water.m4a index 5d07867f..de5f784c 100644 Binary files a/web/storyline/audio/sfx/water.m4a and b/web/storyline/audio/sfx/water.m4a differ diff --git a/web/storyline/audio/sfx/water.ogg b/web/storyline/audio/sfx/water.ogg index ec81c786..05aba0a3 100644 Binary files a/web/storyline/audio/sfx/water.ogg and b/web/storyline/audio/sfx/water.ogg differ diff --git a/web/storyline/audio/voice/female/e1_dawn.ogg b/web/storyline/audio/voice/female/e1_dawn.ogg index 80e169b2..1194d76d 100644 Binary files a/web/storyline/audio/voice/female/e1_dawn.ogg and b/web/storyline/audio/voice/female/e1_dawn.ogg differ diff --git a/web/storyline/audio/voice/female/e2_price.ogg b/web/storyline/audio/voice/female/e2_price.ogg index 4129ce01..7d2a896a 100644 Binary files a/web/storyline/audio/voice/female/e2_price.ogg and b/web/storyline/audio/voice/female/e2_price.ogg differ diff --git a/web/storyline/audio/voice/female/e3_alone.ogg b/web/storyline/audio/voice/female/e3_alone.ogg index 848f5676..09eb7785 100644 Binary files a/web/storyline/audio/voice/female/e3_alone.ogg and b/web/storyline/audio/voice/female/e3_alone.ogg differ diff --git a/web/storyline/audio/voice/female/e4_static.ogg b/web/storyline/audio/voice/female/e4_static.ogg index b08fc5a6..7d2cdb4d 100644 Binary files a/web/storyline/audio/voice/female/e4_static.ogg and b/web/storyline/audio/voice/female/e4_static.ogg differ diff --git a/web/storyline/audio/voice/female/help.ogg b/web/storyline/audio/voice/female/help.ogg index f73aa678..9328d927 100644 Binary files a/web/storyline/audio/voice/female/help.ogg and b/web/storyline/audio/voice/female/help.ogg differ diff --git a/web/storyline/audio/voice/female/menu.ogg b/web/storyline/audio/voice/female/menu.ogg index 001f9b5f..72a194f7 100644 Binary files a/web/storyline/audio/voice/female/menu.ogg and b/web/storyline/audio/voice/female/menu.ogg differ diff --git a/web/storyline/audio/voice/female/n00_intro.ogg b/web/storyline/audio/voice/female/n00_intro.ogg index 701fa22c..da7217ad 100644 Binary files a/web/storyline/audio/voice/female/n00_intro.ogg and b/web/storyline/audio/voice/female/n00_intro.ogg differ diff --git a/web/storyline/audio/voice/female/n01_wake.ogg b/web/storyline/audio/voice/female/n01_wake.ogg index a4977a91..b99a0e32 100644 Binary files a/web/storyline/audio/voice/female/n01_wake.ogg and b/web/storyline/audio/voice/female/n01_wake.ogg differ diff --git a/web/storyline/audio/voice/female/n04_exit.ogg b/web/storyline/audio/voice/female/n04_exit.ogg index 8de15cbe..5792a4c8 100644 Binary files a/web/storyline/audio/voice/female/n04_exit.ogg and b/web/storyline/audio/voice/female/n04_exit.ogg differ diff --git a/web/storyline/audio/voice/female/n05_signal.ogg b/web/storyline/audio/voice/female/n05_signal.ogg index 23e85363..6fcb6fbb 100644 Binary files a/web/storyline/audio/voice/female/n05_signal.ogg and b/web/storyline/audio/voice/female/n05_signal.ogg differ diff --git a/web/storyline/audio/voice/female/n06_whistle.ogg b/web/storyline/audio/voice/female/n06_whistle.ogg index ddcc9ab4..ce407932 100644 Binary files a/web/storyline/audio/voice/female/n06_whistle.ogg and b/web/storyline/audio/voice/female/n06_whistle.ogg differ diff --git a/web/storyline/audio/voice/female/n08_vent.ogg b/web/storyline/audio/voice/female/n08_vent.ogg index 0eb026b9..3db61695 100644 Binary files a/web/storyline/audio/voice/female/n08_vent.ogg and b/web/storyline/audio/voice/female/n08_vent.ogg differ diff --git a/web/storyline/audio/voice/female/n09_dead.ogg b/web/storyline/audio/voice/female/n09_dead.ogg index d8f82b14..889d9c7b 100644 Binary files a/web/storyline/audio/voice/female/n09_dead.ogg and b/web/storyline/audio/voice/female/n09_dead.ogg differ diff --git a/web/storyline/audio/voice/female/n10_rain.ogg b/web/storyline/audio/voice/female/n10_rain.ogg index f858e998..c9ef2c91 100644 Binary files a/web/storyline/audio/voice/female/n10_rain.ogg and b/web/storyline/audio/voice/female/n10_rain.ogg differ diff --git a/web/storyline/audio/voice/female/n11_child.ogg b/web/storyline/audio/voice/female/n11_child.ogg index 903d5683..e71c5391 100644 Binary files a/web/storyline/audio/voice/female/n11_child.ogg and b/web/storyline/audio/voice/female/n11_child.ogg differ diff --git a/web/storyline/audio/voice/female/n13_leave.ogg b/web/storyline/audio/voice/female/n13_leave.ogg index f4cee26d..e7e017d9 100644 Binary files a/web/storyline/audio/voice/female/n13_leave.ogg and b/web/storyline/audio/voice/female/n13_leave.ogg differ diff --git a/web/storyline/audio/voice/female/n14_ignore.ogg b/web/storyline/audio/voice/female/n14_ignore.ogg index cd514b1b..2d26dcae 100644 Binary files a/web/storyline/audio/voice/female/n14_ignore.ogg and b/web/storyline/audio/voice/female/n14_ignore.ogg differ diff --git a/web/storyline/audio/voice/female/n15_bridge.ogg b/web/storyline/audio/voice/female/n15_bridge.ogg index ce0ece0c..dc04a9d8 100644 Binary files a/web/storyline/audio/voice/female/n15_bridge.ogg and b/web/storyline/audio/voice/female/n15_bridge.ogg differ diff --git a/web/storyline/audio/voice/female/n16_culvert.ogg b/web/storyline/audio/voice/female/n16_culvert.ogg index 11f3aca9..5c84449b 100644 Binary files a/web/storyline/audio/voice/female/n16_culvert.ogg and b/web/storyline/audio/voice/female/n16_culvert.ogg differ diff --git a/web/storyline/audio/voice/female/n16b_culvert_c.ogg b/web/storyline/audio/voice/female/n16b_culvert_c.ogg index 8fc87726..52d5b743 100644 Binary files a/web/storyline/audio/voice/female/n16b_culvert_c.ogg and b/web/storyline/audio/voice/female/n16b_culvert_c.ogg differ diff --git a/web/storyline/audio/voice/female/n17_cable.ogg b/web/storyline/audio/voice/female/n17_cable.ogg index 3ab6a806..6864c22d 100644 Binary files a/web/storyline/audio/voice/female/n17_cable.ogg and b/web/storyline/audio/voice/female/n17_cable.ogg differ diff --git a/web/storyline/audio/voice/female/n17b_cable_c.ogg b/web/storyline/audio/voice/female/n17b_cable_c.ogg index 395412aa..2e056645 100644 Binary files a/web/storyline/audio/voice/female/n17b_cable_c.ogg and b/web/storyline/audio/voice/female/n17b_cable_c.ogg differ diff --git a/web/storyline/audio/voice/female/n20_convoy.ogg b/web/storyline/audio/voice/female/n20_convoy.ogg index fe8538f9..f5d9e98d 100644 Binary files a/web/storyline/audio/voice/female/n20_convoy.ogg and b/web/storyline/audio/voice/female/n20_convoy.ogg differ diff --git a/web/storyline/audio/voice/female/n22_fight.ogg b/web/storyline/audio/voice/female/n22_fight.ogg index fc79ad83..5491f56b 100644 Binary files a/web/storyline/audio/voice/female/n22_fight.ogg and b/web/storyline/audio/voice/female/n22_fight.ogg differ diff --git a/web/storyline/audio/voice/female/n23_tower.ogg b/web/storyline/audio/voice/female/n23_tower.ogg index 5f165d25..e7219010 100644 Binary files a/web/storyline/audio/voice/female/n23_tower.ogg and b/web/storyline/audio/voice/female/n23_tower.ogg differ diff --git a/web/storyline/audio/voice/female/p1.ogg b/web/storyline/audio/voice/female/p1.ogg index 1297f02f..f66bb3fc 100644 Binary files a/web/storyline/audio/voice/female/p1.ogg and b/web/storyline/audio/voice/female/p1.ogg differ diff --git a/web/storyline/audio/voice/female/p2.ogg b/web/storyline/audio/voice/female/p2.ogg index 6096add2..1ec49a10 100644 Binary files a/web/storyline/audio/voice/female/p2.ogg and b/web/storyline/audio/voice/female/p2.ogg differ diff --git a/web/storyline/audio/voice/female/p3.ogg b/web/storyline/audio/voice/female/p3.ogg index a26cfd3d..837caf3b 100644 Binary files a/web/storyline/audio/voice/female/p3.ogg and b/web/storyline/audio/voice/female/p3.ogg differ diff --git a/web/storyline/audio/voice/female/p4.ogg b/web/storyline/audio/voice/female/p4.ogg index 92aa870c..6e1e6ea2 100644 Binary files a/web/storyline/audio/voice/female/p4.ogg and b/web/storyline/audio/voice/female/p4.ogg differ diff --git a/web/storyline/audio/voice/female/p5.ogg b/web/storyline/audio/voice/female/p5.ogg index df81c5a8..09c53475 100644 Binary files a/web/storyline/audio/voice/female/p5.ogg and b/web/storyline/audio/voice/female/p5.ogg differ diff --git a/web/storyline/audio/voice/female/p6.ogg b/web/storyline/audio/voice/female/p6.ogg index 1e2af5c3..ef640009 100644 Binary files a/web/storyline/audio/voice/female/p6.ogg and b/web/storyline/audio/voice/female/p6.ogg differ diff --git a/web/storyline/audio/voice/female/p7.ogg b/web/storyline/audio/voice/female/p7.ogg index 4d23df98..37a57027 100644 Binary files a/web/storyline/audio/voice/female/p7.ogg and b/web/storyline/audio/voice/female/p7.ogg differ diff --git a/web/storyline/audio/voice/female/p8.ogg b/web/storyline/audio/voice/female/p8.ogg index 61f19bdb..515147fb 100644 Binary files a/web/storyline/audio/voice/female/p8.ogg and b/web/storyline/audio/voice/female/p8.ogg differ diff --git a/web/storyline/audio/voice/female/skip_hint.m4a b/web/storyline/audio/voice/female/skip_hint.m4a new file mode 100644 index 00000000..dbe79d7a Binary files /dev/null and b/web/storyline/audio/voice/female/skip_hint.m4a differ diff --git a/web/storyline/audio/voice/female/skip_hint.ogg b/web/storyline/audio/voice/female/skip_hint.ogg new file mode 100644 index 00000000..2e9f39e8 Binary files /dev/null and b/web/storyline/audio/voice/female/skip_hint.ogg differ diff --git a/web/storyline/audio/voice/female/status1.ogg b/web/storyline/audio/voice/female/status1.ogg index b840bbd1..50662dd6 100644 Binary files a/web/storyline/audio/voice/female/status1.ogg and b/web/storyline/audio/voice/female/status1.ogg differ diff --git a/web/storyline/audio/voice/female/status2.ogg b/web/storyline/audio/voice/female/status2.ogg index dcc2cf36..cb4e508c 100644 Binary files a/web/storyline/audio/voice/female/status2.ogg and b/web/storyline/audio/voice/female/status2.ogg differ diff --git a/web/storyline/audio/voice/female/status3.ogg b/web/storyline/audio/voice/female/status3.ogg index 61b2f5f0..798a25a6 100644 Binary files a/web/storyline/audio/voice/female/status3.ogg and b/web/storyline/audio/voice/female/status3.ogg differ diff --git a/web/storyline/audio/voice/male/e1_dawn.ogg b/web/storyline/audio/voice/male/e1_dawn.ogg index 375914c6..e4d1cbe8 100644 Binary files a/web/storyline/audio/voice/male/e1_dawn.ogg and b/web/storyline/audio/voice/male/e1_dawn.ogg differ diff --git a/web/storyline/audio/voice/male/e2_price.ogg b/web/storyline/audio/voice/male/e2_price.ogg index 5be1146e..c7e41214 100644 Binary files a/web/storyline/audio/voice/male/e2_price.ogg and b/web/storyline/audio/voice/male/e2_price.ogg differ diff --git a/web/storyline/audio/voice/male/e3_alone.ogg b/web/storyline/audio/voice/male/e3_alone.ogg index b0424913..b36ba085 100644 Binary files a/web/storyline/audio/voice/male/e3_alone.ogg and b/web/storyline/audio/voice/male/e3_alone.ogg differ diff --git a/web/storyline/audio/voice/male/e4_static.ogg b/web/storyline/audio/voice/male/e4_static.ogg index 14b07e71..16669ead 100644 Binary files a/web/storyline/audio/voice/male/e4_static.ogg and b/web/storyline/audio/voice/male/e4_static.ogg differ diff --git a/web/storyline/audio/voice/male/help.ogg b/web/storyline/audio/voice/male/help.ogg index 092a2a62..bebd9a1d 100644 Binary files a/web/storyline/audio/voice/male/help.ogg and b/web/storyline/audio/voice/male/help.ogg differ diff --git a/web/storyline/audio/voice/male/menu.ogg b/web/storyline/audio/voice/male/menu.ogg index c786c2b4..9233179d 100644 Binary files a/web/storyline/audio/voice/male/menu.ogg and b/web/storyline/audio/voice/male/menu.ogg differ diff --git a/web/storyline/audio/voice/male/n00_intro.ogg b/web/storyline/audio/voice/male/n00_intro.ogg index 0f88d7bc..815ee52f 100644 Binary files a/web/storyline/audio/voice/male/n00_intro.ogg and b/web/storyline/audio/voice/male/n00_intro.ogg differ diff --git a/web/storyline/audio/voice/male/n01_wake.ogg b/web/storyline/audio/voice/male/n01_wake.ogg index d3502c61..ad061b17 100644 Binary files a/web/storyline/audio/voice/male/n01_wake.ogg and b/web/storyline/audio/voice/male/n01_wake.ogg differ diff --git a/web/storyline/audio/voice/male/n04_exit.ogg b/web/storyline/audio/voice/male/n04_exit.ogg index e402d6c7..fa7fa6c4 100644 Binary files a/web/storyline/audio/voice/male/n04_exit.ogg and b/web/storyline/audio/voice/male/n04_exit.ogg differ diff --git a/web/storyline/audio/voice/male/n05_signal.ogg b/web/storyline/audio/voice/male/n05_signal.ogg index 7b275a99..83bf30b3 100644 Binary files a/web/storyline/audio/voice/male/n05_signal.ogg and b/web/storyline/audio/voice/male/n05_signal.ogg differ diff --git a/web/storyline/audio/voice/male/n06_whistle.ogg b/web/storyline/audio/voice/male/n06_whistle.ogg index b7220a21..a38c1747 100644 Binary files a/web/storyline/audio/voice/male/n06_whistle.ogg and b/web/storyline/audio/voice/male/n06_whistle.ogg differ diff --git a/web/storyline/audio/voice/male/n08_vent.ogg b/web/storyline/audio/voice/male/n08_vent.ogg index 278b6fe2..7debac7c 100644 Binary files a/web/storyline/audio/voice/male/n08_vent.ogg and b/web/storyline/audio/voice/male/n08_vent.ogg differ diff --git a/web/storyline/audio/voice/male/n09_dead.ogg b/web/storyline/audio/voice/male/n09_dead.ogg index b13343fa..7c1181fd 100644 Binary files a/web/storyline/audio/voice/male/n09_dead.ogg and b/web/storyline/audio/voice/male/n09_dead.ogg differ diff --git a/web/storyline/audio/voice/male/n10_rain.ogg b/web/storyline/audio/voice/male/n10_rain.ogg index 02c6cdca..de33bd0f 100644 Binary files a/web/storyline/audio/voice/male/n10_rain.ogg and b/web/storyline/audio/voice/male/n10_rain.ogg differ diff --git a/web/storyline/audio/voice/male/n11_child.ogg b/web/storyline/audio/voice/male/n11_child.ogg index 0f318359..ce7c314d 100644 Binary files a/web/storyline/audio/voice/male/n11_child.ogg and b/web/storyline/audio/voice/male/n11_child.ogg differ diff --git a/web/storyline/audio/voice/male/n13_leave.ogg b/web/storyline/audio/voice/male/n13_leave.ogg index 9a1393bd..821f996e 100644 Binary files a/web/storyline/audio/voice/male/n13_leave.ogg and b/web/storyline/audio/voice/male/n13_leave.ogg differ diff --git a/web/storyline/audio/voice/male/n14_ignore.ogg b/web/storyline/audio/voice/male/n14_ignore.ogg index 82d592bc..07e0f64c 100644 Binary files a/web/storyline/audio/voice/male/n14_ignore.ogg and b/web/storyline/audio/voice/male/n14_ignore.ogg differ diff --git a/web/storyline/audio/voice/male/n15_bridge.ogg b/web/storyline/audio/voice/male/n15_bridge.ogg index 2cb03fff..079a6243 100644 Binary files a/web/storyline/audio/voice/male/n15_bridge.ogg and b/web/storyline/audio/voice/male/n15_bridge.ogg differ diff --git a/web/storyline/audio/voice/male/n16_culvert.ogg b/web/storyline/audio/voice/male/n16_culvert.ogg index 8635fd48..dcb8ca13 100644 Binary files a/web/storyline/audio/voice/male/n16_culvert.ogg and b/web/storyline/audio/voice/male/n16_culvert.ogg differ diff --git a/web/storyline/audio/voice/male/n16b_culvert_c.ogg b/web/storyline/audio/voice/male/n16b_culvert_c.ogg index 0d7df4a0..b9da9c0d 100644 Binary files a/web/storyline/audio/voice/male/n16b_culvert_c.ogg and b/web/storyline/audio/voice/male/n16b_culvert_c.ogg differ diff --git a/web/storyline/audio/voice/male/n17_cable.ogg b/web/storyline/audio/voice/male/n17_cable.ogg index ff696930..4202802c 100644 Binary files a/web/storyline/audio/voice/male/n17_cable.ogg and b/web/storyline/audio/voice/male/n17_cable.ogg differ diff --git a/web/storyline/audio/voice/male/n17b_cable_c.ogg b/web/storyline/audio/voice/male/n17b_cable_c.ogg index 5d2dc117..e5a62857 100644 Binary files a/web/storyline/audio/voice/male/n17b_cable_c.ogg and b/web/storyline/audio/voice/male/n17b_cable_c.ogg differ diff --git a/web/storyline/audio/voice/male/n20_convoy.ogg b/web/storyline/audio/voice/male/n20_convoy.ogg index 9c4f133e..7ffe5198 100644 Binary files a/web/storyline/audio/voice/male/n20_convoy.ogg and b/web/storyline/audio/voice/male/n20_convoy.ogg differ diff --git a/web/storyline/audio/voice/male/n22_fight.ogg b/web/storyline/audio/voice/male/n22_fight.ogg index 8d375d56..98c2bb49 100644 Binary files a/web/storyline/audio/voice/male/n22_fight.ogg and b/web/storyline/audio/voice/male/n22_fight.ogg differ diff --git a/web/storyline/audio/voice/male/n23_tower.ogg b/web/storyline/audio/voice/male/n23_tower.ogg index 7f423809..c247d4f2 100644 Binary files a/web/storyline/audio/voice/male/n23_tower.ogg and b/web/storyline/audio/voice/male/n23_tower.ogg differ diff --git a/web/storyline/audio/voice/male/p1.ogg b/web/storyline/audio/voice/male/p1.ogg index b3ac2413..a08bf3d0 100644 Binary files a/web/storyline/audio/voice/male/p1.ogg and b/web/storyline/audio/voice/male/p1.ogg differ diff --git a/web/storyline/audio/voice/male/p2.ogg b/web/storyline/audio/voice/male/p2.ogg index ebc86831..cb92d921 100644 Binary files a/web/storyline/audio/voice/male/p2.ogg and b/web/storyline/audio/voice/male/p2.ogg differ diff --git a/web/storyline/audio/voice/male/p3.ogg b/web/storyline/audio/voice/male/p3.ogg index fd3ffc78..bea469f4 100644 Binary files a/web/storyline/audio/voice/male/p3.ogg and b/web/storyline/audio/voice/male/p3.ogg differ diff --git a/web/storyline/audio/voice/male/p4.ogg b/web/storyline/audio/voice/male/p4.ogg index 010f5d6d..faa399fe 100644 Binary files a/web/storyline/audio/voice/male/p4.ogg and b/web/storyline/audio/voice/male/p4.ogg differ diff --git a/web/storyline/audio/voice/male/p5.ogg b/web/storyline/audio/voice/male/p5.ogg index 876eb3af..817ba688 100644 Binary files a/web/storyline/audio/voice/male/p5.ogg and b/web/storyline/audio/voice/male/p5.ogg differ diff --git a/web/storyline/audio/voice/male/p6.ogg b/web/storyline/audio/voice/male/p6.ogg index e1342dfe..b4d6c6d9 100644 Binary files a/web/storyline/audio/voice/male/p6.ogg and b/web/storyline/audio/voice/male/p6.ogg differ diff --git a/web/storyline/audio/voice/male/p7.ogg b/web/storyline/audio/voice/male/p7.ogg index cc1b4ab4..ad56e105 100644 Binary files a/web/storyline/audio/voice/male/p7.ogg and b/web/storyline/audio/voice/male/p7.ogg differ diff --git a/web/storyline/audio/voice/male/p8.ogg b/web/storyline/audio/voice/male/p8.ogg index 01587931..c94e414a 100644 Binary files a/web/storyline/audio/voice/male/p8.ogg and b/web/storyline/audio/voice/male/p8.ogg differ diff --git a/web/storyline/audio/voice/male/skip_hint.m4a b/web/storyline/audio/voice/male/skip_hint.m4a new file mode 100644 index 00000000..99b6f3f7 Binary files /dev/null and b/web/storyline/audio/voice/male/skip_hint.m4a differ diff --git a/web/storyline/audio/voice/male/skip_hint.ogg b/web/storyline/audio/voice/male/skip_hint.ogg new file mode 100644 index 00000000..e6108736 Binary files /dev/null and b/web/storyline/audio/voice/male/skip_hint.ogg differ diff --git a/web/storyline/audio/voice/male/status1.ogg b/web/storyline/audio/voice/male/status1.ogg index 42c6e11e..3c596c4c 100644 Binary files a/web/storyline/audio/voice/male/status1.ogg and b/web/storyline/audio/voice/male/status1.ogg differ diff --git a/web/storyline/audio/voice/male/status2.ogg b/web/storyline/audio/voice/male/status2.ogg index 80ddfcb9..59378ace 100644 Binary files a/web/storyline/audio/voice/male/status2.ogg and b/web/storyline/audio/voice/male/status2.ogg differ diff --git a/web/storyline/audio/voice/male/status3.ogg b/web/storyline/audio/voice/male/status3.ogg index 5b6766a9..ed7e101d 100644 Binary files a/web/storyline/audio/voice/male/status3.ogg and b/web/storyline/audio/voice/male/status3.ogg differ diff --git a/web/storyline/game.js b/web/storyline/game.js index cbb2d9b5..b3e3c9bb 100644 --- a/web/storyline/game.js +++ b/web/storyline/game.js @@ -1,15 +1,22 @@ /** * 《声命线》——末世互动广播剧(Web 移植版,忠实对齐 Android StorylineGame.kt)。 * - * 手势映射: - * - tap :继续剧情 / 结局后重新开始 + * 手势映射(12 号文档 §1 裁定): + * - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句; + * 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情; + * 结局后 = 重新开始 * - doubletap :重听当前节点的全部台词 * - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice) * - up :听操作说明 + 当前进度,随后自动重播当前剧情 * - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助) * + * 状态 BGM(10 号文档 §2):剧情/结局 = bgm/dark_drone 不切;菜单 = bgm/menu + * (进菜单 crossfade 500ms,关菜单淡回 dark_drone 800ms)。 + * * 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、 - * 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局。 + * 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、 + * 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死—— + * 13 号文档 §1 边界断言)。 * * 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎 * 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。 @@ -68,16 +75,25 @@ const ALL_SFX = [ const MODE = { STORY: 'STORY', MENU: 'MENU' }; +/** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */ +const VOICE_WATCHDOG_MS = 30000; + +/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */ +const PROMPT_RE = /^voice\/p\d/; + export class StorylineGame extends Game { constructor(audio, root) { super(audio, root); this.mode = MODE.STORY; this.nodeId = 'n00'; this.voicePlaying = false; + this.currentVoiceId = null; // 链上当前句 id + this.promptPlaying = false; // 当前句是抉择提示 p* this.choiceArmed = false; this.pendingChoice = null; this.awaitingRestart = false; this.seqToken = 0; + this.skipHintShown = false; // 开场"单击可跳过旁白"提示每局只播一次 // 剧情标记 this.companion = false; // 带上了小满 @@ -108,8 +124,10 @@ export class StorylineGame extends Game { switch (g) { case 'tap': if (this.awaitingRestart) this.restart(); - else if (!this.voicePlaying && node.next != null) this.playNode(this.resolve(node.next)); - // 对白播放中,单击不做任何事(避免误跳) + else if (this.voicePlaying) { + // 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 p* 不可跳 + if (!this.promptPlaying) this.audio.skipVoice(); + } else if (node.next != null) this.playNode(this.resolve(node.next)); break; case 'doubletap': this.replayNode(); @@ -166,6 +184,11 @@ export class StorylineGame extends Game { this.awaitingRestart = false; if (node.sfx) this.audio.play(node.sfx); const seq = [...node.voices]; + // 开场教学:intro 早期追加一次"单击可跳过旁白"提示(每局只播一次,重开不重播) + if (id === 'n00' && !this.skipHintShown) { + this.skipHintShown = true; + seq.push('voice/skip_hint'); + } // 好结局彩蛋:带着小满抵达黎明时追加他的台词 if (id === 'e1' && this.companion) seq.push('voice/e1_child'); if (node.prompt) seq.push(node.prompt); @@ -210,11 +233,28 @@ export class StorylineGame extends Game { const step = (i) => { if (token !== this.seqToken) return; if (i >= ids.length) { + this.currentVoiceId = null; + this.promptPlaying = false; onDone(); return; } + this.currentVoiceId = ids[i]; + this.promptPlaying = PROMPT_RE.test(ids[i]); + let advanced = false; + // 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死) + const wd = setTimeout(() => { + if (advanced || token !== this.seqToken) return; + advanced = true; + console.warn('[StorylineGame] 语音看门狗超时,自动续链:', ids[i]); + step(i + 1); + }, VOICE_WATCHDOG_MS); this.audio.play(ids[i], { - onComplete: () => { if (token === this.seqToken) step(i + 1); }, + onComplete: () => { + if (advanced || token !== this.seqToken) return; + advanced = true; + clearTimeout(wd); + step(i + 1); + }, }); }; step(0); @@ -225,8 +265,8 @@ export class StorylineGame extends Game { if (!node || node.left == null || node.right == null) return; this.choiceArmed = false; this.audio.play('sfx/ui_confirm'); - // 选择带来的剧情标记 - if (this.nodeId === 'n09' && g === 'right') this.knowTrap = true; + // 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标) + if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true; // 装死听情报 if (this.nodeId === 'n11' && g === 'left') this.companion = true; if (this.nodeId === 'n18' && g === 'left') this.betrayed = true; this.playNode(this.resolve(g === 'left' ? node.left : node.right)); @@ -261,6 +301,7 @@ export class StorylineGame extends Game { this.seqToken++; this.audio.stopVoice(); this.audio.play('sfx/ui_menu'); + this.audio.play('bgm/menu', { fadeMs: 500 }); // 状态 BGM:菜单 pad(10 号文档 §2) this.playSeq(['voice/menu'], () => { this.voicePlaying = false; }); } @@ -271,6 +312,7 @@ export class StorylineGame extends Game { } this.mode = MODE.STORY; this.audio.play('sfx/ui_confirm'); + this.audio.play('bgm/dark_drone', { fadeMs: 800 }); // 关菜单淡回主题 drone this.playNode(this.nodeId); // 回到故事,重播当前节点 } @@ -282,6 +324,7 @@ export class StorylineGame extends Game { this.betrayed = false; this.mode = MODE.STORY; this.audio.play('sfx/ui_confirm'); + this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启) this.playNode('n00'); } diff --git a/web/storyline/test/playtest.mjs b/web/storyline/test/playtest.mjs new file mode 100644 index 00000000..db937b7e --- /dev/null +++ b/web/storyline/test/playtest.mjs @@ -0,0 +1,434 @@ +/** + * 《声命线》无头试玩仿真测试(docs/design/13-playtest-criteria.md §1 + §7)。 + * + * 方案: + * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext + * (decodeAudioData 返回固定 duration 的假 buffer;fetch 直接读真实资产目录, + * 缺失资产走引擎 console.error 静默路径); + * - 使用**真实** engine/audio.js + settings.js(voice 链靠引擎 endTimer + + * onComplete 推进,虚拟时钟驱动,不真实等待); + * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); + * - 虚拟时钟:patch setTimeout/clearTimeout/performance.now。 + * + * 运行:tools/node/bin/node web/storyline/test/playtest.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); +const AUDIO_DIR = path.join(GAME_DIR, 'audio'); + +// ---------------------------------------------------------------- 断言收集 + +const realLog = console.log.bind(console); +let passCount = 0; +const failures = []; +function assert(cond, msg) { + if (cond) { passCount++; realLog(` ✓ ${msg}`); } + else { failures.push(msg); realLog(` ✗ ${msg}`); } +} + +// console 捕获(引擎/游戏的 warn/error 进日志,测试输出用 realLog) +const consoleErrs = []; +const consoleWarns = []; +console.error = (...a) => { consoleErrs.push(a.join(' ')); }; +console.warn = (...a) => { consoleWarns.push(a.join(' ')); }; +console.log = () => {}; // 游戏逐手势 console.log,静默 + +// ---------------------------------------------------------------- 虚拟时钟 + +let now = 0; // 虚拟毫秒 +let timerSeq = 1; +const timers = new Map(); // id -> { fireAt, cb, args } +const realSetImmediate = setImmediate; + +globalThis.setTimeout = (cb, ms = 0, ...args) => { + const id = timerSeq++; + timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args }); + return id; +}; +globalThis.clearTimeout = (id) => { timers.delete(id); }; +Object.defineProperty(globalThis, 'performance', { + value: { now: () => now }, configurable: true, +}); + +/** 排空微任务(fetch/decode 的 promise 链)。 */ +async function flush(rounds = 8) { + for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); +} + +/** + * 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势) + * 或 cond() 满足。返回 'cond' | 'idle'。 + */ +async function advance(cond = null) { + for (let n = 0; n < 50000; n++) { + await flush(2); + if (cond && cond()) return 'cond'; + let nextId = -1; let nextAt = Infinity; + for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } + if (nextId < 0) return 'idle'; + now = nextAt; + const t = timers.get(nextId); + timers.delete(nextId); + t.cb(...t.args); + } + throw new Error('虚拟时钟定时器超上限(疑似死循环)'); +} +const idle = () => advance(null); + +// ---------------------------------------------------------------- 浏览器 mock + +const winListeners = new Map(); +globalThis.window = { + addEventListener: (type, fn) => { + if (!winListeners.has(type)) winListeners.set(type, []); + winListeners.get(type).push(fn); + }, + AudioContext: null, // 下方赋值 +}; +function fireWindowEvent(type) { + for (const fn of winListeners.get(type) || []) fn(); +} + +const storageData = new Map(); +globalThis.localStorage = { + getItem: (k) => (storageData.has(k) ? storageData.get(k) : null), + setItem: (k, v) => { storageData.set(k, String(v)); }, + removeItem: (k) => { storageData.delete(k); }, +}; + +const vibrateLog = []; +Object.defineProperty(globalThis, 'navigator', { + value: { vibrate: (...a) => { vibrateLog.push(a); return true; } }, configurable: true, +}); + +class MockParam { + constructor(v = 1) { this.value = v; } + setValueAtTime() {} + linearRampToValueAtTime() {} + cancelScheduledValues() {} +} +class MockGainNode { + constructor() { this.gain = new MockParam(); } + connect() {} + disconnect() {} +} +function fakeBuffer() { + return { + duration: 2.0, // 固定句长 2s(虚拟时钟推进语音链) + numberOfChannels: 1, + sampleRate: 44100, + getChannelData: () => new Float32Array(16), + }; +} +class MockAudioContext { + constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; } + createGain() { return new MockGainNode(); } + createDynamicsCompressor() { + const n = new MockGainNode(); + n.threshold = new MockParam(); n.ratio = new MockParam(); + n.attack = new MockParam(); n.release = new MockParam(); + return n; + } + createBufferSource() { + return { buffer: null, loop: false, connect() {}, start() {}, stop() {} }; + } + createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } + decodeAudioData() { return Promise.resolve(fakeBuffer()); } + resume() { this.state = 'running'; return Promise.resolve(); } +} +globalThis.window.AudioContext = MockAudioContext; + +// fetch:读真实资产目录;blockedAssets 中的片段强制 404(看门狗测试用) +const requestedUrls = []; +const blockedAssets = new Set(); +globalThis.fetch = async (u) => { + const s = String(u); + requestedUrls.push(s); + const blocked = [...blockedAssets].some((b) => s.includes(b)); + const p = path.join(GAME_DIR, decodeURIComponent(s)); + if (blocked || !fs.existsSync(p)) { + return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; + } + const data = fs.readFileSync(p); + return { + ok: true, status: 200, + arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength), + }; +}; + +// ---------------------------------------------------------------- 加载引擎与游戏 + +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { StorylineGame } = await import('../game.js'); + +let gestureCount = 0; +function makeRig() { + const audio = new WebAudioEngine('audio/'); + const playLog = []; + const origPlay = audio.play.bind(audio); + audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); }; + const game = new StorylineGame(audio, {}); + return { audio, playLog, game }; +} +async function g(game, gesture) { + gestureCount++; + game.onGesture(gesture); + await flush(); + if (process.env.TRACE) { + realLog(` [gesture ${gesture}] node=${game.nodeId} mode=${game.mode} playing=${game.voicePlaying} cur=${game.currentVoiceId} armed=${game.choiceArmed}`); + } +} +const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length; +const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length; + +// ================================================================ 主路径试玩 + +realLog('== 主路径试玩(关键路径 + 边界断言)=='); +{ + const { audio, playLog, game } = makeRig(); + + // §7.1 解锁前 play 为 no-op + audio.play('voice/n00_intro'); + await flush(); + assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')), + '启动遮罩点按前无音频播放(play 为 no-op)'); + await audio.unlock(); + + // 1. start() → BGM + n00 旁白 + game.start(); + await flush(); + assert(playLog.some((e) => e.id === 'bgm/dark_drone'), '开局播放 bgm/dark_drone'); + assert(game.nodeId === 'n00' && game.voicePlaying && game.currentVoiceId === 'voice/n00_intro', + 'n00 旁白开始(voice 层有播放记录)'); + + // 长按快进(引擎内置):临时 1.7× 不变调,松手恢复 + fireWindowEvent('soundgame:longpress'); + assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速'); + fireWindowEvent('soundgame:longpressend'); + assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed'); + + // ② 单击跳过本句:n00_intro → skip_hint(链继续,不断) + await g(game, 'tap'); + assert(game.voicePlaying && game.currentVoiceId === 'voice/skip_hint', + 'voice 播放中单击 = 跳过本句(skipVoice 续链到 skip_hint)'); + await idle(); + assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,等待单击继续'); + assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 开场 skip_hint 提示播了一次'); + + // 切男声:下一句起用 male 目录 + settings.set({ voice: 'male' }); + await g(game, 'tap'); // → n01 + await flush(); + assert(urlHits('audio/voice/male/n01_wake.') > 0, 'settings.voice=male 后旁白走 male 目录'); + + // 双击重听当前节点 + const beforeReplay = voicePlays(playLog, 'voice/n01_wake'); + await g(game, 'doubletap'); + assert(game.currentVoiceId === 'voice/n01_wake' + && voicePlays(playLog, 'voice/n01_wake') === beforeReplay + 1, + '语音播放中双击 = 重听当前节点(同一 voice id 重播)'); + + // 菜单:BGM 切 menu pad,返回剧情节点不丢、BGM 淡回 + await g(game, 'down'); + assert(game.mode === 'MENU', '下滑呼出菜单'); + await flush(); + assert(audio._bgm && audio._bgm.id === 'bgm/menu', '② 进菜单 BGM 切 bgm/menu'); + await idle(); // voice/menu 播完 + await g(game, 'tap'); // 关菜单 + assert(game.mode === 'STORY' && game.nodeId === 'n01', '关菜单返回剧情,节点不丢(不重头)'); + await flush(); + assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', '② 关菜单 BGM 淡回 dark_drone'); + await idle(); // 重播 n01 播完 + + // 上滑:帮助 + 进度,随后自动重播当前节点 + await g(game, 'up'); + assert(game.currentVoiceId === 'voice/help', '上滑播放操作说明'); + await idle(); + assert(voicePlays(playLog, 'voice/status1') === 1, '帮助后播当前进度(第一幕 status1)'); + + // settings.bgm = 0 → 立即静默;调回 → 补播最后状态 BGM + settings.set({ bgm: 0 }); + await flush(); + assert(audio._bgm === null, 'settings.bgm=0 时无 bgm 层播放'); + settings.set({ bgm: 1 }); + await flush(); + assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', 'bgm 调回后自动补播最后状态 BGM'); + + // → n02(抉择点)。n02_linwan 是角色音(林晚):male 目录无,平铺回退命中 + settings.set({ speed: 1.7 }); + requestedUrls.length = 0; + await g(game, 'tap'); + await flush(); + assert(game.nodeId === 'n02', '进入 n02 抉择节点'); + assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效'); + assert(urlHits('audio/voice/male/n02_linwan.') > 0 && urlHits('audio/voice/n02_linwan.ogg') > 0, + '④ 角色音 n02_linwan 走平铺目录回退(不随性别设置变化)'); + settings.set({ speed: 1.0 }); + + // 抉择提示 p1 播放中:单击不可跳;滑动缓存 pendingChoice + await advance(() => game.currentVoiceId === 'voice/p1'); + assert(game.promptPlaying, '抉择提示 p1 播放中(promptPlaying=true)'); + await g(game, 'tap'); + assert(game.currentVoiceId === 'voice/p1', '① 抉择提示播放中单击不可跳'); + await g(game, 'right'); + assert(game.pendingChoice === 'right', '提示音播放中右滑缓存 pendingChoice'); + await idle(); + assert(game.nodeId === 'n04', '链尾 pendingChoice 生效 → n04'); + assert(!game.companion && !game.knowTrap && !game.betrayed, '剧情标记初始为 false'); + + // n04 → n05 → n06 → n07(无语音时单击维持原义:继续剧情) + settings.set({ voice: 'female' }); // 切回女声,验证女性设置下角色音同样平铺 + for (const next of ['n05', 'n06', 'n07']) { + await idle(); + await g(game, 'tap'); + assert(game.nodeId === next, `单击继续剧情 → ${next}`); + } + await idle(); + assert(game.choiceArmed, 'n07 提示播完 choiceArmed=true(等待抉择)'); + await g(game, 'right'); // 装死 → n09 + assert(game.nodeId === 'n09' && game.knowTrap === true, 'n09 右滑 → knowTrap === true'); + + requestedUrls.length = 0; + await idle(); + await g(game, 'tap'); // → n10 + await idle(); + await g(game, 'left'); // 闻声寻去 → n11 + assert(game.nodeId === 'n11', 'n10 左滑 → n11'); + await idle(); + await g(game, 'left'); // 带上小满 → n12 + assert(game.nodeId === 'n12' && game.companion === true, 'n11 左滑 → companion === true'); + await idle(); + await g(game, 'tap'); // → n15 断桥 + await idle(); + await g(game, 'left'); // 涵洞(带小满 → 虚拟节点解析 n16b) + assert(game.nodeId === 'n16b', '虚拟节点 resolve:带小满时 v_culvert → n16b'); + await idle(); + await g(game, 'tap'); // → n18 + await idle(); + assert(urlHits('audio/voice/n18_dog.ogg') > 0 || urlHits('audio/voice/female/n18_dog.') > 0, + 'n18 旁白正常加载'); + await g(game, 'left'); // 交出坐标 → n19 + assert(game.nodeId === 'n19' && game.betrayed === true, 'n18 左滑 → betrayed === true'); + await idle(); + await g(game, 'tap'); // → n20 + await idle(); + await g(game, 'left'); // 赎罪 → n21 + assert(game.nodeId === 'n21', 'n20 左滑 → n21'); + await idle(); + await g(game, 'tap'); // → e2 结局:代价 + assert(game.nodeId === 'e2', '到达结局节点 e2(4 结局之一)'); + await idle(); + assert(voicePlays(playLog, 'voice/e2_price') === 1, '结局语音 e2_price 播出'); + assert(game.awaitingRestart === true, '结局链播完进入结算态(awaitingRestart)'); + assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', + '② 结局结算保持 dark_drone 不切(10 号文档 §2)'); + + // 结算态单击重开回 n00 + await g(game, 'tap'); + assert(game.nodeId === 'n00' && game.mode === 'STORY' + && !game.companion && !game.knowTrap && !game.betrayed, + '结算态单击重开回 n00,剧情标记重置'); + await idle(); + assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 重开后 skip_hint 不重播(每局一次)'); + + assert(gestureCount <= 40, `关键路径手势预算 ≤40(实际 ${gestureCount})`); +} + +// ================================================================ 看门狗路径 + +realLog('== 看门狗路径(屏蔽 voice/n05_signal)=='); +{ + settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); + blockedAssets.add('n05_signal'); + const errBefore = consoleErrs.length; + const { audio, game } = makeRig(); + await audio.unlock(); + game.start(); + await idle(); + await g(game, 'tap'); // n01 + await idle(); + await g(game, 'tap'); // n02 + await idle(); // n02_linwan + p1 播完 + await g(game, 'right'); // → n04 + await idle(); + await g(game, 'tap'); // → n05(第一句 n05_signal 被屏蔽) + await idle(); // 看门狗 30s 超时续链 → n05_bcast 播完 + assert(consoleErrs.slice(errBefore).some((e) => e.includes('n05_signal')), + '屏蔽资产仅 console.error,不抛异常'); + assert(consoleWarns.some((w) => w.includes('看门狗')), '语音看门狗超时自动续链'); + assert(game.nodeId === 'n05' && !game.voicePlaying, '缺失 voice 不卡死:n05 链走完'); + await g(game, 'tap'); // n06 + await idle(); + await g(game, 'tap'); // n07(非抉择点 n06 之后继续剧情) + await idle(); + await g(game, 'right'); // n07 装死 → n09 + await idle(); + await g(game, 'tap'); // n10 + await idle(); + await g(game, 'right'); // n14 + await idle(); + await g(game, 'tap'); // n15 + await idle(); + await g(game, 'right'); // n17(无小满 → v_cable → n17) + assert(game.nodeId === 'n17', '虚拟节点 resolve:无小满时 v_cable → n17'); + await idle(); + await g(game, 'tap'); // n18 + await idle(); + await g(game, 'right'); // n22 + await idle(); + await g(game, 'tap'); // n23 + await idle(); + await g(game, 'left'); // n23 开闸 → n24 + await idle(); + await g(game, 'tap'); // n24 播完单击 → e4 + assert(game.nodeId === 'e4', '屏蔽资产后仍可推进到结局 e4'); + await idle(); + assert(game.awaitingRestart === true, '看门狗路径到达结算态'); + blockedAssets.clear(); +} + +// ================================================================ 资产引用校验 + +realLog('== 资产引用校验 =='); +{ + const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); + const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))]; + assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`); + const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext))); + let missing = 0; + for (const id of ids) { + if (id.startsWith('voice/')) { + const name = id.slice(6); + const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`); + if (!ok) { missing++; realLog(` ✗ 资产缺失: ${id}`); } + } else if (!both(id)) { + missing++; realLog(` ✗ 资产缺失: ${id}`); + } + } + assert(missing === 0, `全部 ${ids.length} 个引用 id 均有 ogg+m4a(含性别目录/平铺回退),零缺失`); + + // ③ skip_hint 双性别齐备 + assert(both('voice/male/skip_hint') && both('voice/female/skip_hint'), + 'voice/skip_hint 双性别 ogg+m4a 齐备'); + + // ④ 角色音只在平铺目录(不随性别设置变化,12 号文档 §4 回归) + const charVoices = ['n02_linwan', 'n03_msg', 'n05_bcast', 'n07_dog', 'e1_child']; + const flatOk = charVoices.every((n) => both(`voice/${n}`)); + const notGendered = charVoices.every((n) => !fs.existsSync(path.join(AUDIO_DIR, `voice/male/${n}.ogg`)) + && !fs.existsSync(path.join(AUDIO_DIR, `voice/female/${n}.ogg`))); + assert(flatOk && notGendered, '④ 角色音(林晚/猎狗/小满等)仅存平铺目录,性别目录无副本'); +} + +// ================================================================ 汇总 + +realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); +if (failures.length > 0) { + realLog('失败清单:'); + for (const f of failures) realLog(` - ${f}`); + process.exit(1); +} +realLog('全部断言通过。');