diff --git a/.gitignore b/.gitignore index ca5e9c22..5fbfab3b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ tools/gen_voice_dual_wav/ tools/gen_amb_wav/ tools/gen_inst_wav/ tools/gen_rpg_sfx_wav/ +tools/gen_hunt_syn_wav/ # 批次 1/2(radio/dungeon/abyss/detective)资产中间产物(可由 gen 脚本复现,不入库) web/radio/gen/staging/ web/dungeon/gen/staging/ diff --git a/README.md b/README.md index aa0640cf..e006093c 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,12 @@ BGM 用 Mureka API 生成 + 程序合成。 - **全局设置**:旁白音色(男声/女声)、语速(正常/快/特快)、背景音乐(正常/小/关闭) - 所有游戏用**震动反馈**交互(确认/错误/成功/警告各有震型;iOS 不支持震动,静默降级) -6 款游戏: +7 款游戏: +- **《围猎》** `web/hunt/` — 2-4 人实时联机协作守卫(新作,纯音频 MMORPG 路线第一阶段切片): + 夜幕下野兽从八方逼近营地,听声辨向滑出出击、单击呼喊队友补位、双击旋风斩, + 撑过 5 波天亮就赢。**联机必须用 `./web/serve.sh` 启动的 Node 服务器** + (WebSocket 服务端权威;公网纯静态托管只可进大厅,不能开局) - **《剑与声》** `web/rpg/` — 纯音频回合制 RPG(新作):盲剑客题材,节点式场景探索 + 回合制战斗(听前摇音双击格挡)+ 升级/装备/金币 + 服务器存档,第一章完整可玩 - **《声命线》** `web/storyline/` — 末世互动广播剧:3 幕 8 抉择 4 结局, diff --git a/docs/design/13-playtest-criteria.md b/docs/design/13-playtest-criteria.md index 2edd5275..3e5dbea5 100644 --- a/docs/design/13-playtest-criteria.md +++ b/docs/design/13-playtest-criteria.md @@ -795,3 +795,42 @@ R1 起每轮的增量断言(修复回归)在各自设计文档的轮次小 结算链 800ms 手势死窗(判定出口/NIGHT/d9/e*);键盘 _k 变体 11 对按 inputMode 选播;pendingChoice/p* 不可跳/ 摩尔斯首播不可跳;中断复述(含 DECODE 态恢复链 prog_t→book 档→gd_tune2);存档 schema(summary 带 暴露档名)。 + + +--- + +## 24. hunt《围猎》(联机切片;设计 [23-hunt.md](23-hunt.md),台词表 23b) + +> 本作是首个联机游戏:断言分两侧——**服务器侧**(`web/server/test/` 新增 hunt-room 单测,直接 import 房间模块 + 假 WS 连接,固定 seed 复现)与**客户端侧**(`web/hunt/test/playtest.mjs`,mock 引擎 + mock transport 注入 ev/snap)。协议字段以 23 号文档 §6 为唯一契约。测试钩子可读 game 实例字段与房间实例字段;手势只经引擎回调注入。 + +**关键路径**(首局教学房 → 5 波 → 结算 → 排行榜,≤150 次手势): +1. 启动遮罩点按 → 音频解锁(此前零播放)→ TITLE;无存档单击 → 断言客户端发出 `join { room:"quick", teach:true, ver:1 }`。 +2. **教学房**:注入 `joined`(phase:'teach',slot 0,roster 含 2 bot,seed=42)→ T0(intro_1→intro_2→tut_rule 有序)→ 单击 → T1(tut_attack + 注入 spawn 狼 N eta12)→ 先错向滑 W(断言 `act attack dir:W` → 注入 `ev miss` → atk_swish + error 震 + gd_idle 重教,怪物不受伤)→ 向 N 滑 → 断言发出 `act attack dir:N` → 注入 `ev kill` → tut_kill_ok;T2 单击 → `act callout` 发出 → 注入 bot help_ack(断言 `sfx/npc_yan_help_ack` 播放且 pan=该 bot 防区中心)→ tut_callout_ok;T3 双击 → `act skill` → 注入 `ev skill cleared=[2]` → tut_skill_cd;T4 上滑 → 状态链 4 段有序(hp_band→st_wave→team_3→kill_band)→ tut_breach→tut_done → 注入 `ev wave 1` 进 WAVE。**教学狼突入零惩罚**:注入教学波 breach → dur 不变。 +3. **bot 补位**:非首局(tutDone=true)→ LOBBY(room_wait 播)→ 单击发 `act start` → 开局 roster ev 断言猎人总数 = max(3, 人类数)、bot 槽 char 齐全、bot greet 逐只间隔 ≥1.5s。 +4. **5 波推进**(seed=42 服务器侧快进 tick):每波 spawn 事件数/型别/逼近时长与 23 §3.4 表逐波一致;方位分配 ∈ 各座位防区;狼击杀(滑向 → act → ev kill)断言归属 slot 与 kills 账;漏一只狼 → ev breach,dur -1,`breach` 播报 + hit 震;熊漏 → dur -2 + `breach_heavy`。 +5. **终局**:注入 `ev end { win:true, dur:8, grade:"A", kills }` → 结算链有序(end_win→grade_a→kill_band_*→end_rank→end_again)→ 断言 PUT `/api/progress { game:"hunt" }` 且 `data.summary` 命中生成规则(`最佳评级A · 击杀14 · 营地余8 · 共N局`)、best 按"评级>耐久>击杀"序更新;单击 → 重新进匹配。失守路径(dur 归零):end_lose 首句、无 grade_*、无 end_rank、不写 best、runs+1。 + +**边界断言**: +- **断线转 bot**:关连接 30s(服务器侧快进)→ roster ev 该槽 bot=true 且 char 沿用 → 该槽开始产出 act(tick 内断言);剩余人类 ≥3 时改为槽位移除(roster 缩短)。 +- **重连回槽**:断开 15s 内带 `resume` 重连 → joined 同 slot/seed/char + 紧随 snap 全量 → 客户端怪表与 snap 一致、net_back 播报、战斗续接;超 30s → err `room_gone` → net_gone → LOST。 +- **旋风斩冷却**:放出后 8s 内双击 → 无 act 发出、播 threat_*(按内圈只数选档);冷却完成 → skill_ready(queue:true 不顶句);伪造 `act skill` 越冷却 → 服务器 err `skill_cd`。 +- **出击冷却 2.5s**:冷却内滑 → 仅 error 震、零 act;伪造连发 → 服务器只裁决首发,其余 err `cooldown`。 +- **支援呼喊 bot 补位**:呼喊后 10s 内,服务器侧断言补位 bot 的下一次 act.dir ∈ 呼喊者防区(偏置 ×2 生效),且 `help_ack` 恰播一次;呼喊节流 5s(连点只出一条 callout ev)。 +- **听觉裁剪上限**:eta>6s 怪零 loop;任意时刻 loop ≤3 路且为 eta 最小 3 只(注入 5 只内圈怪断言);threat_* 选档只计 eta≤4 只数;状态链/威胁句无任何逐方位枚举(台词表结构保证)。 +- **拥挤控制(R1-R8)**:第 5 波峰值剧本下断言——同刻 voice ≤1;同刻 `npc_*` ≤1 且 10s 窗 ≤2 条;旁白在播时喊话延迟 ≤2s 或丢弃;one-shot 定位音同刻 ≤2;breach/danger_low/wave 播报零丢失。 +- **snap 兜底**:客户端随机丢弃 50% ev,仅凭 snap(2Hz)收敛 → 怪表与服务器真值一致(id 集合 + eta 偏差 ≤0.5s 校正)。 +- **菜单不暂停(E3)**:菜单开着注入 breach → dur 照扣、菜单不收;菜单 3 项固定、单击循环/双击确认/下滑关闭。 +- **学期零惩罚**:教学波 breach 不扣 dur;T1 做错 2 次自动放过不卡死。 +- **结算死窗**:end 到达前后 800ms 内注入 tap → 被吞(不进 TITLE/不重开);死窗后单击正常再来一局。 +- **屏蔽任意 voice 资产**:结算链/警报链照走完(看门狗兜底推进),不卡死。 +- **资产齐套**:voice 双性别 60 句集合一致、key 变体 11 对 ⊆ 主集、`sfx/npc_*` 24 条双格式(assetExists 口径)。 +- **opts.pan 回归**:无 pan 调用路径零 panner 节点(引擎行为不变);pan=-1/0.7/1 时 panner 值正确;老 Safari 无 createStereoPanner 静默降级。 + +**全局通用(§7 五条全跑,另加本作条目)**: +- 启动遮罩点按前零音频播放;遮罩手势不被游戏误收(引擎双保险)。 +- **guide 三档**:full=签名+语音+震;standard 无向导语音;**off=全静且零看门狗定时器**(WAVE 30s 空闲零向导播放,内容层 loop/音效除外)。 +- **震动语义**:逐条对照 23 §2 表——击杀 pickup / 挥空 error / 旋风斩 success / 突入 hit / 收到呼喊 warning / 耐久 ≤2 danger / 波次 heartbeat;手势本身震动由引擎内置承担,游戏层不重复(无双震)。 +- 设置边界:speed 切换下一句生效;bgm=0 全程无 bgm 加载;voice 切换下一句用对应目录。 +- 服务器不可达:TITLE 单击 → net_lost → net_gone → LOST 回大厅(明示失败,不静默死屏)。 + +**失败即不通过**:评级与 dur 不符(归因假话);服务器接受违规 act(冷却/方位/近战窗外命中);ev 与 snap 矛盾不收敛;bot 喊话压过/打断旁白(R1/R2 破);loop >3 路;教学狼扣耐久;失守局写入 best 排行榜。 diff --git a/docs/design/23-hunt-voice-ids.md b/docs/design/23-hunt-voice-ids.md new file mode 100644 index 00000000..48ff57bb --- /dev/null +++ b/docs/design/23-hunt-voice-ids.md @@ -0,0 +1,203 @@ +# 23b · 《围猎》语音台词表(voice ids,录音依据) + +> **给音频总监的录制说明** +> - **旁白/系统音双性别组(60 句,预算打满)**:落 `web/hunt/audio/voice/female|male/.{ogg,m4a}`,两性别 id 集合完全一致。 +> - female = `Serena`,instruct 常量:`清亮沉稳的女声,像守夜猎人的领队,冷静中带着鼓舞` +> - male = `Uncle_Fu`,instruct 常量:`低沉醇厚的男声,像守夜猎人的领队,沉稳有力` +> - 两常量全表统一(含教学/警报/结算),禁止句级变体、禁语速词(引擎 SoundTouch 变速)。 +> - **bot 喊话组(N 组,24 句 = 3 角色 × 8 句)**:固定单音色平铺、**不做双性别**。因喊话经 **sfx 层 + opts.pan 空间化播放**(引擎按 id 前缀分发,主设计 §8 R2 / §15 裁决 7),资产**落盘 `web/hunt/audio/sfx/npc__.{ogg,m4a}`**——这是 NPC 平铺惯例的异例:生成仍走 voice 管线(TTS、峰值归一 0.89 沿用 voice 策略),仅落盘目录不同。角色 speaker+instruct 恒定: +> +> | 角色 | char | speaker | instruct 常量 | 区分度风险 | +> |---|---|---|---|---| +> | 阿岩 | yan | `Uncle_Fu` | `憨厚壮实的中年男声,猎户,话少踏实` | **中**:与 male 旁白同 speaker——缓解:sfx 层 pan 空间分离 + 1-2s 短喊话语境 + instruct 差异(abyss 先例口径) | +> | 小筝 | zheng | `Serena` | `轻快爽利的年轻女声,猎手,干脆利落` | **中**:与 female 旁白同 speaker——缓解同上 | +> | 老周 | zhou | `Eric` | `沙哑沉稳的老年男声,老猎手,带点调侃` | 低:与两侧旁白零重叠 | +> +> - **数字规避**:耐久 4 档 / 波次 3 档(st_wave_*)+ 5 整句(wave_band1-5)/ 在场猎人 2 句 / 击杀 3 档 / 威胁 3 档 / 评级 4 整句——全部分档整句,无运行时拼接。「八秒冷却」「五波」等数字写死在整句内(一次生成,非拼接)。 +> - **称呼中性**:旁白称「你/你们」,无性别化称呼;bot 句不含绝对方位(方位由 pan 承载,主设计 §15 裁决 3/7——任务书示例句"东侧交给我!"据此落地为"这边交给我")。 +> - **多音字自查**:全表无「血」「爪」「挨」「壳」「弹」「重(chóng/zhòng 歧义句)」;「重听」一律写「重新听」(tut_skill_cd)。已标注风险 3 处:「还」(hái:营地还在/守得还行/还没开张——asset-manifest 既有风险项,三处均 hái 语境,试听确认);「啧」(zé,npc_zhou_breach 叹词,试听异常则整句改「哼,溜进去一只。」);评级字母 S/A/B/C(rhythm「评级,S!」先例,试听确认字母读音)。 +> - **触发纪律**(开发轮次对照主设计):警报类(breach/breach_heavy/danger_low)可顶掉状态/播报类,教学/结算链不可顶;skill_ready 与状态链一律 `queue:true`;gd_* idle-only + onceKey;bot 喊话调度上限见主设计 §7.4/§8 R2。 +> - **键盘变体(V 组,11 对)**:操作提示句在 `gesture.inputMode()==='key'` 时改播 `voice//key/.*` 变体,变体缺失回退主资产;与 `tools/gen_voice_dual.py` 的 `KEY_LINES` 逐字一致(两表一致铁律:改一处必改另一处)。 +> - 管线:`tools/gen_voice_dual.py` 照录(SPEAKERS 新增 hunt 行 + HUNT_NPC 平铺表);生成后 `--convert` 落盘 + `tools/check_all_assets.py` 终检(双格式配对、双性别集合一致、key 变体 ⊆ 主集)。 + +## A 组 · 开场/房间(旁白 ×4) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `intro_1` | 夜幕降临,野兽在山林里游荡。 | 开场 1(TITLE 单击后/教学房 T0) | +| `intro_2` | 你们是守夜的猎人,营地就在身后。 | 开场 2 | +| `room_wait` | 房间已开好,等同伴到齐。单击,立即开局。 | LOBBY 进入(12s 倒计时自动开局) | +| `room_start` | 狩猎开始,先听声音的方向。 | 开局(ev wave 1 前) | + +## B 组 · 教学 TEACH(旁白 ×11;教学波突入不扣耐久,学期零惩罚) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `tut_rule` | 野兽会从八个方向围上来。声音在哪边,就往哪边滑。 | T0(规则总述,含后半平面发闷约定由音效演示承担) | +| `tut_attack` | 听,有狼在靠近。朝声音的方向,滑一下。 | T1(教学狼 1 放出后) | +| `tut_kill_ok` | 好,杀掉了。就是这样。 | T1 首杀(成功只给新短句) | +| `tut_callout` | 单击一下,呼喊支援,队友会来帮你。 | T2 | +| `tut_callout_ok` | 听到没,同伴回应你了。 | T2(bot help_ack 播完后,呼喊-响应闭环点明) | +| `tut_skill` | 双击,旋风斩,清掉身边一圈。 | T3(教学狼 2、3 贴近后) | +| `tut_skill_cd` | 旋风斩有八秒冷却。冷却中双击,重新听威胁。 | T3 放出后(讲冷却与宪法双击语义) | +| `tut_status` | 上滑,听营地状态。下滑,打开菜单。 | T4 | +| `tut_breach` | 野兽冲进营地,营地就会受损。撑过五波就赢。 | T4 状态链播完后 | +| `tut_done` | 教学完了。真正的围猎,现在开始。 | TEACH 完成 → 进 WAVE 1 | +| `tut_skip` | 已跳过教学,开打。 | TEACH 中下滑跳过 | + +## C 组 · 波次(旁白 ×6) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `wave_band1` | 第一波来了,别慌。 | ev wave 1 | +| `wave_band2` | 第二波,比刚才多。 | ev wave 2 | +| `wave_band3` | 第三波,有大家伙混在里面。 | ev wave 3(首只暴熊) | +| `wave_band4` | 第四波,更快,更多。 | ev wave 4 | +| `wave_band5` | 最后一波,全来了! | ev wave 5 | +| `wave_clear` | 这一波,守住了。 | ev waveclear(波间休整 8s 开场) | + +## D 组 · 状态链(旁白 ×12;上滑 4 段链播:耐久档 → 波次档 → 猎人数 → 击杀档) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `hp_band1` | 营地固若金汤。 | 耐久 >75%(10 制:≥8) | +| `hp_band2` | 营地受了些损伤。 | 50~75%(5-7) | +| `hp_band3` | 营地损毁过半。 | 25~50%(3-4) | +| `hp_band4` | 营地快守不住了。 | ≤25%(≤2) | +| `st_wave_a` | 还在头几波。 | 波次档:第 1-2 波 | +| `st_wave_b` | 打到中段了。 | 第 3-4 波 | +| `st_wave_c` | 最后一波了。 | 第 5 波 | +| `team_3` | 三名猎人在守。 | 在场 3 猎人(含 bot) | +| `team_4` | 四名猎人全在。 | 在场 4 猎人 | +| `kill_band0` | 你还没开张。 | 个人击杀 0 | +| `kill_band1` | 你小有斩获。 | 1-9 | +| `kill_band2` | 你战果累累。 | ≥10 | + +## E 组 · 威胁重听(旁白 ×3;冷却中双击 = 重听;只报数量档,方位由声像承载) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `threat_none` | 近处暂时没有野兽。 | 内圈(eta≤4s)0 只 | +| `threat_few` | 有野兽贴近了,听方向出手。 | 内圈 1-2 只 | +| `threat_many` | 四周全是动静,快出手! | 内圈 ≥3 只 | + +## F 组 · 警报(旁白 ×3;警报类,可顶状态/播报类) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `breach` | 有野兽冲进营地了! | ev breach(狼,-1);节流 ≥3.5s | +| `breach_heavy` | 巨兽撞进营地,损伤很重! | ev breach(熊,-2);节流同上 | +| `danger_low` | 营地危险了,一只都不能再漏! | 耐久首次 ≤2(每局 1 次,配 danger 震) | + +## G 组 · 技能(旁白 ×1) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `skill_ready` | 旋风斩准备好了。 | 旋风斩冷却完成(queue:true,每轮冷却 1 次) | + +## H 组 · 结算(旁白 ×9;进 END 盖 800ms 手势死窗) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `end_win` | 天亮了,营地还在。 | 胜利结算链首句 | +| `end_lose` | 营地守不住了,撤退! | 失守结算首句(耐久归零) | +| `grade_s` | 评级,S!完美的围猎。 | 胜利评级 S(耐久 ≥9) | +| `grade_a` | 评级,A。漂亮。 | 评级 A(7-8) | +| `grade_b` | 评级,B。守得还行。 | 评级 B(4-6) | +| `grade_c` | 评级,C。险胜。 | 评级 C(1-3) | +| `end_rank` | 成绩已记入猎手榜。 | 胜利局排行榜写入后(失守局不播) | +| `end_again` | 单击,再猎一局。 | 结算链尾句(重开入口) | + +结算链:胜利 = `end_win → grade_* → kill_band_*(自己击杀档)→ end_rank → end_again`;失守 = `end_lose → kill_band_* → end_again`。 + +## I 组 · 断线/重连(旁白 ×4) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `net_lost` | 与营地断了联系,正在重连。 | WS 断开(本地静默重连开始,≤30s) | +| `net_back` | 联系恢复了,接着打。 | resume 回槽成功(joined + snap 到达) | +| `net_to_bot` | 有猎人离队,老猎手顶上。 | roster 变更:他人槽位转 bot(非自己) | +| `net_gone` | 回不去那场围猎了,回大厅。 | 重连失败/房已灭(err room_gone 或 30s 超时)→ LOST | + +## J 组 · 菜单/帮助(旁白 ×6;菜单不暂停,E3 豁免) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `sys_menu` | 菜单。单击切换,双击确认。 | 下滑呼出模态菜单 | +| `sys_menu_resume` | 回到猎场。 | 菜单项 1 | +| `sys_menu_help` | 重新听帮助。 | 菜单项 2 | +| `sys_menu_home` | 退出房间,回大厅。 | 菜单项 3(= 发 leave) | +| `help_main` | 朝声音的方向滑,就是出击。单击呼喊支援,双击旋风斩。上滑状态,下滑菜单。 | 菜单内上滑 / 帮助项 | +| `settings_hint` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内设置提示 | + +## K 组 · 向导/纠错(旁白 ×2;idle-only,queue:true) + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `gd_invalid` | 这个操作现在用不上。 | 无效手势纠错(onceKey 同局 1 次,之后仅 error 震) | +| `gd_idle` | 听声音的方向,滑过去。 | WAVE/LOBBY 看门狗第 1 级(10s);TEACH 错向重教复用 | + +--- + +**旁白合计:60 句 ×2 性别 = 120 条音频**(×2 格式 = 240 文件;预算 ≤60 打满,无冗余位)。 + +## N 组 · bot 喊话(24 句 = 3 角色 × 8;固定单音色,落盘 `audio/sfx/`,见头注) + +> 触发映射(服务端纪律见主设计 §7.4:全局 10s ≤2 条,每 bot 20s 节流,help_call/help_ack 优先): +> `greet`=开局补位完成逐只间隔 ≥1.5s;`help_call`=bot 防区压力呼救 / **真人单击呼喊(用其座位角色声)**; +> `help_ack`=响应呼喊(空闲 bot 一只);`cover`=补位落定 / 转 bot 接管;`kill`=杀熊或三连杀(15s 节流); +> `breach`=本防区漏怪(20s 节流);`skill`=bot 放旋风斩;`wave`=第 2 波起轮转一只鼓劲。 + +| id | 台词全文 | 触发/语境 | +|---|---|---| +| `npc_yan_greet` | 我来了,这边有我。 | 阿岩 · 开局/补位 | +| `npc_yan_help_call` | 我这侧吃紧,来个人搭把手! | 阿岩 · 呼救(或座 0 真人呼喊) | +| `npc_yan_help_ack` | 撑住,我来了。 | 阿岩 · 响应呼喊 | +| `npc_yan_cover` | 这边交给我。 | 阿岩 · 补位/接管 | +| `npc_yan_kill` | 拿下! | 阿岩 · 杀熊/三连杀 | +| `npc_yan_breach` | 漏了一个,都小心。 | 阿岩 · 本防区突入 | +| `npc_yan_skill` | 都闪开,看我的! | 阿岩 · 旋风斩 | +| `npc_yan_wave` | 新一波,稳住。 | 阿岩 · 波次鼓劲 | +| `npc_zheng_greet` | 小筝到位,开工啦。 | 小筝 · 开局/补位 | +| `npc_zheng_help_call` | 这边太多了,谁来帮一下! | 小筝 · 呼救(或座 1 真人呼喊) | +| `npc_zheng_help_ack` | 别慌,我来了! | 小筝 · 响应呼喊 | +| `npc_zheng_cover` | 这边我罩着。 | 小筝 · 补位/接管 | +| `npc_zheng_kill` | 搞定! | 小筝 · 杀熊/三连杀 | +| `npc_zheng_breach` | 哎呀,漏了一个! | 小筝 · 本防区突入 | +| `npc_zheng_skill` | 看我清场! | 小筝 · 旋风斩 | +| `npc_zheng_wave` | 又来一波,打起精神。 | 小筝 · 波次鼓劲 | +| `npc_zhou_greet` | 老周我守着,放心。 | 老周 · 开局/补位 | +| `npc_zhou_help_call` | 我这边压不住了,来人。 | 老周 · 呼救(或座 2 真人呼喊) | +| `npc_zhou_help_ack` | 听见了,这就到。 | 老周 · 响应呼喊 | +| `npc_zhou_cover` | 这边交给我吧。 | 老周 · 补位/接管 | +| `npc_zhou_kill` | 嗯,解决了。 | 老周 · 杀熊/三连杀 | +| `npc_zhou_breach` | 啧,溜进去一只。 | 老周 · 本防区突入(「啧」风险见头注) | +| `npc_zhou_skill` | 让让,老骨头活动活动。 | 老周 · 旋风斩 | +| `npc_zhou_wave` | 听,又来了。 | 老周 · 波次鼓劲 | + +**bot 喊话合计:24 条单音色音频**(×2 格式 = 48 文件;座 3 真人呼喊无角色声,用 `sfx/call_whistle` 哨音,主设计 §15 裁决 6)。 + +--- + +## V 组 · 键盘变体(key,11 对;`gesture.inputMode()==='key'` 时选播,措辞规范同 dungeon/bridge) + +| id | 键盘台词全文(触屏原句见上方对应 id) | +|---|---| +| `room_wait` | 房间已开好,等同伴到齐。按空格,立即开局。 | +| `tut_rule` | 野兽会从八个方向围上来。声音在哪边,就按哪边方向键。 | +| `tut_attack` | 听,有狼在靠近。朝声音的方向,按方向键。 | +| `tut_callout` | 按空格,呼喊支援,队友会来帮你。 | +| `tut_skill` | 按回车,旋风斩,清掉身边一圈。 | +| `tut_skill_cd` | 旋风斩有八秒冷却。冷却中按回车,重新听威胁。 | +| `tut_status` | 按上方向键,听营地状态。按下方向键,打开菜单。 | +| `sys_menu` | 菜单。按空格切换,按回车确认。 | +| `help_main` | 朝声音的方向按方向键,就是出击。按空格呼喊支援,按回车旋风斩。上方向键状态,下方向键菜单。 | +| `end_again` | 按空格,再猎一局。 | +| `gd_idle` | 听声音的方向,按方向键。 | + +**变体合计:11 对 ×2 性别 = 22 条**(×2 格式 = 44 文件;落 `voice//key/`)。 + +--- + +**全集账目**:旁白 60×2 + bot 24 + key 变体 11×2 = **166 条音频**(×2 格式 = 332 文件)。 +主设计触发逻辑对照 [23-hunt.md](23-hunt.md) §2/§5/§7/§8/§11/§12;协议事件(ev 种类 → 播报映射)对照其 §6.3。 diff --git a/docs/design/23-hunt.md b/docs/design/23-hunt.md new file mode 100644 index 00000000..d7cecd29 --- /dev/null +++ b/docs/design/23-hunt.md @@ -0,0 +1,500 @@ +# 23 · 《围猎》完整设计(hunt / Night Hunt) + +> **给开发者的实现要点摘要** +> 1. **2-4 人实时联机纯音频协作守卫**,项目"纯音频 MMORPG"路线的**第一阶段最小验证切片**(§1.3)。本作只验证三件事:实时同步(零依赖 WebSocket + 服务端权威)、音频信道拥挤控制(§8,全文重点)、空间音频协作(方位=pan+防区分工)。其余一切从简。 +> 2. 圆形猎场,猎人位于营地中心。野兽从 8 罗盘方位边缘逼近(8-12s),**四向滑=出击**(滑向映射 90° 弧,击杀弧内内圈最近一只,冷却 2.5s/人);突入中心 = 共享营地耐久 -1(巨兽 -2,初始 10);单击=呼喊支援(队友收到你防区方位的空间化喊话 + warning 震,bot 补位);双击=旋风斩(清内圈全部,冷却 8s;**冷却中双击=重听威胁播报**,宪法双击语义由此保留)。上滑=状态链,下滑=菜单(**不暂停**,联机实时豁免 E3)。 +> 3. 服务端权威:怪物生成/移动/命中/冷却/评级全在 `web/server/` 裁决,客户端只发意图(join/act/leave)。bot 由服务器驱动,与真人同一套事件协议。房间 3-4 座,开局空位由 bot 补至 ≥3 猎人;断线 30s 宽限重连回槽,超时转 bot。 +> 4. 协议全文 §6(两条传输消息 + 三类服务端消息,JSON 字段表逐条给出);零依赖 WS 握手与帧解析规范 §6.1(Node 原生 http upgrade + 手写帧,不引 npm)。 +> 5. 拥挤控制硬规则 R1-R8(§8.1):旁白单句队列(voice 层)、bot 喊话 sfx 层同刻 ≤1、怪物移动 loop ≤3 路(最近 3 只)、非语音信息全部 <1s 短音效、听觉视野裁剪只报内圈(<4s 抵达)。 +> 6. **引擎改动唯一一处(后向兼容)**:`engine/audio.js` 的 sfx/voice 播放各加可选 `opts.pan`(-1..1,StereoPannerNode 接入对应总线),缺省行为逐字节不变(§9)。怪物移动 loop 走游戏层句柄(abyss `_startLoop` 先例 + 游戏层自建 panner),不占引擎改动面。 +> 7. 台词表 [23-hunt-voice-ids.md](23-hunt-voice-ids.md):旁白/系统音 **60 句 ×2 性别**(male=Uncle_Fu / female=Serena,预算打满)+ bot 喊话 **3 角色 ×8 句 = 24 条固定单音色**(阿岩=Uncle_Fu / 小筝=Serena / 老周=Eric;因走 sfx 层带 pan,**落盘 `audio/sfx/`**,异例说明见台词表头注)+ 键盘变体 11 对。数字信息全分档整句,无运行时拼接。 +> 8. 设计裁决与对任务书核心案的修正记录(双击矛盾、出击冷却、防区模型、房码 UX 范围等 9 条)见 §15,**实施前必读**。断言清单住 [13-playtest-criteria.md](13-playtest-criteria.md) §24,本文 §13 只留摘要与指针。 + +--- + +## 1. 概述 + +### 1.1 一句话 + +夜幕降临,野兽从四面八方围上来——你和队友背背守住营地篝火,**听声音的方向滑过去就是一刀**,撑过五波,天亮就赢。 + +### 1.2 题材与纯音频相性 + +守夜围猎是"方位即生死"的题材:怪物唯一的接近预告就是它逼近的声像。视觉游戏里这是小地图+tower defense;纯音频里,**声像本身就是小地图**——题材与媒介互为 justification,与 dungeon「声音就是你的眼睛」同源,但本作是**实时+协作**,不是回合制探索。 + +### 1.3 定位:纯音频 MMORPG 路线 · 第一阶段最小验证切片 + +本项目长线方向是纯音频 MMORPG(持久世界、多玩家同图协作)。《围猎》是该路线的**第一个可玩切片**,刻意把范围切到最小,只验证三个技术-设计命题: + +| 验证点 | 本文档落点 | 通过信号 | +|---|---|---| +| **实时同步**:零依赖 WS、服务端权威战斗、事件流+快照兜底、断线重连 | §6 协议 | 局域网 2-4 端同局,事件一致,断线 30s 内重连无感回槽 | +| **音频信道拥挤控制**:多源并发(旁白/bot 喊话/怪物 loop/定位音效)不糊 | §8 R1-R8 | 最坏并发 7 声流下威胁可辨、警报不丢(§8.4 验证口径) | +| **空间音频协作**:靠 pan 的方位分工与支援是否成立 | §3 防区 + §7 bot 补位 | 玩家能用呼喊-补位闭环分担压力,无需任何视觉 | + +**明确不做(切片纪律)**:无持久世界、无跨房聊天、无跨玩家排行榜("排行榜"= 进度 API 的个人最佳桶 + 大厅卡片 summary,§12)、无房码音频录入 UX(协议保留,客户端 v1 只做快速匹配,§6.4)、无内购/社交。这些列 roadmap,不在本文档。 + +### 1.4 单局 + +5 波为一局,约 5 分钟(含 lobby ≈ 5.5 分钟;首局含教学 ≈ 6.5 分钟)。营地耐久初始 10、共享;耐久归零 = 营地失守(立即终局,突围结算,不入榜);撑过 5 波 = 胜利结算(评级 S~C + 个人击杀分档 + 排行榜写入)。 + +### 1.5 与现有 12 游戏的关系 + +实时方位感承接 space(呼啸来袭+同向反应),但改为**协作分工**;状态分档链承接 dungeon(上滑多段链);威胁裁剪承接 dungeon 脚步距离档音量规则并升级为显式"听觉视野"(§8.2);bot 喊话平铺资产承接 rpg/radio/detective NPC 先例,但首次走 **sfx 层空间化**(§8 R2);结算链死窗沿用 dungeon H1 先例(800ms 手势死窗);看门狗/向导层全照 00 §6。**两个"第一"**:第一个联机游戏、第一个用到运行时 pan(引擎 opts.pan,§9)。 + +### 1.6 本作三大特有风险(均有显式对策) + +1. **多源并发糊耳**(5 波峰值:旁白+喊话+3 路 loop+定位音效)→ §8 规则表 + §8.4 验证断言。 +2. **实时联机与纯音频教学的根本矛盾**(不能暂停真人局教学)→ 首局教学房(§5):单人+bot、教学波、学期零惩罚,教完同场接续第 1 波。 +3. **协作流于形式**(都能打所有方向=不需要队友)→ 出击冷却 2.5s 限单人吞吐 + 软防区 + bot 补位闭环(§3.3、§7)。 + +## 2. 手势映射与豁免登记 + +### 2.1 战斗态(WAVE,含教学局 TEACH 的战斗段) + +| 手势 | 作用 | 反馈 | +|---|---|---| +| 四向滑(上/下/左/右) | **出击**:滑向映射 90° 弧(上→{NW,N,NE}、右→{NE,E,SE}、下→{SE,S,SW}、左→{SW,W,NW}),击杀弧内内圈(eta≤4s)最近一只;冷却 2.5s | 引擎 swipe 震 + `atk_whoosh`;命中 `kill_*`+pickup 震;挥空 `atk_swish`+error 震 | +| 单击 | **呼喊支援**:全队友收到你防区方位的空间化喊话 + warning 震;bot 朝你侧补位 10s(§7.3);节流 5s/人 | 引擎 confirm 震 + 本机播自己座位角色的 help_call(中央) | +| 双击 | **旋风斩就绪** → 放出:清全场内圈(eta≤4s)全部怪物,冷却 8s;**冷却中** → 重听当前威胁播报(threat_*,宪法"双击=重听"落地) | 放出:success 震 + `skill_spin`;冷却中:引擎 confirm 震 + threat_* | +| 上滑 | 状态链:营地耐久档 → 波次档 → 在场猎人数 → 自己击杀档(4 段链播 queue:true) | hp_band_* → st_wave_* → team_* → kill_band_* | +| 下滑 | 模态菜单(**不暂停**,豁免 E3,§2.4) | menu earcon | +| 长按 | 非玩法输入:引擎旁白快进默认行为保留 | — | + +- 出击冷却 2.5s 内四向滑 → 仅 error 震(无语音、不发 act;dungeon 脉冲冷却先例)。 +- 单击呼喊是**非即时操作**,300ms tap 延迟无碍(escape 单击=检查先例)。 +- 旋风斩就绪时刻播 `skill_ready`(queue:true,绝不顶句;每轮冷却完成只播一次)。 +- **事件震动总表**:命中 pickup / 挥空 error / 旋风斩放出 success / 突入 hit / 收到呼喊 warning / 波次开始(ev wave)heartbeat / 耐久首次 ≤2 danger(每局 1 次,配 danger_low)。手势识别震动(swipe/confirm)由引擎内置承担,游戏层不重复(00 §2.3)。 + +### 2.2 标题/匹配/大厅等待(TITLE / MATCHING / LOBBY) + +| 手势 | TITLE | MATCHING | LOBBY | +|---|---|---|---| +| 单击 | 快速匹配(首局=教学房) | —(无效,error 震) | 立即开局(act start) | +| 双击 | 重听开场/提示 | 重听 | 重听 room_wait | +| 上滑 | 个人最佳播报(有档时:best 评级+击杀档) | error 震 | 在场猎人播报(team_*) | +| 下滑 | 菜单(帮助/回大厅) | 菜单(含"取消匹配") | 菜单(退出房间回大厅) | +| 左右滑 | error 震 | error 震 | error 震 | + +### 2.3 结算态(END) + +单击=再来一局(重新进匹配);双击=重听结算链;上滑=重听个人最佳段;下滑=菜单(回大厅)。**结算链进入时盖 800ms 手势死窗**(dungeon H1 先例:吞掉致命事件前后在途的 ghost 手势,死窗后手势照常)。 + +### 2.4 模态菜单(豁免 E3:联机实时不暂停) + +下滑呼出 → 单击循环 → 双击确认 → 上滑=帮助(播 `help_main`)→ 下滑=关闭(00 §5.2 交互骨架不变)。**菜单打开期间战斗照常进行**(怪物照走、突入照扣)——联机不可单方暂停,这是与 00 §5.2"菜单打开时暂停计时类玩法"的显式豁免。菜单项固定 3 项:`sys_menu_resume`(回到猎场)/ `sys_menu_help`(重新听帮助)/ `sys_menu_home`(退出房间回大厅 = 发 leave,槽位按 §6.6 处理)。无"重新开始"(联机房局不可单方重开)。菜单打开即播 `sys_menu` + 首项;菜单内另播 `settings_hint`(设置只读、回大厅修改,00 §1.2)。 + +### 2.5 豁免登记(显式) + +| # | 豁免条目 | 理由(先例) | +|---|---|---| +| E1 | 四向滑=出击(非选择/移动) | 以滑动为核心玩法(space 四向滑=闪避同款豁免);三件套(上滑/下滑/双击重听)全部保留 | +| E2 | 双击优先=旋风斩,仅冷却中=重听威胁 | 旋风斩是即时技,必须绑零延迟双击(00 §5.4:即时判定绑滑动或双击);冷却中重听保留宪法语义 | +| E3 | 菜单打开**不暂停** | 联机实时,暂停不可能;菜单项压到 3 项、打开即播提示,把"逛菜单被咬"的代价做成可感知风险 | +| E4 | 单击=呼喊(非确认/继续) | 非即时操作,300ms 延迟无碍(escape 单击=检查先例) | + +教学(TEACH)逐条讲清 E1/E2/E4(§5);不占用长按(快进保留,无需 rateBoost 豁免);不开 immediateTap。 + +## 3. 世界模型与数值 + +### 3.1 猎场与怪物 + +- 圆形猎场:猎人位于圆心营地,野兽从边缘(8 罗盘方位 N/NE/E/SE/S/SW/W/NW)沿直线匀速逼近圆心。逼近全程 8-12s(按波次表)。 +- 怪物状态由**服务器**持有:`{ id, mkind, dir, eta }`——eta = 距突入的秒数(服务端单调时钟)。客户端不推算位置,只按事件流+快照渲染(§6.7)。 +- 怪物 2 型: + +| 型 | mkind | 逼近 | 突入扣耐久 | 声音 | +|---|---|---|---|---| +| 饿狼 | `wolf` | 8-10s | -1 | `howl_wolf` 生成呼啸 + `loop_wolf` 移动 | +| 暴熊 | `bear` | 11-12s | **-2** | `howl_bear` 低吼 + `loop_bear` 重脚步 | + +- **距离分档**(听觉视野,§8.2):外圈 eta>6s(仅生成呼啸一声);中圈 4-6s(移动 loop 可闻,低音量);内圈 eta≤4s(可击杀的近战窗,loop 升档,计入威胁播报)。 + +### 3.2 出击与技能(服务端裁决,§6.5) + +- **出击**:`act attack dir` → 弧 = dir±45°(3 个罗盘向)→ 弧内活着且 eta≤4.0 的怪中取 eta 最小者击杀;无 → `miss`。冷却 2.5s/座(服务器计时,违规 `err cooldown`)。 +- **旋风斩**:`act skill` → 清除全场 eta≤4.0 全部怪(不分方向)。冷却 8s/座(违规 `err skill_cd`)。 +- **呼喊**:`act callout` → 广播 `ev callout`;bot 补位(§7.3)。节流 5s/座。 +- 击杀归属 = act 来源槽位(结算个人击杀账)。 + +### 3.3 防区(软分工) + +- 每座位固定**主防区**(连续罗盘弧)与防区中心(喊话 pan 用): + +| 猎人数 | 座位 0 | 座位 1 | 座位 2 | 座位 3 | +|---|---|---|---|---| +| 3 | [N,NE,E],中心 NE | [SE,S,SW],中心 S | [W,NW],中心 WNW | — | +| 4 | [N,NE],中心 NNE | [E,SE],中心 ESE | [S,SW],中心 SSW | [W,NW],中心 WNW | + +- 防区**不是硬限制**:任何玩家可打任何方向(弧映射见 §2.1)。防区用于:① bot 目标选择偏置(§7.2);② 呼喊/喊话的 pan 方位来源;③ 刷怪剧本的方位均衡(§3.5)。 +- 座位角色(voice 身份):座 0=阿岩(yan)、座 1=小筝(zheng)、座 2=老周(zhou);**座 3 仅 4 人局存在,无专属角色**(呼喊=`call_whistle` 哨音,见 §15 裁决 6)。真人"扮演"座位角色:你的呼喊用你座位角色的预录声,断线转 bot 后声音身份无缝延续。 + +### 3.4 波次表(5 波,服务器剧本) + +| 波 | 狼 | 熊 | 狼逼近 s | 熊逼近 s | 刷怪间隔 s | 波时长约 | +|---|---|---|---|---|---|---| +| 1 | 4 | 0 | 10.0 | — | 3.0 | ~21s | +| 2 | 6 | 0 | 9.5 | — | 2.5 | ~24s | +| 3 | 7 | 1 | 9.0 | 12.0 | 2.2 | ~29s | +| 4 | 8 | 1 | 8.5 | 11.5 | 1.8 | ~28s | +| 5 | 10 | 2 | 8.0 | 11.0 | 1.5 | ~30s | + +- 波间休息 8s(`wave_clear` → 休整 → 号角 + 下一波播报)。波内怪物匀速逼近不止步,一波必在有限时间内出清(杀或漏),无需超时兜底。 +- 每波开始播对应整句 `wave_band1`…`wave_band5`(台词表 C 组;先号角 `horn_wave` 后播报)。 +- 全程怪物总数 39,耐久预算 10(熊 -2)。平衡目标:3 bot 水准下 solo 余耐久 6-9(评级 A/B),真人协作越好评级越高。 +- 局时长核算:战斗 ≈ 132s + 休息 32s + lobby ≤12s ≈ **3 分钟战斗 + 首尾 ≈ 5 分钟** ✓(任务书"约 5 分钟")。 + +### 3.5 刷怪方位与种子 + +- 房间 seed 开局掷一次(`joined.seed`),mulberry32 派生:`rng(wave) = mulberry32((seed ^ (wave * 0x9E3779B9)) >>> 0)`。 +- 每波方位分配:把本波怪物按座位数轮转发牌到各座位防区内的随机方位(同一方位同刻最多 2 只在场,防叠声);同 seed → 同 spawn 剧本(服务器单测可复现断言)。 +- 服务端战斗模拟 tick = 100ms;快照 2Hz(§6.3)。 + +### 3.6 耐久与评级 + +| 结算时剩余耐久 | 评级 | +|---|---| +| ≥9 | S | +| 7-8 | A | +| 4-6 | B | +| 1-3 | C | +| 0(波内归零) | 失守(立即终局,无评级,不入榜) | + +评级由服务器算好在 `ev end.grade` 下发(客户端不算分,防双源)。 + +**终局播报链**:胜利 = `end_win → grade_s/a/b/c → kill_band_*(自己击杀档)→ end_rank → end_again`;失守 = `end_lose → kill_band_* → end_again`(失守不播评级、不播 end_rank、不写 best,§12)。进 END 盖 800ms 手势死窗(§2.3)。 + +## 4. 状态机(客户端) + +### 4.1 状态列表 + +`TITLE / MATCHING / LOBBY / TEACH / WAVE / MENU(overlay) / END / LOST`。MENU 为挂起 overlay(记录返回态,**不暂停战斗**);LOST = 连接不可恢复(回大厅出口)。 + +### 4.2 转移图 + +``` +TITLE ──单击──> MATCHING ──joined──> LOBBY ──act start / 12s 到 / 满4人──> (ev wave 1) + │ 首局(tutDone=false) → join teach=true → joined.state.phase='teach' → TEACH +MATCHING ──连接失败──> LOST(net_lost→net_gone) ──单击──> TITLE +TEACH ──教学完成 / 下滑跳过──> WAVE(wave 1) +WAVE ──ev end──> END ──单击──> MATCHING(再来一局) +WAVE/LOBBY ──下滑──> MENU ──返回──> 原态(战斗不暂停) +WAVE ──WS 断开──> 本地静默重连 ≤30s(net_lost) ──joined(resume)──> WAVE(net_back) + └──超时/房已灭──> LOST(net_gone) +``` + +### 4.3 每状态手势映射总表 + +| 手势 | TITLE | MATCHING | LOBBY | TEACH | WAVE | MENU | END | +|---|---|---|---|---|---|---|---| +| 四向滑 | error | error | error | 步骤作答/出击 | 出击 | error | error | +| 单击 | 快速匹配 | error | 立即开局 | 步骤作答/呼喊 | 呼喊 | 循环切项 | 再来一局 | +| 双击 | 重听 | 重听 | 重听 | 步骤作答/旋风斩 | 旋风斩/重听威胁 | 确认执行 | 重听结算链 | +| 上滑 | 最佳播报 | error | team_* | 状态链(T4 起) | 状态链 | 帮助 | 重听最佳段 | +| 下滑 | 菜单 | 菜单(取消匹配) | 菜单 | **跳过教学** | 菜单(不暂停) | 关闭 | 菜单 | +| 长按 | 快进 | 快进 | 快进 | 快进 | 快进(非玩法) | — | 快进 | + +无效手势一律 error 震;full 档语音纠错 `gd_invalid`(onceKey 同局 1 次)。看门狗语音 idle-only。 + +## 5. 教学编排(TEACH,首局教学房) + +**矛盾与解法**:实时联机局不能暂停教学 → 首次进入(进度 `tutDone` 为假)走**教学房**:单人 + 2 bot(教学段 bot AI 挂起)、先行一波**教学波**(3 狼,逼近 12s,学期零惩罚:教学波突入**不扣耐久**),教学完成或下滑跳过后,同一房间直接接续第 1 波(bot 激活)。教学即真联机局——这本身就是切片验证点(首局就跑了完整的 WS/事件流路径)。 + +| 步 | 旁白 | 等待动作 | 通过/反馈 | +|---|---|---|---| +| T0 | `intro_1` + `intro_2` + `tut_rule` | 单击 | confirm 震 | +| T1 | tut_attack(服务器放教学狼 1,方位 N) | 朝声音方向滑 | kill → tut_kill_ok;挥空/错向 → gd_idle 重教(≤2 次后自动放过该狼) | +| T2 | tut_callout | 单击呼喊 | callout ev → bot `npc_*_help_ack` 响应演示(教学 bot 必答)→ tut_callout_ok(呼喊-响应闭环点明) | +| T3 | tut_skill(服务器放教学狼 2、3,贴近 eta 5s) | 双击旋风斩 | cleared=2 → tut_skill_cd(讲冷却与冷却中双击=重听) | +| T4 | tut_status | 上滑(状态链) | 链播完 → tut_breach → tut_done → 进 WAVE 1 | +| 任意 | — | 下滑 | tut_skip → 进 WAVE 1(`tutDone` 写入进度) | + +- 教学波零惩罚:教学狼突入不扣耐久(明说规则前不漏真话);T1 首输入 ≈ t+20s(<60s ✓);首个正反馈 = T1 首杀 ≈ t+40s(≤3min ✓)。 +- 看门狗:每步 8s nudge 纯震(教学句不重播),T1/T3 卡 30s 自动放过当前狼推进(space 先例),不卡流程。 +- 二周目起 TITLE 单击直达快速匹配(tutDone 存进度,§12)。 + +## 6. 多人协议(两个开发 agent 的实现契约) + +### 6.1 传输:零依赖 WebSocket(server.js 增补) + +- 端点:`GET /ws/hunt`,与静态/REST 同一 HTTP 服务器、同一端口。`http.createServer` 的 `'upgrade'` 事件接管:校验 `Upgrade: websocket`、`Sec-WebSocket-Key`,回 `101` + `Sec-WebSocket-Accept = base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))`(node:crypto,无 npm)。 +- 帧解析(手写,约 100 行,建议独立 `web/server/ws.js` 便于单测):客户端→服务器帧**必须带 mask**(不带 → close 1002);支持 opcode 0(continuation 拼装)/1(text)/8(close)/9(ping→回 pong)/10(pong);payload 7/16/64 位长度全支持;单条消息上限 **4KB**(超限 close 1009);二进制帧 close 1003。服务器→客户端帧不 mask。 +- 活性:服务器每 15s ping,45s 无 pong 判死(等同断线走 §6.6);每连接应用消息速率帽 20 条/s(超限 err `rate`,再犯 close)。 +- 发送背压:每连接一个待发队列;`snap` 可丢(新顶旧),`ev/joined/err` 不丢;队列 >200 → close 1013。 +- 每消息 = 1 条 text 帧 = 1 个 JSON 对象,必有 `t` 字段。协议版本 `ver: 1`(join 携带,不符 err `bad_msg`)。 + +### 6.2 客户端→服务器消息(3 条) + +**join**(连接后首条,5s 内不发则 close): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `t` | `"join"` | — | +| `ver` | number | 协议版本,恒 1 | +| `room` | string | `"quick"` = 快速匹配;或 4 位房码(大写无歧义字符 `23456789ABCDEFGHJKMNPQRSTUVWXYZ`) | +| `name` | string | 显示名(1-24 字符,取自档案;服务端截断兜底) | +| `teach` | bool? | true = 请求教学房(首局;服务端强制单人房+教学波) | +| `resume` | string? | 重连凭证(`joined.resume` 原值);携带时按 §6.6 回槽 | + +**act**(对局意图;服务器按到达序裁决): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `t` | `"act"` | — | +| `kind` | `"attack" \| "callout" \| "skill" \| "start"` | start 仅 LOBBY(立即开局) | +| `dir` | `"N"\|"NE"\|"E"\|"SE"\|"S"\|"SW"\|"W"\|"NW"` | attack 必填(客户端四向滑只发 N/E/S/W 四基向,弧解析在服务端;bot 可发 8 向) | +| `seq` | number | 客户端单调递增(去重/乱序检测;乱序不拒,仅日志) | +| `at` | number | 客户端发出时刻 ms(仅诊断用,裁决以服务端到达序为准) | + +**leave**:`{ "t": "leave" }` —— 主动退出(菜单"回大厅"),槽位立即按 §6.6 处理(无 30s 宽限)。 + +### 6.3 服务器→客户端消息(joined / ev / snap / err) + +**joined**(join 受理;resume 重连亦发): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `t` | `"joined"` | — | +| `room` | string | 实际房码 | +| `slot` | number | 你的座位号 0-3 | +| `resume` | string | 重连凭证(16 hex,每槽位签发一次,连接存活期内不变) | +| `char` | `"yan"\|"zheng"\|"zhou"\|null` | 座位角色(座 3 恒 null) | +| `sector` | string[] | 你的主防区方位集(§3.3) | +| `seed` | number | 房间种子(复现用) | +| `state` | object | `{ phase: "lobby"\|"teach"\|"wave"\|"end", wave: number, dur: number }` | +| `roster` | array | 全花名册:`[{ slot, name, bot, char, sector }]`(bot=true 时 name=角色名) | + +**ev**(事件流,即时下发;`ts` = 房间单调时钟 ms(开局为 0)): + +| kind | 附加字段 | 含义 | +|---|---|---| +| `spawn` | `id, mkind("wolf"\|"bear"), dir, eta` | 怪物生成(eta=逼近全程秒数) | +| `kill` | `id, slot, dir, mkind` | 出击命中击杀(归属 slot) | +| `miss` | `slot, dir` | 挥空(弧内无可打怪) | +| `skill` | `slot, cleared: number[]` | 旋风斩放出,cleared=被清怪 id 表 | +| `breach` | `id, mkind, dir, dur` | 突入:耐久新值 dur(wolf -1 / bear -2 已结算) | +| `wave` | `wave` | 第 wave 波开始(1-5) | +| `waveclear` | `wave` | 第 wave 波出清 | +| `callout` | `slot` | 求援呼喊(真人单击或 bot 压力呼救;接收端播该座角色 help_call + warning 震) | +| `botline` | `slot, line` | bot 喊话(line ∈ `greet/help_ack/cover/kill/breach/skill/wave`;客户端按 char+line 播 `sfx/npc__`) | +| `roster` | `roster` | 花名册变更(加入/离开/断线/转 bot;形状同 joined.roster) | +| `end` | `win, dur, grade("S"\|"A"\|"B"\|"C"\|null), kills` | 终局;kills=`{ "0": 5, ... }` 按 slot 的个人击杀 | + +**snap**(2Hz 兜底快照): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `t` | `"snap"` | — | +| `ts` | number | 房间单调时钟 ms | +| `phase` / `wave` / `dur` | — | 同 joined.state | +| `mobs` | array | 在场怪物全量 `[{ id, mkind, dir, eta }]`(eta 为 ts 时刻值) | +| `slots` | array | `[{ slot, alive, bot, cd, skillCd }]`(cd/skillCd=剩余冷却秒,重连恢复冷却 UI/判定用) | + +**err**:`{ "t": "err", "code": "cooldown"|"skill_cd"|"bad_dir"|"not_ingame"|"room_full"|"room_gone"|"bad_msg"|"rate", "msg": "人话描述" }`(cooldown/skill_cd 为正常违规拒绝,客户端不应惊扰玩家——客户端本地已先拦)。 + +### 6.4 房间管理与匹配 + +- 快速匹配:找 `phase='lobby'` 且有空人类座的房 → 进;没有 → 建房(房码 4 位随机)。**客户端 v1 只发 `room:"quick"`**;显式房码 join 协议完整、服务端实现并单测,客户端录入 UX 列 roadmap(§15 裁决 5)。 +- LOBBY 上限 12s:到点、任一人类 `act start`、或满 4 人 → 开局(播 `room_start`,随后 `ev wave 1` + `wave_band1`)。开局时空位由 bot 补至 **max(3, 人类数)** 猎人:1 人→2 bot、2 人→1 bot、3 人→0 bot(3 座)、4 人→0 bot(4 座)。第 4 座仅 4 人局启用。 +- join 只在 lobby 受理;对局中 join → err `room_full`(v1 不做中途加入)。 +- 教学房:join `teach:true` 且本地 tutDone=false → 强制单人房(§5 剧本);教学波完直接续 WAVE 1。 + +### 6.5 服务端权威战斗(hunt-room.js,建议独立模块便于单测) + +- 房间结构:`{ seed, phase, slots[≤4], mobs: Map, wave, dur, tickTimer, snapTimer }`;tick 100ms:eta 递减、突入结算、波剧本推进;snap 500ms。 +- 裁决规则(§3.2 数值的权威实现):弧映射、eta≤4.0 近战窗、冷却 2.5/8s、呼喊节流 5s——全部只在服务端生效;客户端可做本地预扣(冷却 UI),但以 err/ev 为准。 +- bot = 特殊 slot(bot:true,无连接):同一套裁决管线,由 §7 AI 产生 act。 +- **时钟**:ts 为房间单调 ms。客户端在 joined/snap 到达时记 `offset = ts - performance.now()`,渲染用 `nowServer = performance.now() + offset`;局域网无需 RTT 补偿(逼近时长 8-12s,误差淹没在听觉分辨率内)。 + +### 6.6 断线与重连 + +- 连接断开(或 45s 无 pong)→ 槽位标记断线(roster 广播 alive=false),**30s 宽限**:槽位静默(无 act 产出,猎人"愣在原地"——这是真实的协作代价)。 +- 宽限内带 `resume` 重连 → 回槽:重发 joined(同 slot/seed/char)+ 紧随 snap 全量,客户端无缝续渲染(net_back)。 +- 宽限超时:剩余人类 ≥3 → 槽位移除(roster);否则槽位转 bot(roster,bot=true,char 沿用座角色,补位 bot 播 `npc_*_cover`,其余客户端播 `net_to_bot`)。转 bot 不可逆(该局内)。 +- 最后一个人类断开:房间保留至其 30s 宽限耗尽后销毁(无人类即灭,bot 不续命)。房灭后迟到 resume → err `room_gone`(客户端 net_gone → LOST)。 +- 终局 end 后房间保留 30s(各端写排行榜)随后 close 1000。 + +### 6.7 客户端渲染模型(client agent 契约) + +- 本地怪表 = ev 增删 + snap 校正:每 snap 对账——本地多出的 id 删、缺的按 snap 补、eta 偏差 >0.5s 校正。事件流为主、快照兜底,二者矛盾以 snap 为准。 +- 每 500ms 重估渲染集:eta>6s 静默;≤6s 按 eta 升序取前 3 只起 loop(§8 R3);loop 音量按档(6-4s:0.30 / 4-2.5s:0.55 / <2.5s:0.80);pan 按方位角映射(§8.3)。 +- 断网期间:本地怪表按最后已知 eta 继续衰减渲染(怪物照爬——戏剧张力保留),恢复后以 snap 校正。 + +## 7. Bot 设计(服务端驱动) + +### 7.1 角色与声音资产 + +| 座位 | 角色 | char | speaker | instruct 常量 | 性格 | +|---|---|---|---|---|---| +| 0 | 阿岩 | yan | Uncle_Fu | `憨厚壮实的中年男声,猎户,话少踏实` | 憨厚 | +| 1 | 小筝 | zheng | Serena | `轻快爽利的年轻女声,猎手,干脆利落` | 轻快 | +| 2 | 老周 | zhou | Eric | `沙哑沉稳的老年男声,老猎手,带点调侃` | 沙哑 | + +每角色 8 句固定短句(事件触发,全集见台词表 N 组):`greet / help_call / help_ack / cover / kill / breach / skill / wave`。**固定单音色平铺、不做双性别**(rpg NPC 先例);因经 sfx 层带 pan 播放,资产落 `audio/sfx/npc__.{ogg,m4a}`(§15 裁决 7)。 + +### 7.2 战斗 AI(tick 500ms,确定性) + +- 出击:冷却就绪且存在目标方向时出手。方向压力 `P(d) = Σ(1/eta)`(d 内活怪,仅计 eta≤4);主防区方向 ×1.5 偏置;取 max P 方向发 `act attack`(dir=该方向精确罗盘向)。**拟人反应带**:只在最优目标 eta ≤ 2.0 + rng()×1.0 时才出手,且每次出手决策带 80% 空转概率(rng 走房间 seed,复现可断言),避免" eta 一进 4 秒就秒"的机械感(数值为 §15 裁决 10 调参后口径)。 +- 旋风斩:全场 eta≤2.5 的怪 ≥3 只且技能就绪,且 60% 概率想起 → `act skill`(裁决 10)。 +- 呼救:主防区压力 ≥3(≥3 只 eta≤6)且 20s 节流 → `act callout`。 +- 难度校准:bot 不失误(挥空不存在——只打有目标的弧),但受 2.5s 冷却、反应带与空转概率上限;3 bot 局实测评级 A/B 为主(§3.4 平衡目标,多种子扫描见 §15 裁决 10),给真人留出"比 bot 强"的空间。 + +### 7.3 补位(呼喊响应) + +收到 `ev callout`(真人或 bot 发出)后:**空闲 bot**(当前 500ms 无待发 act 者,多取座位号小者)播 `help_ack`,且其后 10s 内目标选择偏置改为呼喊者防区方向 ×2.0(覆盖主防区偏置)——即"朝你侧补位"。补位 bot 首次在新防区击杀后再无怪时播 `cover`。呼喊本身不改变 bot 的冷却/反应带(公平)。 + +### 7.4 喊话调度(与 §8 R2 联动的服务端纪律) + +- botline 触发源:greet(开局补位完成,逐只间隔 ≥1.5s)/ cover(转 bot 接管、补位落定)/ kill(杀熊或三连杀,15s 节流)/ breach(本防区漏怪,20s 节流)/ skill(放旋风斩)/ wave(第 2 波起,轮转一只 bot)。 +- 服务端对 botline/callout 全局节流:**10s 窗口 ≤2 条**,超出丢弃(喊话是氛围,不是信息主通道);help_call/help_ack 优先级最高(呼救语义不可被氛围句顶掉)。 + +## 8. 音频信道拥挤控制(本文档重点章节) + +### 8.1 硬规则表 R1-R8 + +| # | 规则 | 数值 | +|---|---|---| +| R1 | **旁白/系统音单句队列**(引擎 voice 层既有):同刻 1 句,链播 onComplete 串行;警报类(breach/breach_heavy/danger_low)可顶掉状态/播报类(上滑链、skill_ready),教学/结算链不可顶 | 同刻 voice=1 | +| R2 | **bot 喊话走 sfx 层**(带 pan,与旁白并发):同刻在播 ≤1 条;待播队列深 ≤1(新到顶掉未播的过期句);旁白在播时延迟投递 ≤2s,超时丢弃;服务端全局 10s ≤2 条(§7.4) | 同刻喊话=1 | +| R3 | **怪物移动 loop ≤3 路**:只渲染 eta 最小的 3 只(500ms 重估),其余静默;loop 经游戏层句柄 + 自建 StereoPannerNode(abyss `_startLoop` 先例,不占引擎改动面) | 同刻 loop≤3 | +| R4 | **定位 one-shot ≤2 并发**(howl/击杀/突入/旋风斩):同类间隔 ≥0.15s;波次生成潮的呼啸队列深 ≤2,超出丢弃(怪近后 loop 已接管信息,呼啸可弃) | 同刻 one-shot≤2 | +| R5 | **非语音信息全部 <1s 短音效**:号角 0.9s、join_chime 0.4s、call_whistle 0.6s、kill 0.5s 量级;语音通道只给人声 | — | +| R6 | **听觉视野裁剪**(§8.2):威胁播报只计内圈(eta≤4);状态链不报具体方位(方位由 loop pan 承载,语音永不逐只报方位——8 向 × 距离档的语音枚举被明确禁止) | — | +| R7 | **电平分层**:voice 层播时 BGM 自动 duck 20%(引擎既有);bot 喊话(sfx 层)不触发 duck → 游戏层统一 vol 0.55 压一档,防压旁白;loop 音量档上限 0.80 | — | +| R8 | **最坏并发核算**:旁白 1 + 喊话 1 + loop 3 + one-shot 2 = **7 声流**;频带分离(loop 低频 100-300Hz / 人声 300Hz-3kHz / howl 中高 1-3kHz / BGM 垫底),引擎限幅器兜底。峰值场景(第 5 波 + 警报 + 呼喊)不得丢警报、不得叠爆 | ≤7 声流 | + +### 8.2 听觉视野裁剪规则 + +8 方位 × 3 距离档: + +| 档 | eta | 可听内容 | 可被打 | +|---|---|---|---| +| 外圈 | >6s | 仅生成呼啸一声(one-shot,pan+距离音量 0.4) | 否 | +| 中圈 | 4-6s | 移动 loop(vol 0.30;受 R3 路数上限约束) | 否 | +| 内圈 | ≤4s | loop 升档(0.55/0.80);计入 threat_* 威胁播报与 danger 评估 | **是(近战窗)** | + +- threat 口径:`threat_none`(内圈 0 只)/ `threat_few`(1-2 只)/ `threat_many`(≥3 只)——**只报数量档,不报方位**;方位信息永远由 loop/呼啸的 pan 实时承载(这本身就是切片要验证的"空间音频协作"命题)。 +- 播报裁剪:状态链/威胁播报里禁止出现逐方位枚举句;台词表不含此类 id(结构性杜绝)。 + +### 8.3 方位 → pan 映射(全作统一) + +方位角 az(N=0°、E=90°、S=180°、W=270°):`pan = sin(az)`(N/S=0、NE/SE=±0.7、E/W=±1、NW/SW=∓0.7)。**前/后歧义**(StereoPanner 不能分前后):后半平面(90°_` ×24 | TTS | bot 喊话(voice 管线生成、落盘 sfx/,峰值归一 0.89 沿用 voice 策略;游戏层 vol 0.55,§8 R7) | + +### 10.3 voice(旁白/系统音) + +60 句 ×2 性别(male=Uncle_Fu / female=Serena,全表同一 instruct 常量/性别),分组与全文见 [23-hunt-voice-ids.md](23-hunt-voice-ids.md)。数字全分档整句:耐久 4 档 / 波次 3 档 + 5 整句 / 在场猎人 2 句 / 击杀 3 档 / 威胁 3 档 / 评级 4 整句。键盘变体 11 对(V 组,`gesture.inputMode()` 选播)。 + +规模核算:voice 60×2×2fmt ≈ 12MB + npc 24×2 ≈ 2MB + key 变体 11×2×2 ≈ 2MB + sfx 13×2 ≈ 1MB + bgm 2×2 ≈ 6MB ≈ **23MB < 30MB 预算** ✓。 + +## 11. 向导层接入(engine/guide.js,不新增 earcon 词表) + +| 状态 | guide.state() | 时点 | +|---|---|---| +| TITLE / MATCHING / LOBBY / MENU | `menu` | 进态即播 | +| TEACH / WAVE | `battle` | 开局/波开始 | +| END | `settle` | 结算进入 | +| danger | `danger` | 耐久首次 ≤2(每局 1 次,配 danger 震 + danger_low) | + +- turn cue 不使用(无回合制);看门狗:WAVE/LOBBY 无操作 [10s, 25s] → `gd_idle` / null(第 2 级纯 nudge);TEACH 每步 [8s] 纯 nudge(教学句不重播)。玩家任意手势复位;guide=off 全静零定时器(既定契约)。 +- 纠错:无效手势 error 震 + `gd_invalid`(onceKey 同局 1 次,之后仅震)。 +- 无卡关三级提示(本作无"卡住"形态:压力来自怪物而非谜题)。 + +## 12. 存档与排行榜(进度 API) + +```json +{ + "schema": 1, "game": "hunt", "updatedAt": 0, + "summary": "最佳评级A · 击杀14 · 营地余8 · 共3局", + "best": { "grade": "A", "kills": 14, "durLeft": 8, "at": "" }, + "runs": 3, "totalKills": 31, "tutDone": true +} +``` + +- **写入时机**:终局结算(每局一次,胜利/失守都写 runs/totalKills;`best` 只被胜利局按 评级 > 营地余耐久 > 击杀 的序更新)。客户端在收到 `ev end` 后自行 PUT(Bearer = 档案 token;离线只写 localStorage `soundgame.progress.hunt`)。00 §4 约定遵守:data 自由格式 + **必带 summary**(大厅卡片直接显示)。 +- **summary 生成规则**:`最佳评级{grade} · 击杀{kills} · 营地余{durLeft} · 共{runs}局`;无 best(全败)时 `共{runs}局 · 还没守到天亮过`。 +- **排行榜形态**:本切片无跨玩家排行 API——"排行榜"= 个人最佳桶 + 大厅卡片 summary(00 §4.3 既有机制)。跨玩家榜列 roadmap。 +- 读取:TITLE 有 best 时上滑播最佳段(评级+击杀档);tutDone 控制是否进教学房(§5)。 + +## 13. playtest 断言(摘要;全表住 13 号文档 §24) + +- **关键路径**:遮罩解锁 → TITLE → 快速匹配(首局教学房 T0-T4 逐步)→ LOBBY → 开局 bot 补位(roster 断言 3 猎人)→ 5 波(spawn/kill/breach/wave 事件序列与 §3.4 表逐波核对)→ 终局结算链(grade 与 dur 一致)→ PUT progress(summary 命中生成规则)→ 再来一局。 +- **边界**:断线 30s 转 bot / 宽限内 resume 回槽无缝 / 旋风斩冷却与冷却中双击=重听 / 呼喊→bot 补位(10s 内 bot act 方向落入呼喊者防区)/ 听觉裁剪(eta>6 静默、loop ≤3、threat 只计内圈)/ 攻击冷却服务端拒绝 / snap 校正 / 菜单不暂停照扣耐久。 +- **失败即不通过**:评级与 dur 不符(归因假话);服务器接受违规 act;ev 与 snap 矛盾不收敛;bot 喊话压过旁白(R1/R2 破);loop >3 路;教学狼扣耐久。 +- 全表与全局通用断言见 [13-playtest-criteria.md](13-playtest-criteria.md) §24。 + +## 14. 自检清单 + +| # | 硬约束 | 自检 | +|---|---|---| +| 1 | FTUE 首输入 <60s / 首正反馈 ≤3min | T1 首滑 ≈t+20s;首杀 ≈t+40s ✓(§5) | +| 2 | 学习期零惩罚 | 教学波突入不扣耐久;做错重教 ≤2 次自动放过;挥空仅 error 震 ✓ | +| 3 | 手势宪法 | 上滑=状态、下滑=菜单、双击=重听(冷却中落地)保留;E1-E4 登记且教学讲清 ✓(§2.5) | +| 4 | 向导层 | 签名 4 态不新增词表;看门狗 [10/25]s 二级纯 nudge;纠错 onceKey;guide=off 全静 ✓(§11) | +| 5 | 旁白规范 | 60 句 ≤ 预算;整句无拼接;数字全分档;同 instruct 常量;无语速词;"重新听"规范写法 ✓(台词表头注) | +| 6 | 拥挤控制 | R1-R8 可断言;最坏并发 ≤7 声流;警报零丢失口径 ✓(§8) | +| 7 | 方位降维 | 8 方位 × 3 距离档,pan 七值 + 后半平面 ×0.6;无连续角度、无逐方位语音枚举 ✓(§8.2/§8.3) | +| 8 | 协议自洽 | C→S 3 条、S→C 4 类字段表全;服务端权威+快照兜底;断线/重连/房灭路径闭合 ✓(§6) | +| 9 | 引擎边界 | 引擎改动仅 opts.pan 一处且后向兼容;loop 走游戏层先例;30MB 预算内 ✓(§9/§10.3) | +| 10 | 服务器约束 | 零依赖(手写 WS 帧);数据落盘/REST 零改动;单实例约束不恶化(房间全内存) ✓(§6.1) | +| 11 | P1 视障 | 双击免费重听威胁;上滑一键复述;方位由声像承载且规则教学讲明;无倒计时焦虑(波次自然出清) ✓ | +| 12 | P3 通勤 | 单手免视;菜单不暂停但 3 项极简;局 5 分钟随玩随放 ✓ | +| 13 | 失败归因真话 | 评级=dur 服务端直发;结算击杀档=个人真账;挥空/失守有真话反馈(atk_swish/end_lose) ✓ | +| 14 | seed 可复现 | 房间 seed + mulberry32 派生;刷怪剧本/bot 反应带全走 seed;服务器单测可复现 ✓(§3.5/§7.2) | + +## 15. 设计裁决与矛盾修正记录(实施前必读) + +对任务书核心案的细化/修正,逐条登记(核心形态零改动): + +1. **双击矛盾修正**:任务书"双击=旋风斩,8 秒冷却;**无冷却时**双击=重听威胁播报"自相矛盾(无冷却=就绪=放旋风斩)。裁定为:**旋风斩就绪时双击=放出;冷却中双击=重听威胁播报**——既保留技能,又让宪法"双击=重听"在全时段有定义(豁免 E2 登记)。 +2. **补出击冷却 2.5s/人**:任务书未给出击冷却。无冷却则单人滑动吞吐无上限,协作失去必要性(§1.6 风险③)。2.5s 使单人峰值约 1 杀/窗,三人局才有分工意义。数值写在 §3.2,服务端裁决。 +3. **防区=软分工模型**:任务书"玩家位于营地中心"与"bot 朝你侧补位"并存但无站位定义。裁定为**软防区**(§3.3):不设站位硬锁(任何方向都能打),防区只做 bot 偏置、喊话 pan 来源、刷怪均衡——保留"补位"语义且不引入连续几何。 +4. **四向滑 → 90° 弧映射**:任务书"四向滑映射到最近罗盘方位"在 8 方位刷怪下不可玩(斜向怪永远打不到)。裁定:滑向映射 ±45° 弧,击杀弧内内圈 eta 最小者(§2.1/§3.2);协议 act.dir 仍用 8 罗盘枚举(bot 用满 8 向),客户端只发四基向。 +5. **房码 UX 范围**:音频录入 4 位房码需数字语音资产(10 条)会击穿 60 句预算。裁定:协议 join.room 完整支持房码(服务端实现+单测),**客户端 v1 只做快速匹配**;音频录码 UX 列 roadmap(届时加数字整句资产)。 +6. **第 4 座无角色声音**:bot 角色预算 3×8=24(任务书定死),而 4 人局第 4 座真人的呼喊无角色可用。裁定:座 3 呼喊=`call_whistle` 哨音(带 pan,<1s,R5 合规),bot 照常响应补位;哨音本身是有效求援信号,明示于 §3.3。 +7. **bot 喊话落盘 sfx/(异例)**:任务书两处表述有张力——拥挤控制章"bot 喊话走 sfx 层空间化播放"与引擎章"voice 层同样支持 pan(bot 喊话用)"。裁定:喊话必须与旁白并发(R1/R2 的核心),voice 层单流做不到 → 走 sfx 层,引擎按 id 前缀分发故资产落 `audio/sfx/npc_*`(voice 管线生成不变);voice 层 opts.pan 能力同步提供但**本作不使用**。旁白 narrator 与 bot 同 speaker 的区分度风险(阿岩×male 旁白、小筝×female 旁白)按 abyss 先例标注"中",缓解=空间 pan + instruct 差异 + 语境(bot 只讲 1-2s 短喊话)。 +8. **巨兽 -2**:任务书"突入=耐久-1"为默认口径;第 3 波起加入暴熊(-2)作为重威胁,给击杀优先级一个明确信号(重 loop/重倒地音可辨),数值与播报(breach_heavy)配套,不改核心形态。 +9. **失守与入榜**:任务书"评级 S~C"隐含必完 5 波。裁定:耐久波内归零=失守终局(end_lose,无评级、不写 best,runs/totalKills 照记);胜利局才按 §3.6 评级写入 best——排行榜不被败局污染,失守有真话结算。 +10. **bot 强度调参(§7.2 数值修订,协议字段不变)**:初版严格按 §7.2 公式(反应带 eta≤2.2+rng×1.2、无空转、旋风斩 eta≤3≥3 必放)实测**零泄漏**——多种子仿真 3 bot 水准局余耐久恒 10(S),甚至真人全程挂机 2 bot 独守也恒 10,§3.4"3 bot 水准余 6-9"与"协作必要性"(§1.6 风险③)落空。根因是吞吐超配:3 猎人 1.2 杀/s vs 峰值到达 0.67 杀/s,冷却 2.5s 不构成约束。裁定修订为:反应带 **eta ≤ 2.0+rng×1.0**;出手决策加 **80% 空转概率**(500ms tick 内愣神,等效出手周期 ~4.5s);旋风斩 **eta≤2.5 ≥3 只且 60% 概率想起**。调后 24 种子扫描(`web/server/test/hunt-balance-scan.mjs`):挂机局 17/24 失守或余 ≤3(6 场失守,mean 3.3,C/失守口径 ✓);3 bot 水准局 17/24 余 6-9(mean 7.5,A/B 为主 ✓);真人最优仍 23/24 满耐久 S("比 bot 强"空间保留 ✓)。bot"不失误"语义保留(只打有目标的弧),弱化的是**反应速度与出手频率**(拟人迟钝),不是命中率。 diff --git a/docs/design/README.md b/docs/design/README.md index 414a44f5..126207ad 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -14,6 +14,7 @@ | [03-rhythm-rebuild.md](03-rhythm-rebuild.md) + [03-rhythm-voice-ids.md](03-rhythm-voice-ids.md) | 《节拍行者》重构施工稿:跟随领奏玩法、三首完整谱面、判定契约、31 句台词表 | ⑧ | | [04-space-rebuild.md](04-space-rebuild.md) + [04-space-voice-ids.md](04-space-voice-ids.md) | 《盲航》重构:交互式演练、同向闪避、60 秒保护期、52 句台词表 | ⑨ | | [05-rpg.md](05-rpg.md) + [05-rpg-chapter1.md](05-rpg-chapter1.md) + [05-rpg-ch1-voice-ids.md](05-rpg-ch1-voice-ids.md) + [05-rpg-addendum.md](05-rpg-addendum.md) | 新 RPG《剑与声》:总设计 + 第一章施工稿(场景图/战斗状态机/数值/存档/资产 prompt)+ 120 句台词表 + 增补(战斗震动配合、无声前摇协议、层叠规则) | ⑩ | +| [23-hunt.md](23-hunt.md) + [23-hunt-voice-ids.md](23-hunt-voice-ids.md) | 《围猎》2-4 人实时联机协作守卫(纯音频 MMORPG 路线第一阶段切片):零依赖 WS 协议 + 服务端权威战斗、bot 行为与补位、音频拥挤控制 R1-R8、引擎 opts.pan 改动 + 84 台词 id(60 旁白 ×2 性别 + 24 bot 喊话) | 长线方向 | ## new8 系列设计文档(8 款新 RPG,research/new8/00-program.md) diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 15030a53..bc918a6e 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -1,6 +1,6 @@ # SoundGame 后续路线图(策划,最终轮) -范围:R5(RPG 收尾)之后的事。分四块:RPG 内容扩展、storyline 打磨、真机听测、商业化前置。 +范围:R5(RPG 收尾)之后的事。分五块:RPG 内容扩展、storyline 打磨、真机听测、商业化前置、纯音频 MMORPG 路线。 设计基准文档索引见 [README.md](README.md);审查结论见 round1/round2/round3-4-review。 --- @@ -112,6 +112,15 @@ 6. **付费墙设计预留**(如需):进度 API 加 `entitlements` 字段的位置先留好;不要在现有 data 自由格式里埋付费标记。 7. **合规扫尾**:无年龄分级素材问题(武侠战斗无血腥音效——atk_hit prompt 已注明 not gory,保持);商店/内购若上 iOS 需走 IAP 不能用网页支付(届时再议)。 -## 6. 建议顺序 +## 6. 纯音频 MMORPG 路线(第一阶段切片已落地) + +- **第一阶段(已落地)**:《围猎》hunt([23-hunt.md](23-hunt.md))——2-4 人实时联机协作守卫, + 验证三个切片命题:零依赖 WS 实时同步 + 服务端权威战斗、音频信道拥挤控制 R1-R8、 + pan 空间音频协作(呼喊-补位闭环)。 +- **后续步骤**(切片纪律外事项,先列 research/ 方向调研,不在本 roadmap 排期): + 持久世界与跨房聊天、跨玩家排行榜、房码音频录入 UX(协议 join.room 已留,客户端 v1 只做快速匹配)、 + 更大规模同图协作的拥挤模型扩展。真机听测时按 23-hunt §8.4 口径补一轮"三源分辨"听测项。 + +## 7. 建议顺序 1. R5 收尾(RPG 第一章上线)→ 2. §3 存量打磨小项(半天级)→ 3. 真机听测 §4(用户执行,反馈回来后定参数旋钮)→ 4. storyline §2(与听测并行)→ 5. RPG ch2/ch3 §1 → 6. 商业化 §5(决定公网化时再启动)。 diff --git a/docs/web-engine-api.md b/docs/web-engine-api.md index 5a1fd3b6..a73ed927 100644 --- a/docs/web-engine-api.md +++ b/docs/web-engine-api.md @@ -129,10 +129,16 @@ new WebAudioEngine(basePath = 'audio/', opts?) (key →「按空格开始」,touch →「轻触开始」);显式传 `text` 完全覆盖。 - `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发: - 公共:`opts.volume`(0~1,乘层音量) + - 公共(sfx/voice):`opts.pan`(可选声像 -1~1)——非 0 有限数时在 gain + 与层总线之间插入 `StereoPannerNode`(clamp 到 [-1,1]);**缺省或 0 时 + 原路径逐字节不变**(不创建 panner 节点);老 Safari 无 + `ctx.createStereoPanner` 时静默忽略 pan(不炸、不降级音质)。 + 方位→声像的游戏层约定见各游戏设计文档(如 hunt §8.3 `pan = sin(az)`)。 - BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启 - voice:`opts.onComplete`(**自然播完**才触发;被 `stopVoice()` 或新 voice 顶掉时**不触发**,对齐 Android——用这一点实现"打断即作废整条语音链"); - `opts.queue = true` 排队等当前对白播完(默认 false 直接顶掉) + `opts.queue = true` 排队等当前对白播完(默认 false 直接顶掉,排队项同样 + 携带 pan) - sfx:即发即弃,天然支持并发叠加 - `prewarm(ids: string[])` — 预解码一批音频,消除首次播放的网络+解码延迟。 建议 `start()` 里把所有短音效 prewarm 一遍。 diff --git a/tools/check_all_assets.py b/tools/check_all_assets.py index 0817d6e8..93bb29c5 100644 --- a/tools/check_all_assets.py +++ b/tools/check_all_assets.py @@ -2,7 +2,8 @@ # -*- coding: utf-8 -*- """全项目音频资产终检(R5)。 -检查 8 个游戏 web//audio/: +检查 11 个游戏 web//audio/(批次 3 起含 hunt;hunt 的 bot 喊话落 +audio/sfx/npc_*,不在 voice 平铺豁免范围,23-hunt.md §15 裁决 7): 1. 每个 .ogg 都有同名 .m4a(反之亦然) 2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外) 3. 键盘变体目录 voice//key/(P1 路线 a): @@ -17,7 +18,7 @@ import sys ROOT = "/home/xsl/SoundGame/web" GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg", "radio", "dungeon", - "abyss", "detective"] + "abyss", "detective", "hunt"] # 平铺 voice 文件(角色/NPC,单音色,不做性别集合一致性要求) FLAT_OK = {"storyline", "rpg", "radio", "abyss", "detective"} @@ -30,7 +31,7 @@ def ids(d, ext): def main(): problems = [] - print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,批次 2 起含 abyss/detective,2026-09-09)\n") + print("# 全项目音频资产终检(R5 建立,批次 1 起含 radio/dungeon,批次 2 起含 abyss/detective,批次 3 起含 hunt,2026-09-13)\n") print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 | key male/female 一致且 ⊆ 主集 |") print("|---|---|---|---|---|---|---|") for g in GAMES: diff --git a/tools/gen_hunt_bgm.sh b/tools/gen_hunt_bgm.sh new file mode 100644 index 00000000..19b08df5 --- /dev/null +++ b/tools/gen_hunt_bgm.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# 《围猎》BGM 后处理(批次 3):Mureka flac 候选 -> 90s 无缝循环 ogg+m4a。 +# 用法: bash tools/gen_hunt_bgm.sh (flac 原件在 tools/mureka_out/,不入库) +# +# 工艺(web/detective/gen/gen_bgm.sh 同款,tools/mureka_log.md 先例): +# 首个强 onset 起点(silenceremove,-35dB 门限)→ 93s 裁剪 → 首尾 3s acrossfade(tri) +# 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(23-hunt.md §10.1,两首均 -20 LUFS; +# 结算态 0.5 volume 由游戏层控制)→ aresample 44.1kHz +# → ogg libvorbis q3 + m4a aac 128k,落 web/hunt/audio/bgm/。 +# hunt 无节拍网格玩法,100bpm 仅为 prompt 特征词,不跑 bpm_precise(dungeon 先例)。 +set -euo pipefail +cd "$(dirname "$0")/.." +export PATH="$PWD/tools/bin:$PATH" + +TMP=tools/bgm_post_wav/hunt # 中间 wav(不入库,可由本脚本复现) +DST=web/hunt/audio/bgm +mkdir -p "$TMP" "$DST" + +post() { # post + local src="$1" track="$2" lufs="$3" + local tmp="$TMP/$track.wav" + ffmpeg -y -v error -i "$src" -af \ +"silenceremove=start_periods=1:start_threshold=-35dB:start_silence=0.02,atrim=end=93,asetpts=PTS-STARTPTS,asplit[a][b];[b]atrim=start=90,asetpts=PTS-STARTPTS[t];[a][t]acrossfade=d=3:c1=tri:c2=tri,loudnorm=I=${lufs}:TP=-2:LRA=11,aresample=44100" \ + "$tmp" + ffmpeg -y -v error -i "$tmp" -c:a libvorbis -q:a 3 "$DST/$track.ogg" + ffmpeg -y -v error -i "$tmp" -c:a aac -b:a 128k "$DST/$track.m4a" + echo "$track <- $src (I=${lufs})" +} + +# 选用候选 = 更接近 90s 的那支(拿不准原则),详见 tools/mureka_log.md 批次 3 节 +post tools/mureka_out/hunt-menu/160549591842821_0.flac menu -20 # 177.3s +post tools/mureka_out/hunt-hunt/160549425905665_0.flac hunt -20 # 192.6s + +echo "== hunt bgm done: $(ls "$DST" | wc -l) files ==" diff --git a/tools/gen_hunt_syn.py b/tools/gen_hunt_syn.py new file mode 100644 index 00000000..26e254ba --- /dev/null +++ b/tools/gen_hunt_syn.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""《围猎》程序合成音效(23-hunt.md §10.2,13 件,SYN 全部程序合成兜底)。 + +落盘:staging wav → tools/gen_hunt_syn_wav/.wav(44.1kHz 立体声); + 成品 → web/hunt/audio/sfx/.{ogg,m4a}(tools/bin/ffmpeg 双格式)。 +pan 由运行时引擎 opts.pan / 游戏层 panner 承担,资产本体为双声道同信号(gen_rpg_syn 先例)。 + +one-shot QC 标准(11 件):去直流 + 首尾 8ms cos² 淡化 + 编码后解码回读 +真峰值 ≤ -3dBFS(MDCT 过冲坑:以 ebur128 peak=true 实测为准,超限自动降档重编)。 +loop QC 标准(2 件,4s 无缝循环):只去直流(首尾禁淡化,事件网格周期化 + 尾部 +回卷 overlap-add 保证循环点连续),另做真峰值校验只报告不强制。 + +频带分离(§8 R8):loop 低频 100-300Hz / howl 中高 1-3kHz / 打击音宽带动感。 +时长口径(§10.2/R5):非语音信息全部 <1s——号角 0.9s、哨音 0.6s、chime 0.4s、 +击杀 0.5-0.7s;loop 源 4.0s。 + +用法:.venv-tts/bin/python tools/gen_hunt_syn.py +""" +import os +import subprocess +import sys + +import numpy as np +import soundfile as sf + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FFMPEG = os.path.join(ROOT, "tools", "bin", "ffmpeg") +OUT = os.path.join(ROOT, "tools", "gen_hunt_syn_wav") +DST = os.path.join(ROOT, "web", "hunt", "audio", "sfx") +os.makedirs(OUT, exist_ok=True) +os.makedirs(DST, exist_ok=True) + +sr = 44100 +ONE_SHOT_TP_CEIL = -3.0 # dBFS,one-shot 真峰值上限 +SAMPLE_PEAK = 0.66 # ≈ -3.6dBFS 采样峰值,给 MDCT 过冲留余量 +FADE_N = int(sr * 0.008) # 8ms cos² + +_rng = np.random.default_rng(23) + + +def dc(y): + return y - np.mean(y) + + +def fade(y): + w = np.sin(np.linspace(0, np.pi / 2, FADE_N)) ** 2 # cos² 等价形态 + y = y.copy() + y[:FADE_N] *= w + y[-FADE_N:] *= w[::-1] + return y + + +def norm(y, peak=SAMPLE_PEAK): + m = np.max(np.abs(y)) + return (y / m * peak) if m > 0 else y + + +def stereo(y): + return np.stack([y, y], axis=1).astype(np.float32) + + +def place(y, start_s, sig): + s = int(start_s * sr) + n = min(len(sig), len(y) - s) + y[s:s + n] += sig[:n] + + +def bp_noise(n, lo, hi, order=4): + """简易零相位带通噪声(FFT 矩形窗,合成音效用足够)。""" + x = _rng.standard_normal(n) + X = np.fft.rfft(x) + freqs = np.fft.rfftfreq(n, 1 / sr) + mask = (freqs >= lo) & (freqs <= hi) + # 软边界,降噼啪 + soft = np.clip(mask.astype(float), 0, 1) + y = np.fft.irfft(X * soft, n) + return y / (np.max(np.abs(y)) + 1e-9) + + +def sine_track(n, f0, f1, amp=1.0, vib_hz=0.0, vib_depth=0.0): + """线性滑音正弦(可选颤音),相位连续。""" + t = np.arange(n) / sr + f = np.linspace(f0, f1, n) + if vib_hz: + f = f * (1 + vib_depth * np.sin(2 * np.pi * vib_hz * t)) + ph = 2 * np.pi * np.cumsum(f) / sr + return amp * np.sin(ph) + + +def harm_stack(n, f0, f1, harms, vib_hz=0.0, vib_depth=0.0): + """谐波簇滑音:harms=[(ratio, gain), ...]。""" + y = np.zeros(n) + for r, g in harms: + y += g * sine_track(n, f0 * r, f1 * r, 1.0, vib_hz, vib_depth) + return y + + +# ---------------------------------------------------------------- one-shot 渲染 +def r_atk_whoosh(): # 挥击风声 0.3s:下扫带通噪声,快起快落 + n = int(sr * 0.30) + t = np.arange(n) / sr + y = bp_noise(n, 400, 2600) + sweep = np.exp(-((t - 0.10) / 0.11) ** 2) # 10ms 处峰顶的粗包络 + env = (1 - np.exp(-t * 90)) * np.exp(-t * 14) + return dc(y * env * (0.6 + 0.4 * sweep)) + + +def r_atk_swish(): # 挥空空荡风声 0.3s:更高更空、无低频着落(归因真话:没打到) + n = int(sr * 0.30) + t = np.arange(n) / sr + y = bp_noise(n, 900, 3400) + env = (1 - np.exp(-t * 70)) * np.exp(-t * 11) + hollow = 1 + 0.35 * np.sin(2 * np.pi * 7 * t) # 轻微空洞感 AM + return dc(y * env * hollow * 0.8) + + +def r_kill_wolf(): # 狼哀嚎倒地 0.5s:短促下滑哀嚎 + 着地闷响 + n = int(sr * 0.50) + t = np.arange(n) / sr + yelp = harm_stack(n, 1150, 520, [(1, 1.0), (2, 0.35), (3, 0.12)], vib_hz=9, vib_depth=0.03) + yelp *= (1 - np.exp(-t * 120)) * np.exp(-t * 9) + thud = np.sin(2 * np.pi * 70 * t) * np.exp(-t * 30) + thud += bp_noise(n, 80, 300) * np.exp(-t * 40) * 0.5 + return dc(yelp * 0.8 + np.roll(thud, int(0.30 * sr)) * 0.9) + + +def r_kill_bear(): # 熊闷重倒地 0.7s:低吼爆 + 重落地 + n = int(sr * 0.70) + t = np.arange(n) / sr + growl = harm_stack(n, 130, 70, [(1, 1.0), (2, 0.5), (3, 0.2)], vib_hz=22, vib_depth=0.10) + growl *= (1 - np.exp(-t * 60)) * np.exp(-t * 6) + thud = np.sin(2 * np.pi * 52 * t) * np.exp(-t * 16) + thud += bp_noise(n, 60, 220) * np.exp(-t * 20) * 0.7 + return dc(growl * 0.5 + np.roll(thud, int(0.32 * sr))) + + +def r_skill_spin(): # 旋风斩气旋 0.8s(中央,大动态):加速旋转呼啸 + 顶部爆点 + n = int(sr * 0.80) + t = np.arange(n) / sr + y = bp_noise(n, 300, 3000) + rate = 3 + 14 * (t / t[-1]) ** 2 # 旋转 AM 加速 3→17Hz + am = 0.5 + 0.5 * np.sin(2 * np.pi * np.cumsum(rate) / sr) + env = (1 - np.exp(-t * 25)) * np.exp(-np.maximum(t - 0.55, 0) * 9) + top = sine_track(n, 900, 1800, 0.25) * np.exp(-np.abs(t - 0.55) * 18) # 气旋顶部哨 + return dc(y * am * env * 1.1 + top) + + +def r_breach_thud(): # 营地被撞闷响 + 木栅裂 0.6s(中央) + n = int(sr * 0.60) + t = np.arange(n) / sr + thud = np.sin(2 * np.pi * 55 * t) * np.exp(-t * 13) + thud += bp_noise(n, 70, 260) * np.exp(-t * 18) * 0.8 + # 木栅裂:3 串高频脆响 + crack = np.zeros(n) + for i, st in enumerate([0.05, 0.14, 0.26]): + seg_n = n - int(st * sr) + seg_t = np.arange(seg_n) / sr + c = bp_noise(seg_n, 1400, 4200) * np.exp(-seg_t * (60 - i * 10)) * (0.8 - i * 0.2) + place(crack, st, c) + return dc(thud + crack * 0.7) + + +def r_horn_wave(): # 波次号角 0.9s(中央):铜管状谐波簇,起音膨胀尾音下沉 + n = int(sr * 0.90) + t = np.arange(n) / sr + f0, f1 = 220, 208 # A3 微沉 + horn = harm_stack(n, f0, f1, [(1, 1.0), (2, 0.55), (3, 0.30), (4, 0.14)], vib_hz=5.5, vib_depth=0.008) + env = (1 - np.exp(-t * 9)) * np.exp(-np.maximum(t - 0.62, 0) * 8) + breath = bp_noise(n, 300, 1200) * env * 0.06 + return dc(horn * env + breath) + + +def r_join_chime(): # 队友加入 0.4s:双音上行(E5→A5 钟声) + n = int(sr * 0.40) + y = np.zeros(n) + for st, f in [(0.0, 659.25), (0.14, 880.0)]: + seg_n = n - int(st * sr) + seg_t = np.arange(seg_n) / sr + bell = sum(g * np.sin(2 * np.pi * f * r * seg_t) * np.exp(-seg_t * d) + for r, g, d in [(1, 1.0, 10), (2.76, 0.25, 16)]) + place(y, st, bell * 0.6) + return dc(y) + + +def r_call_whistle(): # 求援哨音 0.6s(座 3 呼喊用):裁判哨式高频颤音 + n = int(sr * 0.60) + t = np.arange(n) / sr + w = sine_track(n, 2350, 2450, 1.0, vib_hz=28, vib_depth=0.012) + w += 0.2 * sine_track(n, 4700, 4900, 1.0, vib_hz=28, vib_depth=0.012) + env = (1 - np.exp(-t * 200)) * np.exp(-np.maximum(t - 0.42, 0) * 12) + return dc(w * env * 0.5) + + +def r_howl_wolf(): # 生成呼啸 0.8s:起-扬-落狼嚎(1-3kHz 中高带) + n = int(sr * 0.80) + t = np.arange(n) / sr + f = 620 + 520 * np.sin(np.pi * np.clip(t / 0.75, 0, 1)) ** 1.4 # 拱形音高 + ph = 2 * np.pi * np.cumsum(f * (1 + 0.02 * np.sin(2 * np.pi * 6 * t))) / sr + howl = np.sin(ph) + 0.3 * np.sin(2 * ph) + env = (1 - np.exp(-t * 12)) * np.exp(-np.maximum(t - 0.5, 0) * 7) + return dc(howl * env * 0.7) + + +def r_howl_bear(): # 生成低吼 1.0s:低频粗糙吼声,慢 AM + n = int(sr * 1.00) + t = np.arange(n) / sr + g = harm_stack(n, 95, 75, [(1, 1.0), (2, 0.6), (3, 0.3)], vib_hz=17, vib_depth=0.08) + g += bp_noise(n, 150, 500) * 0.25 + env = (1 - np.exp(-t * 10)) * np.exp(-np.maximum(t - 0.6, 0) * 6) + return dc(g * env * 0.8) + + +ONE_SHOTS = { + "atk_whoosh": r_atk_whoosh, + "atk_swish": r_atk_swish, + "kill_wolf": r_kill_wolf, + "kill_bear": r_kill_bear, + "skill_spin": r_skill_spin, + "breach_thud": r_breach_thud, + "horn_wave": r_horn_wave, + "join_chime": r_join_chime, + "call_whistle": r_call_whistle, + "howl_wolf": r_howl_wolf, + "howl_bear": r_howl_bear, +} + + +# ---------------------------------------------------------------- loop 渲染(4s 无缝) +def wrap_seamless(y, extra): + """把超出 4s 的尾部回卷叠加到开头,保证循环点连续。""" + n = int(sr * 4.0) + if len(y) > n: + tail = y[n:] + y = y[:n] + y[:len(tail)] += tail + return y + + +def r_loop_wolf(): # 狼碎步 + 低呜(100-300Hz 低频带;4 步/秒小跑,周期 1s 整除 4s) + n = int(sr * 4.0) + y = np.zeros(n) + step = 0.25 # 4 步/秒,16 步整除 4s,尾 0.09s 不出界无需回卷 + for i in range(16): + L = int(sr * 0.09) + tt = np.arange(L) / sr + paw = bp_noise(L, 120, 300) * np.exp(-tt * 70) * (1 - np.exp(-tt * 400)) + paw += np.sin(2 * np.pi * (95 + (i % 2) * 12) * tt) * np.exp(-tt * 60) * 0.5 + place(y, i * step, paw * (0.55 + 0.15 * ((i * 7) % 3))) + t = np.arange(n) / sr + # 垫底低呜:恒定 200Hz(4s 整 800 周期)+ 0.25Hz AM(1 周期)+ 4.5Hz 颤音 + # (18 整周期、积分归零)——所有成分循环点相位对齐,无接缝。 + whim = sine_track(n, 200, 200, 0.10, vib_hz=4.5, vib_depth=0.05) + y += whim * (0.5 + 0.5 * np.sin(2 * np.pi * 0.25 * t)) # 0.25Hz 整周期 + y = wrap_seamless(y, None) + return dc(y) + + +def r_loop_bear(): # 熊重步 + 喘息(重低频;约 1.5 步/秒,周期 4/3 s 不整除→用 2s 网格 8 步) + n = int(sr * 4.0) + y = np.zeros(n) + for i in range(8): # 每 0.5s 一步(左右交替),8 步整除 4s,尾 0.16s 不出界 + L = int(sr * 0.16) + tt = np.arange(L) / sr + hoof = np.sin(2 * np.pi * (58 + (i % 2) * 7) * tt) * np.exp(-tt * 26) + hoof += bp_noise(L, 70, 240) * np.exp(-tt * 30) * (1 - np.exp(-tt * 300)) * 0.8 + place(y, i * 0.5, hoof) + t = np.arange(n) / sr + # 喘息:恒定 80Hz(4s 整 320 周期)+ 0.5Hz AM/颤音(2 整周期)——循环点相位对齐。 + breath = sine_track(n, 80, 80, 0.12, vib_hz=0.5, vib_depth=0.06) + y += breath * (0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.5 * t))) + y = wrap_seamless(y, None) + return dc(y) + + +LOOPS = { + "loop_wolf": r_loop_wolf, + "loop_bear": r_loop_bear, +} + + +# ---------------------------------------------------------------- 编码 + 真峰值校验 +def encode(wav_path, base): + for args, ext in [(["-c:a", "libvorbis", "-q:a", "3"], ".ogg"), + (["-c:a", "aac", "-b:a", "128k"], ".m4a")]: + subprocess.run([FFMPEG, "-y", "-v", "error", "-i", wav_path, + "-ar", "44100", *args, base + ext], check=True) + + +def true_peak_db(path): + r = subprocess.run([FFMPEG, "-i", path, "-af", "ebur128=peak=true", "-f", "null", "-"], + capture_output=True, text=True) + peaks = [float(line.split("Peak:")[1].strip().split()[0]) + for line in r.stderr.splitlines() if "Peak:" in line and "dBFS" in line] + return max(peaks) if peaks else float("nan") + + +def check_tp(base): + return max(true_peak_db(base + ".ogg"), true_peak_db(base + ".m4a")) + + +def main(): + report = [] + problems = [] + + for fid, fn in ONE_SHOTS.items(): + wav_path = os.path.join(OUT, fid + ".wav") + base = os.path.join(DST, fid) + y = fade(norm(dc(fn()))) + sf.write(wav_path, stereo(y), sr) + encode(wav_path, base) + tp = check_tp(base) + if tp > ONE_SHOT_TP_CEIL: # MDCT 过冲超限:按超出量降档重编一次再验 + gain = 10 ** ((ONE_SHOT_TP_CEIL - tp) / 20) * 0.98 + sf.write(wav_path, stereo(y * gain), sr) + encode(wav_path, base) + tp2 = check_tp(base) + report.append((fid, len(y) / sr, tp, f"降 {20 * np.log10(gain):.1f}dB 重编 -> {tp2:.2f}")) + tp = tp2 + else: + report.append((fid, len(y) / sr, tp, "")) + if tp > ONE_SHOT_TP_CEIL: + problems.append(f"{fid}: 真峰值 {tp:.2f} dBFS 仍超 {ONE_SHOT_TP_CEIL}") + + for fid, fn in LOOPS.items(): + wav_path = os.path.join(OUT, fid + ".wav") + base = os.path.join(DST, fid) + y = norm(fn()) # loop:去直流已在渲染内;无首尾淡化(无缝优先) + sf.write(wav_path, stereo(y), sr) + # 无缝断言:循环点首尾采样差应足够小(回卷后连续) + seam = abs(float(y[0]) - float(y[-1])) + encode(wav_path, base) + tp = check_tp(base) + report.append((fid, len(y) / sr, tp, f"loop seam |y0-y1|={seam:.4f}")) + if tp > ONE_SHOT_TP_CEIL: + problems.append(f"{fid}: loop 真峰值 {tp:.2f} dBFS 超 {ONE_SHOT_TP_CEIL}(建议降档)") + + print(f"{'id':14s} {'dur(s)':>7s} {'TP(dBFS)':>9s} note") + for fid, dur, tp, note in report: + print(f"{fid:14s} {dur:7.2f} {tp:9.2f} {note}") + if problems: + print("PROBLEMS:") + for p in problems: + print(" ", p) + return 1 + print(f"ALL OK: {len(ONE_SHOTS)} one-shot + {len(LOOPS)} loop -> {DST}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gen_voice_dual.py b/tools/gen_voice_dual.py index 6ddaff25..d59f8923 100644 --- a/tools/gen_voice_dual.py +++ b/tools/gen_voice_dual.py @@ -62,6 +62,8 @@ SPEAKERS = { # abyss 默认=DECK 调度(多数行);DIVER 行按 VOICE_SPK["abyss"] 逐句覆盖(radio 同型) "abyss": {"male": "Dylan", "female": "Vivian"}, "detective": {"male": "Uncle_Fu", "female": "Serena"}, + # hunt 旁白双性别(23-hunt-voice-ids.md 头注);bot 喊话固定音色在 HUNT_NPC + "hunt": {"male": "Uncle_Fu", "female": "Serena"}, } # instruct 对:(male, female)。None 表示两性别共用同一 instruct。 @@ -1262,7 +1264,125 @@ DETECTIVE_NPC = [ ("fo_5", "Serena", "冷静干练的女声,验尸官,临床腔,短句平直", "鞋底是干的。当夜,死者没出过后门。"), ] -NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC, "abyss": ABYSS_NPC, "detective": DETECTIVE_NPC} +# ---------------------------------------------------------------- hunt +# 《围猎》批次 3(docs/design/23-hunt-voice-ids.md):旁白/系统音 60 句 ×2 性别 +# (全表同一 instruct 常量/性别,禁语速词;数字全分档整句无拼接;「重听」写「重新听」) +# + bot 喊话 24 条固定单音色平铺(HUNT_NPC)+ 键盘变体 V 组 11 对(KEY_LINES["hunt"])。 +# 异例(23-hunt.md §15 裁决 7):喊话走 sfx 层带 pan,convert 落盘 audio/sfx/ 而非 +# audio/voice/(生成管线、峰值归一 0.89 与 voice 一致;不滤波)。 +# H 组头注「×9」为文档笔误,实表 8 句(end_win/end_lose/grade_×4/end_rank/end_again), +# 旁白合计 4+11+6+12+3+3+1+8+4+6+2 = 60 ✓。 +_HUNT = _pair("低沉醇厚的男声,像守夜猎人的领队,沉稳有力", + "清亮沉稳的女声,像守夜猎人的领队,冷静中带着鼓舞") +HUNT_LINES = [ + # ---- A 组 · 开场/房间 ---- + ("intro_1", _HUNT, "夜幕降临,野兽在山林里游荡。"), + ("intro_2", _HUNT, "你们是守夜的猎人,营地就在身后。"), + ("room_wait", _HUNT, "房间已开好,等同伴到齐。单击,立即开局。"), + ("room_start", _HUNT, "狩猎开始,先听声音的方向。"), + # ---- B 组 · 教学 TEACH ---- + ("tut_rule", _HUNT, "野兽会从八个方向围上来。声音在哪边,就往哪边滑。"), + ("tut_attack", _HUNT, "听,有狼在靠近。朝声音的方向,滑一下。"), + ("tut_kill_ok", _HUNT, "好,杀掉了。就是这样。"), + ("tut_callout", _HUNT, "单击一下,呼喊支援,队友会来帮你。"), + ("tut_callout_ok", _HUNT, "听到没,同伴回应你了。"), + ("tut_skill", _HUNT, "双击,旋风斩,清掉身边一圈。"), + ("tut_skill_cd", _HUNT, "旋风斩有八秒冷却。冷却中双击,重新听威胁。"), + ("tut_status", _HUNT, "上滑,听营地状态。下滑,打开菜单。"), + ("tut_breach", _HUNT, "野兽冲进营地,营地就会受损。撑过五波就赢。"), + ("tut_done", _HUNT, "教学完了。真正的围猎,现在开始。"), + ("tut_skip", _HUNT, "已跳过教学,开打。"), + # ---- C 组 · 波次 ---- + ("wave_band1", _HUNT, "第一波来了,别慌。"), + ("wave_band2", _HUNT, "第二波,比刚才多。"), + ("wave_band3", _HUNT, "第三波,有大家伙混在里面。"), + ("wave_band4", _HUNT, "第四波,更快,更多。"), + ("wave_band5", _HUNT, "最后一波,全来了!"), + ("wave_clear", _HUNT, "这一波,守住了。"), + # ---- D 组 · 状态链(耐久档 → 波次档 → 猎人数 → 击杀档) ---- + ("hp_band1", _HUNT, "营地固若金汤。"), + ("hp_band2", _HUNT, "营地受了些损伤。"), + ("hp_band3", _HUNT, "营地损毁过半。"), + ("hp_band4", _HUNT, "营地快守不住了。"), + ("st_wave_a", _HUNT, "还在头几波。"), + ("st_wave_b", _HUNT, "打到中段了。"), + ("st_wave_c", _HUNT, "最后一波了。"), + ("team_3", _HUNT, "三名猎人在守。"), + ("team_4", _HUNT, "四名猎人全在。"), + ("kill_band0", _HUNT, "你还没开张。"), + ("kill_band1", _HUNT, "你小有斩获。"), + ("kill_band2", _HUNT, "你战果累累。"), + # ---- E 组 · 威胁重听(只报数量档,方位由声像承载) ---- + ("threat_none", _HUNT, "近处暂时没有野兽。"), + ("threat_few", _HUNT, "有野兽贴近了,听方向出手。"), + ("threat_many", _HUNT, "四周全是动静,快出手!"), + # ---- F 组 · 警报(可顶状态/播报类) ---- + ("breach", _HUNT, "有野兽冲进营地了!"), + ("breach_heavy", _HUNT, "巨兽撞进营地,损伤很重!"), + ("danger_low", _HUNT, "营地危险了,一只都不能再漏!"), + # ---- G 组 · 技能 ---- + ("skill_ready", _HUNT, "旋风斩准备好了。"), + # ---- H 组 · 结算 ---- + ("end_win", _HUNT, "天亮了,营地还在。"), + ("end_lose", _HUNT, "营地守不住了,撤退!"), + ("grade_s", _HUNT, "评级,S!完美的围猎。"), + ("grade_a", _HUNT, "评级,A。漂亮。"), + ("grade_b", _HUNT, "评级,B。守得还行。"), + ("grade_c", _HUNT, "评级,C。险胜。"), + ("end_rank", _HUNT, "成绩已记入猎手榜。"), + ("end_again", _HUNT, "单击,再猎一局。"), + # ---- I 组 · 断线/重连 ---- + ("net_lost", _HUNT, "与营地断了联系,正在重连。"), + ("net_back", _HUNT, "联系恢复了,接着打。"), + ("net_to_bot", _HUNT, "有猎人离队,老猎手顶上。"), + ("net_gone", _HUNT, "回不去那场围猎了,回大厅。"), + # ---- J 组 · 菜单/帮助 ---- + ("sys_menu", _HUNT, "菜单。单击切换,双击确认。"), + ("sys_menu_resume", _HUNT, "回到猎场。"), + ("sys_menu_help", _HUNT, "重新听帮助。"), + ("sys_menu_home", _HUNT, "退出房间,回大厅。"), + ("help_main", _HUNT, "朝声音的方向滑,就是出击。单击呼喊支援,双击旋风斩。上滑状态,下滑菜单。"), + ("settings_hint", _HUNT, "音色、语速、背景音乐,请回大厅修改。"), + # ---- K 组 · 向导/纠错(idle-only) ---- + ("gd_invalid", _HUNT, "这个操作现在用不上。"), + ("gd_idle", _HUNT, "听声音的方向,滑过去。"), +] + +# bot 喊话固定单音色平铺(id, speaker, instruct, 台词)→ web/hunt/audio/sfx/.* +# 角色 speaker+instruct 恒定(23-hunt-voice-ids.md N 组表):阿岩=Uncle_Fu / +# 小筝=Serena / 老周=Eric;座 3 真人呼喊无角色声,用 sfx/call_whistle(裁决 6)。 +HUNT_NPC = [ + # 阿岩(座 0,Uncle_Fu) + ("npc_yan_greet", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "我来了,这边有我。"), + ("npc_yan_help_call", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "我这侧吃紧,来个人搭把手!"), + ("npc_yan_help_ack", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "撑住,我来了。"), + ("npc_yan_cover", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "这边交给我。"), + ("npc_yan_kill", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "拿下!"), + ("npc_yan_breach", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "漏了一个,都小心。"), + ("npc_yan_skill", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "都闪开,看我的!"), + ("npc_yan_wave", "Uncle_Fu", "憨厚壮实的中年男声,猎户,话少踏实", "新一波,稳住。"), + # 小筝(座 1,Serena) + ("npc_zheng_greet", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "小筝到位,开工啦。"), + ("npc_zheng_help_call", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "这边太多了,谁来帮一下!"), + ("npc_zheng_help_ack", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "别慌,我来了!"), + ("npc_zheng_cover", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "这边我罩着。"), + ("npc_zheng_kill", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "搞定!"), + ("npc_zheng_breach", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "哎呀,漏了一个!"), + ("npc_zheng_skill", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "看我清场!"), + ("npc_zheng_wave", "Serena", "轻快爽利的年轻女声,猎手,干脆利落", "又来一波,打起精神。"), + # 老周(座 2,Eric) + ("npc_zhou_greet", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "老周我守着,放心。"), + ("npc_zhou_help_call", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "我这边压不住了,来人。"), + ("npc_zhou_help_ack", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "听见了,这就到。"), + ("npc_zhou_cover", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "这边交给我吧。"), + ("npc_zhou_kill", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "嗯,解决了。"), + ("npc_zhou_breach", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "啧,溜进去一只。"), + ("npc_zhou_skill", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "让让,老骨头活动活动。"), + ("npc_zhou_wave", "Eric", "沙哑沉稳的老年男声,老猎手,带点调侃", "听,又来了。"), +] + +NPCS = {"rpg": RPG_NPC, "radio": RADIO_NPC, "abyss": ABYSS_NPC, "detective": DETECTIVE_NPC, + "hunt": HUNT_NPC} # ---------------------------------------------------------------- 键盘变体(P1 路线 a) # 「生效输入模式=键盘」的玩家听按键措辞:向左滑→按左方向键(四向同理,WASD 不进 @@ -1481,6 +1601,20 @@ KEY_LINES = { ("cf_confirm", "就这句?空格,定下来。"), ("settle_hook", "同一场雨里,还有别的案子。按空格,接新案。"), ], + # hunt:V 组 11 对(23-hunt-voice-ids.md 文末;措辞「方向键/空格/回车」同 dungeon 规范)。 + "hunt": [ + ("room_wait", "房间已开好,等同伴到齐。按空格,立即开局。"), + ("tut_rule", "野兽会从八个方向围上来。声音在哪边,就按哪边方向键。"), + ("tut_attack", "听,有狼在靠近。朝声音的方向,按方向键。"), + ("tut_callout", "按空格,呼喊支援,队友会来帮你。"), + ("tut_skill", "按回车,旋风斩,清掉身边一圈。"), + ("tut_skill_cd", "旋风斩有八秒冷却。冷却中按回车,重新听威胁。"), + ("tut_status", "按上方向键,听营地状态。按下方向键,打开菜单。"), + ("sys_menu", "菜单。按空格切换,按回车确认。"), + ("help_main", "朝声音的方向按方向键,就是出击。按空格呼喊支援,按回车旋风斩。上方向键状态,下方向键菜单。"), + ("end_again", "按空格,再猎一局。"), + ("gd_idle", "听声音的方向,按方向键。"), + ], } @@ -1558,6 +1692,7 @@ GAMES = { "dungeon": DUNGEON_LINES, "abyss": ABYSS_LINES, "detective": DETECTIVE_LINES, + "hunt": HUNT_LINES, } @@ -1821,13 +1956,15 @@ def convert(games): subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, "-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True) total += 1 - # NPC 平铺(staging /npc/ → audio/voice/.*;radio 按 NPC_BANDPASS 带通) + # NPC 平铺(staging /npc/ → audio/voice/.*;radio 按 NPC_BANDPASS 带通)。 + # 异例:hunt 喊话走 sfx 层带 pan(23-hunt.md §8 R2 / §15 裁决 7),落 audio/sfx/。 + npc_layer = "sfx" if game == "hunt" else "voice" for fid, _sp, _ins, _t in NPCS.get(game, []): src = os.path.join(STAGING, game, "npc", fid + ".wav") if not os.path.exists(src): print(f"[convert] WARN missing {game}/npc/{fid}.wav") continue - base = os.path.join(ROOT, "web", game, "audio", "voice", fid) + base = os.path.join(ROOT, "web", game, "audio", npc_layer, fid) af = npc_af(game, fid) af_args = ["-af", af] if af else [] subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, *af_args, diff --git a/tools/mureka_log.md b/tools/mureka_log.md index 72e8c44b..849ae129 100644 --- a/tools/mureka_log.md +++ b/tools/mureka_log.md @@ -173,3 +173,22 @@ deck 低响度淡入由游戏层控制;18-detective.md §8.1 明确 3 首 -20 abyss -20.5 ~ -21.9dB;detective -20.2 ~ -21.7dB,与在库 BGM 同量级。 雨声底按 18 号文档烤进 detective 曲目(省第三声流)。无节奏关键曲目,未跑 bpm_precise。 flac 原件存 tools/mureka_out/{abyss,detective}-*/。 + +## 2026-09-13(批次 3:hunt《围猎》) + +| # | task id | 用途 | prompt | 状态 | 花费 | +|---|---|---|---|---|---| +| 37 | 160549425905665 | hunt bgm/hunt(2 候选) | Tense night hunt drums, low tribal percussion 100 bpm, ominous strings pulses, cold wind, no vocals, seamless loop | succeeded | $0.03 | +| 38 | 160549591842821 | hunt bgm/menu(2 候选) | Night camp ambient pad, distant crickets and low wind, warm fireplace crackle subtle, calm anticipation, no vocals, seamless loop | succeeded | $0.03 | + +**本轮花费:$0.06。累计:$1.14。** + +选用(拿不准选时长更接近 90s 的候选):hunt=候选 0(192.6s,choice 160549545639938); +menu=候选 0(177.3s,choice 160549690146817)。备选未用:hunt 候选 1(210.2s)/ +menu 候选 1(207.0s)。flac 原件存 tools/mureka_out/hunt-{hunt,menu}/。 + +后处理(脚本 tools/gen_hunt_bgm.sh,detective 同款工艺):强 onset 起点 → 93s 裁剪 + +首尾 3s acrossfade 无缝循环 → loudnorm I=-20/TP=-2/LRA=11(23-hunt.md §10.1 两首均 +-20 LUFS;结算 0.5 音量由游戏层控制)→ ogg q3 + m4a 128k。成品均 90.00s。 +hunt 无节拍网格玩法,100bpm 仅为 prompt 特征词,未跑 bpm_precise(dungeon 先例)。 +两条任务一次成功无重试。 diff --git a/web/engine/audio.js b/web/engine/audio.js index 2837e780..5ecbe414 100644 --- a/web/engine/audio.js +++ b/web/engine/audio.js @@ -226,6 +226,10 @@ export class WebAudioEngine { * @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名) * @param {object} [opts] * @param {number} [opts.volume] 本次播放音量 0~1(乘层音量) + * @param {number} [opts.pan] 可选声像 -1~1(sfx/voice 两层):非 0 有限数时 + * 在 gain 与层总线之间插 StereoPannerNode;缺省/0 + * 走原路径(零行为变化);老 Safari 无 + * createStereoPanner 时静默忽略(23-hunt §9) * @param {number} [opts.fadeMs] 仅 BGM:本次交叉淡入时长 * @param {boolean} [opts.loop=true] 仅 BGM:是否循环 * @param {() => void} [opts.onComplete] 仅 voice:自然播完回调(被打断不触发) @@ -474,9 +478,9 @@ export class WebAudioEngine { * → gain → voiceBus。自然结束用定时器判定(源消耗位置 ÷ 当前 tempo), * 支持中途改 tempo(长按快进)并随之修正结束时刻。 */ - async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) { + async _playVoice(id, { volume = 1, onComplete, queue = false, pan } = {}) { if (queue && this._voice) { - this._voiceQueue.push({ id, volume, onComplete }); + this._voiceQueue.push({ id, volume, onComplete, pan }); return; } // 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义) @@ -526,7 +530,15 @@ export class WebAudioEngine { const gain = this.ctx.createGain(); gain.gain.value = this.voiceVolume * volume; node.connect(gain); - gain.connect(this._voiceBus); + // 可选声像(23-hunt §9,对称能力一次给足;本作旁白不使用):pan ∈ [-1,1] + // 时 panner 插在 voice gain 与 voiceBus 之间;缺省/0 → 原路径不变 + const panner = this._makePanner(pan); + if (panner) { + gain.connect(panner); + panner.connect(this._voiceBus); + } else { + gain.connect(this._voiceBus); + } const token = { node, gain, st, buf, filter, srcBuf, // srcBuf = 尾部垫静音后的播放源 @@ -631,7 +643,7 @@ export class WebAudioEngine { this._armVoiceEnd(cur); } - async _playSfx(id, { volume = 1 } = {}) { + async _playSfx(id, { volume = 1, pan } = {}) { const buf = await this._load(id); if (!buf) return; const source = this.ctx.createBufferSource(); @@ -639,7 +651,28 @@ export class WebAudioEngine { const gain = this.ctx.createGain(); gain.gain.value = this.sfxVolume * volume; source.connect(gain); - gain.connect(this._sfxBus); + // 可选声像(23-hunt §9):pan ∈ [-1,1] 时在 gain 与总线之间插 + // StereoPannerNode;缺省/0 → 原路径逐字节不变(不建 panner 节点) + const panner = this._makePanner(pan); + if (panner) { + gain.connect(panner); + panner.connect(this._sfxBus); + } else { + gain.connect(this._sfxBus); + } source.start(); // 即发即弃,天然支持并发叠加 } + + /** + * 可选声像节点(opts.pan,-1..1)。pan 缺省/非有限数/0 → null(调用方走 + * 原路径,零行为变化);老 Safari 无 ctx.createStereoPanner → null + * (静默降级:不炸、不报错音,声音照常从原路径出)。 + */ + _makePanner(pan) { + if (typeof pan !== 'number' || !isFinite(pan) || pan === 0) return null; + if (!this.ctx || typeof this.ctx.createStereoPanner !== 'function') return null; + const panner = this.ctx.createStereoPanner(); + panner.pan.value = Math.max(-1, Math.min(1, pan)); + return panner; + } } diff --git a/web/hunt/README.md b/web/hunt/README.md new file mode 100644 index 00000000..9414779b --- /dev/null +++ b/web/hunt/README.md @@ -0,0 +1,58 @@ +# 围猎(HUNT · Night Hunt) + +2-4 人实时联机纯音频协作守卫。夜幕降临,野兽从四面八方围上来——你和队友 +背背守住营地篝火,**听声音的方向滑过去就是一刀**,撑过五波,天亮就赢。 + +> 本作是"纯音频 MMORPG"路线的第一阶段最小验证切片:实时同步(零依赖 +> WebSocket + 服务端权威)、音频信道拥挤控制(R1-R8)、空间音频协作 +> (方位=声像 pan + 防区分工)。设计全文见 docs/design/23-hunt.md。 + +## 玩法 + +- 圆形猎场,猎人位于营地中心篝火旁。野兽从 **8 个罗盘方位**的边缘逼近 + (全程 8-12 秒):**声像就是小地图**——狼的碎步、熊的重步从它所在的 + 方向传来,越近越响;后半平面(身后)的声音会发闷(音量 ×0.6)。 +- **突入中心 = 营地耐久受损**(饿狼 -1,暴熊 -2;初始 10,全队共享)。 + 耐久归零 = 营地失守(立即终局);撑过 5 波 = 胜利结算(评级 S~C)。 +- 同刻最多只有**最近的 3 只**野兽的脚步可闻(听觉视野裁剪);6 秒外的 + 野兽只有生成时的一声呼啸。**威胁播报只说近处有几只,不报方位**—— + 方位永远靠耳朵听声像。 +- 空位由 bot 猎手补齐(开局至少 3 猎人):阿岩(憨厚男声)、小筝(轻快 + 女声)、老周(沙哑老年男声)。bot 的喊话从它防区的方向传来;断线 30 秒 + 内重连可回槽,超时由老猎手(bot)顶上。 + +## 手势 + +| 手势 | 战斗态(WAVE) | +|---|---| +| 四向滑 | **出击**:滑向映射 90° 弧(上→北弧、右→东弧、下→南弧、左→西弧),击杀弧内贴近(≤4 秒)最近一只;冷却 2.5 秒。**上/下滑弧内无可打怪时**:上滑=状态链、下滑=菜单(三件套保留的语境折中) | +| 单击 | **呼喊支援**:队友收到你防区方位的空间化喊话 + 警告震动,空闲 bot 朝你侧补位 10 秒;节流 5 秒 | +| 双击 | 旋风斩**就绪** → 放出(清全场内圈,冷却 8 秒,冷却完成有语音提示);**冷却中** → 重新听威胁播报 | +| 上滑 | 状态链:营地耐久档 → 波次档 → 在场猎人数 → 自己击杀档 | +| 下滑 | 菜单(**不暂停**——联机实时,菜单开着怪物照走) | +| 长按 | 旁白快进(引擎内置) | + +标题/大厅/结算遵循全局约定:单击=快速匹配/立即开局/再来一局,双击=重听, +上滑=个人最佳/在场猎人/最佳段,下滑=菜单。菜单固定 3 项:回到猎场 / +重新听帮助 / 退出房间回大厅(单击循环、双击确认、下滑关闭)。 + +## 教学(首局) + +首次进入自动开**教学房**(单人 + 2 bot,教学波零惩罚):T0 规则总述 → +T1 朝声音方向滑杀教学狼 → T2 单击呼喊听 bot 应答 → T3 双击旋风斩 → +T4 上滑状态链;任意时刻下滑跳过。教学完同场直接接续第 1 波。 + +## 运行 + +```bash +bash web/serve.sh # Node 零依赖服务器(静态 + REST + /ws/hunt 同端口) +# 浏览器打开 http://<局域网地址>:8000/hunt/ ,2-4 人同 WiFi 各开一页即同局 +``` + +## 测试 + +```bash +tools/node/bin/node web/hunt/test/playtest.mjs +# 无头试玩:虚拟时钟 + mock 浏览器环境 + 内存回路 WS mock(按 23 §6 协议 +# 注入 joined/ev/snap/err),覆盖 13 号文档 §24 客户端侧全部断言 +``` diff --git a/web/hunt/audio/bgm/hunt.m4a b/web/hunt/audio/bgm/hunt.m4a new file mode 100644 index 00000000..b5ae2199 Binary files /dev/null and b/web/hunt/audio/bgm/hunt.m4a differ diff --git a/web/hunt/audio/bgm/hunt.ogg b/web/hunt/audio/bgm/hunt.ogg new file mode 100644 index 00000000..aa9a7ac3 Binary files /dev/null and b/web/hunt/audio/bgm/hunt.ogg differ diff --git a/web/hunt/audio/bgm/menu.m4a b/web/hunt/audio/bgm/menu.m4a new file mode 100644 index 00000000..fb65ce75 Binary files /dev/null and b/web/hunt/audio/bgm/menu.m4a differ diff --git a/web/hunt/audio/bgm/menu.ogg b/web/hunt/audio/bgm/menu.ogg new file mode 100644 index 00000000..2b754070 Binary files /dev/null and b/web/hunt/audio/bgm/menu.ogg differ diff --git a/web/hunt/audio/sfx/atk_swish.m4a b/web/hunt/audio/sfx/atk_swish.m4a new file mode 100644 index 00000000..d06bae25 Binary files /dev/null and b/web/hunt/audio/sfx/atk_swish.m4a differ diff --git a/web/hunt/audio/sfx/atk_swish.ogg b/web/hunt/audio/sfx/atk_swish.ogg new file mode 100644 index 00000000..110bb860 Binary files /dev/null and b/web/hunt/audio/sfx/atk_swish.ogg differ diff --git a/web/hunt/audio/sfx/atk_whoosh.m4a b/web/hunt/audio/sfx/atk_whoosh.m4a new file mode 100644 index 00000000..690d69fa Binary files /dev/null and b/web/hunt/audio/sfx/atk_whoosh.m4a differ diff --git a/web/hunt/audio/sfx/atk_whoosh.ogg b/web/hunt/audio/sfx/atk_whoosh.ogg new file mode 100644 index 00000000..b8739387 Binary files /dev/null and b/web/hunt/audio/sfx/atk_whoosh.ogg differ diff --git a/web/hunt/audio/sfx/breach_thud.m4a b/web/hunt/audio/sfx/breach_thud.m4a new file mode 100644 index 00000000..fac9b9ab Binary files /dev/null and b/web/hunt/audio/sfx/breach_thud.m4a differ diff --git a/web/hunt/audio/sfx/breach_thud.ogg b/web/hunt/audio/sfx/breach_thud.ogg new file mode 100644 index 00000000..827ccba2 Binary files /dev/null and b/web/hunt/audio/sfx/breach_thud.ogg differ diff --git a/web/hunt/audio/sfx/call_whistle.m4a b/web/hunt/audio/sfx/call_whistle.m4a new file mode 100644 index 00000000..390b89e3 Binary files /dev/null and b/web/hunt/audio/sfx/call_whistle.m4a differ diff --git a/web/hunt/audio/sfx/call_whistle.ogg b/web/hunt/audio/sfx/call_whistle.ogg new file mode 100644 index 00000000..91a03636 Binary files /dev/null and b/web/hunt/audio/sfx/call_whistle.ogg differ diff --git a/web/hunt/audio/sfx/horn_wave.m4a b/web/hunt/audio/sfx/horn_wave.m4a new file mode 100644 index 00000000..786e769d Binary files /dev/null and b/web/hunt/audio/sfx/horn_wave.m4a differ diff --git a/web/hunt/audio/sfx/horn_wave.ogg b/web/hunt/audio/sfx/horn_wave.ogg new file mode 100644 index 00000000..4d22e35e Binary files /dev/null and b/web/hunt/audio/sfx/horn_wave.ogg differ diff --git a/web/hunt/audio/sfx/howl_bear.m4a b/web/hunt/audio/sfx/howl_bear.m4a new file mode 100644 index 00000000..572db72b Binary files /dev/null and b/web/hunt/audio/sfx/howl_bear.m4a differ diff --git a/web/hunt/audio/sfx/howl_bear.ogg b/web/hunt/audio/sfx/howl_bear.ogg new file mode 100644 index 00000000..8563315c Binary files /dev/null and b/web/hunt/audio/sfx/howl_bear.ogg differ diff --git a/web/hunt/audio/sfx/howl_wolf.m4a b/web/hunt/audio/sfx/howl_wolf.m4a new file mode 100644 index 00000000..97127175 Binary files /dev/null and b/web/hunt/audio/sfx/howl_wolf.m4a differ diff --git a/web/hunt/audio/sfx/howl_wolf.ogg b/web/hunt/audio/sfx/howl_wolf.ogg new file mode 100644 index 00000000..e1434e65 Binary files /dev/null and b/web/hunt/audio/sfx/howl_wolf.ogg differ diff --git a/web/hunt/audio/sfx/join_chime.m4a b/web/hunt/audio/sfx/join_chime.m4a new file mode 100644 index 00000000..dcedabb6 Binary files /dev/null and b/web/hunt/audio/sfx/join_chime.m4a differ diff --git a/web/hunt/audio/sfx/join_chime.ogg b/web/hunt/audio/sfx/join_chime.ogg new file mode 100644 index 00000000..76c4cc4e Binary files /dev/null and b/web/hunt/audio/sfx/join_chime.ogg differ diff --git a/web/hunt/audio/sfx/kill_bear.m4a b/web/hunt/audio/sfx/kill_bear.m4a new file mode 100644 index 00000000..48e4d386 Binary files /dev/null and b/web/hunt/audio/sfx/kill_bear.m4a differ diff --git a/web/hunt/audio/sfx/kill_bear.ogg b/web/hunt/audio/sfx/kill_bear.ogg new file mode 100644 index 00000000..3d2a5edc Binary files /dev/null and b/web/hunt/audio/sfx/kill_bear.ogg differ diff --git a/web/hunt/audio/sfx/kill_wolf.m4a b/web/hunt/audio/sfx/kill_wolf.m4a new file mode 100644 index 00000000..f2fbed07 Binary files /dev/null and b/web/hunt/audio/sfx/kill_wolf.m4a differ diff --git a/web/hunt/audio/sfx/kill_wolf.ogg b/web/hunt/audio/sfx/kill_wolf.ogg new file mode 100644 index 00000000..f586fc48 Binary files /dev/null and b/web/hunt/audio/sfx/kill_wolf.ogg differ diff --git a/web/hunt/audio/sfx/loop_bear.m4a b/web/hunt/audio/sfx/loop_bear.m4a new file mode 100644 index 00000000..568945a3 Binary files /dev/null and b/web/hunt/audio/sfx/loop_bear.m4a differ diff --git a/web/hunt/audio/sfx/loop_bear.ogg b/web/hunt/audio/sfx/loop_bear.ogg new file mode 100644 index 00000000..4df9bac6 Binary files /dev/null and b/web/hunt/audio/sfx/loop_bear.ogg differ diff --git a/web/hunt/audio/sfx/loop_wolf.m4a b/web/hunt/audio/sfx/loop_wolf.m4a new file mode 100644 index 00000000..e274b261 Binary files /dev/null and b/web/hunt/audio/sfx/loop_wolf.m4a differ diff --git a/web/hunt/audio/sfx/loop_wolf.ogg b/web/hunt/audio/sfx/loop_wolf.ogg new file mode 100644 index 00000000..39b37185 Binary files /dev/null and b/web/hunt/audio/sfx/loop_wolf.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_breach.m4a b/web/hunt/audio/sfx/npc_yan_breach.m4a new file mode 100644 index 00000000..aa34b947 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_breach.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_breach.ogg b/web/hunt/audio/sfx/npc_yan_breach.ogg new file mode 100644 index 00000000..b9e15a31 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_breach.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_cover.m4a b/web/hunt/audio/sfx/npc_yan_cover.m4a new file mode 100644 index 00000000..4b601666 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_cover.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_cover.ogg b/web/hunt/audio/sfx/npc_yan_cover.ogg new file mode 100644 index 00000000..7302069d Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_cover.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_greet.m4a b/web/hunt/audio/sfx/npc_yan_greet.m4a new file mode 100644 index 00000000..ed867036 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_greet.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_greet.ogg b/web/hunt/audio/sfx/npc_yan_greet.ogg new file mode 100644 index 00000000..686fc850 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_greet.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_help_ack.m4a b/web/hunt/audio/sfx/npc_yan_help_ack.m4a new file mode 100644 index 00000000..6eeaf986 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_help_ack.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_help_ack.ogg b/web/hunt/audio/sfx/npc_yan_help_ack.ogg new file mode 100644 index 00000000..e9a5cbf6 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_help_ack.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_help_call.m4a b/web/hunt/audio/sfx/npc_yan_help_call.m4a new file mode 100644 index 00000000..1dac1168 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_help_call.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_help_call.ogg b/web/hunt/audio/sfx/npc_yan_help_call.ogg new file mode 100644 index 00000000..0a338bad Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_help_call.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_kill.m4a b/web/hunt/audio/sfx/npc_yan_kill.m4a new file mode 100644 index 00000000..18513cf1 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_kill.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_kill.ogg b/web/hunt/audio/sfx/npc_yan_kill.ogg new file mode 100644 index 00000000..2ad0cd41 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_kill.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_skill.m4a b/web/hunt/audio/sfx/npc_yan_skill.m4a new file mode 100644 index 00000000..9eee68bd Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_skill.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_skill.ogg b/web/hunt/audio/sfx/npc_yan_skill.ogg new file mode 100644 index 00000000..c7033282 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_skill.ogg differ diff --git a/web/hunt/audio/sfx/npc_yan_wave.m4a b/web/hunt/audio/sfx/npc_yan_wave.m4a new file mode 100644 index 00000000..29f2e895 Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_wave.m4a differ diff --git a/web/hunt/audio/sfx/npc_yan_wave.ogg b/web/hunt/audio/sfx/npc_yan_wave.ogg new file mode 100644 index 00000000..afc00b5b Binary files /dev/null and b/web/hunt/audio/sfx/npc_yan_wave.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_breach.m4a b/web/hunt/audio/sfx/npc_zheng_breach.m4a new file mode 100644 index 00000000..c651e30a Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_breach.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_breach.ogg b/web/hunt/audio/sfx/npc_zheng_breach.ogg new file mode 100644 index 00000000..c8bb649e Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_breach.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_cover.m4a b/web/hunt/audio/sfx/npc_zheng_cover.m4a new file mode 100644 index 00000000..1e821519 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_cover.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_cover.ogg b/web/hunt/audio/sfx/npc_zheng_cover.ogg new file mode 100644 index 00000000..3fc23ad7 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_cover.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_greet.m4a b/web/hunt/audio/sfx/npc_zheng_greet.m4a new file mode 100644 index 00000000..a3efbd89 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_greet.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_greet.ogg b/web/hunt/audio/sfx/npc_zheng_greet.ogg new file mode 100644 index 00000000..8f312b5b Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_greet.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_help_ack.m4a b/web/hunt/audio/sfx/npc_zheng_help_ack.m4a new file mode 100644 index 00000000..52d0c27f Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_help_ack.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_help_ack.ogg b/web/hunt/audio/sfx/npc_zheng_help_ack.ogg new file mode 100644 index 00000000..8825e0e3 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_help_ack.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_help_call.m4a b/web/hunt/audio/sfx/npc_zheng_help_call.m4a new file mode 100644 index 00000000..2333922f Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_help_call.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_help_call.ogg b/web/hunt/audio/sfx/npc_zheng_help_call.ogg new file mode 100644 index 00000000..8bfd2ee1 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_help_call.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_kill.m4a b/web/hunt/audio/sfx/npc_zheng_kill.m4a new file mode 100644 index 00000000..f1c76def Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_kill.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_kill.ogg b/web/hunt/audio/sfx/npc_zheng_kill.ogg new file mode 100644 index 00000000..f9a943ec Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_kill.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_skill.m4a b/web/hunt/audio/sfx/npc_zheng_skill.m4a new file mode 100644 index 00000000..01ca3fc2 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_skill.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_skill.ogg b/web/hunt/audio/sfx/npc_zheng_skill.ogg new file mode 100644 index 00000000..5a1d66c1 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_skill.ogg differ diff --git a/web/hunt/audio/sfx/npc_zheng_wave.m4a b/web/hunt/audio/sfx/npc_zheng_wave.m4a new file mode 100644 index 00000000..80429164 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_wave.m4a differ diff --git a/web/hunt/audio/sfx/npc_zheng_wave.ogg b/web/hunt/audio/sfx/npc_zheng_wave.ogg new file mode 100644 index 00000000..595300c5 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zheng_wave.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_breach.m4a b/web/hunt/audio/sfx/npc_zhou_breach.m4a new file mode 100644 index 00000000..af82f6e3 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_breach.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_breach.ogg b/web/hunt/audio/sfx/npc_zhou_breach.ogg new file mode 100644 index 00000000..5c895430 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_breach.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_cover.m4a b/web/hunt/audio/sfx/npc_zhou_cover.m4a new file mode 100644 index 00000000..7a9b71ee Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_cover.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_cover.ogg b/web/hunt/audio/sfx/npc_zhou_cover.ogg new file mode 100644 index 00000000..6cc56daf Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_cover.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_greet.m4a b/web/hunt/audio/sfx/npc_zhou_greet.m4a new file mode 100644 index 00000000..b2d9698f Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_greet.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_greet.ogg b/web/hunt/audio/sfx/npc_zhou_greet.ogg new file mode 100644 index 00000000..ec999744 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_greet.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_help_ack.m4a b/web/hunt/audio/sfx/npc_zhou_help_ack.m4a new file mode 100644 index 00000000..620e354e Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_help_ack.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_help_ack.ogg b/web/hunt/audio/sfx/npc_zhou_help_ack.ogg new file mode 100644 index 00000000..a461d640 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_help_ack.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_help_call.m4a b/web/hunt/audio/sfx/npc_zhou_help_call.m4a new file mode 100644 index 00000000..d3f9c43a Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_help_call.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_help_call.ogg b/web/hunt/audio/sfx/npc_zhou_help_call.ogg new file mode 100644 index 00000000..011a7c38 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_help_call.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_kill.m4a b/web/hunt/audio/sfx/npc_zhou_kill.m4a new file mode 100644 index 00000000..73ef2a34 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_kill.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_kill.ogg b/web/hunt/audio/sfx/npc_zhou_kill.ogg new file mode 100644 index 00000000..37d8f0a5 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_kill.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_skill.m4a b/web/hunt/audio/sfx/npc_zhou_skill.m4a new file mode 100644 index 00000000..6f7275e1 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_skill.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_skill.ogg b/web/hunt/audio/sfx/npc_zhou_skill.ogg new file mode 100644 index 00000000..f199336c Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_skill.ogg differ diff --git a/web/hunt/audio/sfx/npc_zhou_wave.m4a b/web/hunt/audio/sfx/npc_zhou_wave.m4a new file mode 100644 index 00000000..93092987 Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_wave.m4a differ diff --git a/web/hunt/audio/sfx/npc_zhou_wave.ogg b/web/hunt/audio/sfx/npc_zhou_wave.ogg new file mode 100644 index 00000000..9b99747b Binary files /dev/null and b/web/hunt/audio/sfx/npc_zhou_wave.ogg differ diff --git a/web/hunt/audio/sfx/skill_spin.m4a b/web/hunt/audio/sfx/skill_spin.m4a new file mode 100644 index 00000000..d624572a Binary files /dev/null and b/web/hunt/audio/sfx/skill_spin.m4a differ diff --git a/web/hunt/audio/sfx/skill_spin.ogg b/web/hunt/audio/sfx/skill_spin.ogg new file mode 100644 index 00000000..4abd0efe Binary files /dev/null and b/web/hunt/audio/sfx/skill_spin.ogg differ diff --git a/web/hunt/audio/voice/female/breach.m4a b/web/hunt/audio/voice/female/breach.m4a new file mode 100644 index 00000000..bcf5d55f Binary files /dev/null and b/web/hunt/audio/voice/female/breach.m4a differ diff --git a/web/hunt/audio/voice/female/breach.ogg b/web/hunt/audio/voice/female/breach.ogg new file mode 100644 index 00000000..127869ce Binary files /dev/null and b/web/hunt/audio/voice/female/breach.ogg differ diff --git a/web/hunt/audio/voice/female/breach_heavy.m4a b/web/hunt/audio/voice/female/breach_heavy.m4a new file mode 100644 index 00000000..05f5eb96 Binary files /dev/null and b/web/hunt/audio/voice/female/breach_heavy.m4a differ diff --git a/web/hunt/audio/voice/female/breach_heavy.ogg b/web/hunt/audio/voice/female/breach_heavy.ogg new file mode 100644 index 00000000..efa3e259 Binary files /dev/null and b/web/hunt/audio/voice/female/breach_heavy.ogg differ diff --git a/web/hunt/audio/voice/female/danger_low.m4a b/web/hunt/audio/voice/female/danger_low.m4a new file mode 100644 index 00000000..c05b2a6b Binary files /dev/null and b/web/hunt/audio/voice/female/danger_low.m4a differ diff --git a/web/hunt/audio/voice/female/danger_low.ogg b/web/hunt/audio/voice/female/danger_low.ogg new file mode 100644 index 00000000..c89df44b Binary files /dev/null and b/web/hunt/audio/voice/female/danger_low.ogg differ diff --git a/web/hunt/audio/voice/female/end_again.m4a b/web/hunt/audio/voice/female/end_again.m4a new file mode 100644 index 00000000..a664d57a Binary files /dev/null and b/web/hunt/audio/voice/female/end_again.m4a differ diff --git a/web/hunt/audio/voice/female/end_again.ogg b/web/hunt/audio/voice/female/end_again.ogg new file mode 100644 index 00000000..28cf79f0 Binary files /dev/null and b/web/hunt/audio/voice/female/end_again.ogg differ diff --git a/web/hunt/audio/voice/female/end_lose.m4a b/web/hunt/audio/voice/female/end_lose.m4a new file mode 100644 index 00000000..72d88358 Binary files /dev/null and b/web/hunt/audio/voice/female/end_lose.m4a differ diff --git a/web/hunt/audio/voice/female/end_lose.ogg b/web/hunt/audio/voice/female/end_lose.ogg new file mode 100644 index 00000000..ea856297 Binary files /dev/null and b/web/hunt/audio/voice/female/end_lose.ogg differ diff --git a/web/hunt/audio/voice/female/end_rank.m4a b/web/hunt/audio/voice/female/end_rank.m4a new file mode 100644 index 00000000..2e099fc2 Binary files /dev/null and b/web/hunt/audio/voice/female/end_rank.m4a differ diff --git a/web/hunt/audio/voice/female/end_rank.ogg b/web/hunt/audio/voice/female/end_rank.ogg new file mode 100644 index 00000000..abb52de2 Binary files /dev/null and b/web/hunt/audio/voice/female/end_rank.ogg differ diff --git a/web/hunt/audio/voice/female/end_win.m4a b/web/hunt/audio/voice/female/end_win.m4a new file mode 100644 index 00000000..67384703 Binary files /dev/null and b/web/hunt/audio/voice/female/end_win.m4a differ diff --git a/web/hunt/audio/voice/female/end_win.ogg b/web/hunt/audio/voice/female/end_win.ogg new file mode 100644 index 00000000..a06578f9 Binary files /dev/null and b/web/hunt/audio/voice/female/end_win.ogg differ diff --git a/web/hunt/audio/voice/female/gd_idle.m4a b/web/hunt/audio/voice/female/gd_idle.m4a new file mode 100644 index 00000000..bd552b0f Binary files /dev/null and b/web/hunt/audio/voice/female/gd_idle.m4a differ diff --git a/web/hunt/audio/voice/female/gd_idle.ogg b/web/hunt/audio/voice/female/gd_idle.ogg new file mode 100644 index 00000000..25b6901a Binary files /dev/null and b/web/hunt/audio/voice/female/gd_idle.ogg differ diff --git a/web/hunt/audio/voice/female/gd_invalid.m4a b/web/hunt/audio/voice/female/gd_invalid.m4a new file mode 100644 index 00000000..b4588907 Binary files /dev/null and b/web/hunt/audio/voice/female/gd_invalid.m4a differ diff --git a/web/hunt/audio/voice/female/gd_invalid.ogg b/web/hunt/audio/voice/female/gd_invalid.ogg new file mode 100644 index 00000000..a7a8a4f5 Binary files /dev/null and b/web/hunt/audio/voice/female/gd_invalid.ogg differ diff --git a/web/hunt/audio/voice/female/grade_a.m4a b/web/hunt/audio/voice/female/grade_a.m4a new file mode 100644 index 00000000..a2cbc386 Binary files /dev/null and b/web/hunt/audio/voice/female/grade_a.m4a differ diff --git a/web/hunt/audio/voice/female/grade_a.ogg b/web/hunt/audio/voice/female/grade_a.ogg new file mode 100644 index 00000000..a9961700 Binary files /dev/null and b/web/hunt/audio/voice/female/grade_a.ogg differ diff --git a/web/hunt/audio/voice/female/grade_b.m4a b/web/hunt/audio/voice/female/grade_b.m4a new file mode 100644 index 00000000..0903fb60 Binary files /dev/null and b/web/hunt/audio/voice/female/grade_b.m4a differ diff --git a/web/hunt/audio/voice/female/grade_b.ogg b/web/hunt/audio/voice/female/grade_b.ogg new file mode 100644 index 00000000..43674aba Binary files /dev/null and b/web/hunt/audio/voice/female/grade_b.ogg differ diff --git a/web/hunt/audio/voice/female/grade_c.m4a b/web/hunt/audio/voice/female/grade_c.m4a new file mode 100644 index 00000000..e4533e18 Binary files /dev/null and b/web/hunt/audio/voice/female/grade_c.m4a differ diff --git a/web/hunt/audio/voice/female/grade_c.ogg b/web/hunt/audio/voice/female/grade_c.ogg new file mode 100644 index 00000000..3d9d9f5e Binary files /dev/null and b/web/hunt/audio/voice/female/grade_c.ogg differ diff --git a/web/hunt/audio/voice/female/grade_s.m4a b/web/hunt/audio/voice/female/grade_s.m4a new file mode 100644 index 00000000..6d0aeddd Binary files /dev/null and b/web/hunt/audio/voice/female/grade_s.m4a differ diff --git a/web/hunt/audio/voice/female/grade_s.ogg b/web/hunt/audio/voice/female/grade_s.ogg new file mode 100644 index 00000000..8e6376d0 Binary files /dev/null and b/web/hunt/audio/voice/female/grade_s.ogg differ diff --git a/web/hunt/audio/voice/female/help_main.m4a b/web/hunt/audio/voice/female/help_main.m4a new file mode 100644 index 00000000..22a5ee28 Binary files /dev/null and b/web/hunt/audio/voice/female/help_main.m4a differ diff --git a/web/hunt/audio/voice/female/help_main.ogg b/web/hunt/audio/voice/female/help_main.ogg new file mode 100644 index 00000000..14ad26aa Binary files /dev/null and b/web/hunt/audio/voice/female/help_main.ogg differ diff --git a/web/hunt/audio/voice/female/hp_band1.m4a b/web/hunt/audio/voice/female/hp_band1.m4a new file mode 100644 index 00000000..5ec5b953 Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band1.m4a differ diff --git a/web/hunt/audio/voice/female/hp_band1.ogg b/web/hunt/audio/voice/female/hp_band1.ogg new file mode 100644 index 00000000..d9991a34 Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band1.ogg differ diff --git a/web/hunt/audio/voice/female/hp_band2.m4a b/web/hunt/audio/voice/female/hp_band2.m4a new file mode 100644 index 00000000..0a0f2703 Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band2.m4a differ diff --git a/web/hunt/audio/voice/female/hp_band2.ogg b/web/hunt/audio/voice/female/hp_band2.ogg new file mode 100644 index 00000000..754ac5ef Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band2.ogg differ diff --git a/web/hunt/audio/voice/female/hp_band3.m4a b/web/hunt/audio/voice/female/hp_band3.m4a new file mode 100644 index 00000000..aa931a7c Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band3.m4a differ diff --git a/web/hunt/audio/voice/female/hp_band3.ogg b/web/hunt/audio/voice/female/hp_band3.ogg new file mode 100644 index 00000000..fa6a108d Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band3.ogg differ diff --git a/web/hunt/audio/voice/female/hp_band4.m4a b/web/hunt/audio/voice/female/hp_band4.m4a new file mode 100644 index 00000000..f1f9480b Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band4.m4a differ diff --git a/web/hunt/audio/voice/female/hp_band4.ogg b/web/hunt/audio/voice/female/hp_band4.ogg new file mode 100644 index 00000000..0d197eee Binary files /dev/null and b/web/hunt/audio/voice/female/hp_band4.ogg differ diff --git a/web/hunt/audio/voice/female/intro_1.m4a b/web/hunt/audio/voice/female/intro_1.m4a new file mode 100644 index 00000000..72e17ccd Binary files /dev/null and b/web/hunt/audio/voice/female/intro_1.m4a differ diff --git a/web/hunt/audio/voice/female/intro_1.ogg b/web/hunt/audio/voice/female/intro_1.ogg new file mode 100644 index 00000000..5e0fb3bd Binary files /dev/null and b/web/hunt/audio/voice/female/intro_1.ogg differ diff --git a/web/hunt/audio/voice/female/intro_2.m4a b/web/hunt/audio/voice/female/intro_2.m4a new file mode 100644 index 00000000..64304069 Binary files /dev/null and b/web/hunt/audio/voice/female/intro_2.m4a differ diff --git a/web/hunt/audio/voice/female/intro_2.ogg b/web/hunt/audio/voice/female/intro_2.ogg new file mode 100644 index 00000000..9f2cb566 Binary files /dev/null and b/web/hunt/audio/voice/female/intro_2.ogg differ diff --git a/web/hunt/audio/voice/female/key/end_again.m4a b/web/hunt/audio/voice/female/key/end_again.m4a new file mode 100644 index 00000000..c3966540 Binary files /dev/null and b/web/hunt/audio/voice/female/key/end_again.m4a differ diff --git a/web/hunt/audio/voice/female/key/end_again.ogg b/web/hunt/audio/voice/female/key/end_again.ogg new file mode 100644 index 00000000..9e3c8c65 Binary files /dev/null and b/web/hunt/audio/voice/female/key/end_again.ogg differ diff --git a/web/hunt/audio/voice/female/key/gd_idle.m4a b/web/hunt/audio/voice/female/key/gd_idle.m4a new file mode 100644 index 00000000..24470acf Binary files /dev/null and b/web/hunt/audio/voice/female/key/gd_idle.m4a differ diff --git a/web/hunt/audio/voice/female/key/gd_idle.ogg b/web/hunt/audio/voice/female/key/gd_idle.ogg new file mode 100644 index 00000000..4fe4e41c Binary files /dev/null and b/web/hunt/audio/voice/female/key/gd_idle.ogg differ diff --git a/web/hunt/audio/voice/female/key/help_main.m4a b/web/hunt/audio/voice/female/key/help_main.m4a new file mode 100644 index 00000000..891f6a7a Binary files /dev/null and b/web/hunt/audio/voice/female/key/help_main.m4a differ diff --git a/web/hunt/audio/voice/female/key/help_main.ogg b/web/hunt/audio/voice/female/key/help_main.ogg new file mode 100644 index 00000000..2b95551a Binary files /dev/null and b/web/hunt/audio/voice/female/key/help_main.ogg differ diff --git a/web/hunt/audio/voice/female/key/room_wait.m4a b/web/hunt/audio/voice/female/key/room_wait.m4a new file mode 100644 index 00000000..07eccb0c Binary files /dev/null and b/web/hunt/audio/voice/female/key/room_wait.m4a differ diff --git a/web/hunt/audio/voice/female/key/room_wait.ogg b/web/hunt/audio/voice/female/key/room_wait.ogg new file mode 100644 index 00000000..96bb7dbe Binary files /dev/null and b/web/hunt/audio/voice/female/key/room_wait.ogg differ diff --git a/web/hunt/audio/voice/female/key/sys_menu.m4a b/web/hunt/audio/voice/female/key/sys_menu.m4a new file mode 100644 index 00000000..9ec09e80 Binary files /dev/null and b/web/hunt/audio/voice/female/key/sys_menu.m4a differ diff --git a/web/hunt/audio/voice/female/key/sys_menu.ogg b/web/hunt/audio/voice/female/key/sys_menu.ogg new file mode 100644 index 00000000..cf4f6127 Binary files /dev/null and b/web/hunt/audio/voice/female/key/sys_menu.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_attack.m4a b/web/hunt/audio/voice/female/key/tut_attack.m4a new file mode 100644 index 00000000..9dbe799e Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_attack.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_attack.ogg b/web/hunt/audio/voice/female/key/tut_attack.ogg new file mode 100644 index 00000000..47e3f97e Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_attack.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_callout.m4a b/web/hunt/audio/voice/female/key/tut_callout.m4a new file mode 100644 index 00000000..001a719c Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_callout.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_callout.ogg b/web/hunt/audio/voice/female/key/tut_callout.ogg new file mode 100644 index 00000000..eb495a60 Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_callout.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_rule.m4a b/web/hunt/audio/voice/female/key/tut_rule.m4a new file mode 100644 index 00000000..90b1f1fb Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_rule.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_rule.ogg b/web/hunt/audio/voice/female/key/tut_rule.ogg new file mode 100644 index 00000000..d745aa14 Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_rule.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_skill.m4a b/web/hunt/audio/voice/female/key/tut_skill.m4a new file mode 100644 index 00000000..7aeee2a2 Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_skill.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_skill.ogg b/web/hunt/audio/voice/female/key/tut_skill.ogg new file mode 100644 index 00000000..86920d3e Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_skill.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_skill_cd.m4a b/web/hunt/audio/voice/female/key/tut_skill_cd.m4a new file mode 100644 index 00000000..38cceb64 Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_skill_cd.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_skill_cd.ogg b/web/hunt/audio/voice/female/key/tut_skill_cd.ogg new file mode 100644 index 00000000..0162822b Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_skill_cd.ogg differ diff --git a/web/hunt/audio/voice/female/key/tut_status.m4a b/web/hunt/audio/voice/female/key/tut_status.m4a new file mode 100644 index 00000000..e4fdf26a Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_status.m4a differ diff --git a/web/hunt/audio/voice/female/key/tut_status.ogg b/web/hunt/audio/voice/female/key/tut_status.ogg new file mode 100644 index 00000000..0aa5f736 Binary files /dev/null and b/web/hunt/audio/voice/female/key/tut_status.ogg differ diff --git a/web/hunt/audio/voice/female/kill_band0.m4a b/web/hunt/audio/voice/female/kill_band0.m4a new file mode 100644 index 00000000..49ce74fd Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band0.m4a differ diff --git a/web/hunt/audio/voice/female/kill_band0.ogg b/web/hunt/audio/voice/female/kill_band0.ogg new file mode 100644 index 00000000..64ffe1aa Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band0.ogg differ diff --git a/web/hunt/audio/voice/female/kill_band1.m4a b/web/hunt/audio/voice/female/kill_band1.m4a new file mode 100644 index 00000000..6da09835 Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band1.m4a differ diff --git a/web/hunt/audio/voice/female/kill_band1.ogg b/web/hunt/audio/voice/female/kill_band1.ogg new file mode 100644 index 00000000..0d6fb3d1 Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band1.ogg differ diff --git a/web/hunt/audio/voice/female/kill_band2.m4a b/web/hunt/audio/voice/female/kill_band2.m4a new file mode 100644 index 00000000..eff83045 Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band2.m4a differ diff --git a/web/hunt/audio/voice/female/kill_band2.ogg b/web/hunt/audio/voice/female/kill_band2.ogg new file mode 100644 index 00000000..0aa11d4c Binary files /dev/null and b/web/hunt/audio/voice/female/kill_band2.ogg differ diff --git a/web/hunt/audio/voice/female/net_back.m4a b/web/hunt/audio/voice/female/net_back.m4a new file mode 100644 index 00000000..09ada00f Binary files /dev/null and b/web/hunt/audio/voice/female/net_back.m4a differ diff --git a/web/hunt/audio/voice/female/net_back.ogg b/web/hunt/audio/voice/female/net_back.ogg new file mode 100644 index 00000000..1c5e539d Binary files /dev/null and b/web/hunt/audio/voice/female/net_back.ogg differ diff --git a/web/hunt/audio/voice/female/net_gone.m4a b/web/hunt/audio/voice/female/net_gone.m4a new file mode 100644 index 00000000..2b7cc156 Binary files /dev/null and b/web/hunt/audio/voice/female/net_gone.m4a differ diff --git a/web/hunt/audio/voice/female/net_gone.ogg b/web/hunt/audio/voice/female/net_gone.ogg new file mode 100644 index 00000000..47a56246 Binary files /dev/null and b/web/hunt/audio/voice/female/net_gone.ogg differ diff --git a/web/hunt/audio/voice/female/net_lost.m4a b/web/hunt/audio/voice/female/net_lost.m4a new file mode 100644 index 00000000..4ff25cb7 Binary files /dev/null and b/web/hunt/audio/voice/female/net_lost.m4a differ diff --git a/web/hunt/audio/voice/female/net_lost.ogg b/web/hunt/audio/voice/female/net_lost.ogg new file mode 100644 index 00000000..f7269bc4 Binary files /dev/null and b/web/hunt/audio/voice/female/net_lost.ogg differ diff --git a/web/hunt/audio/voice/female/net_to_bot.m4a b/web/hunt/audio/voice/female/net_to_bot.m4a new file mode 100644 index 00000000..3c52d1af Binary files /dev/null and b/web/hunt/audio/voice/female/net_to_bot.m4a differ diff --git a/web/hunt/audio/voice/female/net_to_bot.ogg b/web/hunt/audio/voice/female/net_to_bot.ogg new file mode 100644 index 00000000..0baf031c Binary files /dev/null and b/web/hunt/audio/voice/female/net_to_bot.ogg differ diff --git a/web/hunt/audio/voice/female/room_start.m4a b/web/hunt/audio/voice/female/room_start.m4a new file mode 100644 index 00000000..b9ba7b4a Binary files /dev/null and b/web/hunt/audio/voice/female/room_start.m4a differ diff --git a/web/hunt/audio/voice/female/room_start.ogg b/web/hunt/audio/voice/female/room_start.ogg new file mode 100644 index 00000000..1112d6aa Binary files /dev/null and b/web/hunt/audio/voice/female/room_start.ogg differ diff --git a/web/hunt/audio/voice/female/room_wait.m4a b/web/hunt/audio/voice/female/room_wait.m4a new file mode 100644 index 00000000..7e2e2911 Binary files /dev/null and b/web/hunt/audio/voice/female/room_wait.m4a differ diff --git a/web/hunt/audio/voice/female/room_wait.ogg b/web/hunt/audio/voice/female/room_wait.ogg new file mode 100644 index 00000000..6280f325 Binary files /dev/null and b/web/hunt/audio/voice/female/room_wait.ogg differ diff --git a/web/hunt/audio/voice/female/settings_hint.m4a b/web/hunt/audio/voice/female/settings_hint.m4a new file mode 100644 index 00000000..c3902b1a Binary files /dev/null and b/web/hunt/audio/voice/female/settings_hint.m4a differ diff --git a/web/hunt/audio/voice/female/settings_hint.ogg b/web/hunt/audio/voice/female/settings_hint.ogg new file mode 100644 index 00000000..6146cda6 Binary files /dev/null and b/web/hunt/audio/voice/female/settings_hint.ogg differ diff --git a/web/hunt/audio/voice/female/skill_ready.m4a b/web/hunt/audio/voice/female/skill_ready.m4a new file mode 100644 index 00000000..5d233ae4 Binary files /dev/null and b/web/hunt/audio/voice/female/skill_ready.m4a differ diff --git a/web/hunt/audio/voice/female/skill_ready.ogg b/web/hunt/audio/voice/female/skill_ready.ogg new file mode 100644 index 00000000..c8d089c9 Binary files /dev/null and b/web/hunt/audio/voice/female/skill_ready.ogg differ diff --git a/web/hunt/audio/voice/female/st_wave_a.m4a b/web/hunt/audio/voice/female/st_wave_a.m4a new file mode 100644 index 00000000..91c6ae4d Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_a.m4a differ diff --git a/web/hunt/audio/voice/female/st_wave_a.ogg b/web/hunt/audio/voice/female/st_wave_a.ogg new file mode 100644 index 00000000..80ebab26 Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_a.ogg differ diff --git a/web/hunt/audio/voice/female/st_wave_b.m4a b/web/hunt/audio/voice/female/st_wave_b.m4a new file mode 100644 index 00000000..d6fb7083 Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_b.m4a differ diff --git a/web/hunt/audio/voice/female/st_wave_b.ogg b/web/hunt/audio/voice/female/st_wave_b.ogg new file mode 100644 index 00000000..70164078 Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_b.ogg differ diff --git a/web/hunt/audio/voice/female/st_wave_c.m4a b/web/hunt/audio/voice/female/st_wave_c.m4a new file mode 100644 index 00000000..2602d0cf Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_c.m4a differ diff --git a/web/hunt/audio/voice/female/st_wave_c.ogg b/web/hunt/audio/voice/female/st_wave_c.ogg new file mode 100644 index 00000000..e07b673a Binary files /dev/null and b/web/hunt/audio/voice/female/st_wave_c.ogg differ diff --git a/web/hunt/audio/voice/female/sys_menu.m4a b/web/hunt/audio/voice/female/sys_menu.m4a new file mode 100644 index 00000000..518d1f3d Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu.m4a differ diff --git a/web/hunt/audio/voice/female/sys_menu.ogg b/web/hunt/audio/voice/female/sys_menu.ogg new file mode 100644 index 00000000..4e34b0f1 Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu.ogg differ diff --git a/web/hunt/audio/voice/female/sys_menu_help.m4a b/web/hunt/audio/voice/female/sys_menu_help.m4a new file mode 100644 index 00000000..7f2c5652 Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_help.m4a differ diff --git a/web/hunt/audio/voice/female/sys_menu_help.ogg b/web/hunt/audio/voice/female/sys_menu_help.ogg new file mode 100644 index 00000000..6b4b38c3 Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_help.ogg differ diff --git a/web/hunt/audio/voice/female/sys_menu_home.m4a b/web/hunt/audio/voice/female/sys_menu_home.m4a new file mode 100644 index 00000000..3dc0486c Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_home.m4a differ diff --git a/web/hunt/audio/voice/female/sys_menu_home.ogg b/web/hunt/audio/voice/female/sys_menu_home.ogg new file mode 100644 index 00000000..20140cfe Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_home.ogg differ diff --git a/web/hunt/audio/voice/female/sys_menu_resume.m4a b/web/hunt/audio/voice/female/sys_menu_resume.m4a new file mode 100644 index 00000000..495f31c9 Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_resume.m4a differ diff --git a/web/hunt/audio/voice/female/sys_menu_resume.ogg b/web/hunt/audio/voice/female/sys_menu_resume.ogg new file mode 100644 index 00000000..2593765e Binary files /dev/null and b/web/hunt/audio/voice/female/sys_menu_resume.ogg differ diff --git a/web/hunt/audio/voice/female/team_3.m4a b/web/hunt/audio/voice/female/team_3.m4a new file mode 100644 index 00000000..ef67eae0 Binary files /dev/null and b/web/hunt/audio/voice/female/team_3.m4a differ diff --git a/web/hunt/audio/voice/female/team_3.ogg b/web/hunt/audio/voice/female/team_3.ogg new file mode 100644 index 00000000..96de55df Binary files /dev/null and b/web/hunt/audio/voice/female/team_3.ogg differ diff --git a/web/hunt/audio/voice/female/team_4.m4a b/web/hunt/audio/voice/female/team_4.m4a new file mode 100644 index 00000000..bd29afed Binary files /dev/null and b/web/hunt/audio/voice/female/team_4.m4a differ diff --git a/web/hunt/audio/voice/female/team_4.ogg b/web/hunt/audio/voice/female/team_4.ogg new file mode 100644 index 00000000..ba925cc9 Binary files /dev/null and b/web/hunt/audio/voice/female/team_4.ogg differ diff --git a/web/hunt/audio/voice/female/threat_few.m4a b/web/hunt/audio/voice/female/threat_few.m4a new file mode 100644 index 00000000..6592f5ef Binary files /dev/null and b/web/hunt/audio/voice/female/threat_few.m4a differ diff --git a/web/hunt/audio/voice/female/threat_few.ogg b/web/hunt/audio/voice/female/threat_few.ogg new file mode 100644 index 00000000..2cc0e285 Binary files /dev/null and b/web/hunt/audio/voice/female/threat_few.ogg differ diff --git a/web/hunt/audio/voice/female/threat_many.m4a b/web/hunt/audio/voice/female/threat_many.m4a new file mode 100644 index 00000000..b53462ba Binary files /dev/null and b/web/hunt/audio/voice/female/threat_many.m4a differ diff --git a/web/hunt/audio/voice/female/threat_many.ogg b/web/hunt/audio/voice/female/threat_many.ogg new file mode 100644 index 00000000..0abf2ef3 Binary files /dev/null and b/web/hunt/audio/voice/female/threat_many.ogg differ diff --git a/web/hunt/audio/voice/female/threat_none.m4a b/web/hunt/audio/voice/female/threat_none.m4a new file mode 100644 index 00000000..2517d7b3 Binary files /dev/null and b/web/hunt/audio/voice/female/threat_none.m4a differ diff --git a/web/hunt/audio/voice/female/threat_none.ogg b/web/hunt/audio/voice/female/threat_none.ogg new file mode 100644 index 00000000..3cb16140 Binary files /dev/null and b/web/hunt/audio/voice/female/threat_none.ogg differ diff --git a/web/hunt/audio/voice/female/tut_attack.m4a b/web/hunt/audio/voice/female/tut_attack.m4a new file mode 100644 index 00000000..80a2b388 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_attack.m4a differ diff --git a/web/hunt/audio/voice/female/tut_attack.ogg b/web/hunt/audio/voice/female/tut_attack.ogg new file mode 100644 index 00000000..37899b74 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_attack.ogg differ diff --git a/web/hunt/audio/voice/female/tut_breach.m4a b/web/hunt/audio/voice/female/tut_breach.m4a new file mode 100644 index 00000000..c5ebf4df Binary files /dev/null and b/web/hunt/audio/voice/female/tut_breach.m4a differ diff --git a/web/hunt/audio/voice/female/tut_breach.ogg b/web/hunt/audio/voice/female/tut_breach.ogg new file mode 100644 index 00000000..a31b33a2 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_breach.ogg differ diff --git a/web/hunt/audio/voice/female/tut_callout.m4a b/web/hunt/audio/voice/female/tut_callout.m4a new file mode 100644 index 00000000..a7a31c6f Binary files /dev/null and b/web/hunt/audio/voice/female/tut_callout.m4a differ diff --git a/web/hunt/audio/voice/female/tut_callout.ogg b/web/hunt/audio/voice/female/tut_callout.ogg new file mode 100644 index 00000000..5b0062e7 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_callout.ogg differ diff --git a/web/hunt/audio/voice/female/tut_callout_ok.m4a b/web/hunt/audio/voice/female/tut_callout_ok.m4a new file mode 100644 index 00000000..8d800565 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_callout_ok.m4a differ diff --git a/web/hunt/audio/voice/female/tut_callout_ok.ogg b/web/hunt/audio/voice/female/tut_callout_ok.ogg new file mode 100644 index 00000000..d752281f Binary files /dev/null and b/web/hunt/audio/voice/female/tut_callout_ok.ogg differ diff --git a/web/hunt/audio/voice/female/tut_done.m4a b/web/hunt/audio/voice/female/tut_done.m4a new file mode 100644 index 00000000..1c2794b9 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_done.m4a differ diff --git a/web/hunt/audio/voice/female/tut_done.ogg b/web/hunt/audio/voice/female/tut_done.ogg new file mode 100644 index 00000000..178a181c Binary files /dev/null and b/web/hunt/audio/voice/female/tut_done.ogg differ diff --git a/web/hunt/audio/voice/female/tut_kill_ok.m4a b/web/hunt/audio/voice/female/tut_kill_ok.m4a new file mode 100644 index 00000000..0eb4f387 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_kill_ok.m4a differ diff --git a/web/hunt/audio/voice/female/tut_kill_ok.ogg b/web/hunt/audio/voice/female/tut_kill_ok.ogg new file mode 100644 index 00000000..74c87d8b Binary files /dev/null and b/web/hunt/audio/voice/female/tut_kill_ok.ogg differ diff --git a/web/hunt/audio/voice/female/tut_rule.m4a b/web/hunt/audio/voice/female/tut_rule.m4a new file mode 100644 index 00000000..97983a1e Binary files /dev/null and b/web/hunt/audio/voice/female/tut_rule.m4a differ diff --git a/web/hunt/audio/voice/female/tut_rule.ogg b/web/hunt/audio/voice/female/tut_rule.ogg new file mode 100644 index 00000000..edf87cef Binary files /dev/null and b/web/hunt/audio/voice/female/tut_rule.ogg differ diff --git a/web/hunt/audio/voice/female/tut_skill.m4a b/web/hunt/audio/voice/female/tut_skill.m4a new file mode 100644 index 00000000..ddab8f47 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skill.m4a differ diff --git a/web/hunt/audio/voice/female/tut_skill.ogg b/web/hunt/audio/voice/female/tut_skill.ogg new file mode 100644 index 00000000..6ddfb711 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skill.ogg differ diff --git a/web/hunt/audio/voice/female/tut_skill_cd.m4a b/web/hunt/audio/voice/female/tut_skill_cd.m4a new file mode 100644 index 00000000..7d45ed32 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skill_cd.m4a differ diff --git a/web/hunt/audio/voice/female/tut_skill_cd.ogg b/web/hunt/audio/voice/female/tut_skill_cd.ogg new file mode 100644 index 00000000..335c86cd Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skill_cd.ogg differ diff --git a/web/hunt/audio/voice/female/tut_skip.m4a b/web/hunt/audio/voice/female/tut_skip.m4a new file mode 100644 index 00000000..dbdf8adb Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skip.m4a differ diff --git a/web/hunt/audio/voice/female/tut_skip.ogg b/web/hunt/audio/voice/female/tut_skip.ogg new file mode 100644 index 00000000..12d4423d Binary files /dev/null and b/web/hunt/audio/voice/female/tut_skip.ogg differ diff --git a/web/hunt/audio/voice/female/tut_status.m4a b/web/hunt/audio/voice/female/tut_status.m4a new file mode 100644 index 00000000..b69bf579 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_status.m4a differ diff --git a/web/hunt/audio/voice/female/tut_status.ogg b/web/hunt/audio/voice/female/tut_status.ogg new file mode 100644 index 00000000..330a1bc1 Binary files /dev/null and b/web/hunt/audio/voice/female/tut_status.ogg differ diff --git a/web/hunt/audio/voice/female/wave_band1.m4a b/web/hunt/audio/voice/female/wave_band1.m4a new file mode 100644 index 00000000..f23a799e Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band1.m4a differ diff --git a/web/hunt/audio/voice/female/wave_band1.ogg b/web/hunt/audio/voice/female/wave_band1.ogg new file mode 100644 index 00000000..743f8bba Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band1.ogg differ diff --git a/web/hunt/audio/voice/female/wave_band2.m4a b/web/hunt/audio/voice/female/wave_band2.m4a new file mode 100644 index 00000000..d3e26f08 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band2.m4a differ diff --git a/web/hunt/audio/voice/female/wave_band2.ogg b/web/hunt/audio/voice/female/wave_band2.ogg new file mode 100644 index 00000000..ce761a40 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band2.ogg differ diff --git a/web/hunt/audio/voice/female/wave_band3.m4a b/web/hunt/audio/voice/female/wave_band3.m4a new file mode 100644 index 00000000..eec835e4 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band3.m4a differ diff --git a/web/hunt/audio/voice/female/wave_band3.ogg b/web/hunt/audio/voice/female/wave_band3.ogg new file mode 100644 index 00000000..12c265e1 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band3.ogg differ diff --git a/web/hunt/audio/voice/female/wave_band4.m4a b/web/hunt/audio/voice/female/wave_band4.m4a new file mode 100644 index 00000000..f8864f06 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band4.m4a differ diff --git a/web/hunt/audio/voice/female/wave_band4.ogg b/web/hunt/audio/voice/female/wave_band4.ogg new file mode 100644 index 00000000..838cc23c Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band4.ogg differ diff --git a/web/hunt/audio/voice/female/wave_band5.m4a b/web/hunt/audio/voice/female/wave_band5.m4a new file mode 100644 index 00000000..08dea0d4 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band5.m4a differ diff --git a/web/hunt/audio/voice/female/wave_band5.ogg b/web/hunt/audio/voice/female/wave_band5.ogg new file mode 100644 index 00000000..71eabba1 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_band5.ogg differ diff --git a/web/hunt/audio/voice/female/wave_clear.m4a b/web/hunt/audio/voice/female/wave_clear.m4a new file mode 100644 index 00000000..00a6cf51 Binary files /dev/null and b/web/hunt/audio/voice/female/wave_clear.m4a differ diff --git a/web/hunt/audio/voice/female/wave_clear.ogg b/web/hunt/audio/voice/female/wave_clear.ogg new file mode 100644 index 00000000..a267ac7a Binary files /dev/null and b/web/hunt/audio/voice/female/wave_clear.ogg differ diff --git a/web/hunt/audio/voice/male/breach.m4a b/web/hunt/audio/voice/male/breach.m4a new file mode 100644 index 00000000..930edbf9 Binary files /dev/null and b/web/hunt/audio/voice/male/breach.m4a differ diff --git a/web/hunt/audio/voice/male/breach.ogg b/web/hunt/audio/voice/male/breach.ogg new file mode 100644 index 00000000..e9881a57 Binary files /dev/null and b/web/hunt/audio/voice/male/breach.ogg differ diff --git a/web/hunt/audio/voice/male/breach_heavy.m4a b/web/hunt/audio/voice/male/breach_heavy.m4a new file mode 100644 index 00000000..59c173cb Binary files /dev/null and b/web/hunt/audio/voice/male/breach_heavy.m4a differ diff --git a/web/hunt/audio/voice/male/breach_heavy.ogg b/web/hunt/audio/voice/male/breach_heavy.ogg new file mode 100644 index 00000000..ad2a32d0 Binary files /dev/null and b/web/hunt/audio/voice/male/breach_heavy.ogg differ diff --git a/web/hunt/audio/voice/male/danger_low.m4a b/web/hunt/audio/voice/male/danger_low.m4a new file mode 100644 index 00000000..d3da38d5 Binary files /dev/null and b/web/hunt/audio/voice/male/danger_low.m4a differ diff --git a/web/hunt/audio/voice/male/danger_low.ogg b/web/hunt/audio/voice/male/danger_low.ogg new file mode 100644 index 00000000..0692dff3 Binary files /dev/null and b/web/hunt/audio/voice/male/danger_low.ogg differ diff --git a/web/hunt/audio/voice/male/end_again.m4a b/web/hunt/audio/voice/male/end_again.m4a new file mode 100644 index 00000000..fa156b1a Binary files /dev/null and b/web/hunt/audio/voice/male/end_again.m4a differ diff --git a/web/hunt/audio/voice/male/end_again.ogg b/web/hunt/audio/voice/male/end_again.ogg new file mode 100644 index 00000000..38fd5f55 Binary files /dev/null and b/web/hunt/audio/voice/male/end_again.ogg differ diff --git a/web/hunt/audio/voice/male/end_lose.m4a b/web/hunt/audio/voice/male/end_lose.m4a new file mode 100644 index 00000000..da11d2ce Binary files /dev/null and b/web/hunt/audio/voice/male/end_lose.m4a differ diff --git a/web/hunt/audio/voice/male/end_lose.ogg b/web/hunt/audio/voice/male/end_lose.ogg new file mode 100644 index 00000000..9de8081b Binary files /dev/null and b/web/hunt/audio/voice/male/end_lose.ogg differ diff --git a/web/hunt/audio/voice/male/end_rank.m4a b/web/hunt/audio/voice/male/end_rank.m4a new file mode 100644 index 00000000..024142e5 Binary files /dev/null and b/web/hunt/audio/voice/male/end_rank.m4a differ diff --git a/web/hunt/audio/voice/male/end_rank.ogg b/web/hunt/audio/voice/male/end_rank.ogg new file mode 100644 index 00000000..43856229 Binary files /dev/null and b/web/hunt/audio/voice/male/end_rank.ogg differ diff --git a/web/hunt/audio/voice/male/end_win.m4a b/web/hunt/audio/voice/male/end_win.m4a new file mode 100644 index 00000000..f03f7a13 Binary files /dev/null and b/web/hunt/audio/voice/male/end_win.m4a differ diff --git a/web/hunt/audio/voice/male/end_win.ogg b/web/hunt/audio/voice/male/end_win.ogg new file mode 100644 index 00000000..a5c62d70 Binary files /dev/null and b/web/hunt/audio/voice/male/end_win.ogg differ diff --git a/web/hunt/audio/voice/male/gd_idle.m4a b/web/hunt/audio/voice/male/gd_idle.m4a new file mode 100644 index 00000000..2e9cbefa Binary files /dev/null and b/web/hunt/audio/voice/male/gd_idle.m4a differ diff --git a/web/hunt/audio/voice/male/gd_idle.ogg b/web/hunt/audio/voice/male/gd_idle.ogg new file mode 100644 index 00000000..b59e8461 Binary files /dev/null and b/web/hunt/audio/voice/male/gd_idle.ogg differ diff --git a/web/hunt/audio/voice/male/gd_invalid.m4a b/web/hunt/audio/voice/male/gd_invalid.m4a new file mode 100644 index 00000000..ff23e7a4 Binary files /dev/null and b/web/hunt/audio/voice/male/gd_invalid.m4a differ diff --git a/web/hunt/audio/voice/male/gd_invalid.ogg b/web/hunt/audio/voice/male/gd_invalid.ogg new file mode 100644 index 00000000..1591df5b Binary files /dev/null and b/web/hunt/audio/voice/male/gd_invalid.ogg differ diff --git a/web/hunt/audio/voice/male/grade_a.m4a b/web/hunt/audio/voice/male/grade_a.m4a new file mode 100644 index 00000000..29acdd4a Binary files /dev/null and b/web/hunt/audio/voice/male/grade_a.m4a differ diff --git a/web/hunt/audio/voice/male/grade_a.ogg b/web/hunt/audio/voice/male/grade_a.ogg new file mode 100644 index 00000000..02bfd58e Binary files /dev/null and b/web/hunt/audio/voice/male/grade_a.ogg differ diff --git a/web/hunt/audio/voice/male/grade_b.m4a b/web/hunt/audio/voice/male/grade_b.m4a new file mode 100644 index 00000000..3a2569e7 Binary files /dev/null and b/web/hunt/audio/voice/male/grade_b.m4a differ diff --git a/web/hunt/audio/voice/male/grade_b.ogg b/web/hunt/audio/voice/male/grade_b.ogg new file mode 100644 index 00000000..18721aff Binary files /dev/null and b/web/hunt/audio/voice/male/grade_b.ogg differ diff --git a/web/hunt/audio/voice/male/grade_c.m4a b/web/hunt/audio/voice/male/grade_c.m4a new file mode 100644 index 00000000..c582953d Binary files /dev/null and b/web/hunt/audio/voice/male/grade_c.m4a differ diff --git a/web/hunt/audio/voice/male/grade_c.ogg b/web/hunt/audio/voice/male/grade_c.ogg new file mode 100644 index 00000000..58ba909e Binary files /dev/null and b/web/hunt/audio/voice/male/grade_c.ogg differ diff --git a/web/hunt/audio/voice/male/grade_s.m4a b/web/hunt/audio/voice/male/grade_s.m4a new file mode 100644 index 00000000..2522624c Binary files /dev/null and b/web/hunt/audio/voice/male/grade_s.m4a differ diff --git a/web/hunt/audio/voice/male/grade_s.ogg b/web/hunt/audio/voice/male/grade_s.ogg new file mode 100644 index 00000000..d2fb056b Binary files /dev/null and b/web/hunt/audio/voice/male/grade_s.ogg differ diff --git a/web/hunt/audio/voice/male/help_main.m4a b/web/hunt/audio/voice/male/help_main.m4a new file mode 100644 index 00000000..330a1c4a Binary files /dev/null and b/web/hunt/audio/voice/male/help_main.m4a differ diff --git a/web/hunt/audio/voice/male/help_main.ogg b/web/hunt/audio/voice/male/help_main.ogg new file mode 100644 index 00000000..f251cac7 Binary files /dev/null and b/web/hunt/audio/voice/male/help_main.ogg differ diff --git a/web/hunt/audio/voice/male/hp_band1.m4a b/web/hunt/audio/voice/male/hp_band1.m4a new file mode 100644 index 00000000..139c2633 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band1.m4a differ diff --git a/web/hunt/audio/voice/male/hp_band1.ogg b/web/hunt/audio/voice/male/hp_band1.ogg new file mode 100644 index 00000000..7655ea10 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band1.ogg differ diff --git a/web/hunt/audio/voice/male/hp_band2.m4a b/web/hunt/audio/voice/male/hp_band2.m4a new file mode 100644 index 00000000..c7ffef0e Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band2.m4a differ diff --git a/web/hunt/audio/voice/male/hp_band2.ogg b/web/hunt/audio/voice/male/hp_band2.ogg new file mode 100644 index 00000000..95031d12 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band2.ogg differ diff --git a/web/hunt/audio/voice/male/hp_band3.m4a b/web/hunt/audio/voice/male/hp_band3.m4a new file mode 100644 index 00000000..a05f9beb Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band3.m4a differ diff --git a/web/hunt/audio/voice/male/hp_band3.ogg b/web/hunt/audio/voice/male/hp_band3.ogg new file mode 100644 index 00000000..7380f8d6 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band3.ogg differ diff --git a/web/hunt/audio/voice/male/hp_band4.m4a b/web/hunt/audio/voice/male/hp_band4.m4a new file mode 100644 index 00000000..c0150df7 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band4.m4a differ diff --git a/web/hunt/audio/voice/male/hp_band4.ogg b/web/hunt/audio/voice/male/hp_band4.ogg new file mode 100644 index 00000000..c71ee154 Binary files /dev/null and b/web/hunt/audio/voice/male/hp_band4.ogg differ diff --git a/web/hunt/audio/voice/male/intro_1.m4a b/web/hunt/audio/voice/male/intro_1.m4a new file mode 100644 index 00000000..6a6c6932 Binary files /dev/null and b/web/hunt/audio/voice/male/intro_1.m4a differ diff --git a/web/hunt/audio/voice/male/intro_1.ogg b/web/hunt/audio/voice/male/intro_1.ogg new file mode 100644 index 00000000..9627f083 Binary files /dev/null and b/web/hunt/audio/voice/male/intro_1.ogg differ diff --git a/web/hunt/audio/voice/male/intro_2.m4a b/web/hunt/audio/voice/male/intro_2.m4a new file mode 100644 index 00000000..8622aa8f Binary files /dev/null and b/web/hunt/audio/voice/male/intro_2.m4a differ diff --git a/web/hunt/audio/voice/male/intro_2.ogg b/web/hunt/audio/voice/male/intro_2.ogg new file mode 100644 index 00000000..f2d523ef Binary files /dev/null and b/web/hunt/audio/voice/male/intro_2.ogg differ diff --git a/web/hunt/audio/voice/male/key/end_again.m4a b/web/hunt/audio/voice/male/key/end_again.m4a new file mode 100644 index 00000000..7cfc07f3 Binary files /dev/null and b/web/hunt/audio/voice/male/key/end_again.m4a differ diff --git a/web/hunt/audio/voice/male/key/end_again.ogg b/web/hunt/audio/voice/male/key/end_again.ogg new file mode 100644 index 00000000..040d4aac Binary files /dev/null and b/web/hunt/audio/voice/male/key/end_again.ogg differ diff --git a/web/hunt/audio/voice/male/key/gd_idle.m4a b/web/hunt/audio/voice/male/key/gd_idle.m4a new file mode 100644 index 00000000..8e0c4d85 Binary files /dev/null and b/web/hunt/audio/voice/male/key/gd_idle.m4a differ diff --git a/web/hunt/audio/voice/male/key/gd_idle.ogg b/web/hunt/audio/voice/male/key/gd_idle.ogg new file mode 100644 index 00000000..06d136c8 Binary files /dev/null and b/web/hunt/audio/voice/male/key/gd_idle.ogg differ diff --git a/web/hunt/audio/voice/male/key/help_main.m4a b/web/hunt/audio/voice/male/key/help_main.m4a new file mode 100644 index 00000000..ebfbcd48 Binary files /dev/null and b/web/hunt/audio/voice/male/key/help_main.m4a differ diff --git a/web/hunt/audio/voice/male/key/help_main.ogg b/web/hunt/audio/voice/male/key/help_main.ogg new file mode 100644 index 00000000..7497b772 Binary files /dev/null and b/web/hunt/audio/voice/male/key/help_main.ogg differ diff --git a/web/hunt/audio/voice/male/key/room_wait.m4a b/web/hunt/audio/voice/male/key/room_wait.m4a new file mode 100644 index 00000000..8c53065d Binary files /dev/null and b/web/hunt/audio/voice/male/key/room_wait.m4a differ diff --git a/web/hunt/audio/voice/male/key/room_wait.ogg b/web/hunt/audio/voice/male/key/room_wait.ogg new file mode 100644 index 00000000..89d51ff0 Binary files /dev/null and b/web/hunt/audio/voice/male/key/room_wait.ogg differ diff --git a/web/hunt/audio/voice/male/key/sys_menu.m4a b/web/hunt/audio/voice/male/key/sys_menu.m4a new file mode 100644 index 00000000..0a53bb83 Binary files /dev/null and b/web/hunt/audio/voice/male/key/sys_menu.m4a differ diff --git a/web/hunt/audio/voice/male/key/sys_menu.ogg b/web/hunt/audio/voice/male/key/sys_menu.ogg new file mode 100644 index 00000000..cc4d6497 Binary files /dev/null and b/web/hunt/audio/voice/male/key/sys_menu.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_attack.m4a b/web/hunt/audio/voice/male/key/tut_attack.m4a new file mode 100644 index 00000000..6cb0b41f Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_attack.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_attack.ogg b/web/hunt/audio/voice/male/key/tut_attack.ogg new file mode 100644 index 00000000..35e04d4d Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_attack.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_callout.m4a b/web/hunt/audio/voice/male/key/tut_callout.m4a new file mode 100644 index 00000000..99888433 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_callout.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_callout.ogg b/web/hunt/audio/voice/male/key/tut_callout.ogg new file mode 100644 index 00000000..1c82c97d Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_callout.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_rule.m4a b/web/hunt/audio/voice/male/key/tut_rule.m4a new file mode 100644 index 00000000..94d8bb9c Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_rule.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_rule.ogg b/web/hunt/audio/voice/male/key/tut_rule.ogg new file mode 100644 index 00000000..a2a194c0 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_rule.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_skill.m4a b/web/hunt/audio/voice/male/key/tut_skill.m4a new file mode 100644 index 00000000..e51cc881 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_skill.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_skill.ogg b/web/hunt/audio/voice/male/key/tut_skill.ogg new file mode 100644 index 00000000..b3beaf96 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_skill.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_skill_cd.m4a b/web/hunt/audio/voice/male/key/tut_skill_cd.m4a new file mode 100644 index 00000000..273147a0 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_skill_cd.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_skill_cd.ogg b/web/hunt/audio/voice/male/key/tut_skill_cd.ogg new file mode 100644 index 00000000..3bcf34c6 Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_skill_cd.ogg differ diff --git a/web/hunt/audio/voice/male/key/tut_status.m4a b/web/hunt/audio/voice/male/key/tut_status.m4a new file mode 100644 index 00000000..746be92c Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_status.m4a differ diff --git a/web/hunt/audio/voice/male/key/tut_status.ogg b/web/hunt/audio/voice/male/key/tut_status.ogg new file mode 100644 index 00000000..e3db9def Binary files /dev/null and b/web/hunt/audio/voice/male/key/tut_status.ogg differ diff --git a/web/hunt/audio/voice/male/kill_band0.m4a b/web/hunt/audio/voice/male/kill_band0.m4a new file mode 100644 index 00000000..065e2fbd Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band0.m4a differ diff --git a/web/hunt/audio/voice/male/kill_band0.ogg b/web/hunt/audio/voice/male/kill_band0.ogg new file mode 100644 index 00000000..5f3b7bfa Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band0.ogg differ diff --git a/web/hunt/audio/voice/male/kill_band1.m4a b/web/hunt/audio/voice/male/kill_band1.m4a new file mode 100644 index 00000000..ef615373 Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band1.m4a differ diff --git a/web/hunt/audio/voice/male/kill_band1.ogg b/web/hunt/audio/voice/male/kill_band1.ogg new file mode 100644 index 00000000..3ff02077 Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band1.ogg differ diff --git a/web/hunt/audio/voice/male/kill_band2.m4a b/web/hunt/audio/voice/male/kill_band2.m4a new file mode 100644 index 00000000..5c6b75c6 Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band2.m4a differ diff --git a/web/hunt/audio/voice/male/kill_band2.ogg b/web/hunt/audio/voice/male/kill_band2.ogg new file mode 100644 index 00000000..fb54fcd1 Binary files /dev/null and b/web/hunt/audio/voice/male/kill_band2.ogg differ diff --git a/web/hunt/audio/voice/male/net_back.m4a b/web/hunt/audio/voice/male/net_back.m4a new file mode 100644 index 00000000..a7746a31 Binary files /dev/null and b/web/hunt/audio/voice/male/net_back.m4a differ diff --git a/web/hunt/audio/voice/male/net_back.ogg b/web/hunt/audio/voice/male/net_back.ogg new file mode 100644 index 00000000..14051851 Binary files /dev/null and b/web/hunt/audio/voice/male/net_back.ogg differ diff --git a/web/hunt/audio/voice/male/net_gone.m4a b/web/hunt/audio/voice/male/net_gone.m4a new file mode 100644 index 00000000..0a881b70 Binary files /dev/null and b/web/hunt/audio/voice/male/net_gone.m4a differ diff --git a/web/hunt/audio/voice/male/net_gone.ogg b/web/hunt/audio/voice/male/net_gone.ogg new file mode 100644 index 00000000..32d4da71 Binary files /dev/null and b/web/hunt/audio/voice/male/net_gone.ogg differ diff --git a/web/hunt/audio/voice/male/net_lost.m4a b/web/hunt/audio/voice/male/net_lost.m4a new file mode 100644 index 00000000..9d61ab2a Binary files /dev/null and b/web/hunt/audio/voice/male/net_lost.m4a differ diff --git a/web/hunt/audio/voice/male/net_lost.ogg b/web/hunt/audio/voice/male/net_lost.ogg new file mode 100644 index 00000000..8af9d103 Binary files /dev/null and b/web/hunt/audio/voice/male/net_lost.ogg differ diff --git a/web/hunt/audio/voice/male/net_to_bot.m4a b/web/hunt/audio/voice/male/net_to_bot.m4a new file mode 100644 index 00000000..b79c80f4 Binary files /dev/null and b/web/hunt/audio/voice/male/net_to_bot.m4a differ diff --git a/web/hunt/audio/voice/male/net_to_bot.ogg b/web/hunt/audio/voice/male/net_to_bot.ogg new file mode 100644 index 00000000..a02e4eb1 Binary files /dev/null and b/web/hunt/audio/voice/male/net_to_bot.ogg differ diff --git a/web/hunt/audio/voice/male/room_start.m4a b/web/hunt/audio/voice/male/room_start.m4a new file mode 100644 index 00000000..88e1a612 Binary files /dev/null and b/web/hunt/audio/voice/male/room_start.m4a differ diff --git a/web/hunt/audio/voice/male/room_start.ogg b/web/hunt/audio/voice/male/room_start.ogg new file mode 100644 index 00000000..cc913194 Binary files /dev/null and b/web/hunt/audio/voice/male/room_start.ogg differ diff --git a/web/hunt/audio/voice/male/room_wait.m4a b/web/hunt/audio/voice/male/room_wait.m4a new file mode 100644 index 00000000..ebebcc53 Binary files /dev/null and b/web/hunt/audio/voice/male/room_wait.m4a differ diff --git a/web/hunt/audio/voice/male/room_wait.ogg b/web/hunt/audio/voice/male/room_wait.ogg new file mode 100644 index 00000000..e469ef14 Binary files /dev/null and b/web/hunt/audio/voice/male/room_wait.ogg differ diff --git a/web/hunt/audio/voice/male/settings_hint.m4a b/web/hunt/audio/voice/male/settings_hint.m4a new file mode 100644 index 00000000..8de44984 Binary files /dev/null and b/web/hunt/audio/voice/male/settings_hint.m4a differ diff --git a/web/hunt/audio/voice/male/settings_hint.ogg b/web/hunt/audio/voice/male/settings_hint.ogg new file mode 100644 index 00000000..a00c29f1 Binary files /dev/null and b/web/hunt/audio/voice/male/settings_hint.ogg differ diff --git a/web/hunt/audio/voice/male/skill_ready.m4a b/web/hunt/audio/voice/male/skill_ready.m4a new file mode 100644 index 00000000..fbf552cf Binary files /dev/null and b/web/hunt/audio/voice/male/skill_ready.m4a differ diff --git a/web/hunt/audio/voice/male/skill_ready.ogg b/web/hunt/audio/voice/male/skill_ready.ogg new file mode 100644 index 00000000..44c0dfa6 Binary files /dev/null and b/web/hunt/audio/voice/male/skill_ready.ogg differ diff --git a/web/hunt/audio/voice/male/st_wave_a.m4a b/web/hunt/audio/voice/male/st_wave_a.m4a new file mode 100644 index 00000000..d5c7b484 Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_a.m4a differ diff --git a/web/hunt/audio/voice/male/st_wave_a.ogg b/web/hunt/audio/voice/male/st_wave_a.ogg new file mode 100644 index 00000000..570c6a80 Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_a.ogg differ diff --git a/web/hunt/audio/voice/male/st_wave_b.m4a b/web/hunt/audio/voice/male/st_wave_b.m4a new file mode 100644 index 00000000..221d9ed2 Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_b.m4a differ diff --git a/web/hunt/audio/voice/male/st_wave_b.ogg b/web/hunt/audio/voice/male/st_wave_b.ogg new file mode 100644 index 00000000..a6a1b56a Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_b.ogg differ diff --git a/web/hunt/audio/voice/male/st_wave_c.m4a b/web/hunt/audio/voice/male/st_wave_c.m4a new file mode 100644 index 00000000..322e68c9 Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_c.m4a differ diff --git a/web/hunt/audio/voice/male/st_wave_c.ogg b/web/hunt/audio/voice/male/st_wave_c.ogg new file mode 100644 index 00000000..f8fa31e6 Binary files /dev/null and b/web/hunt/audio/voice/male/st_wave_c.ogg differ diff --git a/web/hunt/audio/voice/male/sys_menu.m4a b/web/hunt/audio/voice/male/sys_menu.m4a new file mode 100644 index 00000000..30daf86c Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu.m4a differ diff --git a/web/hunt/audio/voice/male/sys_menu.ogg b/web/hunt/audio/voice/male/sys_menu.ogg new file mode 100644 index 00000000..1fe0a5cc Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu.ogg differ diff --git a/web/hunt/audio/voice/male/sys_menu_help.m4a b/web/hunt/audio/voice/male/sys_menu_help.m4a new file mode 100644 index 00000000..c649bb88 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_help.m4a differ diff --git a/web/hunt/audio/voice/male/sys_menu_help.ogg b/web/hunt/audio/voice/male/sys_menu_help.ogg new file mode 100644 index 00000000..4f7f2849 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_help.ogg differ diff --git a/web/hunt/audio/voice/male/sys_menu_home.m4a b/web/hunt/audio/voice/male/sys_menu_home.m4a new file mode 100644 index 00000000..8f333140 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_home.m4a differ diff --git a/web/hunt/audio/voice/male/sys_menu_home.ogg b/web/hunt/audio/voice/male/sys_menu_home.ogg new file mode 100644 index 00000000..7b6ff185 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_home.ogg differ diff --git a/web/hunt/audio/voice/male/sys_menu_resume.m4a b/web/hunt/audio/voice/male/sys_menu_resume.m4a new file mode 100644 index 00000000..c1c71134 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_resume.m4a differ diff --git a/web/hunt/audio/voice/male/sys_menu_resume.ogg b/web/hunt/audio/voice/male/sys_menu_resume.ogg new file mode 100644 index 00000000..3a619b53 Binary files /dev/null and b/web/hunt/audio/voice/male/sys_menu_resume.ogg differ diff --git a/web/hunt/audio/voice/male/team_3.m4a b/web/hunt/audio/voice/male/team_3.m4a new file mode 100644 index 00000000..aeb5a129 Binary files /dev/null and b/web/hunt/audio/voice/male/team_3.m4a differ diff --git a/web/hunt/audio/voice/male/team_3.ogg b/web/hunt/audio/voice/male/team_3.ogg new file mode 100644 index 00000000..63130760 Binary files /dev/null and b/web/hunt/audio/voice/male/team_3.ogg differ diff --git a/web/hunt/audio/voice/male/team_4.m4a b/web/hunt/audio/voice/male/team_4.m4a new file mode 100644 index 00000000..a818737c Binary files /dev/null and b/web/hunt/audio/voice/male/team_4.m4a differ diff --git a/web/hunt/audio/voice/male/team_4.ogg b/web/hunt/audio/voice/male/team_4.ogg new file mode 100644 index 00000000..49b2d485 Binary files /dev/null and b/web/hunt/audio/voice/male/team_4.ogg differ diff --git a/web/hunt/audio/voice/male/threat_few.m4a b/web/hunt/audio/voice/male/threat_few.m4a new file mode 100644 index 00000000..8177ddfe Binary files /dev/null and b/web/hunt/audio/voice/male/threat_few.m4a differ diff --git a/web/hunt/audio/voice/male/threat_few.ogg b/web/hunt/audio/voice/male/threat_few.ogg new file mode 100644 index 00000000..fd93506e Binary files /dev/null and b/web/hunt/audio/voice/male/threat_few.ogg differ diff --git a/web/hunt/audio/voice/male/threat_many.m4a b/web/hunt/audio/voice/male/threat_many.m4a new file mode 100644 index 00000000..55da7ca7 Binary files /dev/null and b/web/hunt/audio/voice/male/threat_many.m4a differ diff --git a/web/hunt/audio/voice/male/threat_many.ogg b/web/hunt/audio/voice/male/threat_many.ogg new file mode 100644 index 00000000..345cc348 Binary files /dev/null and b/web/hunt/audio/voice/male/threat_many.ogg differ diff --git a/web/hunt/audio/voice/male/threat_none.m4a b/web/hunt/audio/voice/male/threat_none.m4a new file mode 100644 index 00000000..ef54a7d5 Binary files /dev/null and b/web/hunt/audio/voice/male/threat_none.m4a differ diff --git a/web/hunt/audio/voice/male/threat_none.ogg b/web/hunt/audio/voice/male/threat_none.ogg new file mode 100644 index 00000000..5b709bab Binary files /dev/null and b/web/hunt/audio/voice/male/threat_none.ogg differ diff --git a/web/hunt/audio/voice/male/tut_attack.m4a b/web/hunt/audio/voice/male/tut_attack.m4a new file mode 100644 index 00000000..ff054368 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_attack.m4a differ diff --git a/web/hunt/audio/voice/male/tut_attack.ogg b/web/hunt/audio/voice/male/tut_attack.ogg new file mode 100644 index 00000000..af146075 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_attack.ogg differ diff --git a/web/hunt/audio/voice/male/tut_breach.m4a b/web/hunt/audio/voice/male/tut_breach.m4a new file mode 100644 index 00000000..c8430c56 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_breach.m4a differ diff --git a/web/hunt/audio/voice/male/tut_breach.ogg b/web/hunt/audio/voice/male/tut_breach.ogg new file mode 100644 index 00000000..a276cfcb Binary files /dev/null and b/web/hunt/audio/voice/male/tut_breach.ogg differ diff --git a/web/hunt/audio/voice/male/tut_callout.m4a b/web/hunt/audio/voice/male/tut_callout.m4a new file mode 100644 index 00000000..7e828c31 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_callout.m4a differ diff --git a/web/hunt/audio/voice/male/tut_callout.ogg b/web/hunt/audio/voice/male/tut_callout.ogg new file mode 100644 index 00000000..e9e10836 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_callout.ogg differ diff --git a/web/hunt/audio/voice/male/tut_callout_ok.m4a b/web/hunt/audio/voice/male/tut_callout_ok.m4a new file mode 100644 index 00000000..3580e198 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_callout_ok.m4a differ diff --git a/web/hunt/audio/voice/male/tut_callout_ok.ogg b/web/hunt/audio/voice/male/tut_callout_ok.ogg new file mode 100644 index 00000000..7ad2da2c Binary files /dev/null and b/web/hunt/audio/voice/male/tut_callout_ok.ogg differ diff --git a/web/hunt/audio/voice/male/tut_done.m4a b/web/hunt/audio/voice/male/tut_done.m4a new file mode 100644 index 00000000..3aeef533 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_done.m4a differ diff --git a/web/hunt/audio/voice/male/tut_done.ogg b/web/hunt/audio/voice/male/tut_done.ogg new file mode 100644 index 00000000..1186d1ad Binary files /dev/null and b/web/hunt/audio/voice/male/tut_done.ogg differ diff --git a/web/hunt/audio/voice/male/tut_kill_ok.m4a b/web/hunt/audio/voice/male/tut_kill_ok.m4a new file mode 100644 index 00000000..6ff4a7ea Binary files /dev/null and b/web/hunt/audio/voice/male/tut_kill_ok.m4a differ diff --git a/web/hunt/audio/voice/male/tut_kill_ok.ogg b/web/hunt/audio/voice/male/tut_kill_ok.ogg new file mode 100644 index 00000000..c2bf0762 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_kill_ok.ogg differ diff --git a/web/hunt/audio/voice/male/tut_rule.m4a b/web/hunt/audio/voice/male/tut_rule.m4a new file mode 100644 index 00000000..c8cce15f Binary files /dev/null and b/web/hunt/audio/voice/male/tut_rule.m4a differ diff --git a/web/hunt/audio/voice/male/tut_rule.ogg b/web/hunt/audio/voice/male/tut_rule.ogg new file mode 100644 index 00000000..1df4e959 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_rule.ogg differ diff --git a/web/hunt/audio/voice/male/tut_skill.m4a b/web/hunt/audio/voice/male/tut_skill.m4a new file mode 100644 index 00000000..3feaeb87 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skill.m4a differ diff --git a/web/hunt/audio/voice/male/tut_skill.ogg b/web/hunt/audio/voice/male/tut_skill.ogg new file mode 100644 index 00000000..e903f649 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skill.ogg differ diff --git a/web/hunt/audio/voice/male/tut_skill_cd.m4a b/web/hunt/audio/voice/male/tut_skill_cd.m4a new file mode 100644 index 00000000..4191a4af Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skill_cd.m4a differ diff --git a/web/hunt/audio/voice/male/tut_skill_cd.ogg b/web/hunt/audio/voice/male/tut_skill_cd.ogg new file mode 100644 index 00000000..8389ba7c Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skill_cd.ogg differ diff --git a/web/hunt/audio/voice/male/tut_skip.m4a b/web/hunt/audio/voice/male/tut_skip.m4a new file mode 100644 index 00000000..b40fc18b Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skip.m4a differ diff --git a/web/hunt/audio/voice/male/tut_skip.ogg b/web/hunt/audio/voice/male/tut_skip.ogg new file mode 100644 index 00000000..cb1e34bf Binary files /dev/null and b/web/hunt/audio/voice/male/tut_skip.ogg differ diff --git a/web/hunt/audio/voice/male/tut_status.m4a b/web/hunt/audio/voice/male/tut_status.m4a new file mode 100644 index 00000000..c527c64f Binary files /dev/null and b/web/hunt/audio/voice/male/tut_status.m4a differ diff --git a/web/hunt/audio/voice/male/tut_status.ogg b/web/hunt/audio/voice/male/tut_status.ogg new file mode 100644 index 00000000..15d5a7d1 Binary files /dev/null and b/web/hunt/audio/voice/male/tut_status.ogg differ diff --git a/web/hunt/audio/voice/male/wave_band1.m4a b/web/hunt/audio/voice/male/wave_band1.m4a new file mode 100644 index 00000000..af9f4b81 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band1.m4a differ diff --git a/web/hunt/audio/voice/male/wave_band1.ogg b/web/hunt/audio/voice/male/wave_band1.ogg new file mode 100644 index 00000000..20e9adab Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band1.ogg differ diff --git a/web/hunt/audio/voice/male/wave_band2.m4a b/web/hunt/audio/voice/male/wave_band2.m4a new file mode 100644 index 00000000..4c9b14c3 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band2.m4a differ diff --git a/web/hunt/audio/voice/male/wave_band2.ogg b/web/hunt/audio/voice/male/wave_band2.ogg new file mode 100644 index 00000000..3cf1640b Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band2.ogg differ diff --git a/web/hunt/audio/voice/male/wave_band3.m4a b/web/hunt/audio/voice/male/wave_band3.m4a new file mode 100644 index 00000000..7c723c24 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band3.m4a differ diff --git a/web/hunt/audio/voice/male/wave_band3.ogg b/web/hunt/audio/voice/male/wave_band3.ogg new file mode 100644 index 00000000..5bbf3490 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band3.ogg differ diff --git a/web/hunt/audio/voice/male/wave_band4.m4a b/web/hunt/audio/voice/male/wave_band4.m4a new file mode 100644 index 00000000..4399afb8 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band4.m4a differ diff --git a/web/hunt/audio/voice/male/wave_band4.ogg b/web/hunt/audio/voice/male/wave_band4.ogg new file mode 100644 index 00000000..c269fe16 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band4.ogg differ diff --git a/web/hunt/audio/voice/male/wave_band5.m4a b/web/hunt/audio/voice/male/wave_band5.m4a new file mode 100644 index 00000000..7f39061d Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band5.m4a differ diff --git a/web/hunt/audio/voice/male/wave_band5.ogg b/web/hunt/audio/voice/male/wave_band5.ogg new file mode 100644 index 00000000..6bca4906 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_band5.ogg differ diff --git a/web/hunt/audio/voice/male/wave_clear.m4a b/web/hunt/audio/voice/male/wave_clear.m4a new file mode 100644 index 00000000..9264d227 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_clear.m4a differ diff --git a/web/hunt/audio/voice/male/wave_clear.ogg b/web/hunt/audio/voice/male/wave_clear.ogg new file mode 100644 index 00000000..93698331 Binary files /dev/null and b/web/hunt/audio/voice/male/wave_clear.ogg differ diff --git a/web/hunt/cover.png b/web/hunt/cover.png new file mode 100644 index 00000000..43c49c80 Binary files /dev/null and b/web/hunt/cover.png differ diff --git a/web/hunt/game.js b/web/hunt/game.js new file mode 100644 index 00000000..d98bb642 --- /dev/null +++ b/web/hunt/game.js @@ -0,0 +1,1384 @@ +/** + * 《围猎》客户端 —— 2-4 人实时联机纯音频协作守卫。 + * 设计:docs/design/23-hunt.md(协议 §6、拥挤控制 R1-R8 §8、教学 §5) + * 台词表:docs/design/23-hunt-voice-ids.md(60+ 旁白 id / 24 bot 喊话 id) + * + * 架构要点: + * - 服务端权威:怪物生成/移动/命中/冷却/评级全在服务器裁决;本客户端只发 + * 意图(join / act / leave),消费 joined / ev / snap / err 四类消息。 + * - 本地怪表 = ev 增删 + snap 校正(§6.7):每 snap 对账——本地多出的 id 删、 + * 缺的补、eta 偏差 >0.5s 校正;断网期间按最后已知 eta 继续衰减渲染。 + * - 时钟:joined/snap/ev 携带房间单调时钟 ts,记 offset = ts - performance.now(), + * 渲染用 nowSrv() = performance.now() + offset(§6.5,局域网无需 RTT 补偿)。 + * - 拥挤控制客户端侧(§8):旁白单句队列(引擎 voice 层既有,R1)+ 警报可顶 + * 状态类/不可顶教学与结算链;bot 喊话走 sfx 层带 pan、同刻 ≤1、待播 ≤1、 + * 旁白在播延迟 ≤2s(R2);怪物 loop ≤3 路只渲染 eta 最小 3 只(R3);定位 + * one-shot ≤2 并发、同类间隔 ≥0.15s、呼啸队列 ≤2 超出丢弃(R4);喊话统一 + * vol 0.55 压档(R7);听觉视野只报内圈数量档(R6,threat_* 三档)。 + * - 方位 → pan(§8.3):pan = sin(az),后半平面(90° DIR_AZ[dir] ?? 0; +const panOfAz = (az) => Math.sin((az * Math.PI) / 180); +const isBackAz = (az) => az > 90 && az < 270; // 后半平面:loop/呼啸音量 ×0.6 +const panOfDir = (dir) => panOfAz(azOf(dir)); +/** 座位防区中心方位角:防区为连续罗盘弧,取各方位角均值(§3.3 中心列同值)。 */ +function sectorCenterAz(sector) { + if (!sector || !sector.length) return 0; + return sector.reduce((s, d) => s + azOf(d), 0) / sector.length; +} + +// ---------------- 数值(23 §3.2/§8;裁决以服务器为准,本地仅预扣/门控) ---------------- +const ATTACK_CD_MS = 2500; // 出击冷却/座 +const SKILL_CD_MS = 8000; // 旋风斩冷却/座 +const CALLOUT_THROTTLE_MS = 5000; // 呼喊节流/座 +const INNER_ETA_S = 4; // 内圈(近战窗 + threat 播报口径) +const MID_ETA_S = 6; // 中圈上限(loop 可闻;之外仅生成呼啸一声) +const LOOP_MAX = 3; // R3:同刻 loop ≤3 路(eta 最小 3 只) +const LOOP_REFRESH_MS = 500; // §6.7 每 500ms 重估渲染集 +const ONESHOT_MAX = 2; // R4:定位 one-shot ≤2 并发 +const ONESHOT_SPACING_MS = 150; // R4:同类间隔 ≥0.15s +const HOWL_QUEUE_MAX = 2; // R4:生成潮呼啸队列深 ≤2,超出丢弃 +const HOWL_QUEUE_TTL_MS = 1500; // 排队的呼啸过期丢弃(怪近后 loop 已接管信息) +const NPC_VOL = 0.55; // R7:bot 喊话统一压一档,防压旁白 +const NPC_VOICE_DELAY_MS = 2000;// R2:旁白在播时喊话延迟投递上限,超时丢弃 +const NPC_PRIO_TTL_MS = 4000; // help_call/help_ack 呼救语义:更长的待播宽限 +const BREACH_VOICE_GAP_MS = 3500; // F 组:breach/breach_heavy 播报节流 ≥3.5s +const END_GRACE_MS = 800; // H1:结算链进入手势死窗(dungeon 先例) +const RECONNECT_LIMIT_MS = 30000; // §6.6 断线 30s 宽限 +const RECONNECT_STEP_MS = 2000; // 本地静默重连节奏 +const VOICE_GUARD_MS = 9000; // 语音链单句看门狗(资产缺失/解码失败兜底推进) + +/** sfx 时长表(秒,one-shot 调度记账用;资产值见 23 §10.2)。 */ +const SFX_DUR = { + atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7, + skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4, + call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0, +}; +const NPC_LINE_FALLBACK_DUR_S = 1.6; // npc 喊话时长兜底(精确值取解码后 buffer) + +const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_help', 'sys_menu_home']; // §2.4 固定 3 项 +const GRADE_RANK = { S: 4, A: 3, B: 2, C: 1 }; + +/** 状态链分档(D 组;耐久 10 制)。 */ +const hpBandId = (dur) => (dur >= 8 ? 'hp_band1' : dur >= 5 ? 'hp_band2' : dur >= 3 ? 'hp_band3' : 'hp_band4'); +const stWaveId = (wave) => (wave <= 2 ? 'st_wave_a' : wave <= 4 ? 'st_wave_b' : 'st_wave_c'); +const killBandId = (k) => (k <= 0 ? 'kill_band0' : k <= 9 ? 'kill_band1' : 'kill_band2'); + +/** 默认浏览器传输:原生 WebSocket 包成 { send, close } + handlers 形态。 */ +function defaultTransport(url, handlers) { + const ws = new WebSocket(url); + ws.onopen = () => handlers.onopen(); + ws.onmessage = (e) => { + let msg = null; + try { msg = JSON.parse(e.data); } catch (_) { /* 坏帧忽略 */ } + if (msg) handlers.onmessage(msg); + }; + ws.onclose = () => handlers.onclose(); + ws.onerror = () => { /* onclose 随后必达,统一走关闭路径 */ }; + return { + send: (s) => { try { ws.send(s); } catch (_) { /* 连接已死,重连流程兜底 */ } }, + close: () => { try { ws.close(); } catch (_) { /* 已关闭 */ } }, + }; +} + +export class HuntGame extends Game { + /** + * @param {WebAudioEngine} audio + * @param {HTMLElement} root + * @param {object} [opts] + * @param {Function} [opts.transport] 传输工厂(测试注入内存回路 mock) + * @param {string} [opts.wsUrl] WS 端点(缺省按页面 location 拼 /ws/hunt) + */ + constructor(audio, root, opts = {}) { + super(audio, root); + this.guide = new Guide(audio); + this._transport = opts.transport || defaultTransport; + this._wsUrl = opts.wsUrl || null; + + // 宏观状态:title / matching / lobby / teach / wave / end / lost(menu 为 overlay) + this.phase = 'title'; + this._menuOpen = false; + this._menuIdx = 0; + this._goingHome = false; + + // 房间/连接 + this._conn = null; + this._connAlive = false; + this._joined = false; + this._intentionalClose = false; + this.room = null; + this.slot = -1; + this._resumeToken = null; + this._myChar = null; + this._mySector = []; + this.seed = 0; + this._roster = new Map(); // slot → { slot, name, bot, char, sector, alive? } + this._seq = 0; + this._srvOffset = 0; // 房间时钟 - 本地时钟(ms) + this._srvPhase = null; // joined.state.phase / snap.phase(教学零惩罚判定用) + + // 战斗渲染状态 + this.mobs = new Map(); // id → { id, mkind, dir, eta0, t0 }(eta0 为 t0 时刻值) + this.wave = 0; + this.dur = 10; + this._myKills = 0; + this._loops = new Map(); // mobId → { want, src, gain, panner, vol, eta } + this._renderTimer = null; + + // 本地冷却预扣(服务端 err 为准;冷却内手势本地先拦) + this._atkReadyAt = 0; + this._skillReadyAt = 0; + this._skillAnnounced = true; // 每轮冷却完成只播一次 skill_ready + this._skillReadyTimer = null; + this._calloutReadyAt = 0; + + // 语音纪律(R1) + this._chainSeq = 0; // 语音链代数:硬播/打断作废旧链 + this._protect = null; // 'announce' | 'settle'(教学段恒视为保护,见 _alertQueued) + this._lastBreachVoiceAt = -1e9; + this._dangerAnnounced = false; + + // R4 定位 one-shot 调度 + this._posActive = []; // [{ until }] + this._posQueue = []; // [{ id, pan, vol, type, droppable, queuedAt }] + this._posTypeAt = {}; // type → 上次播出时刻 + this._posPumpTimer = null; + this._posLog = []; // 断言/转写用:{ id, pan, vol, t } + + // R2 bot 喊话调度 + this._npcBusyUntil = 0; + this._npcPending = null; // { id, pan, onDone, expireAt } + this._npcPumpTimer = null; + this._npcLog = []; // 断言用:{ id, pan, t, dur } + + // 教学 + this._tut = -1; // 0..4(T0..T4),-1 = 非教学 + this._tutMiss = 0; // T1 挥空/错向次数(重教 ≤2 次后服务器自动放过) + this._tutDoneWaiting = false; // 教学完成/跳过,等 ev wave 1 + + // 断线重连 + this._netDown = false; + this._netDownAt = 0; + this._netLostSaid = false; + this._reconnectTimer = null; + this._openGuardTimer = null; + + // 结算 + this._graceUntil = 0; + this._endChain = null; + this._endAwaiting = false; + + // 进度 + this._progress = { best: null, runs: 0, totalKills: 0, tutDone: false }; + } + + // ================================================================ Game + + start() { + this.audio.prewarm([ + 'sfx/atk_whoosh', 'sfx/atk_swish', 'sfx/kill_wolf', 'sfx/kill_bear', + 'sfx/skill_spin', 'sfx/breach_thud', 'sfx/horn_wave', 'sfx/join_chime', + 'sfx/call_whistle', 'sfx/howl_wolf', 'sfx/howl_bear', + 'sfx/loop_wolf', 'sfx/loop_bear', + ]); + this._loadProgress(); + this._pullServerProgress(); // 登录态异步拉回(服务器为准),不阻塞进 TITLE + this._enterTitle(); + } + + stop() { + this._chainSeq++; + this._protect = null; + this.guide.disarm(); + if (this._renderTimer !== null) { clearInterval(this._renderTimer); this._renderTimer = null; } + if (this._skillReadyTimer !== null) { clearTimeout(this._skillReadyTimer); this._skillReadyTimer = null; } + if (this._reconnectTimer !== null) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } + if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } + if (this._posPumpTimer !== null) { clearTimeout(this._posPumpTimer); this._posPumpTimer = null; } + if (this._npcPumpTimer !== null) { clearTimeout(this._npcPumpTimer); this._npcPumpTimer = null; } + this._stopAllLoops(); + this._closeConn(); + this.audio.stopAll(); + } + + onGesture(g) { + this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op) + if (this._goingHome) return; + // H1 结算死窗:吞掉终局前后在途的 ghost 手势 + if (performance.now() < this._graceUntil) return; + if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') return; // 引擎快进接管 + if (this._menuOpen) { this._onMenuGesture(g); return; } + switch (this.phase) { + case 'title': this._onTitleGesture(g); break; + case 'matching': this._onMatchingGesture(g); break; + case 'lobby': this._onLobbyGesture(g); break; + case 'teach': this._onTeachGesture(g); break; + case 'wave': this._onWaveGesture(g); break; + case 'end': this._onEndGesture(g); break; + case 'lost': this._onLostGesture(g); break; + default: break; + } + this._rearmWatchdog(); + } + + // ================================================================ 状态进入 + + _enterTitle() { + this.phase = 'title'; + this.audio.play('bgm/menu'); + this.guide.state('menu'); + } + + _enterMatching() { + this._teardownRound(); + this.phase = 'matching'; + this.audio.play('bgm/menu'); + this.guide.state('menu'); + // A 组触发口径「TITLE 单击后/教学房 T0」:首局教学房 T0 播 intro, + // 二周目起在匹配等待时播;教学房到 T0 再播,不重复 + if (this._progress.tutDone) this._playChain(['intro_1', 'intro_2'], { protect: 'announce' }); + this._connect(); + } + + _enterLobby() { + this.phase = 'lobby'; + this.audio.play('bgm/menu'); + this.guide.state('menu'); + this._playChain(['room_wait'], { protect: 'announce' }); + this._rearmWatchdog(); + } + + _enterTeach() { + this.phase = 'teach'; + this._tut = 0; + this._tutMiss = 0; + this._tutDoneWaiting = false; + this.audio.play('bgm/hunt'); + this.guide.state('battle'); + this._startRenderer(); + // T0:开场 + 规则总述(教学链不可顶,播完武装步骤看门狗) + this._playChain(['intro_1', 'intro_2', 'tut_rule'], { + protect: 'teach', + onDone: () => this._armTutWatchdog(0), + }); + } + + /** + * 进波次(开局/波间/重连恢复)。from: 'lobby' | 'teach' | 'wave' | 'resume'。 + * 开局链:lobby → room_start + wave_band1;teach → wave_band1(tut_done 已衔接); + * 第 2 波起只播 wave_bandN;先号角后播报(§3.4)。 + */ + _enterWave(n, from) { + const wasInWave = this.phase === 'wave'; + this.phase = 'wave'; + this._tut = -1; + this._tutDoneWaiting = false; + this.wave = n; + this.audio.play('bgm/hunt'); + if (!wasInWave) { + this.guide.state('battle'); + this._startRenderer(); + } + vibrate('heartbeat'); // §2.1 震动总表:波次开始 + this._pos('sfx/horn_wave', { pan: 0, vol: 1, type: 'horn' }); + const band = 'wave_band' + Math.min(5, Math.max(1, n)); + if (n === 1 && from === 'lobby') this._playChain(['room_start', band], { protect: 'announce' }); + else this._playChain([band], { protect: 'announce' }); + this._rearmWatchdog(); + } + + _enterEnd(ev) { + this.phase = 'end'; + this._menuOpen = false; // 终局强制收菜单 overlay(E3 不暂停期间菜单可能被开着) + this._graceUntil = performance.now() + END_GRACE_MS; // H1 死窗 + this._endAwaiting = false; + this._srvPhase = 'end'; + this.dur = ev.dur; + const kills = (ev.kills && ev.kills[String(this.slot)]) || 0; + this._myKills = kills; + this._stopAllLoops(); + this._dropAllNpc(); + this.audio.play('bgm/menu', { volume: 0.5 }); + this.guide.state('settle'); + this._saveProgress(ev); // 结算 PUT(含 summary),end_rank 播到时已写入 + const win = !!ev.win; + const chain = win + ? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), killBandId(kills), 'end_rank', 'end_again'] + : ['end_lose', killBandId(kills), 'end_again']; + this._endChain = chain; + this._playChain(chain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } }); + } + + _enterLost() { + this.phase = 'lost'; + this._menuOpen = false; + this._netDown = false; + this._stopAllLoops(); + this._dropAllNpc(); + this.audio.play('bgm/menu'); + this.guide.state('menu'); + } + + /** 再来一局 / 重进匹配:收拾残局 → 重新连接。 */ + _again() { + this._sendLeave(); + this._enterMatching(); + } + + // ================================================================ 手势:各状态 + + _onTitleGesture(g) { + if (g === 'tap') { this._enterMatching(); return; } + if (g === 'doubletap') { this._playChain(['intro_1', 'intro_2']); return; } // 重听开场 + if (g === 'up') { + const best = this._progress.best; + if (!best) { this._invalid(); return; } + this._playChain(['grade_' + String(best.grade).toLowerCase(), killBandId(best.kills)], { queueFirst: true }); + return; + } + if (g === 'down') { this._openMenu(); return; } + this._invalid(); // left/right + } + + _onMatchingGesture(g) { + if (g === 'doubletap') { // 重听匹配提示(开场链) + if (this._progress.tutDone) this._playChain(['intro_1', 'intro_2']); + return; + } + if (g === 'down') { this._openMenu(); return; } // 菜单含"回大厅"=取消匹配 + if (g === 'tap') { this._invalid(); return; } + this._invalid(); + } + + _onLobbyGesture(g) { + if (g === 'tap') { this._sendAct('start'); return; } // 立即开局 + if (g === 'doubletap') { this._playChain(['room_wait']); return; } // 重听 + if (g === 'up') { + this._playChain([this._teamCount() >= 4 ? 'team_4' : 'team_3'], { queueFirst: true }); + return; + } + if (g === 'down') { this._openMenu(); return; } + this._invalid(); // 四向滑 + } + + _onTeachGesture(g) { + if (g === 'down') { // 任意步下滑 = 跳过教学(§5) + this._chainSeq++; + this.audio.stopVoice(); + this._tut = -1; + this._playChain(['tut_skip'], { protect: 'teach', onDone: () => this._finishTeach() }); + return; + } + switch (this._tut) { + case 0: + if (g === 'tap') this._tutGoto(1, ['tut_attack']); + else this._invalid(); + break; + case 1: + if (g in SWIPE_BASE) { + this._attackDir(SWIPE_BASE[g]); // 步骤作答:恒发 act(错向/挥空走重教路径) + } else this._invalid(); + break; + case 2: + if (g === 'tap') this._callout(); + else this._invalid(); + break; + case 3: + if (g === 'doubletap') this._skill(); + else this._invalid(); + break; + case 4: + if (g === 'up') { + // 状态链 4 段 → tut_breach → tut_done → 完成教学(等 ev wave 1) + const ids = this._statusIds(); + this._chainSeq++; + this._playChain([...ids, 'tut_breach', 'tut_done'], { + protect: 'teach', + onDone: () => this._finishTeach(), + }); + } else this._invalid(); + break; + default: + this._invalid(); // 已完成等 wave 1 + break; + } + } + + _onWaveGesture(g) { + if (g === 'tap') { this._callout(); return; } + if (g === 'doubletap') { this._skill(); return; } + if (g in SWIPE_BASE) { + // 语境折中(见文件头裁决):上/下滑弧内有可打怪 → 出击;否则 + // 上滑=状态链、下滑=菜单(三件套保留)。左/右滑恒=出击。 + const arc = ARCS[g]; + const hasTarget = this._arcHasTarget(arc); + if (!hasTarget && g === 'up') { this._statusChain(); return; } + if (!hasTarget && g === 'down') { this._openMenu(); return; } + this._attackDir(SWIPE_BASE[g]); + return; + } + this._invalid(); + } + + _onEndGesture(g) { + if (g === 'tap') { this._again(); return; } // 再来一局(语音在播也直接重开,rhythm 改口先例) + if (g === 'doubletap') { // 重听结算链 + if (this._endChain) this._playChain(this._endChain, { protect: 'settle', onDone: () => { this._endAwaiting = true; } }); + return; + } + if (g === 'up') { // 重听个人最佳段 + const best = this._progress.best; + if (!best) { this._invalid(); return; } + this._playChain(['grade_' + String(best.grade).toLowerCase(), killBandId(best.kills)], { queueFirst: true }); + return; + } + if (g === 'down') { this._openMenu(); return; } + this._invalid(); + } + + _onLostGesture(g) { + if (g === 'tap') { this._enterTitle(); return; } // 回大厅 + this._invalid(); + } + + // ================================================================ 菜单(E3:不暂停) + + _openMenu() { + this._menuOpen = true; + this._menuIdx = 0; + this.guide.state('menu'); + // 打开即播 sys_menu + 首项;另播 settings_hint(设置只读、回大厅修改,00 §1.2) + this._playChain(['sys_menu', MENU_ITEMS[0], 'settings_hint']); + } + + _closeMenu() { + this._menuOpen = false; + this._chainSeq++; // 作废弃菜单链(在播当前句自然播完;警报随时可顶) + // 恢复返回态签名 + const back = this.phase; + if (back === 'wave' || back === 'teach') this.guide.state('battle'); + else if (back === 'end') this.guide.state('settle'); + else this.guide.state('menu'); + this._rearmWatchdog(); + } + + _onMenuGesture(g) { + if (g === 'tap') { // 单击循环 + this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length; + this._playChain([MENU_ITEMS[this._menuIdx]]); + return; + } + if (g === 'doubletap') { // 双击确认 + const item = MENU_ITEMS[this._menuIdx]; + if (item === 'sys_menu_resume') this._closeMenu(); + else if (item === 'sys_menu_help') this._playChain(['help_main']); + else this._goHome(); // sys_menu_home = 退出房间回大厅 + return; + } + if (g === 'up') { this._playChain(['help_main']); return; } // 帮助 + if (g === 'down') { this._closeMenu(); return; } // 关闭 + this._invalid(); // 左右滑 + } + + // ================================================================ 战斗动作(客户端意图) + + /** 出击:冷却内仅 error 震(无语音、不发 act,§2.1)。 */ + _attackDir(dir) { + const now = performance.now(); + if (now < this._atkReadyAt) { vibrate('error'); return; } + this._atkReadyAt = now + ATTACK_CD_MS; + this._sendAct('attack', dir); + this._pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk' }); + } + + /** 呼喊支援:节流 5s/人;本机播自己座位角色 help_call(中央;座 3=哨音带 pan)。 */ + _callout() { + const now = performance.now(); + if (now < this._calloutReadyAt) { vibrate('error'); return; } + this._calloutReadyAt = now + CALLOUT_THROTTLE_MS; + this._sendAct('callout'); + if (this._myChar) this._npcFire(`sfx/npc_${this._myChar}_help_call`, 0, null); + else this._pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this._mySector)), vol: 1, type: 'whistle' }); + } + + /** 双击:旋风斩就绪 → 放出;冷却中 → 重听威胁播报(宪法双击语义,E2)。 */ + _skill() { + const now = performance.now(); + if (now < this._skillReadyAt) { this._threatReport(); return; } + this._skillReadyAt = now + SKILL_CD_MS; + this._skillAnnounced = false; + this._sendAct('skill'); + this._armSkillReady(SKILL_CD_MS); + } + + _armSkillReady(ms) { + if (this._skillReadyTimer !== null) clearTimeout(this._skillReadyTimer); + this._skillReadyTimer = setTimeout(() => { + this._skillReadyTimer = null; + if (this._skillAnnounced) return; + this._skillAnnounced = true; + if (this.phase !== 'wave' && this.phase !== 'teach') return; + this.audio.play('voice/skill_ready', { queue: true }); // 绝不顶句(§2.1) + }, Math.max(0, ms)); + } + + /** 威胁重听(E 组):只报内圈数量档,方位永远由声像承载(R6)。 */ + _threatReport() { + const inner = [...this.mobs.values()].filter((m) => this._mobEta(m) <= INNER_ETA_S && this._mobEta(m) > 0).length; + const id = inner === 0 ? 'threat_none' : inner <= 2 ? 'threat_few' : 'threat_many'; + this._playChain([id], { queueFirst: true }); + } + + /** 状态链(D 组):耐久档 → 波次档 → 在场猎人数 → 自己击杀档(queue:true 链播)。 */ + _statusIds() { + return [ + hpBandId(this.dur), + stWaveId(this.wave || 1), + this._teamCount() >= 4 ? 'team_4' : 'team_3', + killBandId(this._myKills), + ]; + } + + _statusChain(onDone = null) { + this._playChain(this._statusIds(), { queueFirst: true, onDone }); + } + + _arcHasTarget(arc) { + for (const m of this.mobs.values()) { + if (!arc.includes(m.dir)) continue; + const eta = this._mobEta(m); + if (eta <= INNER_ETA_S && eta > 0) return true; + } + return false; + } + + // ================================================================ 网络:连接与消息 + + _wsEndpoint() { + if (this._wsUrl) return this._wsUrl; + try { + const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + return proto + window.location.host + WS_PATH; + } catch (_) { + return 'ws://localhost' + WS_PATH; + } + } + + _connect() { + this._intentionalClose = false; + const handlers = { + onopen: () => this._onOpen(), + onmessage: (msg) => this._onMessage(msg), + onclose: () => this._onClose(), + }; + let conn = null; + try { + conn = this._transport(this._wsEndpoint(), handlers); + } catch (_) { + // 传输构造即失败(如域名非法):等同连接失败 + this._onClose(); + return; + } + this._conn = conn; + // 建立超时看守:对端不响应(服务器不可达)时按失败走重连/放弃流程, + // 否则无 close 事件可达会永远卡在"重连中" + if (this._openGuardTimer !== null) clearTimeout(this._openGuardTimer); + this._openGuardTimer = setTimeout(() => { + this._openGuardTimer = null; + if (!this._connAlive && this._conn === conn) { + this._conn = null; + this._onClose(); + } + }, RECONNECT_STEP_MS); + } + + _onOpen() { + this._connAlive = true; + if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } + const join = { + t: 'join', + ver: PROTO_VER, + room: this._resumeToken && this._netDown ? this.room || 'quick' : 'quick', + name: this._playerName(), + }; + if (!this._progress.tutDone) join.teach = true; // 首局=教学房(§5) + if (this._resumeToken && this._netDown) join.resume = this._resumeToken; // §6.6 回槽 + this._send(join); + } + + _onMessage(msg) { + if (!msg || typeof msg.t !== 'string') return; + if (typeof msg.ts === 'number') this._srvOffset = msg.ts - performance.now(); + switch (msg.t) { + case 'joined': this._onJoined(msg); break; + case 'ev': this._onEv(msg); break; + case 'snap': this._onSnap(msg); break; + case 'err': this._onErr(msg); break; + default: break; + } + } + + _onClose() { + this._connAlive = false; + this._conn = null; + if (this._openGuardTimer !== null) { clearTimeout(this._openGuardTimer); this._openGuardTimer = null; } + if (this._intentionalClose || this._goingHome) return; + const inRoom = this._joined && (this.phase === 'lobby' || this.phase === 'teach' || this.phase === 'wave'); + if (this.phase === 'matching' || inRoom) { + if (!this._netDown) { + // 断线:本地静默重连 ≤30s(net_lost 只播一次);怪物按最后已知 eta 继续衰减渲染 + this._netDown = true; + this._netDownAt = performance.now(); + if (!this._netLostSaid) { + this._netLostSaid = true; + this._alert('net_lost'); + } + } + this._scheduleReconnect(); + } + } + + _scheduleReconnect() { + if (this._reconnectTimer !== null) return; + if (performance.now() - this._netDownAt > RECONNECT_LIMIT_MS) { this._netGone(); return; } + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + if (!this._netDown) return; + if (performance.now() - this._netDownAt > RECONNECT_LIMIT_MS) { this._netGone(); return; } + this._connect(); + }, RECONNECT_STEP_MS); + } + + /** 重连失败/房已灭:明示失败回大厅出口(不静默死屏)。 */ + _netGone() { + this._netDown = false; + this._joined = false; + this._resumeToken = null; + this._playChain(['net_gone'], { protect: 'announce' }); + this._enterLost(); + } + + _onJoined(j) { + const resumed = this._netDown && this._joined; + this._netDown = false; + this._netLostSaid = false; + if (this._reconnectTimer !== null) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } + this._joined = true; + this._menuOpen = false; + this.room = j.room; + this.slot = j.slot; + this._resumeToken = j.resume; + this._myChar = j.char || null; + this._mySector = j.sector || []; + this.seed = j.seed || 0; + this._seq = 0; + this._applyRoster(j.roster || [], true); + const st = j.state || {}; + this._srvPhase = st.phase || 'lobby'; + if (!resumed) this._resetRoundState(); + this.wave = st.wave ?? this.wave; + this.dur = st.dur ?? this.dur; + if (this._srvPhase === 'lobby') this._enterLobby(); + else if (this._srvPhase === 'teach') this._enterTeach(); + else if (this._srvPhase === 'wave') { + if (resumed) { + // 回槽续局:net_back + 紧随 snap 全量无缝续渲染(不重播波次播报) + this.phase = 'wave'; + this.audio.play('bgm/hunt'); + this.guide.state('battle'); + this._startRenderer(); + this._playChain(['net_back'], { protect: 'announce' }); + this._rearmWatchdog(); + } else { + this._enterWave(this.wave || 1, 'lobby'); + } + } else { // end:迟到回槽,直接落结算等待位 + this.phase = 'end'; + this._endAwaiting = true; + this._playChain(['end_again']); + } + } + + _onErr(msg) { + // cooldown/skill_cd 为正常违规拒绝——客户端本地已先拦,不惊扰玩家(§6.3) + if (msg.code === 'room_gone' || msg.code === 'room_full') { this._netGone(); return; } + if (msg.code === 'bad_msg' || msg.code === 'rate') { + console.error('[hunt] 服务器拒绝:', msg.code, msg.msg || ''); + } + } + + _send(obj) { + if (this._conn) this._conn.send(JSON.stringify(obj)); + } + + _sendAct(kind, dir) { + this._seq++; + const act = { t: 'act', kind, seq: this._seq, at: Date.now() }; + if (dir) act.dir = dir; + this._send(act); + } + + _sendLeave() { + if (this._conn && this._joined) this._send({ t: 'leave' }); + this._closeConn(); + } + + _closeConn() { + this._intentionalClose = true; + const c = this._conn; + this._conn = null; + this._connAlive = false; + this._joined = false; + this._resumeToken = null; + if (c) c.close(); + } + + _playerName() { + try { + const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); + if (auth && auth.username) return String(auth.username).slice(0, 24); + } catch (_) { /* 静默 */ } + return '猎人'; + } + + // ================================================================ 事件流(§6.3) + + _onEv(msg) { + switch (msg.kind) { + case 'spawn': this._onSpawn(msg); break; + case 'kill': this._onKill(msg); break; + case 'miss': this._onMiss(msg); break; + case 'skill': this._onSkillEv(msg); break; + case 'breach': this._onBreach(msg); break; + case 'wave': this._srvPhase = 'wave'; this._enterWave(msg.wave, this.phase); break; + case 'waveclear': this._playChain(['wave_clear'], { queueFirst: true }); break; + case 'callout': this._onCalloutEv(msg); break; + case 'botline': this._onBotline(msg); break; + case 'roster': this._applyRoster(msg.roster || [], false); break; + case 'end': this._enterEnd(msg); break; + default: break; + } + } + + _onSpawn(ev) { + this.mobs.set(ev.id, { id: ev.id, mkind: ev.mkind, dir: ev.dir, eta0: ev.eta, t0: ev.ts ?? this._nowSrv() }); + // 生成呼啸一声(外圈可听内容;R4 可丢弃档) + const az = azOf(ev.dir); + this._pos('sfx/howl_' + ev.mkind, { + pan: panOfAz(az), + vol: 0.4 * (isBackAz(az) ? 0.6 : 1), + type: 'howl', + droppable: true, + }); + this._refreshLoops(); + } + + _onKill(ev) { + this.mobs.delete(ev.id); + this._stopMobLoop(ev.id); + this._pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill' }); + if (ev.slot === this.slot) { + this._myKills++; + vibrate('pickup'); // 命中 + } + // 教学 T1:首杀 → tut_kill_ok → T2 + if (this.phase === 'teach' && this._tut === 1) this._tutGoto(2, ['tut_kill_ok', 'tut_callout']); + } + + _onMiss(ev) { + if (ev.slot !== this.slot) return; + this._pos('sfx/atk_swish', { pan: panOfDir(ev.dir), vol: 1, type: 'atk' }); // 挥空真话:没打到 + vibrate('error'); + // 教学 T1:错向/挥空 → gd_idle 重教(≤2 次后服务器自动放过该狼) + if (this.phase === 'teach' && this._tut === 1) { + this._tutMiss++; + this._playChain(['gd_idle'], { protect: 'teach' }); + } + } + + _onSkillEv(ev) { + for (const id of ev.cleared || []) { + this.mobs.delete(id); + this._stopMobLoop(id); + } + this._pos('sfx/skill_spin', { pan: 0, vol: 1, type: 'skill' }); + if (ev.slot === this.slot) vibrate('success'); + // 教学 T3:放出 → tut_skill_cd → T4 + if (this.phase === 'teach' && this._tut === 3) this._tutGoto(4, ['tut_skill_cd', 'tut_status']); + } + + _onBreach(ev) { + this.mobs.delete(ev.id); + this._stopMobLoop(ev.id); + this.dur = ev.dur; + if (this._srvPhase === 'teach') return; // 学期零惩罚:教学狼突入不扣耐久、不播警报(§5) + this._pos('sfx/breach_thud', { pan: 0, vol: 1, type: 'breach' }); + vibrate('hit'); + // F 组警报:可顶状态/播报类;节流 ≥3.5s(breach/breach_heavy 共窗) + const now = performance.now(); + if (now - this._lastBreachVoiceAt >= BREACH_VOICE_GAP_MS) { + this._lastBreachVoiceAt = now; + this._alert(ev.mkind === 'bear' ? 'breach_heavy' : 'breach'); + } + // 耐久首次 ≤2:danger_low + danger 震 + danger 签名(每局 1 次)。 + // danger_low 走排队(不顶刚开播的 breach 警报——警报零丢失,§8.4 口径) + if (this.dur <= 2 && !this._dangerAnnounced) { + this._dangerAnnounced = true; + this.audio.play('voice/danger_low', { queue: true }); + vibrate('danger'); + this.guide.state('danger'); + } + } + + /** 求援呼喊:他人呼喊 → 该座角色 help_call(防区中心 pan)+ warning 震;自己的本地已播。 */ + _onCalloutEv(ev) { + if (ev.slot === this.slot) return; + const char = this._charOf(ev.slot); + const pan = panOfAz(sectorCenterAz(this._sectorOf(ev.slot))); + if (char) this._npcSchedule(`sfx/npc_${char}_help_call`, pan, true, null); + else this._pos('sfx/call_whistle', { pan, vol: 1, type: 'whistle' }); // 座 3 无角色声(§15 裁决 6) + vibrate('warning'); + } + + /** bot 喊话(R2 调度):按 char+line 播 sfx/npc__。 */ + _onBotline(ev) { + const char = this._charOf(ev.slot); + if (!char) return; + const priority = ev.line === 'help_call' || ev.line === 'help_ack'; + const pan = panOfAz(sectorCenterAz(this._sectorOf(ev.slot))); + // 教学 T2:呼喊-响应闭环——bot help_ack 播完后点明 + const onDone = (this.phase === 'teach' && this._tut === 2 && ev.line === 'help_ack') + ? () => this._tutGoto(3, ['tut_callout_ok', 'tut_skill']) + : null; + this._npcSchedule(`sfx/npc_${char}_${ev.line}`, pan, priority, onDone); + } + + // ================================================================ 快照兜底(§6.7) + + _onSnap(s) { + if (typeof s.ts === 'number') this._srvOffset = s.ts - performance.now(); + if (s.phase) this._srvPhase = s.phase; + if (typeof s.dur === 'number') this.dur = s.dur; + if (typeof s.wave === 'number') this.wave = s.wave; + // 怪表对账:本地多出的删、缺的补、eta 偏差 >0.5s 校正(矛盾以 snap 为准) + const seen = new Set(); + for (const sm of s.mobs || []) { + seen.add(sm.id); + const cur = this.mobs.get(sm.id); + if (!cur) { + this.mobs.set(sm.id, { id: sm.id, mkind: sm.mkind, dir: sm.dir, eta0: sm.eta, t0: s.ts }); + } else { + if (Math.abs(this._mobEta(cur) - sm.eta) > 0.5) { cur.eta0 = sm.eta; cur.t0 = s.ts; } + cur.mkind = sm.mkind; + cur.dir = sm.dir; + } + } + for (const id of [...this.mobs.keys()]) { + if (!seen.has(id)) { + this.mobs.delete(id); + this._stopMobLoop(id); + } + } + // 冷却恢复(重连 UI/判定用) + const me = (s.slots || []).find((x) => x.slot === this.slot); + if (me) { + const now = performance.now(); + if (typeof me.cd === 'number') this._atkReadyAt = now + me.cd * 1000; + if (typeof me.skillCd === 'number') { + this._skillReadyAt = now + me.skillCd * 1000; + if (me.skillCd > 0 && !this._skillAnnounced) this._armSkillReady(me.skillCd * 1000); + } + } + this._refreshLoops(); + } + + // ================================================================ 听觉视野渲染(R3 + §8.2/§8.3) + + _nowSrv() { + return performance.now() + this._srvOffset; + } + + _mobEta(m) { + return m.eta0 - (this._nowSrv() - m.t0) / 1000; + } + + _startRenderer() { + if (this._renderTimer !== null) return; + this._renderTimer = setInterval(() => this._refreshLoops(), LOOP_REFRESH_MS); + } + + /** + * 渲染集重估(每 500ms + 事件驱动):eta>6s 静默;≤6s 按 eta 升序取前 3 只 + * 起 loop(R3);音量按档(6-4s:0.30 / 4-2.5s:0.55 / <2.5s:0.80);pan=sin(az), + * 后半平面 ×0.6(§8.3)。 + */ + _refreshLoops() { + if (this.phase !== 'wave' && this.phase !== 'teach') { this._stopAllLoops(); return; } + const cand = [...this.mobs.values()] + .map((m) => ({ m, eta: this._mobEta(m) })) + .filter((x) => x.eta <= MID_ETA_S && x.eta > 0) + .sort((a, b) => a.eta - b.eta) + .slice(0, LOOP_MAX); + const keep = new Set(cand.map((x) => x.m.id)); + for (const id of [...this._loops.keys()]) if (!keep.has(id)) this._stopMobLoop(id); + for (const { m, eta } of cand) this._ensureMobLoop(m, eta); + } + + /** 怪物移动 loop(游戏层句柄 + 自建 panner,abyss _startLoop 先例,不占引擎面)。 */ + _ensureMobLoop(m, eta) { + const az = azOf(m.dir); + const pan = panOfAz(az); + const bandVol = eta > 4 ? 0.3 : eta > 2.5 ? 0.55 : 0.8; + const vol = bandVol * (isBackAz(az) ? 0.6 : 1); + const h = this._loops.get(m.id); + if (h) { // 同怪在位:原地更新音量/声像,不重触发 + h.eta = eta; + h.vol = vol; + if (h.gain) h.gain.gain.value = this.audio.sfxVolume * vol; + if (h.panner) h.panner.pan.value = pan; + return; + } + const handle = { want: true, src: null, gain: null, panner: null, vol, eta, mkind: m.mkind }; + this._loops.set(m.id, handle); + this.audio._load('sfx/loop_' + m.mkind).then((buf) => { + if (!buf || !handle.want || this._loops.get(m.id) !== handle) return; + const ctx = this.audio.ctx; + if (!ctx) return; + const src = ctx.createBufferSource(); + src.buffer = buf; + src.loop = true; + const gain = ctx.createGain(); + gain.gain.value = this.audio.sfxVolume * vol; + src.connect(gain); + if (typeof ctx.createStereoPanner === 'function') { + const panner = ctx.createStereoPanner(); + panner.pan.value = pan; + gain.connect(panner); + panner.connect(this.audio._sfxBus); + handle.panner = panner; + } else { + gain.connect(this.audio._sfxBus); // 老 Safari 静默降级 + } + src.start(); + handle.src = src; + handle.gain = gain; + }).catch(() => { /* 资产缺失降级:威胁信息由播报/呼啸承载 */ }); + } + + _stopMobLoop(id) { + const h = this._loops.get(id); + if (!h) return; + h.want = false; + if (h.src) { + try { h.src.stop(); } catch (_) { /* 已停止 */ } + } + this._loops.delete(id); + } + + _stopAllLoops() { + for (const id of [...this._loops.keys()]) this._stopMobLoop(id); + } + + // ================================================================ R4:定位 one-shot 调度 + + /** + * 定位 one-shot(呼啸/击杀/突入/技能/号角等):同刻 ≤2 并发;同类间隔 + * ≥0.15s;droppable(呼啸)队列深 ≤2、排队 ≤1.5s,超出丢弃(怪近后 loop + * 已接管信息,呼啸可弃);其余按 FIFO 等位播出。 + */ + _pos(id, { pan = 0, vol = 1, type = 'misc', droppable = false } = {}) { + const now = performance.now(); + this._posActive = this._posActive.filter((a) => a.until > now); + const lastType = this._posTypeAt[type] ?? -1e9; + if (this._posActive.length < ONESHOT_MAX && now - lastType >= ONESHOT_SPACING_MS) { + this._posFire(id, pan, vol, type, now); + return; + } + if (droppable) { + if (this._posQueue.filter((q) => q.type === 'howl').length >= HOWL_QUEUE_MAX) return; // 超出丢弃 + } + this._posQueue.push({ id, pan, vol, type, droppable, queuedAt: now }); + this._armPosPump(); + } + + _posFire(id, pan, vol, type, now) { + this.audio.play(id, { volume: vol, pan }); + const durMs = (SFX_DUR[id.slice(id.lastIndexOf('/') + 1)] ?? 0.5) * 1000; + this._posActive.push({ until: now + durMs }); + this._posTypeAt[type] = now; + this._posLog.push({ id, pan, vol, t: now }); + } + + _armPosPump() { + if (this._posPumpTimer !== null) return; + this._posPumpTimer = setTimeout(() => { + this._posPumpTimer = null; + const now = performance.now(); + this._posActive = this._posActive.filter((a) => a.until > now); + const rest = []; + for (const q of this._posQueue) { + if (q.droppable && now - q.queuedAt > HOWL_QUEUE_TTL_MS) continue; // 过期呼啸丢弃 + const lastType = this._posTypeAt[q.type] ?? -1e9; + if (this._posActive.length < ONESHOT_MAX && now - lastType >= ONESHOT_SPACING_MS) { + this._posFire(q.id, q.pan, q.vol, q.type, now); + } else { + rest.push(q); + } + } + this._posQueue = rest; + if (this._posQueue.length) this._armPosPump(); + }, 100); + } + + // ================================================================ R2:bot 喊话调度 + + /** + * bot 喊话走 sfx 层(带 pan,与旁白并发):同刻在播 ≤1 条;待播队列深 ≤1 + * (新到顶掉未播的过期句);旁白在播时延迟投递 ≤2s,超时丢弃;呼救语义 + * (help_call/help_ack)优先级最高(待播宽限更长,不被氛围句顶掉)。 + */ + _npcSchedule(id, pan, priority, onDone) { + const now = performance.now(); + if (now >= this._npcBusyUntil && !this.audio.isVoicePlaying()) { + this._npcFire(id, pan, onDone); + return; + } + this._npcPending = { + id, pan, onDone, + expireAt: now + (priority ? NPC_PRIO_TTL_MS : NPC_VOICE_DELAY_MS), + }; + this._armNpcPump(); + } + + _npcFire(id, pan, onDone) { + const now = performance.now(); + this._npcPending = null; + this.audio.play(id, { volume: NPC_VOL, pan }); + // 在播时长取解码后 buffer(精确互不叠),加载失败退兜底值 + this._npcBusyUntil = now + NPC_LINE_FALLBACK_DUR_S * 1000; + const rec = { id, pan, t: now, dur: NPC_LINE_FALLBACK_DUR_S }; + this._npcLog.push(rec); + this.audio._load(id).then((buf) => { + if (!buf) return; + rec.dur = buf.duration; + this._npcBusyUntil = Math.max(this._npcBusyUntil, now + buf.duration * 1000); + }).catch(() => { /* 静默 */ }); + if (onDone) setTimeout(onDone, NPC_LINE_FALLBACK_DUR_S * 1000); + } + + _armNpcPump() { + if (this._npcPumpTimer !== null) return; + this._npcPumpTimer = setTimeout(() => { + this._npcPumpTimer = null; + const p = this._npcPending; + if (!p) return; + const now = performance.now(); + if (now > p.expireAt) { this._npcPending = null; return; } // 超时丢弃 + if (now >= this._npcBusyUntil && !this.audio.isVoicePlaying()) { + this._npcFire(p.id, p.pan, p.onDone); + return; + } + this._armNpcPump(); + }, 200); + } + + _dropAllNpc() { + this._npcPending = null; + this._npcBusyUntil = 0; + } + + // ================================================================ 语音纪律(R1) + + /** + * 语音链:整句链播(onComplete 串行);protect 链不可被警报顶(教学/结算/ + * 波次播报);queueFirst 时首句排队等当前对白(状态链/威胁播报礼仪); + * 单句看门狗兜底:资产缺失/被静默跳过时链照样推进,绝不卡死。 + */ + _playChain(ids, { protect = null, onDone = null, queueFirst = false } = {}) { + if (!ids.length) { if (onDone) onDone(); return; } + const token = ++this._chainSeq; + if (protect) this._protect = protect; + const step = (i) => { + if (token !== this._chainSeq) return; // 链被作废(硬播/打断/关菜单) + if (i >= ids.length) { + if (protect && this._protect === protect) this._protect = null; + if (onDone) onDone(); + return; + } + let done = false; + let guardId = null; + const finish = () => { + if (done) return; + done = true; + if (guardId !== null) clearTimeout(guardId); + step(i + 1); + }; + const guard = () => { + if (done || token !== this._chainSeq) return; + if (this.audio.isVoicePlaying()) { guardId = setTimeout(guard, 2000); return; } + finish(); // 资产缺失等静默跳过:看门狗推进 + }; + guardId = setTimeout(guard, VOICE_GUARD_MS); + this.audio.play('voice/' + ids[i], { + queue: queueFirst && i === 0, + onComplete: finish, + }); + }; + step(0); + } + + /** + * 警报类播报(breach/breach_heavy/danger_low/net_*):教学/结算/波次播报链 + * 在播时排队(不可顶,R1);否则硬播顶掉状态/播报类(顶句作废旧链 = + * 引擎"打断即作废语音链"语义)。 + */ + _alert(id) { + if (this._protect || this.phase === 'teach') this.audio.play('voice/' + id, { queue: true }); + else this._playChain([id]); + } + + /** 无效手势:error 震(全档)+ full 档 gd_invalid(onceKey 同局 1 次)。 */ + _invalid() { + vibrate('error'); + this.guide.error({ voice: 'gd_invalid', onceKey: 'hunt_invalid', skipHaptic: true }); + } + + /** 看门狗(§11):WAVE/LOBBY 无操作 [10s,25s] → gd_idle / null;TEACH 逐步 [8s] 纯 nudge。 */ + _rearmWatchdog() { + if (this._menuOpen) return; + if (this.phase === 'wave' || this.phase === 'lobby') { + this.guide.watchdog({ + at: [10000, 25000], + say: ['gd_idle', null], + cond: () => (this.phase === 'wave' || this.phase === 'lobby') && !this._menuOpen, + }); + } else if (this.phase === 'teach' && this._tut >= 0) { + this._armTutWatchdog(this._tut); + } + } + + /** 教学逐步看门狗:8s nudge 纯震(教学句不重播);卡 30s 由服务器自动放过推进。 */ + _armTutWatchdog(step) { + this.guide.watchdog({ + at: [8000], + say: [null], + onEscalate: () => vibrate('confirm'), + cond: () => this.phase === 'teach' && this._tut === step && !this._tutDoneWaiting, + }); + } + + /** 教学跳步:作废旧链 → 播新教学句(保护链)→ 武装该步看门狗。 */ + _tutGoto(step, ids) { + this._chainSeq++; + this.audio.stopVoice(); + this._tut = step; + if (ids && ids.length) { + this._playChain(ids, { protect: 'teach', onDone: () => this._armTutWatchdog(step) }); + } else { + this._armTutWatchdog(step); + } + } + + /** 教学完成/跳过:tutDone 写入进度,同场等 ev wave 1 接续第 1 波(§5)。 */ + _finishTeach() { + this._tut = -1; + this._tutDoneWaiting = true; + this._progress.tutDone = true; + this._writeProgress(false); // 立即落盘(不进结算也生效) + } + + // ================================================================ 花名册 + + _applyRoster(roster, initial) { + const old = this._roster; + this._roster = new Map(roster.map((r) => [r.slot, r])); + if (initial || !old.size) return; + for (const r of roster) { + const o = old.get(r.slot); + if (!o && this.phase === 'lobby') { + this._pos('sfx/join_chime', { pan: 0, vol: 1, type: 'chime' }); // 队友加入 + } else if (o && !o.bot && r.bot && r.slot !== this.slot) { + this._alert('net_to_bot'); // 他人槽位转 bot(§6.6) + } + } + } + + _teamCount() { + return this._roster.size || 3; + } + + _charOf(slot) { + const r = this._roster.get(slot); + return r ? r.char || null : null; + } + + _sectorOf(slot) { + const r = this._roster.get(slot); + return r && r.sector ? r.sector : []; + } + + // ================================================================ 进度 / 排行榜(§12) + + _loadProgress() { + try { + const raw = localStorage.getItem(SAVE_KEY); + if (raw) this._progress = this._normalizeProgress(JSON.parse(raw)); + } catch (_) { /* 损坏回退默认 */ } + } + + _normalizeProgress(d) { + return { + best: d && d.best && typeof d.best === 'object' ? d.best : null, + runs: (d && d.runs) | 0, + totalKills: (d && d.totalKills) | 0, + tutDone: !!(d && d.tutDone), + }; + } + + /** 登录态拉回服务器进度(服务器为准);失败静默用本地。 */ + _pullServerProgress() { + let auth = null; + try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ } + if (!auth || !auth.token) return; + fetch('/api/progress?game=hunt', { headers: { Authorization: `Bearer ${auth.token}` } }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (j && j.data) this._progress = this._normalizeProgress(j.data); + }) + .catch(() => { /* 离线静默 */ }); + } + + /** + * 终局写入(每局一次,胜利/失守都记 runs/totalKills;best 只被胜利局按 + * 评级 > 营地余耐久 > 击杀 的序更新,失守不入榜 §15 裁决 9)。 + * summary 生成规则(§12,大厅卡片直接显示): + * 有 best:`最佳评级{grade} · 击杀{kills} · 营地余{durLeft} · 共{runs}局` + * 无 best:`共{runs}局 · 还没守到天亮过` + */ + _saveProgress(ev) { + const p = this._progress; + p.runs += 1; + p.totalKills += this._myKills; + if (ev.win && ev.grade) { + const cand = { grade: ev.grade, kills: this._myKills, durLeft: ev.dur, at: new Date().toISOString() }; + if (this._betterThan(cand, p.best)) p.best = cand; + } + this._writeProgress(true); + } + + _betterThan(a, b) { + if (!b) return true; + const ga = GRADE_RANK[a.grade] || 0; + const gb = GRADE_RANK[b.grade] || 0; + if (ga !== gb) return ga > gb; + if (a.durLeft !== b.durLeft) return a.durLeft > b.durLeft; + return a.kills > b.kills; + } + + _summaryText() { + const p = this._progress; + if (!p.best) return `共${p.runs}局 · 还没守到天亮过`; + return `最佳评级${p.best.grade} · 击杀${p.best.kills} · 营地余${p.best.durLeft} · 共${p.runs}局`; + } + + /** 写进度:localStorage 必写;登录态 PUT /api/progress(必带 summary,00 §4.3)。 */ + _writeProgress(withPut) { + const p = this._progress; + const data = { + schema: 1, + game: 'hunt', + updatedAt: new Date().toISOString(), + summary: this._summaryText(), + best: p.best, + runs: p.runs, + totalKills: p.totalKills, + tutDone: p.tutDone, + }; + try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ } + if (!withPut) return; + try { + const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); + if (!auth || !auth.token) return; + fetch('/api/progress', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + auth.token, + }, + body: JSON.stringify({ game: 'hunt', data }), + }).catch(() => { /* 离线静默 */ }); + } catch (_) { /* 静默 */ } + } + + // ================================================================ 残局收拾 / 回大厅 + + /** 每局状态复位(新 joined 受理时;重连回槽不复位)。 */ + _resetRoundState() { + this.mobs.clear(); + this._stopAllLoops(); + this._myKills = 0; + this.wave = 0; + this.dur = 10; + this._atkReadyAt = 0; + this._skillReadyAt = 0; + this._skillAnnounced = true; + if (this._skillReadyTimer !== null) { clearTimeout(this._skillReadyTimer); this._skillReadyTimer = null; } + this._calloutReadyAt = 0; + this._dangerAnnounced = false; + this._lastBreachVoiceAt = -1e9; + this._tut = -1; + this._tutMiss = 0; + this._tutDoneWaiting = false; + this._endChain = null; + this._endAwaiting = false; + this._posQueue.length = 0; + this._posActive = []; + this._dropAllNpc(); + } + + _teardownRound() { + this._chainSeq++; + this._protect = null; + this.audio.stopVoice(); + this._closeConn(); + this._resetRoundState(); + } + + _goHome() { + if (this._goingHome) return; + this._goingHome = true; + if (this._conn && this._joined) this._send({ t: 'leave' }); // 主动退出无 30s 宽限(§6.2) + this.stop(); + this._navigatedHome = '../'; + try { window.location.href = '../'; } catch (_) { /* mock 环境无 location */ } + } +} diff --git a/web/hunt/gen/gen_cover.py b/web/hunt/gen/gen_cover.py new file mode 100644 index 00000000..eb64ae0a --- /dev/null +++ b/web/hunt/gen/gen_cover.py @@ -0,0 +1,116 @@ +"""围猎 cover.png 生成:1080x1920,夜幕山林底 + 篝火辉光 + 八方位刻度 + 游戏名 + 玩法提示。""" +import math +import random + +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops + +W, H = 1080, 1920 +FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" +OUT = "/home/xsl/AutoVideo/SoundGame/web/hunt/cover.png" + +random.seed(23) + +img = Image.new("RGB", (W, H)) +px = img.load() +# 夜幕竖向渐变:上 #060913 -> 下 #1a1206(底部篝火映暖) +top, bottom = (6, 9, 19), (26, 18, 6) +for y in range(H): + t = y / (H - 1) + for x in range(W): + px[x, y] = tuple(int(a + (b - a) * t) for a, b in zip(top, bottom)) + +d = ImageDraw.Draw(img) +# 星点 +for _ in range(260): + x, y = random.randint(0, W - 1), random.randint(0, int(H * 0.55)) + c = random.randint(100, 220) + d.point((x, y), fill=(c, c, min(255, c + 15))) + +# 月亮(右上,冷光) +moon = Image.new("RGB", (W, H), (0, 0, 0)) +md = ImageDraw.Draw(moon) +md.ellipse([W * 0.66, H * 0.07, W * 0.86, H * 0.07 + W * 0.20], fill=(200, 214, 235)) +moon = moon.filter(ImageFilter.GaussianBlur(2)) +img = ImageChops.add(img, ImageChops.multiply(moon, Image.new("RGB", (W, H), (1, 1, 1)).point(lambda v: v))) +# 直接画月盘 + 光晕 +d = ImageDraw.Draw(img) +d.ellipse([W * 0.68, H * 0.075, W * 0.84, H * 0.075 + W * 0.16], fill=(214, 224, 240)) +mglow = Image.new("RGB", (W, H), (0, 0, 0)) +mg = ImageDraw.Draw(mglow) +mg.ellipse([W * 0.63, H * 0.05, W * 0.89, H * 0.05 + W * 0.26], fill=(40, 48, 66)) +mglow = mglow.filter(ImageFilter.GaussianBlur(40)) +img = ImageChops.add(img, mglow) +d = ImageDraw.Draw(img) +d.ellipse([W * 0.68, H * 0.075, W * 0.84, H * 0.075 + W * 0.16], fill=(214, 224, 240)) + +# 远山与林木剪影(两层) +d.polygon([(0, H * 0.52), (W * 0.22, H * 0.40), (W * 0.45, H * 0.50), + (W * 0.70, H * 0.38), (W, H * 0.50), (W, H), (0, H)], fill=(8, 10, 16)) +for i in range(26): + x = random.randint(0, W) + base = H * 0.52 + random.randint(-8, 8) + h = random.randint(60, 150) + w = random.randint(18, 40) + d.polygon([(x, base - h), (x - w, base), (x + w, base)], fill=(5, 7, 11)) + +# 八方位刻度环(猎场罗盘,围绕篝火):刻线 + 四正向加亮 +cx, cy, r = W / 2, H * 0.76, W * 0.30 +for k in range(8): + az = math.radians(k * 45 - 90) # N 在上 + x0, y0 = cx + (r - 26) * math.cos(az), cy + (r - 26) * math.sin(az) + x1, y1 = cx + r * math.cos(az), cy + r * math.sin(az) + main = k % 2 == 0 + d.line([x0, y0, x1, y1], fill=(232, 168, 92) if main else (120, 96, 70), width=10 if main else 6) +# 外环暗弧 +d.ellipse([cx - r - 14, cy - r - 14, cx + r + 14, cy + r + 14], outline=(66, 52, 38), width=4) + +# 篝火:地面暖光晕 + 火堆 +glow = Image.new("RGB", (W, H), (0, 0, 0)) +gd = ImageDraw.Draw(glow) +gd.ellipse([cx - 300, cy - 130, cx + 300, cy + 190], fill=(96, 46, 10)) +gd.ellipse([cx - 170, cy - 70, cx + 170, cy + 110], fill=(150, 70, 14)) +glow = glow.filter(ImageFilter.GaussianBlur(60)) +img = ImageChops.add(img, glow) +d = ImageDraw.Draw(img) +# 柴堆 +d.line([cx - 90, cy + 66, cx + 90, cy + 30], fill=(26, 16, 8), width=18) +d.line([cx - 90, cy + 30, cx + 90, cy + 66], fill=(30, 18, 9), width=18) +# 火焰(三层锥形) +flame = Image.new("RGB", (W, H), (0, 0, 0)) +fd = ImageDraw.Draw(flame) +fd.polygon([(cx, cy - 150), (cx - 84, cy + 44), (cx + 84, cy + 44)], fill=(178, 62, 8)) +fd.polygon([(cx, cy - 108), (cx - 56, cy + 40), (cx + 56, cy + 40)], fill=(232, 120, 16)) +fd.polygon([(cx, cy - 62), (cx - 30, cy + 36), (cx + 30, cy + 36)], fill=(255, 196, 66)) +flame = flame.filter(ImageFilter.GaussianBlur(6)) +img = ImageChops.add(img, flame) +d = ImageDraw.Draw(img) + + +def text_center(y, s, size, fill): + f = ImageFont.truetype(FONT, size) + bbox = d.textbbox((0, 0), s, font=f) + w = bbox[2] - bbox[0] + d.text(((W - w) / 2 - bbox[0], y), s, font=f, fill=fill) + + +# 标题(大字 + 辉光) +title = "围 猎" +f = ImageFont.truetype(FONT, 280) +bbox = d.textbbox((0, 0), title, font=f) +tx, ty = (W - (bbox[2] - bbox[0])) / 2 - bbox[0], 330 +tglow = Image.new("RGB", (W, H), (0, 0, 0)) +td = ImageDraw.Draw(tglow) +td.text((tx, ty), title, font=f, fill=(140, 64, 12)) +tglow = tglow.filter(ImageFilter.GaussianBlur(26)) +img = ImageChops.add(img, tglow) +d = ImageDraw.Draw(img) +d.text((tx, ty), title, font=f, fill=(248, 226, 188)) + +text_center(700, "NIGHT HUNT", 44, (150, 118, 82)) +text_center(880, "听声辨向,背背守夜", 64, (238, 214, 176)) +text_center(980, "野兽从八个方向围上来,朝声音的方向滑", 40, (160, 136, 104)) +text_center(1058, "单击呼喊支援 · 双击旋风斩 · 撑过五波天亮就赢", 40, (160, 136, 104)) +text_center(1780, "2-4 人联机 · 建议戴耳机", 40, (120, 104, 82)) + +img.save(OUT) +print("saved", OUT, img.size) diff --git a/web/hunt/index.html b/web/hunt/index.html new file mode 100644 index 00000000..9d4d25a2 --- /dev/null +++ b/web/hunt/index.html @@ -0,0 +1,43 @@ + + + + + + + 围猎 · SoundGame Web + + + + +
+ +
+ + + + + diff --git a/web/hunt/test/playtest.mjs b/web/hunt/test/playtest.mjs new file mode 100644 index 00000000..1ac62a5f --- /dev/null +++ b/web/hunt/test/playtest.mjs @@ -0,0 +1,1432 @@ +/** + * 《围猎》无头试玩仿真测试(docs/design/13-playtest-criteria.md §24 客户端侧断言)。 + * + * 方案: + * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext + * (createStereoPanner 有记录,供 opts.pan 引擎回归断言); + * - 使用**真实** engine/audio.js + settings.js + guide.js; + * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); + * - 虚拟时钟:patch setTimeout/setInterval/clearTimeout/performance.now; + * - WS 用内存回路 mock(23 §6 协议字段注入 joined/ev/snap/err;发送帧逐条记录); + * 不依赖 web/server 实现(并行开发中),协议字段以 23 §6 为唯一契约; + * - 音频资产并行生成中:fetch 对 audio/* 一律合成假 buffer(按 url 估时长: + * voice 1.8s / npc 1.5s / loop 4s / bgm 90s / 其余 sfx 0.5s),语音链靠引擎 + * 自然 onComplete 推进;blockedAssets 中的片段强制 404(资产缺失兜底断言用)。 + * + * 运行:tools/node/bin/node web/hunt/test/playtest.mjs + */ + +const SAVE_KEY = 'soundgame.progress.hunt'; +const AUTH_KEY = 'soundgame.auth'; + +// ---------------------------------------------------------------- 断言收集 + +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}`); } +} + +const consoleErrs = []; +const consoleWarns = []; +console.error = (...a) => { consoleErrs.push(a.join(' ')); }; +console.warn = (...a) => { consoleWarns.push(a.join(' ')); }; +console.log = () => {}; + +// ---------------------------------------------------------------- 虚拟时钟 + +let now = 0; +let timerSeq = 1; +const timers = new Map(); +const intervals = new Set(); +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); }; +globalThis.setInterval = (cb, ms = 0, ...args) => { + const id = timerSeq++; + const step = (...a) => { + if (!intervals.has(id)) return; + cb(...a); + if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); + }; + intervals.add(id); + timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); + return id; +}; +globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); }; +Object.defineProperty(globalThis, 'performance', { + value: { now: () => now }, configurable: true, +}); + +async function flush(rounds = 8) { + for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); +} + +/** 推进虚拟时钟到 cond() 满足或空闲。 */ +async function advance(cond = null) { + for (let n = 0; n < 100000; 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('虚拟时钟定时器超上限(疑似死循环)'); +} + +/** 固定步长推进(常驻 interval 存在时不可用它等空闲)。 */ +async function advanceMs(ms) { + const until = now + ms; + while (now < until) { + await flush(2); + let nextId = -1; let nextAt = Infinity; + for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } + if (nextId < 0 || nextAt > until) { now = until; break; } + now = nextAt; + const t = timers.get(nextId); + timers.delete(nextId); + t.cb(...t.args); + } + await flush(); +} + +// ---------------------------------------------------------------- 浏览器 mock + +const winListeners = new Map(); +globalThis.window = { + addEventListener: (type, fn) => { + if (!winListeners.has(type)) winListeners.set(type, []); + winListeners.get(type).push(fn); + }, + location: { protocol: 'http:', host: 'test.local', href: '' }, + AudioContext: null, // 下方赋值 +}; + +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', { + // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,voice 走主资产 + value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } }, + configurable: true, +}); + +class MockParam { + constructor(v = 1) { this.value = v; } + setValueAtTime() {} + linearRampToValueAtTime() {} + cancelScheduledValues() {} +} +class MockNode { + constructor() { this.connectedTo = null; } + connect(t) { this.connectedTo = t; } + disconnect() {} +} +class MockGainNode extends MockNode { + constructor() { super(); this.gain = new MockParam(); } +} +class MockPannerNode extends MockNode { + constructor() { super(); this.pan = new MockParam(0); } +} +class MockAudioContext { + constructor() { + this.state = 'running'; this.destination = {}; this.sampleRate = 44100; + this.panners = []; // createStereoPanner 创建记录(opts.pan 回归断言) + this.gains = []; // createGain 创建记录 + this.sources = []; // createBufferSource 创建记录 + } + get currentTime() { return now / 1000; } + createGain() { const g = new MockGainNode(); this.gains.push(g); return g; } + createStereoPanner() { const p = new MockPannerNode(); this.panners.push(p); return p; } + createDynamicsCompressor() { + const n = new MockGainNode(); + n.threshold = new MockParam(); n.ratio = new MockParam(); + n.attack = new MockParam(); n.release = new MockParam(); + return n; + } + createBufferSource() { + const s = new MockNode(); + s.buffer = null; s.loop = false; s.playbackRate = new MockParam(); + s.start = () => {}; s.stop = () => {}; + this.sources.push(s); + return s; + } + createScriptProcessor() { return new MockNode(); } + decodeAudioData(ab) { + return Promise.resolve({ + duration: durationOfUrl(ab && ab.__url), + numberOfChannels: 1, sampleRate: 44100, + getChannelData: () => new Float32Array(16), + }); + } + resume() { this.state = 'running'; return Promise.resolve(); } +} +globalThis.window.AudioContext = MockAudioContext; + +/** 按 url 估假 buffer 时长(语音链自然推进 + npc 在播时长账目)。 */ +function durationOfUrl(u) { + const s = String(u || ''); + if (s.includes('/bgm/')) return 90; + if (s.includes('/voice/')) return 1.8; + if (s.includes('npc_')) return 1.5; + if (s.includes('loop_')) return 4.0; + return 0.5; +} + +// fetch:audio/* 一律合成假资产(并行生成中不依赖真实文件);blockedAssets 强制 404; +// /api/progress GET/PUT 走内存服务器桶(PUT 全量记录供结算断言) +const requestedUrls = []; +const blockedAssets = new Set(); +const apiPuts = []; +const serverBuckets = new Map(); // game → data(登录态进度桶) +globalThis.fetch = async (u, opts = {}) => { + const s = String(u); + requestedUrls.push(s); + if (s.startsWith('/api/')) { + if (s.startsWith('/api/progress') && (opts.method || 'GET') === 'PUT') { + let body = null; + try { body = JSON.parse(opts.body); } catch (_) { /* 静默 */ } + apiPuts.push({ body, t: now }); + if (body && body.game) serverBuckets.set(body.game, body.data); + return { ok: true, status: 200, json: async () => ({ ok: true, game: body && body.game }) }; + } + if (s.startsWith('/api/progress')) { + const gname = new URLSearchParams(s.split('?')[1] || '').get('game'); + const data = serverBuckets.get(gname) || null; + return { ok: true, status: 200, json: async () => ({ game: gname, data }) }; + } + return { ok: false, status: 404, json: async () => ({}) }; + } + const blocked = [...blockedAssets].some((b) => s.includes(b)); + if (blocked) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; + const ab = new ArrayBuffer(8); + ab.__url = s; + return { ok: true, status: 200, arrayBuffer: async () => ab }; +}; + +// ---------------------------------------------------------------- WS 内存回路 mock + +function makeWsMock() { + const mock = { + conns: [], + sent: [], // { conn, msg } + transport(u, handlers) { + const conn = { + url: u, handlers, opened: false, closed: false, + send(s) { let m = null; try { m = JSON.parse(s); } catch (_) { /* 坏帧 */ } mock.sent.push({ conn, msg: m }); }, + close() { if (!conn.closed) { conn.closed = true; handlers.onclose(); } }, + }; + mock.conns.push(conn); + return conn; + }, + last() { return mock.conns[mock.conns.length - 1]; }, + open(conn) { conn.opened = true; conn.handlers.onopen(); }, + inject(conn, msg) { conn.handlers.onmessage(msg); }, + drop(conn) { if (!conn.closed) { conn.closed = true; conn.handlers.onclose(); } }, + msgs(conn) { return mock.sent.filter((x) => x.conn === conn).map((x) => x.msg); }, + acts(conn, kind) { return mock.msgs(conn).filter((m) => m && m.t === 'act' && (!kind || m.kind === kind)); }, + }; + return mock; +} + +// ---------------------------------------------------------------- 加载引擎与游戏 + +const { WebAudioEngine } = await import('../../engine/audio.js'); +const { settings } = await import('../../engine/settings.js'); +const { HuntGame } = await import('../game.js'); + +function resetSettings(guide = 'off') { + settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' }); +} +resetSettings('off'); +storageData.set(AUTH_KEY, JSON.stringify({ username: '试炼猎人', token: 'tok1' })); + +// ---------------------------------------------------------------- 协议夹具(23 §6 字段) + +const SECTORS3 = [['N', 'NE', 'E'], ['SE', 'S', 'SW'], ['W', 'NW']]; +function joinedMsg(over = {}) { + return { + t: 'joined', room: 'ABCD', slot: 0, resume: 'r0', char: 'yan', sector: SECTORS3[0], seed: 42, + state: { phase: 'lobby', wave: 0, dur: 10 }, + roster: [ + { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, + { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, + { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, + ], + ...over, + }; +} +const evSpawn = (id, mkind, dir, eta, ts = now) => ({ t: 'ev', kind: 'spawn', ts, id, mkind, dir, eta }); +const evKill = (id, slot, dir, mkind = 'wolf', ts = now) => ({ t: 'ev', kind: 'kill', ts, id, slot, dir, mkind }); +const evMiss = (slot, dir, ts = now) => ({ t: 'ev', kind: 'miss', ts, slot, dir }); +const evSkill = (slot, cleared, ts = now) => ({ t: 'ev', kind: 'skill', ts, slot, cleared }); +const evBreach = (id, mkind, dir, dur, ts = now) => ({ t: 'ev', kind: 'breach', ts, id, mkind, dir, dur }); +const evWave = (wave, ts = now) => ({ t: 'ev', kind: 'wave', ts, wave }); +const evCallout = (slot, ts = now) => ({ t: 'ev', kind: 'callout', ts, slot }); +const evBotline = (slot, line, ts = now) => ({ t: 'ev', kind: 'botline', ts, slot, line }); +const evRoster = (roster, ts = now) => ({ t: 'ev', kind: 'roster', ts, roster }); +const evEnd = (over, ts = now) => ({ t: 'ev', kind: 'end', ts, ...over }); +const snapMsg = (mobs, over = {}, ts = now) => ({ + t: 'snap', ts, phase: 'wave', wave: 1, dur: 10, + mobs, slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 0 }], ...over, +}); + +// ---------------------------------------------------------------- 测试工具 + +const ERR_PATTERN = JSON.stringify([60, 40, 60]); +const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); +const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); +const HIT_PATTERN = JSON.stringify([80]); +const PICKUP_PATTERN = JSON.stringify([15]); +const SUCCESS_PATTERN = JSON.stringify([100]); +const HEARTBEAT_PATTERN = JSON.stringify([40, 100, 40]); +const vibCount = (pattern, since = 0) => vibrateLog.filter((v) => JSON.stringify(v.pattern) === pattern && v.t >= since).length; +const playsOf = (playLog, id) => playLog.filter((e) => e.id === id).length; + +/** 全量播放 id 记录(静态审计:只引用台词表内 id)。 */ +const allPlayedIds = new Set(); + +function makeRig() { + const audio = new WebAudioEngine('audio/'); + const playLog = []; + const origPlay = audio.play.bind(audio); + audio.play = (id, opts) => { + playLog.push({ id, opts: opts || {}, t: now }); + allPlayedIds.add(id); + return origPlay(id, opts); + }; + const ws = makeWsMock(); + const game = new HuntGame(audio, {}, { transport: ws.transport, wsUrl: 'ws://test.local/ws/hunt' }); + return { audio, playLog, game, ws }; +} + +let gestureCount = 0; +async function g(game, gesture) { + gestureCount++; + game.onGesture(gesture); + await flush(); +} + +function setProgress(p) { + storageData.set(SAVE_KEY, JSON.stringify({ + best: null, runs: 0, totalKills: 0, tutDone: false, ...p, + })); +} +function getProgress() { + try { return JSON.parse(storageData.get(SAVE_KEY) || 'null'); } catch (_) { return null; } +} + +/** 链序断言:ids 按序出现在 playLog 的 voice 流中(子序列匹配)。 */ +function chainOrderOk(playLog, ids) { + const seq = playLog.filter((e) => e.id.startsWith('voice/')).map((e) => e.id); + let at = -1; + for (const id of ids) { + const i = seq.indexOf('voice/' + id, at + 1); + if (i < 0) return false; + at = i; + } + return true; +} + +/** TITLE 单击 → 匹配 → 注入 joined(lobby)。返回 { conn, join }。 */ +async function driveToLobby(rig, joinedOver = {}) { + const { game, ws } = rig; + await g(game, 'tap'); + const conn = ws.last(); + ws.open(conn); + await flush(); + const join = ws.msgs(conn)[0]; + // 等开场链播完再进房(真实网络有延迟;瞬进会顶掉 intro 链,属预期行为但此处不验) + await advance(() => !rig.audio.isVoicePlaying()); + ws.inject(conn, joinedMsg(joinedOver)); + await flush(); + return { conn, join }; +} + +/** lobby → 单击开局 → ev wave 1 → WAVE。 */ +async function driveToWave(rig, conn) { + const { game, ws } = rig; + await g(game, 'tap'); // LOBBY 单击 = 立即开局 + await advance(() => !rig.audio.isVoicePlaying()); + ws.inject(conn, evWave(1)); + await advance(() => !rig.audio.isVoicePlaying()); +} + +// ================================================================ Block 0 · 引擎 opts.pan 回归(23 §9) + +realLog('== Block 0 · 引擎 opts.pan 后向兼容 =='); +{ + resetSettings('off'); + const audio = new WebAudioEngine('audio/'); + await audio.unlock(); + const ctx = audio.ctx; + const busGainCount = ctx.gains.length; // master/bgmPref/bgm/voice/sfx 五条总线 + + // 无 pan 调用路径:零 panner 节点,原链路 source → gain → sfxBus + audio.play('sfx/atk_whoosh'); + await flush(); + assert(ctx.panners.length === 0, 'opts.pan 回归:无 pan 调用路径零 panner 节点(引擎行为不变)'); + assert(ctx.gains.length === busGainCount + 1, '无 pan sfx:只多一个播放 gain'); + assert(ctx.gains[busGainCount].connectedTo === audio._sfxBus, '无 pan sfx:gain 直连 sfxBus(原路径逐字节不变)'); + + // pan=0 与缺省等价 + audio.play('sfx/atk_whoosh', { pan: 0 }); + await flush(); + assert(ctx.panners.length === 0, 'opts.pan 回归:pan=0 与缺省等价(零 panner)'); + // 非法 pan 静默忽略 + audio.play('sfx/atk_whoosh', { pan: NaN }); + audio.play('sfx/atk_whoosh', { pan: 'x' }); + await flush(); + assert(ctx.panners.length === 0, 'opts.pan 回归:NaN/非数字 pan 静默忽略'); + + // pan=-1 / 0.7 / 1:panner 值正确,链路 source → gain → panner → sfxBus + for (const [pan, expect] of [[-1, -1], [0.7, 0.7], [1, 1]]) { + const n0 = ctx.panners.length; + audio.play('sfx/kill_wolf', { pan }); + await flush(); + assert(ctx.panners.length === n0 + 1, `pan=${pan}:创建 panner`); + const p = ctx.panners[ctx.panners.length - 1]; + assert(Math.abs(p.pan.value - expect) < 1e-9, `pan=${pan}:panner.pan.value === ${expect}`); + assert(p.connectedTo === audio._sfxBus, `pan=${pan}:panner 接 sfxBus`); + const gNode = ctx.gains[ctx.gains.length - 1]; + assert(gNode.connectedTo === p, `pan=${pan}:gain 接 panner(链路 source→gain→panner→bus)`); + } + // clamp:越界钳到 [-1,1] + audio.play('sfx/kill_wolf', { pan: 2 }); + await flush(); + assert(ctx.panners[ctx.panners.length - 1].pan.value === 1, 'pan=2 钳到 1'); + audio.play('sfx/kill_wolf', { pan: -3 }); + await flush(); + assert(ctx.panners[ctx.panners.length - 1].pan.value === -1, 'pan=-3 钳到 -1'); + + // voice 层同款能力(本作旁白不使用,对称能力一次给足) + { + const n0 = ctx.panners.length; + audio.play('voice/intro_1', { pan: -0.5 }); + await flush(); + assert(ctx.panners.length === n0 + 1, 'voice opts.pan:创建 panner'); + const p = ctx.panners[ctx.panners.length - 1]; + assert(Math.abs(p.pan.value - (-0.5)) < 1e-9, 'voice pan=-0.5:panner 值正确'); + assert(p.connectedTo === audio._voiceBus, 'voice panner 接 voiceBus'); + } + // voice 无 pan:零 panner(回归) + { + const n0 = ctx.panners.length; + audio.play('voice/intro_2'); + await advance(() => !audio.isVoicePlaying()); + assert(ctx.panners.length === n0, 'voice 无 pan:零 panner(既有行为不变)'); + } + + // 老 Safari:无 createStereoPanner → 静默忽略 pan(不炸、照常出声) + ctx.createStereoPanner = undefined; + const srcBefore = ctx.sources.length; + audio.play('sfx/atk_whoosh', { pan: 1 }); + audio.play('voice/room_wait', { pan: 0.8 }); + await flush(); + assert(ctx.sources.length === srcBefore + 1, '老 Safari 降级:无 createStereoPanner 时 sfx 照常播放'); + assert(ctx.gains[ctx.gains.length - 1].connectedTo === audio._voiceBus, '老 Safari 降级:voice gain 直连 voiceBus(pan 静默忽略)'); + await advance(() => !audio.isVoicePlaying()); +} + +// ================================================================ Block 1 · 关键路径:首局教学房(§24 关键路径 1-2) + +realLog('== Block 1 · 首局教学房 T0-T4(guide=off)=='); +{ + resetSettings('off'); + storageData.delete(SAVE_KEY); // 无存档 → tutDone=false + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + const g0 = gestureCount; + + // §7 全局:解锁前 play 为 no-op + audio.play('voice/intro_1'); + await flush(); + assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')), + '启动遮罩点按前无音频播放(play 为 no-op)'); + await audio.unlock(); + playLog.length = 0; + + game.start(); + await flush(); + assert(game.phase === 'title', '启动进 TITLE'); + assert(playLog.some((e) => e.id === 'bgm/menu'), 'TITLE 播放 bgm/menu'); + + // 无效手势(左右滑):error 震 + guide=off 零 gd_invalid 语音 + await g(game, 'left'); + assert(vibCount(ERR_PATTERN) >= 1, 'TITLE 左右滑:error 震'); + assert(playsOf(playLog, 'voice/gd_invalid') === 0, 'guide=off:无效手势零向导语音'); + + // 无存档单击 → 快速匹配,join 必带 teach:true(首局=教学房)+ ver:1 + room:"quick" + await g(game, 'tap'); + assert(game.phase === 'matching', 'TITLE 单击 → MATCHING'); + const conn = ws.last(); + ws.open(conn); + await flush(); + const join = ws.msgs(conn)[0]; + assert(join && join.t === 'join' && join.ver === 1 && join.room === 'quick' && join.teach === true, + '首局单击发出 join { room:"quick", teach:true, ver:1 }'); + assert(join.name === '试炼猎人', 'join.name 取自档案'); + + // 注入 joined(教学房:slot 0 + 2 bot,seed=42)→ TEACH + ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); + await flush(); + assert(game.phase === 'teach', 'joined(phase:teach) → TEACH'); + assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, 'joined 字段落位(slot/char/seed)'); + assert(playLog.some((e) => e.id === 'bgm/hunt'), 'TEACH BGM 切 bgm/hunt'); + await advance(() => !audio.isVoicePlaying()); + assert(chainOrderOk(playLog, ['intro_1', 'intro_2', 'tut_rule']), 'T0:intro_1→intro_2→tut_rule 有序'); + + // T0 单击 → T1(tut_attack) + await g(game, 'tap'); + assert(game._tut === 1 && playsOf(playLog, 'voice/tut_attack') === 1, 'T0 单击 → T1 播 tut_attack'); + await advance(() => !audio.isVoicePlaying()); + + // T1:服务器放教学狼 1(N,eta 12)→ 生成呼啸一声;eta>6 零 loop + ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12)); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/howl_wolf' && e.opts.pan === 0 && Math.abs(e.opts.volume - 0.4) < 1e-9), + 'T1 教学狼生成呼啸:howl_wolf,pan=0(N),vol=0.4(外圈一声)'); + await advanceMs(600); // 渲染器重估一轮 + assert(game._loops.size === 0, '听觉裁剪:eta>6s 怪零 loop'); + + // 先错向滑 W → act attack dir:W → ev miss → atk_swish + error 震 + gd_idle 重教,怪物不受伤 + await g(game, 'left'); + let acts = ws.acts(conn, 'attack'); + assert(acts.length === 1 && acts[0].dir === 'W', 'T1 错向滑 W → 发出 act attack dir:W'); + ws.inject(conn, evMiss(0, 'W')); + await advanceMs(400); // atk_swish 与 atk_whoosh 同类间隔 ≥0.15s,走 one-shot 队列延迟播出 + assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'T1 挥空播 atk_swish(归因真话)'); + assert(vibCount(ERR_PATTERN) >= 1, 'T1 挥空 error 震'); + assert(game.mobs.has('w1'), 'T1 挥空怪物不受伤'); + await advance(() => playsOf(playLog, 'voice/gd_idle') >= 1); + assert(playsOf(playLog, 'voice/gd_idle') === 1, 'T1 错向 → gd_idle 重教'); + await advance(() => !audio.isVoicePlaying()); + + // 冷却 2.5s 内再滑 → 仅 error 震、零 act(冷却门控在 T1 同样生效) + await g(game, 'up'); + assert(ws.acts(conn, 'attack').length === 1, '出击冷却 2.5s 内滑:零 act 发出'); + await advanceMs(2600); + + // 向 N 滑 → act attack dir:N → ev kill → tut_kill_ok + await g(game, 'up'); + acts = ws.acts(conn, 'attack'); + assert(acts.length === 2 && acts[1].dir === 'N', 'T1 向 N 滑 → 发出 act attack dir:N'); + ws.inject(conn, evKill('w1', 0, 'N')); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/kill_wolf'), 'T1 首杀播 kill_wolf'); + assert(vibCount(PICKUP_PATTERN) >= 1, 'T1 首杀 pickup 震'); + assert(game._myKills === 1, '击杀归属:kills 账 slot 0 = 1'); + assert(game._tut === 2, 'T1 首杀 → T2'); + await advance(() => !audio.isVoicePlaying()); + assert(chainOrderOk(playLog, ['tut_kill_ok', 'tut_callout']), 'T1 首杀 → tut_kill_ok → T2 tut_callout'); + + // 再放一只教学狼(稍后验证突入零惩罚) + ws.inject(conn, evSpawn('w9', 'wolf', 'N', 12)); + await flush(); + + // T2:单击 → act callout 发出;本机播自己座位角色 help_call(中央 pan=0) + await g(game, 'tap'); + assert(ws.acts(conn, 'callout').length === 1, 'T2 单击 → 发出 act callout'); + assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call' && r.pan === 0), + 'T2 本机播自己座位角色 help_call(npc_yan_help_call,中央)'); + // 自己的 callout ev 回环不重复播 + ws.inject(conn, evCallout(0)); + await flush(); + assert(game._npcLog.filter((r) => r.id === 'sfx/npc_yan_help_call').length === 1, '自己呼喊的 ev 回环不重复播'); + // 注入 bot help_ack(座 2 老周,防区中心 WNW=292.5°,pan≈-0.92)→ 播完 → tut_callout_ok + ws.inject(conn, evBotline(2, 'help_ack')); + await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_ack')); + const ackRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_ack'); + assert(Math.abs(ackRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, + 'T2 bot help_ack 带 pan=该 bot 防区中心(座 2 WNW,pan≈-0.92)'); + await advance(() => game._tut === 3); + await advance(() => !audio.isVoicePlaying()); + assert(chainOrderOk(playLog, ['tut_callout_ok', 'tut_skill']), + 'T2 bot 应答播完 → tut_callout_ok(呼喊-响应闭环点明)→ T3 tut_skill'); + + // T3:服务器放教学狼 2、3(贴近 eta 5s)→ 双击旋风斩 → ev skill cleared=2 + ws.inject(conn, evSpawn('w2', 'wolf', 'NE', 5)); + ws.inject(conn, evSpawn('w3', 'wolf', 'SE', 5)); + await flush(); + await g(game, 'doubletap'); + assert(ws.acts(conn, 'skill').length === 1, 'T3 双击 → 发出 act skill'); + ws.inject(conn, evSkill(0, ['w2', 'w3'])); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/skill_spin'), 'T3 旋风斩放出播 skill_spin'); + assert(vibCount(SUCCESS_PATTERN) >= 1, 'T3 旋风斩 success 震'); + assert(!game.mobs.has('w2') && !game.mobs.has('w3'), 'T3 旋风斩清掉教学狼 2、3'); + assert(game._tut === 4, 'T3 放出 → T4'); + await advance(() => !audio.isVoicePlaying()); + assert(chainOrderOk(playLog, ['tut_skill_cd', 'tut_status']), + 'T3 放出 → tut_skill_cd(讲冷却与冷却中双击=重新听)→ T4 tut_status'); + + // 教学狼突入零惩罚:ev breach 不扣耐久、不播 breach 警报、无 hit 震 + const hitVib0 = vibCount(HIT_PATTERN); + ws.inject(conn, evBreach('w9', 'wolf', 'N', 10)); + await flush(); + assert(game.dur === 10, '学期零惩罚:教学狼突入不扣耐久'); + assert(playsOf(playLog, 'voice/breach') === 0 && playsOf(playLog, 'voice/breach_heavy') === 0, + '学期零惩罚:教学段突入不播 breach 警报(明说规则前不漏真话)'); + assert(vibCount(HIT_PATTERN) === hitVib0, '学期零惩罚:教学段突入无 hit 震'); + + // T4:上滑 → 状态链 4 段有序 → tut_breach → tut_done → tutDone 写入进度 + await g(game, 'up'); + await advance(() => game._tutDoneWaiting); + assert(chainOrderOk(playLog, ['hp_band1', 'st_wave_a', 'team_3', 'kill_band1', 'tut_breach', 'tut_done']), + 'T4 上滑 → 状态链 4 段有序(hp_band→st_wave→team_3→kill_band)→ tut_breach → tut_done'); + assert(getProgress() && getProgress().tutDone === true, '教学完成 tutDone 写入进度'); + + // 注入 ev wave 1 → WAVE(教学房同场接续第 1 波;先号角后播报;无 room_start) + const vibHb0 = vibCount(HEARTBEAT_PATTERN); + ws.inject(conn, evWave(1)); + await flush(); + assert(game.phase === 'wave', '教学完成 → ev wave 1 → WAVE'); + assert(playLog.some((e) => e.id === 'sfx/horn_wave'), 'ev wave 1:先号角 horn_wave'); + assert(vibCount(HEARTBEAT_PATTERN) > vibHb0, 'ev wave:heartbeat 震'); + const iHorn = playLog.findIndex((e) => e.id === 'sfx/horn_wave'); + const iBand = playLog.findIndex((e) => e.id === 'voice/wave_band1'); + assert(iBand > iHorn, '先号角后播报(wave_band1 在 horn_wave 后)'); + assert(playsOf(playLog, 'voice/room_start') === 0, '教学房接续第 1 波不播 room_start(tut_done 已衔接)'); + await advance(() => !audio.isVoicePlaying()); + + // guide=off:WAVE 30s 空闲零向导播放、零看门狗定时器 + await advanceMs(30000); + assert(playsOf(playLog, 'voice/gd_idle') === 1, 'guide=off:WAVE 30s 空闲零 gd_idle(仍是 T1 那次)'); + assert(game.guide._wd === null, 'guide=off:零看门狗定时器'); + + // 手势预算:教学路径 ≤150 次手势(§24 关键路径预算) + assert(gestureCount - g0 <= 150, `教学关键路径手势预算 ≤150(实际 ${gestureCount - g0})`); + + game.stop(); + await flush(); +} + +// ================================================================ Block 2 · 战斗态:听觉裁剪/冷却/威胁/呼喊/突入(接教学房同一实例) + +// (为块独立性另起实例,从 WAVE 开始) +realLog('== Block 2 · WAVE 战斗:听觉裁剪 / 冷却 / 威胁 / 呼喊 / 突入 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + + // 非首局:join 不带 teach + const { conn, join } = await driveToLobby(rig); + assert(join && !('teach' in join), '二周目(tutDone=true)join 不带 teach'); + assert(game.phase === 'lobby', 'joined(phase:lobby) → LOBBY'); + assert(playsOf(playLog, 'voice/room_wait') === 1, 'LOBBY 进入播 room_wait'); + // LOBBY 上滑=在场猎人播报;双击=重听 room_wait + await g(game, 'up'); + assert(playsOf(playLog, 'voice/team_3') === 1, 'LOBBY 上滑播 team_3(在场猎人)'); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'doubletap'); + assert(playsOf(playLog, 'voice/room_wait') === 2, 'LOBBY 双击重听 room_wait'); + await advance(() => !audio.isVoicePlaying()); + + // 单击发 act start;开局 roster ev:3 猎人、bot 槽 char 齐全;新队友 join_chime + await g(game, 'tap'); + assert(ws.acts(conn, 'start').length === 1, 'LOBBY 单击 → 发出 act start'); + ws.inject(conn, evRoster([ + { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, + { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, + { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, + ])); + await flush(); + assert(game._roster.size === 3, '开局 roster ev:猎人总数 = max(3, 人类数) = 3'); + assert(game._charOf(1) === 'zheng' && game._charOf(2) === 'zhou', 'bot 槽 char 齐全'); + + // ev wave 1 → room_start → wave_band1(从 LOBBY 开局) + ws.inject(conn, evWave(1)); + await flush(); + assert(game.phase === 'wave', 'ev wave 1 → WAVE'); + await advance(() => !audio.isVoicePlaying()); + assert(chainOrderOk(playLog, ['room_start', 'wave_band1']), '开局链:room_start → wave_band1'); + + // ---- 听觉视野裁剪:外圈呼啸一声(pan=1),eta>6 零 loop;进中圈起 loop 0.30 + ws.inject(conn, evSpawn('e1', 'wolf', 'E', 9)); + await flush(); + const e1Howl = playLog.find((e) => e.id === 'sfx/howl_wolf' && Math.abs(e.opts.pan - 1) < 1e-9); + assert(e1Howl && Math.abs(e1Howl.opts.volume - 0.4) < 1e-9, '外圈生成呼啸:pan=sin(90°)=1(E),vol 0.4'); + await advanceMs(600); + assert(game._loops.size === 0, 'eta>6s:零 loop(外圈静默)'); + await advanceMs(3200); // eta 9 → ~5.8,跨进中圈 + await advance(() => game._loops.size === 1); + { + const h = game._loops.get('e1'); + assert(h && Math.abs(h.vol - 0.3) < 1e-9, '中圈(4-6s)起 loop,音量档 0.30'); + assert(h.mkind === 'wolf', 'loop 资产按 mkind=loop_wolf'); + } + + // ---- R3:注入 5 只内圈怪 → loop ≤3 路且为 eta 最小 3 只 + ws.inject(conn, snapMsg([ + { id: 'm1', mkind: 'wolf', dir: 'N', eta: 1.0 }, + { id: 'm2', mkind: 'wolf', dir: 'NE', eta: 2.0 }, + { id: 'm3', mkind: 'wolf', dir: 'E', eta: 3.0 }, + { id: 'm4', mkind: 'bear', dir: 'SE', eta: 3.5 }, + { id: 'm5', mkind: 'wolf', dir: 'S', eta: 3.9 }, + ])); + await flush(); + assert(game._loops.size === 3, 'R3:5 只内圈怪 → loop 恰 3 路'); + assert(game._loops.has('m1') && game._loops.has('m2') && game._loops.has('m3'), + 'R3:loop 渲染 eta 最小 3 只(m1/m2/m3)'); + assert(Math.abs(game._loops.get('m1').vol - 0.8) < 1e-9, '内圈 <2.5s:loop 音量档 0.80'); + assert(Math.abs(game._loops.get('m3').vol - 0.55) < 1e-9, '内圈 2.5-4s:loop 音量档 0.55'); + // snap 把 m4/m5/e1 全清出本地(不在 snap 的 id 删除) + assert(!game.mobs.has('e1'), 'snap 对账:本地多出/缺席的 id 被清理'); + await flush(); + { + const h2 = game._loops.get('m2'); + assert(h2 && h2.panner && Math.abs(h2.panner.pan.value - Math.sin(45 * Math.PI / 180)) < 1e-6, + 'loop 声像 pan=sin(az)(NE≈0.7,自建 panner 句柄)'); + assert(game._loops.get('m1').gain, 'loop 句柄建立(gain 节点就位)'); + } + + // ---- 双击:旋风斩就绪 → 放出;冷却中 → 重听威胁(threat_many) + await g(game, 'doubletap'); + assert(ws.acts(conn, 'skill').length === 1, 'WAVE 双击(就绪)→ 发出 act skill'); + ws.inject(conn, evSkill(0, ['m1', 'm2', 'm3', 'm4', 'm5'])); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/skill_spin'), '旋风斩放出播 skill_spin(中央)'); + assert(game.mobs.size === 0, '旋风斩清全场内圈'); + await advanceMs(600); + assert(game._loops.size === 0, '清场后 loop 全停'); + // 再造 3 只内圈 → 冷却中双击 = 重听威胁(≥3 只 → threat_many),零 act + // (snap slots 携带服务端冷却真值:旋风斩刚放过,skillCd≈7.4) + ws.inject(conn, snapMsg([ + { id: 'n1', mkind: 'wolf', dir: 'N', eta: 2.0 }, + { id: 'n2', mkind: 'wolf', dir: 'W', eta: 3.0 }, + { id: 'n3', mkind: 'bear', dir: 'NW', eta: 1.5 }, + ], { slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 7.4 }] })); + await flush(); + const skillActs0 = ws.acts(conn, 'skill').length; + await g(game, 'doubletap'); + assert(ws.acts(conn, 'skill').length === skillActs0, '旋风斩冷却中双击:零 act(本地先拦)'); + assert(playsOf(playLog, 'voice/threat_many') === 1, '冷却中双击=重听威胁:内圈 3 只 → threat_many'); + await advance(() => !audio.isVoicePlaying()); + // 8s 冷却完成 → skill_ready(queue:true 不顶句;每轮 1 次) + await advanceMs(8100); + assert(playsOf(playLog, 'voice/skill_ready') === 1, '旋风斩冷却完成播 skill_ready(每轮冷却 1 次)'); + + // ---- 出击冷却 2.5s:冷却内滑仅 error 震、零 act + await g(game, 'right'); + const atkActs = ws.acts(conn, 'attack'); + assert(atkActs.length === 1 && atkActs[0].dir === 'E', 'WAVE 右滑 → act attack dir:E'); + await g(game, 'left'); + assert(ws.acts(conn, 'attack').length === 1, '出击冷却内滑:零 act'); + assert(vibCount(ERR_PATTERN) >= 1, '出击冷却内滑:仅 error 震'); + await advanceMs(2600); + await g(game, 'left'); + assert(ws.acts(conn, 'attack').length === 2 && ws.acts(conn, 'attack')[1].dir === 'W', + '冷却后滑正常发出 act attack dir:W'); + // 击杀归属 + 挥空真话 + ws.inject(conn, evKill('n2', 0, 'W')); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/kill_wolf' && Math.abs(e.opts.pan - (-1)) < 1e-9), + '击杀音带 pan=出击方位(W,pan=-1)'); + assert(vibCount(PICKUP_PATTERN) >= 1, '命中 pickup 震'); + ws.inject(conn, evMiss(0, 'E')); + await advanceMs(400); // one-shot 同类间隔排队 + assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'WAVE 挥空播 atk_swish + error 震'); + + // ---- 呼喊:单击 → act callout + 本机座位角色 help_call;节流 5s + await g(game, 'tap'); + assert(ws.acts(conn, 'callout').length === 1, 'WAVE 单击 → act callout'); + assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call'), '本机播 npc_yan_help_call'); + await g(game, 'tap'); + assert(ws.acts(conn, 'callout').length === 1, '呼喊节流 5s:连点只出一条 callout act'); + assert(vibCount(ERR_PATTERN) >= 1, '呼喊节流内单击:error 震'); + // 他人呼喊:该座角色 help_call 带防区中心 pan + warning 震 + const warn0 = vibCount(WARN_PATTERN); + ws.inject(conn, evCallout(2)); + await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_call')); + const callRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_call'); + assert(Math.abs(callRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, '收到呼喊:座 2 防区中心 pan(WNW≈-0.92)'); + assert(vibCount(WARN_PATTERN) > warn0, '收到呼喊 warning 震'); + + // ---- 突入:breach_thud + hit 震 + breach 播报(节流 3.5s);熊 -2 → breach_heavy + const hit0 = vibCount(HIT_PATTERN); + ws.inject(conn, evBreach('n1', 'wolf', 'N', 9)); + await flush(); + assert(game.dur === 9, '狼突入耐久 -1(dur=9)'); + assert(playLog.some((e) => e.id === 'sfx/breach_thud'), '突入播 breach_thud(中央)'); + assert(vibCount(HIT_PATTERN) > hit0, '突入 hit 震'); + assert(playsOf(playLog, 'voice/breach') === 1, '狼突入播 breach 警报'); + await advance(() => !audio.isVoicePlaying()); + // 3.5s 节流窗内再突入 → 不重复播报 + ws.inject(conn, evBreach('n3', 'bear', 'NW', 7)); + await flush(); + assert(game.dur === 7, '熊突入耐久 -2(dur=7)'); + assert(playsOf(playLog, 'voice/breach_heavy') === 0 && playsOf(playLog, 'voice/breach') === 1, + 'breach 播报节流 ≥3.5s:窗内突入不重复播报'); + await advanceMs(3600); + ws.inject(conn, evSpawn('b2', 'bear', 'SW', 11)); + await flush(); + ws.inject(conn, evBreach('b2', 'bear', 'SW', 5)); + await flush(); + assert(playsOf(playLog, 'voice/breach_heavy') === 1, '节流窗后熊突入播 breach_heavy'); + // 后半平面呼啸音量 ×0.6(SW=225°,身后发闷) + const b2Howl = playLog.filter((e) => e.id === 'sfx/howl_bear').pop(); + assert(b2Howl && Math.abs(b2Howl.opts.volume - 0.4 * 0.6) < 1e-9 && Math.abs(b2Howl.opts.pan - Math.sin(225 * Math.PI / 180)) < 1e-6, + '后半平面呼啸:音量 ×0.6("身后发闷"),pan=sin(225°)≈-0.7'); + await advance(() => !audio.isVoicePlaying()); + + // ---- 耐久首次 ≤2 → danger_low(每局 1 次)+ danger 震 + const danger0 = vibCount(DANGER_PATTERN); + ws.inject(conn, evSpawn('d1', 'wolf', 'N', 8)); + ws.inject(conn, evBreach('d1', 'wolf', 'N', 2)); + await advance(() => playsOf(playLog, 'voice/danger_low') === 1); + assert(playsOf(playLog, 'voice/danger_low') === 1, '耐久首次 ≤2 播 danger_low'); + assert(vibCount(DANGER_PATTERN) > danger0, '耐久 ≤2 danger 震'); + ws.inject(conn, evSpawn('d2', 'wolf', 'N', 8)); + await advanceMs(3600); + ws.inject(conn, evBreach('d2', 'wolf', 'N', 1)); + await flush(); + assert(playsOf(playLog, 'voice/danger_low') === 1, 'danger_low 每局只播 1 次'); + + // ---- 波次推进:waveclear → wave_clear;ev wave 2 → horn + wave_band2 + ws.inject(conn, { t: 'ev', kind: 'waveclear', ts: now, wave: 1 }); + await advance(() => playsOf(playLog, 'voice/wave_clear') === 1); + assert(playsOf(playLog, 'voice/wave_clear') === 1, 'ev waveclear 播 wave_clear(波间休整)'); + ws.inject(conn, evWave(2)); + await flush(); + assert(chainOrderOk(playLog, ['wave_clear', 'wave_band2']), '第 2 波:号角 + wave_band2'); + assert(game.wave === 2, 'wave=2'); + await advance(() => !audio.isVoicePlaying()); + + game.stop(); + await flush(); +} + +// ================================================================ Block 3 · 菜单 E3(不暂停)+ 上/下滑语境折中 + +realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await driveToWave(rig, conn); + + // 语境折中(文件头裁决):S 弧有可打怪时下滑 = 出击(不开菜单) + ws.inject(conn, snapMsg([{ id: 's1', mkind: 'wolf', dir: 'S', eta: 3.0 }])); + await flush(); + await g(game, 'down'); + assert(ws.acts(conn, 'attack').length === 1 && ws.acts(conn, 'attack')[0].dir === 'S', + '下滑弧内有可打怪 → 出击(act attack dir:S),不开菜单'); + assert(game._menuOpen === false, '下滑出击时菜单不开'); + ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2) + await flush(); + + // 无 S 弧目标时下滑 = 菜单(三件套保留):sys_menu + 首项 + settings_hint + await g(game, 'down'); + assert(game._menuOpen === true, '无目标下滑 → 模态菜单打开'); + await advance(() => playsOf(playLog, 'voice/settings_hint') === 1); + assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume', 'settings_hint']), + '菜单打开即播 sys_menu + 首项 + settings_hint(设置只读、回大厅修改)'); + + // E3:菜单打开期间战斗照常——注入 breach,耐久照扣、菜单不收 + ws.inject(conn, evSpawn('x1', 'wolf', 'N', 3)); + await flush(); + ws.inject(conn, evBreach('x1', 'wolf', 'N', 4)); + await flush(); + assert(game.dur === 4, 'E3:菜单开着 breach 照扣耐久(联机不可单方暂停)'); + assert(game._menuOpen === true, 'E3:菜单开着 breach 菜单不收'); + await advance(() => !audio.isVoicePlaying()); + + // 菜单 3 项固定、单击循环(回绕) + await g(game, 'tap'); + await g(game, 'tap'); + await g(game, 'tap'); + const menuIdx = playLog.filter((e) => e.id.startsWith('voice/sys_menu_')).map((e) => e.id); + const tail = menuIdx.slice(-3); + assert(tail[0] === 'voice/sys_menu_help' && tail[1] === 'voice/sys_menu_home' && tail[2] === 'voice/sys_menu_resume', + '菜单固定 3 项,单击循环(help → home → 回绕 resume)'); + // 上滑 = 帮助 + await g(game, 'up'); + assert(playsOf(playLog, 'voice/help_main') === 1, '菜单内上滑播 help_main(帮助)'); + await advance(() => !audio.isVoicePlaying()); + // 下滑 = 关闭 + await g(game, 'down'); + assert(game._menuOpen === false, '菜单内下滑 = 关闭'); + + // 双击确认:选中"重新听帮助"→ 播 help_main 且菜单不收 + await g(game, 'down'); + assert(game._menuOpen === true, '菜单再次打开'); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'tap'); // idx 1 = sys_menu_help + await g(game, 'doubletap'); + await advance(() => playsOf(playLog, 'voice/help_main') === 2); + assert(game._menuOpen === true, '双击确认"重新听帮助":播帮助且菜单不收'); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'down'); // 关闭 + assert(game._menuOpen === false, '菜单再次关闭'); + + // 双击确认"退出房间回大厅":发 leave + 跳转大厅 + await g(game, 'down'); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'tap'); + await g(game, 'tap'); // idx 2 = sys_menu_home + await g(game, 'doubletap'); + await flush(); + assert(ws.msgs(conn).some((m) => m && m.t === 'leave'), '菜单"回大厅"确认 → 发出 leave'); + assert(game._navigatedHome === '../' && window.location.href === '../', '回大厅跳转(leaving 收尾,不留尸体页面)'); +} + +// ================================================================ Block 4 · 终局结算 + 排行榜(§12) + +realLog('== Block 4 · 胜利/失守结算链 + PUT progress(summary 生成规则)=='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + serverBuckets.delete('hunt'); + const putCount0 = apiPuts.length; + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + + // TITLE 上滑无 best → error 震(不播评级段) + await g(game, 'up'); + assert(vibCount(ERR_PATTERN) >= 1 && playsOf(playLog, 'voice/grade_a') === 0, + 'TITLE 上滑无最佳档:error 震,不播评级段'); + + const { conn } = await driveToLobby(rig); + assert(playsOf(playLog, 'voice/intro_1') === 1 && playsOf(playLog, 'voice/intro_2') === 1, + '二周目 TITLE 单击后播开场 intro_1/intro_2(A 组触发口径)'); + // 第 4 位队友加入 → join_chime + ws.inject(conn, evRoster([ + { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, + { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, + { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, + { slot: 3, name: '路人甲', bot: false, char: null, sector: ['W', 'NW'] }, + ])); + await flush(); + assert(playLog.some((e) => e.id === 'sfx/join_chime'), '队友加入播 join_chime'); + await g(game, 'tap'); // 立即开局 + ws.inject(conn, evWave(1)); + await flush(); + assert(game.phase === 'wave', 'wave 1 进 WAVE'); + await advance(() => !audio.isVoicePlaying()); + + // 终局(胜利,A,击杀 14)→ 800ms 手势死窗 + ws.inject(conn, evEnd({ win: true, dur: 8, grade: 'A', kills: { '0': 14, '1': 9, '2': 6 } })); + await flush(); + assert(game.phase === 'end', 'ev end → END 结算态'); + const connsAtEnd = ws.conns.length; + await advanceMs(400); + await g(game, 'tap'); // 死窗内 ghost tap → 被吞 + assert(game.phase === 'end' && ws.conns.length === connsAtEnd, + '结算死窗:end 到达后 800ms 内 tap 被吞(不重开)'); + // 结算链有序:end_win → grade_a → kill_band2 → end_rank → end_again + await advance(() => playsOf(playLog, 'voice/end_again') === 1); + assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']), + '胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)'); + // PUT progress:summary 命中生成规则;best 按"评级>耐久>击杀"写入 + const winPut = apiPuts.slice(putCount0).find((p) => p.body && p.body.game === 'hunt'); + assert(winPut, '终局 PUT /api/progress { game:"hunt" }'); + assert(winPut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共1局', + 'summary 命中生成规则(最佳评级A · 击杀14 · 营地余8 · 共1局)'); + assert(winPut.body.data.best && winPut.body.data.best.grade === 'A' && winPut.body.data.best.kills === 14 + && winPut.body.data.best.durLeft === 8, 'best 写入(胜利局:评级/击杀/营地余)'); + assert(winPut.body.data.runs === 1 && winPut.body.data.tutDone === true, 'runs/tutDone 账正确'); + assert(getProgress().summary === '最佳评级A · 击杀14 · 营地余8 · 共1局', 'localStorage 离线缓存同步写入'); + + // END:双击=重听结算链;上滑=重听个人最佳段 + await g(game, 'doubletap'); + await advance(() => playsOf(playLog, 'voice/end_win') === 2); + assert(playsOf(playLog, 'voice/end_win') === 2, 'END 双击重听结算链'); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'up'); + const gradeABefore = playsOf(playLog, 'voice/grade_a'); + await advance(() => playsOf(playLog, 'voice/grade_a') > gradeABefore || !audio.isVoicePlaying()); + assert(playsOf(playLog, 'voice/grade_a') >= gradeABefore, 'END 上滑重听个人最佳段(评级+击杀档)'); + await advance(() => !audio.isVoicePlaying()); + + // 死窗后单击 = 再来一局(重新进匹配) + await g(game, 'tap'); + assert(game.phase === 'matching' && ws.conns.length === connsAtEnd + 1, '死窗后单击再来一局 → 重新进匹配'); + + // ---- 第二局:失守路径(dur 归零,无评级、不入榜) + const conn2 = ws.last(); + ws.open(conn2); + await flush(); + ws.inject(conn2, joinedMsg()); + await flush(); + await g(game, 'tap'); // start + ws.inject(conn2, evWave(1)); + await flush(); + await advance(() => !audio.isVoicePlaying()); + const gradeTotal0 = playLog.filter((e) => e.id.startsWith('voice/grade_')).length; + const endRank0 = playsOf(playLog, 'voice/end_rank'); + const endAgain0 = playsOf(playLog, 'voice/end_again'); + ws.inject(conn2, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 3, '1': 8, '2': 5 } })); + await flush(); + await advance(() => playsOf(playLog, 'voice/end_again') > endAgain0); + assert(chainOrderOk(playLog, ['end_lose', 'kill_band1', 'end_again']), + '失守结算链:end_lose → kill_band → end_again'); + assert(playLog.filter((e) => e.id.startsWith('voice/grade_')).length === gradeTotal0, + '失守不播评级(无 grade_*)'); + assert(playsOf(playLog, 'voice/end_rank') === endRank0, '失守不播 end_rank'); + const losePut = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop(); + assert(losePut.body.data.best && losePut.body.data.best.grade === 'A' && losePut.body.data.best.kills === 14, + '失守局不写 best(排行榜不被败局污染)'); + assert(losePut.body.data.runs === 2 && losePut.body.data.totalKills === 14 + 3, + '失守照记 runs/totalKills(runs=2,totalKills=14+3=17)'); + assert(losePut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共2局', + '失守后 summary 仍按 best 生成(共2局)'); + + game.stop(); + await flush(); +} + +// ---- 全败 summary:无 best 时「共N局 · 还没守到天亮过」 +realLog('== Block 4b · 全败 summary =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + serverBuckets.delete('hunt'); + const putCount0 = apiPuts.length; + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await g(game, 'tap'); + ws.inject(conn, evWave(1)); + await flush(); + await advance(() => !audio.isVoicePlaying()); + ws.inject(conn, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 2 } })); + await advance(() => playsOf(playLog, 'voice/end_again') === 1); + const put = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop(); + assert(put.body.data.summary === '共1局 · 还没守到天亮过', '无 best(全败)summary:共1局 · 还没守到天亮过'); + game.stop(); + await flush(); +} + +// ================================================================ Block 5 · 断线重连 / 房灭 / 服务器不可达(§6.6) + +realLog('== Block 5 · 断线 resume 回槽 / room_gone / 不可达 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await driveToWave(rig, conn); + ws.inject(conn, evSpawn('x1', 'wolf', 'N', 8)); + await flush(); + + // 断线 → net_lost 播报 + 本地静默重连(怪物照爬:eta 继续衰减) + ws.drop(conn); + await advance(() => playsOf(playLog, 'voice/net_lost') === 1); + assert(playsOf(playLog, 'voice/net_lost') === 1, 'WS 断开播 net_lost(正在重连)'); + const etaAtDrop = game._mobEta(game.mobs.get('x1')); + await advanceMs(1500); + assert(game._mobEta(game.mobs.get('x1')) < etaAtDrop, + '断网期间怪物按最后已知 eta 继续衰减渲染(戏剧张力保留)'); + + // 宽限内带 resume 重连 → 回槽:joined 同 slot/seed/char + 紧随 snap 全量 → net_back + await advanceMs(700); // 越过 2s 重连节奏 + const conn2 = ws.last(); + assert(conn2 !== conn, '断线后自动发起新连接(静默重连)'); + ws.open(conn2); + await flush(); + const rejoin = ws.msgs(conn2)[0]; + assert(rejoin && rejoin.resume === 'r0' && rejoin.room === 'ABCD', '重连 join 携带 resume 凭证回原房'); + ws.inject(conn2, joinedMsg({ state: { phase: 'wave', wave: 1, dur: 10 } })); + await flush(); + ws.inject(conn2, snapMsg([{ id: 'x1', mkind: 'wolf', dir: 'N', eta: 7.5 }])); + await flush(); + assert(playsOf(playLog, 'voice/net_back') === 1, 'resume 回槽成功播 net_back(接着打)'); + assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, '回槽:同 slot/seed/char'); + assert(game.mobs.has('x1') && Math.abs(game._mobEta(game.mobs.get('x1')) - 7.5) < 0.2, + '紧随 snap 全量:怪表与快照一致(eta 按 snap 校正)'); + assert(game.phase === 'wave', '回槽后战斗续接(WAVE)'); + + // 再断 → 重连 → 房已灭:err room_gone → net_gone → LOST + ws.drop(conn2); + await advanceMs(2200); + const conn3 = ws.last(); + ws.open(conn3); + await flush(); + ws.inject(conn3, { t: 'err', code: 'room_gone', msg: '房间已销毁' }); + await flush(); + assert(playsOf(playLog, 'voice/net_gone') === 1, '房灭后迟到 resume:err room_gone → net_gone'); + assert(game.phase === 'lost', '重连失败 → LOST(明示失败,不静默死屏)'); + await g(game, 'tap'); + assert(game.phase === 'title', 'LOST 单击 → 回大厅(TITLE)'); + + game.stop(); + await flush(); +} + +// ---- 30s 宽限耗尽 → net_gone(战斗态断线不恢复) +realLog('== Block 5b · 30s 宽限耗尽 → LOST =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await driveToWave(rig, conn); + ws.drop(conn); + await flush(); + const connsAtDrop = ws.conns.length; + await advanceMs(32000); // 每 2s 一次重连尝试,对端均不响应 + assert(ws.conns.length >= connsAtDrop + 3, '30s 宽限内持续静默重连(≥3 次尝试)'); + assert(playsOf(playLog, 'voice/net_lost') === 1, 'net_lost 每次断线只播一次'); + assert(playsOf(playLog, 'voice/net_gone') === 1 && game.phase === 'lost', + '30s 宽限耗尽 → net_gone → LOST'); + game.stop(); + await flush(); +} + +// ---- 服务器不可达:TITLE 单击 → net_lost → net_gone → LOST(明示失败) +realLog('== Block 5c · 服务器不可达 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + await g(game, 'tap'); // TITLE 单击 = 快速匹配;连接永不建立 + assert(game.phase === 'matching', 'TITLE 单击 → MATCHING'); + await advanceMs(36000); // 2s 建立看守 + 2s 重连节奏,30s 宽限后放弃 + assert(playsOf(playLog, 'voice/net_lost') === 1 && playsOf(playLog, 'voice/net_gone') === 1, + '服务器不可达:net_lost → net_gone(明示失败)'); + assert(game.phase === 'lost', '服务器不可达 → LOST(不静默死屏)'); + await g(game, 'tap'); + assert(game.phase === 'title', 'LOST 单击回大厅'); + game.stop(); + await flush(); +} + +// ================================================================ Block 6 · snap 兜底(随机丢 ev 后收敛) + +realLog('== Block 6 · snap 兜底收敛 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await driveToWave(rig, conn); + + // 注入 4 只生成(s3 的 spawn ev "丢了"不注入 = 丢弃 25%+ 事件)+ 1 只幽灵怪 s9 + ws.inject(conn, evSpawn('s1', 'wolf', 'N', 8)); + ws.inject(conn, evSpawn('s2', 'wolf', 'E', 9)); + ws.inject(conn, evSpawn('s4', 'bear', 'S', 10)); + ws.inject(conn, evSpawn('s9', 'wolf', 'W', 8)); // 幽灵:snap 里不存在 + await flush(); + await advanceMs(2000); + // 仅凭 snap(2Hz 兜底)收敛:s3 补上、s9 删除、s1 eta 偏差 >0.5s 校正 + ws.inject(conn, snapMsg([ + { id: 's1', mkind: 'wolf', dir: 'N', eta: 5.4 }, + { id: 's2', mkind: 'wolf', dir: 'E', eta: 7.0 }, + { id: 's3', mkind: 'wolf', dir: 'NE', eta: 6.5 }, + { id: 's4', mkind: 'bear', dir: 'S', eta: 8.0 }, + ])); + await flush(); + const ids = [...game.mobs.keys()].sort(); + assert(JSON.stringify(ids) === JSON.stringify(['s1', 's2', 's3', 's4']), + 'snap 对账:缺失的 s3 补上、幽灵 s9 删除(id 集合收敛一致)'); + assert(Math.abs(game._mobEta(game.mobs.get('s1')) - 5.4) < 0.05, + 'snap 对账:s1 eta 偏差 0.6s > 0.5s → 按 snap 校正'); + assert(Math.abs(game._mobEta(game.mobs.get('s2')) - 7.0) < 0.05, 's2 eta 未偏差则不动'); + // 渲染收敛:仅 s1(5.4s)进听觉视野 + await advanceMs(600); + assert(game._loops.size === 1 && game._loops.has('s1'), '听觉视野按 snap 真值渲染(仅 eta≤6 的 s1)'); + game.stop(); + await flush(); +} + +// ================================================================ Block 7 · 拥挤控制 R2/R4(§8) + +realLog('== Block 7 · 拥挤控制:R2 喊话调度 / R4 one-shot 上限 =='); +{ + resetSettings('off'); + setProgress({ tutDone: true }); + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + const { conn } = await driveToLobby(rig); + await driveToWave(rig, conn); + + // ---- R4:波次生成潮 4 只同刻生成 → 呼啸并发 ≤2、同类间隔 ≥0.15s、队列 ≤2(超出丢弃) + const posLog0 = game._posLog.length; + ws.inject(conn, evSpawn('h1', 'wolf', 'N', 9)); + ws.inject(conn, evSpawn('h2', 'wolf', 'NE', 9)); + ws.inject(conn, evSpawn('h3', 'wolf', 'E', 9)); + ws.inject(conn, evSpawn('h4', 'wolf', 'W', 9)); + await flush(); + await advanceMs(2500); // 等队列消化完 + const howls = game._posLog.slice(posLog0).filter((r) => r.id === 'sfx/howl_wolf'); + assert(howls.length === 3, 'R4:生成潮呼啸队列深 ≤2,超出丢弃(4 只 → 播 3 声)'); + for (let i = 1; i < howls.length; i++) { + assert(howls[i].t - howls[i - 1].t >= 150, `R4:同类 one-shot 间隔 ≥0.15s(第 ${i} 间隔 ${howls[i].t - howls[i - 1].t}ms)`); + } + // one-shot 并发核算(SFX_DUR 账面):任意时刻 ≤2 + const DUR = { atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7, skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4, call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0 }; + const base = (id) => id.slice(id.lastIndexOf('/') + 1); + const posAll = game._posLog.slice(posLog0); + const pts = []; + for (const r of posAll) { + pts.push({ t: r.t, d: 1 }); + pts.push({ t: r.t + (DUR[base(r.id)] || 0.5) * 1000, d: -1 }); + } + pts.sort((a, b) => a.t - b.t || a.d - b.d); + let cur = 0, maxConcur = 0; + for (const p of pts) { cur += p.d; maxConcur = Math.max(maxConcur, cur); } + assert(maxConcur <= 2, `R4:定位 one-shot 同刻 ≤2 并发(峰值实测 ${maxConcur})`); + + // ---- R2:旁白在播时喊话延迟投递 ≤2s + game._playChain(['threat_few']); // 占住 voice(1.92s) + await flush(); + assert(audio.isVoicePlaying(), 'R2 前置:旁白在播'); + const npcLog0 = game._npcLog.length; + ws.inject(conn, evBotline(1, 'greet')); + await flush(); + assert(game._npcLog.length === npcLog0, 'R2:旁白在播时喊话不即时出声(延迟投递)'); + await advance(() => game._npcLog.length > npcLog0); + const greetRec = game._npcLog[npcLog0]; + assert(greetRec.id === 'sfx/npc_zheng_greet' && greetRec.t <= now, 'R2:旁白播完后喊话延迟投递成功(≤2s 内)'); + + // ---- R2:同刻在播 ≤1;待播队列深 ≤1(新到顶掉未播的过期句) + ws.inject(conn, evBotline(2, 'kill')); + ws.inject(conn, evBotline(1, 'cover')); + await flush(); + await advance(() => game._npcLog.length > npcLog0 + 1); + assert(game._npcLog.some((r) => r.id === 'sfx/npc_zheng_cover'), 'R2:待播位被新句顶掉后播出最新句'); + assert(!game._npcLog.some((r) => r.id === 'sfx/npc_zhou_kill'), 'R2:被顶掉的过期句不再播出(队列深 ≤1)'); + // 同刻 ≤1:逐条账目两两不重叠 + const npcs = game._npcLog.map((r) => ({ t: r.t, until: r.t + r.dur * 1000 })); + let overlap = false; + for (let i = 1; i < npcs.length; i++) if (npcs[i].t < npcs[i - 1].until - 1) overlap = true; + assert(!overlap, 'R2:bot 喊话同刻在播 ≤1(账目两两不重叠)'); + + // ---- R2:长旁白链期间待播超时丢弃(≤2s 延迟上限) + ws.inject(conn, snapMsg([])); // 清空怪表(否则内圈怪会让上滑变成出击) + await flush(); + await advanceMs(600); + await g(game, 'up'); // 状态链 4 段(~7.7s voice 占用) + await flush(); + assert(audio.isVoicePlaying(), 'R2 前置:状态链在播'); + const npcLog1 = game._npcLog.length; + ws.inject(conn, evBotline(2, 'wave')); + await advanceMs(9000); + assert(!game._npcLog.slice(npcLog1).some((r) => r.id === 'sfx/npc_zhou_wave'), + 'R2:旁白在播延迟 >2s → 喊话超时丢弃(氛围句可弃)'); + await advance(() => !audio.isVoicePlaying()); + + game.stop(); + await flush(); +} + +// ================================================================ Block 8 · 设置边界 + 座 3 哨音 + +realLog('== Block 8 · 设置边界 / 座 3 呼喊哨音 =='); +{ + resetSettings('off'); + // 设置边界(§7 全局断言):voice 切换下一句用对应目录 + const audio = new WebAudioEngine('audio/'); + await audio.unlock(); + settings.set({ voice: 'male' }); + const url0 = requestedUrls.length; + audio.play('voice/intro_1'); + await flush(); + assert(requestedUrls.slice(url0).some((u) => u.includes('voice/male/intro_1')), + 'settings.voice 切换后下一句用 male 目录'); + await advance(() => !audio.isVoicePlaying()); + // 键盘变体:input=key 时候选链前置 voice//key/ + settings.set({ voice: 'female', input: 'key' }); + const url1 = requestedUrls.length; + audio.play('voice/tut_rule'); + await flush(); + assert(requestedUrls.slice(url1).some((u) => u.includes('voice/female/key/tut_rule')), + 'input=key:候选链前置 key 变体目录(缺失回退主资产)'); + await advance(() => !audio.isVoicePlaying()); + settings.set({ input: 'touch' }); + // bgm=0:不加载不播放 + settings.set({ bgm: 0 }); + const url2 = requestedUrls.length; + audio.play('bgm/hunt'); + await flush(); + assert(!requestedUrls.slice(url2).some((u) => u.includes('/bgm/')), 'settings.bgm=0:无 bgm 层加载'); + settings.set({ bgm: 1.0 }); + resetSettings('off'); + + // 座 3 呼喊 = call_whistle 哨音(带 pan,§15 裁决 6) + const rig = makeRig(); + const { playLog, game, ws } = rig; + await rig.audio.unlock(); + game.start(); + await flush(); + await g(game, 'tap'); + const conn = ws.last(); + ws.open(conn); + await flush(); + ws.inject(conn, joinedMsg({ + slot: 3, char: null, sector: ['W', 'NW'], + roster: [ + { slot: 0, name: '甲', bot: false, char: 'yan', sector: ['N', 'NE'] }, + { slot: 1, name: '乙', bot: false, char: 'zheng', sector: ['E', 'SE'] }, + { slot: 2, name: '丙', bot: false, char: 'zhou', sector: ['S', 'SW'] }, + { slot: 3, name: '试炼猎人', bot: false, char: null, sector: ['W', 'NW'] }, + ], + })); + await flush(); + await g(game, 'tap'); // start + ws.inject(conn, evWave(1)); + await flush(); + await advance(() => !rig.audio.isVoicePlaying()); + await g(game, 'tap'); // 呼喊 + assert(game._npcLog.length === 0, '座 3 无角色声:不播 npc_*'); + const whistle = playLog.find((e) => e.id === 'sfx/call_whistle'); + assert(whistle && Math.abs(whistle.opts.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, + '座 3 呼喊 = call_whistle 哨音(带防区中心 pan≈-0.92)'); + assert(playsOf(playLog, 'voice/team_4') === 0, '(状态链 4 人局另行验证)'); + await g(game, 'up'); // 无 N 弧目标 → 状态链 + await advance(() => playsOf(playLog, 'voice/team_4') === 1); + assert(playsOf(playLog, 'voice/team_4') === 1, '4 人局状态链播 team_4'); + await advance(() => !rig.audio.isVoicePlaying()); + game.stop(); + await flush(); +} + +// ================================================================ Block 9 · 资产缺失兜底(看门狗推进不卡死) + +realLog('== Block 9 · 屏蔽 voice 资产:流程不卡死 =='); +{ + resetSettings('off'); + storageData.delete(SAVE_KEY); + blockedAssets.add('tut_attack'); // 屏蔽 T1 教学句 + const rig = makeRig(); + const { audio, playLog, game, ws } = rig; + await audio.unlock(); + game.start(); + await flush(); + await g(game, 'tap'); + const conn = ws.last(); + ws.open(conn); + await flush(); + ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); + await advance(() => !audio.isVoicePlaying()); + await g(game, 'tap'); // T0 → T1(tut_attack 缺失 → 单句看门狗兜底推进) + ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12)); + await flush(); + await g(game, 'up'); + ws.inject(conn, evKill('w1', 0, 'N')); + await advance(() => playsOf(playLog, 'voice/tut_kill_ok') === 1); + assert(playsOf(playLog, 'voice/tut_kill_ok') === 1 && game._tut === 2, + '屏蔽教学句资产:看门狗兜底推进,流程不卡死(T1 → T2)'); + blockedAssets.clear(); + game.stop(); + await flush(); +} + +// ================================================================ Block 10 · 静态审计(台词表 id 一致) + +realLog('== Block 10 · 静态审计:只引用台词表内 id(23-hunt-voice-ids.md)=='); +{ + const VOICE_IDS = new Set([ + // A 组(4) + 'intro_1', 'intro_2', 'room_wait', 'room_start', + // B 组(11) + 'tut_rule', 'tut_attack', 'tut_kill_ok', 'tut_callout', 'tut_callout_ok', + 'tut_skill', 'tut_skill_cd', 'tut_status', 'tut_breach', 'tut_done', 'tut_skip', + // C 组(6) + 'wave_band1', 'wave_band2', 'wave_band3', 'wave_band4', 'wave_band5', 'wave_clear', + // D 组(12) + 'hp_band1', 'hp_band2', 'hp_band3', 'hp_band4', + 'st_wave_a', 'st_wave_b', 'st_wave_c', 'team_3', 'team_4', + 'kill_band0', 'kill_band1', 'kill_band2', + // E 组(3) + 'threat_none', 'threat_few', 'threat_many', + // F 组(3) + 'breach', 'breach_heavy', 'danger_low', + // G 组(1) + 'skill_ready', + // H 组(9) + 'end_win', 'end_lose', 'grade_s', 'grade_a', 'grade_b', 'grade_c', 'end_rank', 'end_again', + // I 组(4) + 'net_lost', 'net_back', 'net_to_bot', 'net_gone', + // J 组(6) + 'sys_menu', 'sys_menu_resume', 'sys_menu_help', 'sys_menu_home', 'help_main', 'settings_hint', + // K 组(2) + 'gd_invalid', 'gd_idle', + ]); + const SFX_IDS = new Set([ + 'atk_whoosh', 'atk_swish', 'kill_wolf', 'kill_bear', 'skill_spin', 'breach_thud', + 'horn_wave', 'join_chime', 'call_whistle', 'howl_wolf', 'howl_bear', 'loop_wolf', 'loop_bear', + ]); + for (const char of ['yan', 'zheng', 'zhou']) { + for (const line of ['greet', 'help_call', 'help_ack', 'cover', 'kill', 'breach', 'skill', 'wave']) { + SFX_IDS.add(`npc_${char}_${line}`); + } + } + const BGM_IDS = new Set(['menu', 'hunt']); + let bad = 0; + for (const id of allPlayedIds) { + const slash = id.indexOf('/'); + const layer = id.slice(0, slash); + const name = id.slice(slash + 1); + if (layer === 'voice') { if (!VOICE_IDS.has(name)) { bad++; realLog(` ✗ 表外 voice id:${id}`); } } + else if (layer === 'sfx') { if (!SFX_IDS.has(name)) { bad++; realLog(` ✗ 表外 sfx id:${id}`); } } + else if (layer === 'bgm') { if (!BGM_IDS.has(name)) { bad++; realLog(` ✗ 表外 bgm id:${id}`); } } + } + assert(bad === 0, `全部播放 id 均在台词表/资产表内(共审计 ${allPlayedIds.size} 个不同 id)`); + + // §7 全局:全程无未捕获异常;资产缺失仅 console.error 不崩 + const unexpected = consoleErrs.filter((e) => !e.includes('资产缺失')); + assert(unexpected.length === 0, '全程无未捕获异常/非预期 console.error(资产缺失仅 console.error 不崩)'); +} + +// ================================================================ 汇总 + +realLog('\n========================================'); +realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); +if (failures.length) { + realLog('失败明细:'); + for (const f of failures) realLog(` - ${f}`); + process.exit(1); +} +realLog('全部断言通过。'); diff --git a/web/index.html b/web/index.html index 20e9de9c..f568bdfb 100644 --- a/web/index.html +++ b/web/index.html @@ -267,6 +267,12 @@
+ +

围猎 新作

+ HUNT · NIGHT HUNT +

联机协作守卫 · 2-4 人。听声辨向滑出出击、单击呼喊队友补位,守住营地篝火撑过五波,天亮就赢。

+

+

不眠频率 新作

RADIO · MIDNIGHT HOTLINE diff --git a/web/profiles.js b/web/profiles.js index 62389e9c..bd6e6f13 100644 --- a/web/profiles.js +++ b/web/profiles.js @@ -31,6 +31,7 @@ export const BUCKETS = [ { key: 'soundgame.progress.dungeon', game: 'dungeon' }, { key: 'soundgame.progress.radio', game: 'radio' }, { key: 'soundgame.progress.abyss', game: 'abyss' }, + { key: 'soundgame.progress.hunt', game: 'hunt' }, { key: 'soundgame.progress.detective', game: 'detective' }, { key: 'soundgame.rhythm.progress', game: 'rhythm' }, { key: 'soundgame.simon.best', game: 'simon' }, diff --git a/web/server/README.md b/web/server/README.md index b4272549..18d62274 100644 --- a/web/server/README.md +++ b/web/server/README.md @@ -11,6 +11,16 @@ P1 增补**配对码跨设备**(`01 §4.2` 旅程 3):老设备在「档案 生成 6 位配对码(逐位朗读),新设备输入即可把本机进度按桶 LWW 合并进老档案; token 180 天滑动过期,到期后大厅引导「重新登录或新建」。 +23 增补**《围猎》联机 WebSocket**(设计 `docs/design/23-hunt.md` §6):同一 HTTP +服务器、同一端口上新增 `GET /ws/hunt` 升级端点(`ws.js` 手写握手与帧编解码,零依赖), +房间管理与服务端权威战斗模拟在 `hunt-room.js`(全内存,不落盘)。协议见下文 +[《围猎》WS 协议](#围猎wshunt-协议23-huntmd-6)。 + +> 模块格式标记:本目录 `package.json` 仅有 `{"type":"commonjs"}`(私有、零依赖、 +> 无可安装内容)——祖先目录 `AutoVideo/package.json` 带 `"type":"module"`,会把本目录 +> `.js` 误判为 ESM 导致 server.js 无法启动;该标记把本目录 `.js` 固定回 CommonJS。 +> (`test/*.mjs` 恒为 ESM,不受影响。) + ## 启动 ```bash @@ -150,6 +160,70 @@ PORT=8001 SG_DATA_DIR=/tmp/sg-data tools/node/bin/node web/server/server.js `{ "error": "…", "game": "rpg", "server": { "updatedAt": "…", "rev": 4, "data": { … } } }` ——客户端应比较 `data.updatedAt` 择优后带新基线重试(大厅已实现) +## 《围猎》WS(/ws/hunt)协议(23-hunt.md §6) + +联机守卫游戏《围猎》的实时通道:零依赖 WebSocket(`ws.js` 手写握手 + 帧编解码, +仅 node:crypto/node:events)+ 房间与服务端权威战斗模拟(`hunt-room.js`,全内存 +不落盘,单实例约束不恶化)。字段级契约的唯一来源是 `docs/design/23-hunt.md` §6, +本节是实现侧的运行/调试说明。 + +### 传输(§6.1,ws.js) + +- 端点 `GET /ws/hunt`;其它路径的 upgrade 请求一律 404 断开。 +- 握手校验 `Upgrade: websocket` / `Connection: …upgrade…` / `Sec-WebSocket-Key` / + `Sec-WebSocket-Version: 13`,回 `101` + `Sec-WebSocket-Accept`。 +- 帧纪律:客户端帧必须带 mask(否则 close 1002);支持 opcode 0/1/8/9/10; + 二进制帧 close 1003;单条消息 >4KB close 1009;非法 UTF-8 close 1007; + RSV/未知 opcode/控制帧分片 close 1002。服务器帧不 mask。 +- 活性:服务器每 15s ping,**45s 无 pong 判死**(等同断线,走 §6.6 宽限)。 +- 速率帽:每连接 20 条应用消息/s,超限 err `rate`,10s 内再犯 close 1008。 +- 背压:每连接待发队列;`snap` 可丢(新顶旧),`joined/ev/err` 不丢;队列 >200 close 1013。 +- 连接后 5s 内必须发 `join`,否则 close 1008。 + +### 消息骨架 + +- C→S 三条:`join`(`{t,ver:1,room:"quick"|4位房码,name,teach?,resume?}`)、 + `act`(`{t,kind:"attack"|"callout"|"skill"|"start",dir?,seq,at}`)、`leave`。 +- S→C 四类:`joined` / `ev`(11 种 kind:spawn/kill/miss/skill/breach/wave/ + waveclear/callout/botline/roster/end,带房间单调时钟 `ts`)/ `snap`(2Hz 全量兜底: + mobs + slots 冷却)/ `err`(code ∈ cooldown|skill_cd|bad_dir|not_ingame|room_full| + room_gone|bad_msg|rate)。字段逐条以 23-hunt.md §6.2/§6.3 表为准。 +- `roster`(joined 内与 ev 内同构)每条目附带 `alive`(断线广播 false)—— + 是 §6.3 字段表的显式超集,断线/回槽通知所必需。 + +### 房间生命周期(§6.4-§6.6 实现要点) + +- 快速匹配复用 `phase='lobby'` 未满房;显式房码 join 不存在即以该码建房; + 对局中/教学房 join → err `room_full`。 +- lobby 上限 12s(自建房起算);任一人类 `act start` 或满 4 人立即开局; + 开局 bot 补位至 max(3, 人类数),第 4 座仅 4 人局启用(座 3 `char=null`, + 该槽喊话/呼喊由客户端哨音处理,服务器不发其 botline)。 +- 教学房:`join teach:true` → 强制单人新房、跳过 lobby 直入 TEACH(joined.state + .phase='teach'),教学波 3 狼(方位 N,逼近 12s,突入零惩罚);狼 1 未杀则补放 + 至多 2 次(对应"做错重教 ≤2 次后放过"),教学段 bot AI 挂起但呼喊必答 + help_ack;教学波出清后休整 8s 同场接续第 1 波。 +- 断线(含 45s 无 pong)→ 槽位 alive=false(roster 广播)+ 30s 宽限静默; + 宽限内带 `resume` 重连 → 重发 joined(同 slot/seed/char/resume)+ 紧随 snap; + 超时:剩余人类 ≥3 → 槽位移除,否则转 bot(不可逆,char 沿用、播 cover、 + 冷却延续不清零);lobby/end 阶段超时直接移除。最后一个人类宽限耗尽 → 房灭 + (bot 不续命)。`leave` 立即按同一规则处理(无宽限)。房灭/转 bot 后迟到的 + resume → err `room_gone`。终局后房间保留 30s 再对全员 close 1000。 +- 防区:槽位号入房后不变;开局时按**在座顺序**分配 §3.3 防区表(roster ev 下发最终 + sector)——中途离开造成槽位空洞时,槽 3 玩家可能拿到 3 人局第 3 防区(char 仍 null), + 属有意的边界处理。 +- seed 建房即掷(joined.seed);刷怪剧本 `rng(wave)=mulberry32((seed^(wave*0x9E3779B9))>>>0)` + + 座位数轮转发牌,同 seed 同剧本;bot 反应带 rng 走独立的 seed 派生流。 +- botline/callout 喊话节流(§7.4):10s 窗氛围句(greet/cover/kill/breach/skill/wave) + ≤2 条超出丢弃;`ev callout` 与 `botline help_ack` 为优先级句永不被氛围顶掉 + (其速率由 5s/座呼喊节流与"一次呼喊一次应答"源头约束)。 + +### 时钟与测试钩子 + +房间一切时间逻辑由 `manager.tick(dtMs)` 推进(本进程 100ms setInterval 驱动); +测试直接 import `hunt-room.js`、用 FakeConn 替身连接 + 快进 tick 即可确定性复现 +(`createManager({ rateLimit: Infinity })` 关闭墙钟速率帽,`joinTimeoutMs` 可调)。 +导出 `buildWaveScript/gradeFor/mulberry32/DIRS/LAYOUTS/WAVE_TABLE` 等供断言。 + ## 数据格式 ``` @@ -184,10 +258,24 @@ web/server/data/ ## 测试 ```bash -tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖) +tools/node/bin/node web/server/test/api-smoke.mjs # API 冒烟 + 大厅分桶纯函数单测(零依赖) +tools/node/bin/node web/server/test/hunt-sim.test.mjs # 《围猎》房间/战斗模拟 + WS 传输冒烟(零依赖) +tools/node/bin/node web/server/test/hunt-balance-scan.mjs 24 # bot 难度多种子平衡扫描(开发工具) ``` -覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/ +api-smoke 覆盖:device 建档/重附着、进度 PUT-GET-LWW 冲突(含 legacy 放行)、claim 命名/设 PIN/ 合并、旧用户登录迁移、per-IP 频控、/server/ 静态封禁、localStorage 分桶逻辑(H1-H6)、 配对码全流程(I1:start 规约/join 桶级 LWW 合并/单次作废/无 token 转入/幂等/日志脱敏)、 配对码防护(I2:join 5 连错封锁、start per-IP 频控)、token 过期 401 路径(J1)。 + +hunt-sim 覆盖(13-playtest-criteria.md §24 服务器侧条目;固定 seed + 快进 tick 确定性复现): +1 真人+2 bot 打完 5 波全流程(逐波核对 §3.4 剧本、击杀账与 ev end 评级/耐久一致、 +snap 逐 tick 与房间真值一致、greet 间隔 ≥1.5s)、seed 复现、断线 30s 内 resume 回槽 +(同 slot/seed/char + 紧随 snap)、宽限超时转 bot(char 沿用、转后产出 act、cover 喊话)、 +4 人局剩余 ≥3 改槽位移除、leave 立即转 bot、出击冷却 2.5s/旋风斩 8s/呼喊 5s 与 +bad_dir/not_ingame/bad_msg、出击弧 ±45° 与 eta≤4 近战窗、突入狼 -1/熊 -2、失守立即终局 +(grade:null 即"失守不写榜"的服务器侧口径)、终局 30s 后 close 1000 房灭回收、 +教学波突入零惩罚与同场接续第 1 波、lobby 12s 自动开局/满 4 人开局/房码 join、 +呼喊→bot 补位偏置 ×2(下次出击方向落入呼喊者防区,help_ack 恰一次)、 +喊话 10s ≤2 条氛围节流与呼救优先、速率帽与 join 超时;WS 真机冒烟(手写握手/帧客户端): +101 与 Accept 哈希、无 mask→1002、二进制→1003、ping→pong、分片拼装、静态/REST 回归。 diff --git a/web/server/hunt-room.js b/web/server/hunt-room.js new file mode 100644 index 00000000..80e482e1 --- /dev/null +++ b/web/server/hunt-room.js @@ -0,0 +1,1021 @@ +/** + * hunt-room.js — 《围猎》(web/hunt) 房间管理器 + 服务端权威战斗模拟(零依赖,CommonJS)。 + * + * 设计契约:docs/design/23-hunt.md §3/§5/§6/§7(协议字段以 §6 为唯一契约)。 + * 职责(§6.4-§6.6、§7 的权威实现): + * - 房间:快速匹配 / 显式 4 位房码、lobby ≤12s(act start / 满 4 人立即开局)、 + * 开局 bot 补位至 max(3, 人类数)、断线 30s 宽限 resume 回槽、超时转 bot + * (剩余人类 ≥3 改为槽位移除)、房灭回收、终局保留 30s 后 close 1000。 + * - 战斗:波次表(§3.4)刷怪剧本(§3.5 seed 复现)、100ms tick 推进 eta、突入扣耐久 + * (狼 -1 / 熊 -2)、出击弧 ±45° + eta≤4 近战窗、冷却 2.5s / 旋风斩 8s / 呼喊节流 5s、 + * 评级结算(§3.6,ev end.grade 服务端直发)。教学房(§5):强制单人、教学波 3 狼 + * 零惩罚(突入不扣耐久)、教学段 bot AI 挂起(呼喊必答)、教学波完接续第 1 波。 + * - bot(§7):与真人同一套裁决管线;500ms AI tick:P(d)=Σ(1/eta) 方向压力 + + * 主防区 ×1.5 偏置 + 反应带(eta ≤ 2.2+rng×1.2,rng 走房间 seed);旋风斩 + * (全场 eta≤3 ≥3);压力呼救(主防区 eta≤6 ≥3,20s 节流);补位(§7.3)与 + * 喊话调度(§7.4:全局 10s 窗氛围 ≤2 条,callout/help_ack 优先不丢)。 + * + * 时钟模型:房间一切时间逻辑由 tick(dtMs) 推进——生产环境 server.js 以 100ms + * setInterval 驱动;测试直接快进 tick 复现(test/hunt-sim.test.mjs)。 + * 消息 ts = 房间单调时钟 ms(开局为 0,§6.3);内部一律用 now(建房起单调 ms)。 + * + * 连接契约(与 ws.js 的 WsConn 对齐,测试用 FakeConn 替身): + * conn: EventEmitter,'message'(jsonString) / 'close'();方法 send(str, {droppable}) 、 + * close(code);管理器在 attach 时挂上 joinedRoom/slotId/限流状态。 + */ +'use strict'; + +const crypto = require('node:crypto'); + +// ---------------- 常量(§3 数值的权威实现) ---------------- + +const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; +const DIR_IDX = Object.fromEntries(DIRS.map((d, i) => [d, i])); +const CHARS = ['yan', 'zheng', 'zhou', null]; // 座位角色(§3.3;座 3 恒 null,呼喊用哨音) +const CHAR_NAMES = { yan: '阿岩', zheng: '小筝', zhou: '老周' }; +// §3.3 防区表(center 供客户端取呼喊/喊话 pan 方位) +const LAYOUTS = { + 3: [ + { sector: ['N', 'NE', 'E'], center: 'NE' }, + { sector: ['SE', 'S', 'SW'], center: 'S' }, + { sector: ['W', 'NW'], center: 'WNW' }, + ], + 4: [ + { sector: ['N', 'NE'], center: 'NNE' }, + { sector: ['E', 'SE'], center: 'ESE' }, + { sector: ['S', 'SW'], center: 'SSW' }, + { sector: ['W', 'NW'], center: 'WNW' }, + ], +}; +// §3.4 波次表 +const WAVE_TABLE = [ + null, + { wolf: 4, bear: 0, wolfEta: 10.0, bearEta: 0, gap: 3.0 }, + { wolf: 6, bear: 0, wolfEta: 9.5, bearEta: 0, gap: 2.5 }, + { wolf: 7, bear: 1, wolfEta: 9.0, bearEta: 12.0, gap: 2.2 }, + { wolf: 8, bear: 1, wolfEta: 8.5, bearEta: 11.5, gap: 1.8 }, + { wolf: 10, bear: 2, wolfEta: 8.0, bearEta: 11.0, gap: 1.5 }, +]; +const LAST_WAVE = 5; +const DUR_MAX = 10; +const MELEE_ETA = 4.0; // 内圈近战窗(§3.1) +const ATTACK_CD_MS = 2500; +const SKILL_CD_MS = 8000; +const CALLOUT_THROTTLE_MS = 5000; +const LOBBY_MS = 12000; +const GRACE_MS = 30000; +const END_KEEP_MS = 30000; +const REST_MS = 8000; // 波间休整 +const WAVE1_DELAY_MS = 2000; // 开局到 ev wave 1 的间隔(room_start 播报留白) +const TICK_MS = 100; +const BOT_TICK_MS = 500; +const SNAP_MS = 500; // 2Hz +const ROOM_CODE_CHARS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; // §6.2 无歧义字符集 +const BOTLINE_WINDOW_MS = 10000; // §7.4 全局节流窗口 +const BOTLINE_BUDGET = 2; // 窗口内氛围句上限 +const GREET_FIRST_MS = 1000; +const GREET_GAP_MS = 1600; // ≥1.5s(§7.4 greet 间隔) +const COVER_MS = 10000; // §7.3 补位偏置持续 10s +const RATE_PER_SEC = 20; // §6.1 应用消息速率帽 +const RATE_STRIKE_MS = 10000; // 再犯判定窗 + +// §7.2 bot 难度校准(§15 裁决 10 调参;初版 2.2+1.2r 无犹豫实测零泄漏过强): +// 反应带更迟、出手决策带空转概率(500ms tick 内愣神)、旋风斩节制。 +// 目标口径见 §3.4:挂机局失守/C、3 bot 水准局 A/B。默认值经 +// test/hunt-balance-scan.mjs 多种子扫描选定;改动只涉及服务端 AI 参数,不动协议字段。 +const BOT_TUNING_DEFAULT = { + reactBase: 2.0, // 反应带:eta ≤ reactBase + rng×reactSpan 才出手 + reactSpan: 1.0, + hesitateP: 0.8, // 决策空转概率(500ms tick 内 80% 愣神——bot 有效出手周期 ~4.5s) + skillEta: 2.5, // 旋风斩触发:全场 eta≤skillEta 的怪 ≥skillMin 且 60% 想起 + skillMin: 3, + skillP: 0.6, +}; + +// ---------------- 确定性随机(§3.5/§7.2) ---------------- + +function mulberry32(a) { + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** §3.5:rng(wave) = mulberry32((seed ^ (wave * 0x9E3779B9)) >>> 0) */ +function waveRng(seed, wave) { + return mulberry32((seed ^ (wave * 0x9E3779B9)) >>> 0); +} + +/** + * §3.5 刷怪剧本:同 seed → 同剧本(可复现断言)。 + * 怪物按座位数轮转发牌到各座位防区内的随机方位;熊确定性洗牌混入。 + * 返回 [{ at, mkind, dir, seat, eta }](at = 波开始后的 ms;首只 = 1×gap)。 + */ +function buildWaveScript(seed, wave, seatCount) { + const spec = WAVE_TABLE[wave]; + const rng = waveRng(seed, wave); + const layout = LAYOUTS[seatCount]; + const mkinds = []; + for (let i = 0; i < spec.wolf; i++) mkinds.push('wolf'); + for (let i = 0; i < spec.bear; i++) mkinds.push('bear'); + for (let i = mkinds.length - 1; i > 0; i--) { // Fisher-Yates,走波次 rng + const j = Math.floor(rng() * (i + 1)); + [mkinds[i], mkinds[j]] = [mkinds[j], mkinds[i]]; + } + return mkinds.map((mkind, i) => { + const seat = i % seatCount; + const sector = layout[seat].sector; + const dir = sector[Math.floor(rng() * sector.length)]; + return { + at: (i + 1) * spec.gap * 1000, + mkind, + dir, + seat, + eta: mkind === 'bear' ? spec.bearEta : spec.wolfEta, + }; + }); +} + +/** §3.6 评级(服务端直发,客户端不算分) */ +function gradeFor(dur) { + if (dur >= 9) return 'S'; + if (dur >= 7) return 'A'; + if (dur >= 4) return 'B'; + if (dur >= 1) return 'C'; + return null; +} + +function round2(x) { + return Math.round(x * 100) / 100; +} + +function arcOf(dir) { // 出击弧 = dir±45°(3 个罗盘向,§3.2) + const c = DIR_IDX[dir]; + return [(c + 7) % 8, c, (c + 1) % 8]; +} + +// ---------------- 房间 ---------------- + +class Room { + constructor(manager, { code, teach = false, seed } = {}) { + this.manager = manager; + this.code = code; + this.teach = !!teach; + this.seed = (seed === undefined ? crypto.randomInt(0, 2 ** 32) : seed) >>> 0; + this.phase = 'lobby'; // lobby / teach / wave / end + this.now = 0; // 建房起单调 ms(tick 推进) + this.startedAt = null; // 开局时刻(now 基准);ts = now - startedAt + this.slots = [null, null, null, null]; + this.seatCount = 0; // 开局定(3 或 4,§3.3) + this.mobs = new Map(); // id -> { id, mkind, dir, eta, breached? } + this.nextMobId = 1; + this.wave = 0; + this.dur = DUR_MAX; + this.killsBySlot = {}; // 结算个人击杀账(含已离场槽位) + this.script = null; // 当前波剧本(atTs 绝对 ts) + this.scriptIdx = 0; + this.pendingWave = null; // { wave, atTs } 波间休整/开局延迟 + this.teachState = null; + this.botRng = mulberry32((this.seed ^ 0xB07A17) >>> 0); // bot 反应带流(§7.2 复现用) + this.botTuning = manager.botTuning; // §15 裁决 10 调参(默认 BOT_TUNING_DEFAULT) + this.lineLog = []; // 喊话/callout 发出时刻(§7.4 全局节流) + this.pendingLines = []; // [{ at, slot, line }](greet 间隔调度,at 为 ts) + this.waveLineIdx = 0; + this.lastBotAt = -Infinity; + this.lastSnapAt = -Infinity; + this.endAt = null; + this.destroyed = false; + } + + ts() { return this.startedAt === null ? 0 : this.now - this.startedAt; } + occupiedSlots() { return this.slots.filter(Boolean); } + humanCount() { return this.slots.filter((s) => s && !s.bot).length; } + botSlots() { return this.slots.filter((s) => s && s.bot); } + + // ---------------- 槽位 ---------------- + + _newSlot(slotId, { bot, conn, name }) { + const provisional = slotId <= 2 ? LAYOUTS[3][slotId] : LAYOUTS[4][3]; + return { + slot: slotId, + name, + bot, + char: CHARS[slotId], + sector: provisional.sector.slice(), // 开局时按最终布局重排(roster ev 下发) + center: provisional.center, + conn: conn || null, + alive: true, // bot 恒 true;人类=连接存活 + resume: bot ? null : crypto.randomBytes(8).toString('hex'), // 16 hex,槽位存活期不变 + cdUntil: 0, + skillCdUntil: 0, + calloutUntil: 0, + lastSeq: -1, + graceUntil: 0, + kills: 0, + // bot 状态(§7) + lastActAt: -Infinity, + lastCalloutAt: -Infinity, + lastKillLineAt: -Infinity, + lastBreachLineAt: -Infinity, + recentKills: [], + cover: null, // { callerSlot, callerSector, until, killDone, settled } + }; + } + + addHuman(slotId, conn, name) { + const slot = this._newSlot(slotId, { bot: false, conn, name }); + this.slots[slotId] = slot; + return slot; + } + + addBot(slotId) { + const char = CHARS[slotId]; + const slot = this._newSlot(slotId, { bot: true, name: CHAR_NAMES[char] || '老猎手' }); + this.slots[slotId] = slot; + return slot; + } + + removeSlot(slot) { + this.slots[slot.slot] = null; + if (slot.resume) this.manager.resumeIndex.delete(slot.resume); + this.broadcastRoster(); + } + + /** §6.6 宽限超时/主动 leave 的槽位转 bot(不可逆);char 沿用座角色,播 cover。 */ + convertToBot(slot) { + if (slot.resume) this.manager.resumeIndex.delete(slot.resume); + slot.resume = null; + slot.bot = true; + slot.conn = null; + slot.alive = true; + slot.name = CHAR_NAMES[slot.char] || '老猎手'; + slot.lastActAt = -Infinity; + slot.lastCalloutAt = -Infinity; + slot.cover = null; + // 冷却不清零:真人用过的冷却延续到 bot(§7.3 公平口径) + this.broadcastRoster(); + this.tryBotline(slot, 'cover'); // 转 bot 接管(§7.4 cover 触发源) + } + + // ---------------- 消息发送 ---------------- + + roster() { + return this.occupiedSlots() + .sort((a, b) => a.slot - b.slot) + .map((s) => ({ + slot: s.slot, name: s.name, bot: s.bot, char: s.char, sector: s.sector, alive: s.alive, + })); + } + + /** 房间内广播(序列化一次);bots/断线槽无 conn 自动跳过。 */ + broadcast(obj, opts) { + const str = JSON.stringify(obj); + for (const s of this.occupiedSlots()) { + if (s.conn) s.conn.send(str, opts); + } + } + + /** ev 事件流(§6.3;即时下发,不丢) */ + broadcastEv(fields) { + this.broadcast({ t: 'ev', ts: this.ts(), ...fields }); + } + + broadcastRoster() { + this.broadcastEv({ kind: 'roster', roster: this.roster() }); + } + + sendJoined(slot) { + this.manager.sendMsg(slot.conn, { + t: 'joined', + room: this.code, + slot: slot.slot, + resume: slot.resume, + char: slot.char, + sector: slot.sector, + seed: this.seed, + state: { phase: this.phase, wave: this.wave, dur: this.dur }, + roster: this.roster(), + }); + } + + buildSnap() { + return { + t: 'snap', + ts: this.ts(), + phase: this.phase, + wave: this.wave, + dur: this.dur, + mobs: [...this.mobs.values()].map((m) => ({ + id: m.id, mkind: m.mkind, dir: m.dir, eta: round2(m.eta), + })), + slots: this.occupiedSlots().sort((a, b) => a.slot - b.slot).map((s) => ({ + slot: s.slot, + alive: s.alive, + bot: s.bot, + cd: round2(Math.max(0, s.cdUntil - this.now) / 1000), + skillCd: round2(Math.max(0, s.skillCdUntil - this.now) / 1000), + })), + }; + } + + /** snap 可丢(新顶旧,§6.1 背压纪律) */ + broadcastSnap() { + this.broadcast(this.buildSnap(), { droppable: true }); + } + + sendErrTo(slot, code, msg) { + if (slot.conn) this.manager.sendErr(slot.conn, code, msg); + } + + // ---------------- 开局 / 波次 ---------------- + + /** 开局:bot 补位至 max(3, 人类数);防区按在座顺序分配;教学房进 TEACH。 */ + startGame() { + if (this.phase !== 'lobby') return; + const humans = this.humanCount(); + const target = Math.max(3, humans); + for (let i = 0; i < target; i++) { + if (!this.slots[i]) this.addBot(i); + } + const occupied = this.occupiedSlots().sort((a, b) => a.slot - b.slot); + this.seatCount = occupied.length; // 3 或 4(槽 3 有人类占用时按 4 猎人布局) + const layout = LAYOUTS[this.seatCount]; + occupied.forEach((s, i) => { + s.sector = layout[i].sector.slice(); + s.center = layout[i].center; + }); + this.startedAt = this.now; + this.phase = this.teach ? 'teach' : 'wave'; + this.broadcastRoster(); // 补位完成花名册 + this.botSlots().sort((a, b) => a.slot - b.slot).forEach((b, i) => { // §7.4 greet 逐只 ≥1.5s + this.pendingLines.push({ at: GREET_FIRST_MS + i * GREET_GAP_MS, slot: b.slot, line: 'greet' }); + }); + if (this.teach) { + // §5 教学波:3 狼、逼近 12s、突入零惩罚。狼 1 至多补放 2 次(做错重教 ≤2 次)。 + this.teachState = { stage: 1, attempts: 0, currentMob: null, nextAt: 6000, spawn23: null, done: false }; + } else { + this.pendingWave = { wave: 1, atTs: WAVE1_DELAY_MS }; + } + } + + startWave(n) { + this.phase = 'wave'; + this.wave = n; + this.broadcastEv({ kind: 'wave', wave: n }); + const base = this.ts(); + this.script = buildWaveScript(this.seed, n, this.seatCount) + .map((e) => ({ ...e, atTs: base + e.at })); + this.scriptIdx = 0; + if (n >= 2) { // §7.4 wave 喊话:第 2 波起轮转一只 bot + const bots = this.botSlots().sort((a, b) => a.slot - b.slot); + if (bots.length) this.tryBotline(bots[this.waveLineIdx++ % bots.length], 'wave'); + } + } + + /** + * 刷怪(§3.5 防叠声:同方位同刻最多 2 只在场,必要时同防区内换向)。 + * 独立成方法供剧本与测试钩子共用。 + */ + spawnMob({ mkind, dir, eta, seat }) { + let d = dir; + const countAt = (dd) => [...this.mobs.values()].filter((m) => m.dir === dd).length; + if (countAt(d) >= 2) { + const sector = seat !== undefined && this.seatCount ? LAYOUTS[this.seatCount][seat].sector : DIRS; + const alt = sector.find((dd) => countAt(dd) < 2); + if (alt) d = alt; + } + const id = this.nextMobId++; + const mob = { id, mkind, dir: d, eta }; + this.mobs.set(id, mob); + this.broadcastEv({ kind: 'spawn', id, mkind, dir: d, eta }); + return mob; + } + + breachMob(mob) { + this.mobs.delete(mob.id); + mob.breached = true; + if (this.phase !== 'teach') { // §5 教学波突入零惩罚(不扣耐久) + this.dur = Math.max(0, this.dur - (mob.mkind === 'bear' ? 2 : 1)); + } + this.broadcastEv({ kind: 'breach', id: mob.id, mkind: mob.mkind, dir: mob.dir, dur: this.dur }); + // §7.4 breach 喊话:本防区漏怪(20s/只节流,氛围预算) + const guard = this.botSlots() + .filter((b) => b.char && b.sector.includes(mob.dir) && this.now - b.lastBreachLineAt >= 20000) + .sort((a, b) => a.slot - b.slot)[0]; + if (guard) { + guard.lastBreachLineAt = this.now; + this.tryBotline(guard, 'breach'); + } + if (this.dur <= 0 && this.phase !== 'teach') this.endGame(false); // 失守:立即终局 + } + + // ---------------- 裁决(§3.2 服务端权威) ---------------- + + adjudicateAttack(slot, dir) { + if (this.now < slot.cdUntil) { + return this.sendErrTo(slot, 'cooldown', '出击冷却中(2.5 秒)'); + } + slot.cdUntil = this.now + ATTACK_CD_MS; // 挥空也进冷却(与客户端本地预扣一致) + slot.lastActAt = this.now; + const arc = arcOf(dir); + let best = null; + for (const mob of this.mobs.values()) { + if (!arc.includes(DIR_IDX[mob.dir]) || mob.eta > MELEE_ETA) continue; + if (!best || mob.eta < best.eta) best = mob; + } + if (!best) { + this.broadcastEv({ kind: 'miss', slot: slot.slot, dir }); + return; + } + this.mobs.delete(best.id); + this.broadcastEv({ kind: 'kill', id: best.id, slot: slot.slot, dir: best.dir, mkind: best.mkind }); + this.creditKill(slot, best); + } + + adjudicateSkill(slot) { + if (this.now < slot.skillCdUntil) { + return this.sendErrTo(slot, 'skill_cd', '旋风斩冷却中(8 秒)'); + } + slot.skillCdUntil = this.now + SKILL_CD_MS; + slot.lastActAt = this.now; + const clearedMobs = [...this.mobs.values()].filter((m) => m.eta <= MELEE_ETA); + for (const mob of clearedMobs) this.mobs.delete(mob.id); + this.broadcastEv({ kind: 'skill', slot: slot.slot, cleared: clearedMobs.map((m) => m.id) }); + for (const mob of clearedMobs) this.creditKill(slot, mob); + if (slot.bot && clearedMobs.length) this.tryBotline(slot, 'skill'); + } + + adjudicateCallout(slot) { + if (this.now < slot.calloutUntil) { + return this.sendErrTo(slot, 'cooldown', '呼喊太频繁(5 秒节流)'); + } + slot.calloutUntil = this.now + CALLOUT_THROTTLE_MS; + slot.lastActAt = this.now; + this._logLine(); // callout 计入喊话窗(氛围句须让路,§7.4) + this.broadcastEv({ kind: 'callout', slot: slot.slot }); + // §7.3 补位:空闲 bot(500ms 未出手、座位号小者优先)必答 help_ack + 10s 偏置 + const responder = this.botSlots() + .filter((b) => b.slot !== slot.slot && this.now - b.lastActAt >= BOT_TICK_MS) + .sort((a, b) => a.slot - b.slot)[0]; + if (responder) { + this.emitBotline(responder, 'help_ack', { priority: true }); // 呼救语义不可被顶掉 + responder.cover = { + callerSlot: slot.slot, + callerSector: slot.sector.slice(), + until: this.now + COVER_MS, + killDone: false, + settled: false, + }; + } + } + + creditKill(slot, mob) { + slot.kills += 1; + this.killsBySlot[String(slot.slot)] = slot.kills; + if (slot.cover && !slot.cover.settled && slot.cover.callerSector.includes(mob.dir)) { + slot.cover.killDone = true; + } + if (slot.bot && slot.char) { // §7.4 kill 喊话:杀熊或三连杀,15s/只节流 + slot.recentKills.push(this.now); + slot.recentKills = slot.recentKills.filter((t) => this.now - t <= 6000); + const triple = slot.recentKills.length >= 3; + if ((mob.mkind === 'bear' || triple) && this.now - slot.lastKillLineAt >= 15000) { + slot.lastKillLineAt = this.now; + this.tryBotline(slot, 'kill'); + } + } + } + + // ---------------- bot 喊话调度(§7.4 + §8 R2 服务端纪律) ---------------- + + _logLine() { + this.lineLog.push(this.now); + } + + /** + * 全局节流:10s 窗氛围 ≤2 条,超出丢弃;priority(callout/help_ack)永远投递—— + * 呼救语义不可被氛围句顶掉(其速率已由 5s/座源头节流约束)。 + */ + emitBotline(slot, line, { priority = false } = {}) { + if (!slot.char) return; // 座 3 无角色声(§15 裁决 6,客户端用哨音;无 bot 在座 3) + const cutoff = this.now - BOTLINE_WINDOW_MS; + this.lineLog = this.lineLog.filter((t) => t > cutoff); + if (!priority && this.lineLog.length >= BOTLINE_BUDGET) return; // 丢弃氛围句 + this._logLine(); + this.broadcastEv({ kind: 'botline', slot: slot.slot, line }); + } + + tryBotline(slot, line) { + this.emitBotline(slot, line, { priority: false }); + } + + // ---------------- bot AI(§7.2,500ms tick,确定性) ---------------- + + /** 方向压力 P(d)=Σ(1/eta)(仅计 eta≤4);主防区 ×1.5;补位期呼喊者防区 ×2.0(覆盖主防区偏置)。 */ + botPickDir(bot) { + const cov = bot.cover && this.now < bot.cover.until ? bot.cover : null; + let bestDir = null; + let bestP = 0; + for (let d = 0; d < 8; d++) { + let p = 0; + for (const m of this.mobs.values()) { + if (DIR_IDX[m.dir] === d && m.eta <= MELEE_ETA) p += 1 / Math.max(m.eta, 0.1); + } + if (p <= 0) continue; + if (cov ? cov.callerSector.includes(DIRS[d]) : bot.sector.includes(DIRS[d])) { + p *= cov ? 2.0 : 1.5; + } + if (p > bestP) { bestP = p; bestDir = DIRS[d]; } + } + return bestDir; + } + + /** §7.2 单 slot 的 500ms 决策(bot 由 botTick 驱动;平衡仿真脚本可对真人槽复用同一口径)。 */ + botDecide(bot) { + const tune = this.botTuning; + // 旋风斩:全场 eta≤skillEta ≥skillMin 只 + 技能就绪 + skillP 想起(裁决 10 节制) + if (this.now >= bot.skillCdUntil) { + const close = [...this.mobs.values()].filter((m) => m.eta <= tune.skillEta).length; + if (close >= tune.skillMin && this.botRng() < tune.skillP) this.adjudicateSkill(bot); + } + // 出击:冷却就绪 + max P 方向 + 反应带 + 犹豫空转(裁决 10) + if (this.now >= bot.cdUntil) { + const dir = this.botPickDir(bot); + if (dir) { + const arc = arcOf(dir); + let best = null; + for (const m of this.mobs.values()) { + if (!arc.includes(DIR_IDX[m.dir]) || m.eta > MELEE_ETA) continue; + if (!best || m.eta < best.eta) best = m; + } + if (best + && best.eta <= tune.reactBase + this.botRng() * tune.reactSpan + && this.botRng() >= tune.hesitateP) { + this.adjudicateAttack(bot, dir); + } + } + } + // 呼救:主防区压力 ≥3(eta≤6)且 20s 节流 + if (this.now - bot.lastCalloutAt >= 20000) { + const pressure = [...this.mobs.values()] + .filter((m) => bot.sector.includes(m.dir) && m.eta <= 6).length; + if (pressure >= 3) { + bot.lastCalloutAt = this.now; + this.adjudicateCallout(bot); + } + } + // §7.3 补位落定:首次在新防区击杀后再无怪 → cover(每次指派 1 次) + const cov = bot.cover; + if (cov && !cov.settled && cov.killDone + && ![...this.mobs.values()].some((m) => cov.callerSector.includes(m.dir))) { + cov.settled = true; + this.tryBotline(bot, 'cover'); + } + } + + botTick() { + if (this.phase !== 'wave') return; // §5 教学段 bot AI 挂起(呼喊必答走 callout 路径) + for (const bot of this.botSlots().sort((a, b) => a.slot - b.slot)) this.botDecide(bot); + } + + // ---------------- 教学波(§5) ---------------- + + tickTeach() { + const st = this.teachState; + if (!st || st.done) return; + const ts = this.ts(); + if (st.stage === 1) { + if (!st.currentMob && ts >= st.nextAt && st.attempts < 3) { + st.currentMob = this.spawnMob({ mkind: 'wolf', dir: 'N', eta: 12 }); // T1 教学狼,方位 N + st.attempts += 1; + } + if (st.currentMob && !this.mobs.has(st.currentMob.id)) { + if (st.currentMob.breached && st.attempts < 3) { // 突入零惩罚,补放重教 + st.currentMob = null; + st.nextAt = ts + 2000; + } else { // 杀掉(或最后一次放过)→ T3 双狼 + st.currentMob = null; + st.stage = 2; + st.nextAt = ts + 4000; + } + } + } else if (st.stage === 2) { + if (!st.spawn23 && ts >= st.nextAt) { + const a = this.spawnMob({ mkind: 'wolf', dir: 'N', eta: 5 }); // T3 教学狼 2、3,贴近 + const b = this.spawnMob({ mkind: 'wolf', dir: 'NE', eta: 5 }); + st.spawn23 = [a.id, b.id]; + } + if (st.spawn23 && st.spawn23.every((id) => !this.mobs.has(id))) { + st.done = true; + this.pendingWave = { wave: 1, atTs: ts + REST_MS }; // 教学完同场接续第 1 波 + } + } + } + + // ---------------- 终局 ---------------- + + endGame(win) { + this.phase = 'end'; + this.mobs.clear(); + this.script = null; + this.pendingWave = null; + const grade = win ? gradeFor(this.dur) : null; // 失守无评级(§3.6/§15 裁决 9) + const kills = {}; + for (const [k, v] of Object.entries(this.killsBySlot)) kills[k] = v; + for (const s of this.occupiedSlots()) kills[String(s.slot)] = s.kills; + this.broadcastEv({ kind: 'end', win, dur: this.dur, grade, kills }); + this.endAt = this.now; + } + + /** 房灭回收:断全部连接(end 后 close 1000)、清 resume 索引、出管理器。 */ + destroy(code = 1000) { + if (this.destroyed) return; + this.destroyed = true; + for (const s of this.occupiedSlots()) { + const c = s.conn; + s.conn = null; + if (s.resume) this.manager.resumeIndex.delete(s.resume); + if (c) { + c.joinedRoom = null; + c.slotId = null; + try { c.close(code); } catch (_) { /* 忽略 */ } + } + } + this.manager.rooms.delete(this.code); + } + + // ---------------- 断线宽限(§6.6) ---------------- + + graceTimeout(slot) { + const remaining = this.humanCount() - 1; // 除本槽外剩余人类 + if (remaining <= 0) { // 最后一个人类宽限耗尽 → 房灭(bot 不续命) + this.destroy(); + return; + } + if (this.phase === 'lobby' || this.phase === 'end' || remaining >= 3) { + this.removeSlot(slot); // 剩余人类 ≥3 → 槽位移除 + } else { + this.convertToBot(slot); // 否则槽位转 bot(不可逆) + } + } + + // ---------------- 主循环 ---------------- + + tick(dt) { + if (this.destroyed) return; + this.now += dt; + + // 断线宽限到期处理 + for (const s of this.occupiedSlots()) { + if (!s.bot && !s.alive && !s.conn && s.graceUntil && this.now >= s.graceUntil) { + this.graceTimeout(s); + if (this.destroyed) return; + } + } + + if (this.phase === 'lobby') { + if (this.now >= LOBBY_MS) this.startGame(); // LOBBY 上限 12s + return; + } + if (this.phase === 'end') { + if (this.endAt !== null && this.now >= this.endAt + END_KEEP_MS) this.destroy(1000); + return; + } + + const ts = this.ts(); + // 波次启动(开局延迟 / 波间休整 / 教学接续) + if (this.pendingWave && ts >= this.pendingWave.atTs) { + const w = this.pendingWave.wave; + this.pendingWave = null; + this.startWave(w); + } + // 剧本刷怪 + if (this.script) { + while (this.scriptIdx < this.script.length && this.script[this.scriptIdx].atTs <= ts) { + this.spawnMob(this.script[this.scriptIdx++]); + } + } + // 教学波推进 + if (this.phase === 'teach') this.tickTeach(); + // 怪物推进 + 突入结算 + for (const mob of [...this.mobs.values()]) { + mob.eta -= dt / 1000; + if (mob.eta <= 0) { + this.breachMob(mob); + if (this.phase === 'end' || this.destroyed) return; + } + } + // 波出清 → 休整 → 下一波 / 终局 + if (this.phase === 'wave' && !this.pendingWave && this.script + && this.scriptIdx >= this.script.length && this.mobs.size === 0 && this.wave >= 1) { + if (this.wave >= LAST_WAVE) { + this.endGame(true); + return; + } + this.broadcastEv({ kind: 'waveclear', wave: this.wave }); + this.pendingWave = { wave: this.wave + 1, atTs: ts + REST_MS }; + } + // bot AI(500ms) + if (this.now - this.lastBotAt >= BOT_TICK_MS) { + this.lastBotAt = this.now; + this.botTick(); + } + // 喊话调度(greet 等) + while (this.pendingLines.length && this.pendingLines[0].at <= ts) { + const l = this.pendingLines.shift(); + const s = this.slots[l.slot]; + if (s && s.bot) this.tryBotline(s, l.line); + } + // 快照兜底(2Hz) + if (this.now - this.lastSnapAt >= SNAP_MS) { + this.lastSnapAt = this.now; + this.broadcastSnap(); + } + } +} + +// ---------------- 房间管理器(§6.4 匹配 + 消息路由) ---------------- + +class HuntManager { + constructor(opts = {}) { + this.rooms = new Map(); // code -> Room + this.resumeIndex = new Map(); // resume token -> { room, slotId } + this.joinTimeoutMs = opts.joinTimeoutMs || 5000; // §6.2 连接后 5s 内须发 join + // 速率帽(§6.1)按真实墙钟计;仿真测试以快进 tick 驱动、瞬间大量消息,可传 Infinity 关闭 + this.rateLimit = opts.rateLimit === undefined ? RATE_PER_SEC : opts.rateLimit; + // bot 难度参数(§15 裁决 10):生产用默认值;平衡扫描/测试可覆盖 + this.botTuning = { ...BOT_TUNING_DEFAULT, ...(opts.botTuning || {}) }; + } + + createRoom(opts = {}) { + let code = opts.code; + if (!code) { + do { + code = Array.from({ length: 4 }, () => ROOM_CODE_CHARS[crypto.randomInt(ROOM_CODE_CHARS.length)]).join(''); + } while (this.rooms.has(code)); + } + const room = new Room(this, { ...opts, code }); + this.rooms.set(code, room); + return room; + } + + /** 生产驱动(server.js setInterval)与测试快进共用入口。 */ + tick(dtMs) { + for (const room of [...this.rooms.values()]) room.tick(dtMs); + } + + attach(conn) { + conn.joinedRoom = null; + conn.slotId = null; + conn.rlWindowStart = 0; + conn.rlCount = 0; + conn.rlStrikeAt = 0; + conn.joinTimer = setTimeout(() => { + if (!conn.joinedRoom) conn.close(1008, 'join timeout'); // 5s 未 join + }, this.joinTimeoutMs); + if (conn.joinTimer.unref) conn.joinTimer.unref(); + conn.on('message', (str) => { + try { + this.handleMessage(conn, str); + } catch (e) { + console.error('[hunt] 消息处理异常:', e); + } + }); + conn.on('close', () => this.handleClose(conn)); + } + + sendMsg(conn, obj) { + if (conn) conn.send(JSON.stringify(obj)); + } + + sendErr(conn, code, msg) { + this.sendMsg(conn, { t: 'err', code, msg }); + } + + handleMessage(conn, str) { + // §6.1 速率帽:20 条/s,超限 err rate,10s 内再犯 close + const now = Date.now(); + if (now - conn.rlWindowStart > 1000) { + conn.rlWindowStart = now; + conn.rlCount = 0; + } + conn.rlCount += 1; + if (conn.rlCount > this.rateLimit) { + if (conn.rlStrikeAt && now - conn.rlStrikeAt < RATE_STRIKE_MS) { + conn.close(1008, 'rate limit'); + return; + } + conn.rlStrikeAt = now; + return this.sendErr(conn, 'rate', '消息太快,慢一点。'); + } + + let msg; + try { + msg = JSON.parse(str); + } catch (_) { + return this.sendErr(conn, 'bad_msg', '不是合法 JSON'); + } + if (!msg || typeof msg !== 'object' || Array.isArray(msg)) { + return this.sendErr(conn, 'bad_msg', '消息需为 JSON 对象'); + } + + if (!conn.joinedRoom) { + if (msg.t !== 'join') return this.sendErr(conn, 'bad_msg', '首条消息必须是 join'); + return this.handleJoin(conn, msg); + } + if (msg.t === 'act') return this.handleAct(conn, msg); + if (msg.t === 'leave') return this.handleLeave(conn); + return this.sendErr(conn, 'bad_msg', '未知消息类型'); + } + + handleClose(conn) { + if (conn.joinTimer) { + clearTimeout(conn.joinTimer); + conn.joinTimer = null; + } + const room = conn.joinedRoom; + if (!room) return; + conn.joinedRoom = null; + const slotId = conn.slotId; + conn.slotId = null; + if (room.destroyed) return; + const slot = room.slots[slotId]; + if (!slot || slot.conn !== conn || slot.bot) return; + // §6.6:断线 → 标记 + roster 广播 + 30s 宽限(lobby/end 同走宽限,到期按规则处理) + slot.conn = null; + slot.alive = false; + slot.graceUntil = room.now + GRACE_MS; + room.broadcastRoster(); + } + + handleJoin(conn, msg) { + if (msg.ver !== 1) return this.sendErr(conn, 'bad_msg', '协议版本不符(ver 恒 1)'); + if (msg.resume !== undefined && msg.resume !== null) return this.handleResume(conn, msg); + + const roomName = msg.room; + const isCode = typeof roomName === 'string' && /^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4}$/.test(roomName); + if (roomName !== 'quick' && !isCode) { + return this.sendErr(conn, 'bad_msg', 'room 需为 "quick" 或 4 位房码'); + } + let name = String(msg.name === undefined || msg.name === null ? '猎人' : msg.name).trim().slice(0, 24); + if (!name) name = '猎人'; + + let room; + if (msg.teach === true) { + room = this.createRoom({ teach: true }); // §5/§6.4 教学房强制单人 + } else if (roomName === 'quick') { + room = [...this.rooms.values()].find( + (r) => !r.destroyed && r.phase === 'lobby' && !r.teach && r.occupiedSlots().length < 4, + ); + if (!room) room = this.createRoom({}); + } else { + room = this.rooms.get(roomName); + if (room && (room.destroyed || room.phase !== 'lobby' || room.teach)) { + return this.sendErr(conn, 'room_full', '房间已开局或不可用'); // §6.4 对局中 join → room_full + } + if (!room) room = this.createRoom({ code: roomName }); + } + + const slotId = room.slots.findIndex((s) => s === null); + if (slotId === -1) return this.sendErr(conn, 'room_full', '房间已满(4 人)'); + + const slot = room.addHuman(slotId, conn, name); + conn.joinedRoom = room; + conn.slotId = slotId; + if (conn.joinTimer) { + clearTimeout(conn.joinTimer); + conn.joinTimer = null; + } + this.resumeIndex.set(slot.resume, { room, slotId }); + + if (room.teach && room.phase === 'lobby') room.startGame(); // 教学房直入 TEACH(无 lobby 等待) + room.sendJoined(slot); + room.broadcastRoster(); + if (!room.teach && room.humanCount() === 4) room.startGame(); // 满 4 人立即开局 + } + + /** §6.6 宽限内 resume 回槽:重发 joined(同 slot/seed/char)+ 紧随 snap 全量。 */ + handleResume(conn, msg) { + const token = String(msg.resume); + const rec = this.resumeIndex.get(token); + if (!rec || rec.room.destroyed) { + return this.sendErr(conn, 'room_gone', '那场围猎已经结束了'); // 房灭后迟到 resume + } + const { room, slotId } = rec; + const slot = room.slots[slotId]; + if (!slot || slot.bot || slot.resume !== token) { + return this.sendErr(conn, 'room_gone', '座位已经没了(已转 bot)'); + } + if (slot.conn && slot.conn !== conn) { // 顶号:旧连接让位 + const old = slot.conn; + old.joinedRoom = null; + old.slotId = null; + try { old.close(1008, 'replaced'); } catch (_) { /* 忽略 */ } + } + slot.conn = conn; + slot.alive = true; + slot.graceUntil = 0; + conn.joinedRoom = room; + conn.slotId = slotId; + if (conn.joinTimer) { + clearTimeout(conn.joinTimer); + conn.joinTimer = null; + } + room.sendJoined(slot); + this.sendMsg(conn, room.buildSnap()); // 紧随 snap 全量 + room.broadcastRoster(); + } + + handleAct(conn, msg) { + const room = conn.joinedRoom; + const slot = room.slots[conn.slotId]; + if (!slot || slot.bot) return this.sendErr(conn, 'bad_msg', '座位状态异常'); + const kind = msg.kind; + if (!['attack', 'callout', 'skill', 'start'].includes(kind)) { + return this.sendErr(conn, 'bad_msg', '未知 act.kind'); + } + if (typeof msg.seq === 'number') { // 乱序/重复不拒,仅日志(§6.2) + if (msg.seq <= slot.lastSeq) { + console.log(`[hunt] act 乱序/重复: room=${room.code} slot=${slot.slot} seq=${msg.seq} last=${slot.lastSeq}`); + } + slot.lastSeq = Math.max(slot.lastSeq, msg.seq); + } + if (kind === 'start') { + if (room.phase !== 'lobby') return this.sendErr(conn, 'not_ingame', '已经开局了'); + room.startGame(); + return; + } + if (room.phase !== 'wave' && room.phase !== 'teach') { + return this.sendErr(conn, 'not_ingame', '不在对局中'); + } + if (kind === 'attack') { + if (typeof msg.dir !== 'string' || !(msg.dir in DIR_IDX)) { + return this.sendErr(conn, 'bad_dir', '方位需为 8 罗盘向之一'); + } + room.adjudicateAttack(slot, msg.dir); + } else if (kind === 'skill') { + room.adjudicateSkill(slot); + } else { + room.adjudicateCallout(slot); + } + } + + /** leave:主动退出,槽位立即按 §6.6 处理(无 30s 宽限)。 */ + handleLeave(conn) { + const room = conn.joinedRoom; + if (!room) { + conn.close(1000); + return; + } + conn.joinedRoom = null; + const slotId = conn.slotId; + conn.slotId = null; + const slot = room.slots[slotId]; + if (!room.destroyed && slot && !slot.bot) { + slot.conn = null; + if (slot.resume) this.resumeIndex.delete(slot.resume); + slot.resume = null; + if (room.phase === 'lobby' || room.phase === 'end') { + room.slots[slotId] = null; + room.broadcastRoster(); + if (room.humanCount() === 0) room.destroy(); + } else { + const remaining = room.humanCount() - 1; + if (remaining <= 0) room.destroy(); // 无人类即灭 + else if (remaining >= 3) room.removeSlot(slot); + else room.convertToBot(slot); + } + } + conn.close(1000); + } +} + +function createManager(opts) { + return new HuntManager(opts); +} + +module.exports = { + createManager, + Room, + buildWaveScript, + gradeFor, + mulberry32, + DIRS, + LAYOUTS, + WAVE_TABLE, + LAST_WAVE, + DUR_MAX, + MELEE_ETA, + TICK_MS, + ATTACK_CD_MS, + SKILL_CD_MS, + CALLOUT_THROTTLE_MS, + LOBBY_MS, + GRACE_MS, + END_KEEP_MS, + REST_MS, + ROOM_CODE_CHARS, + BOT_TUNING_DEFAULT, +}; diff --git a/web/server/package.json b/web/server/package.json new file mode 100644 index 00000000..be9a0373 --- /dev/null +++ b/web/server/package.json @@ -0,0 +1,5 @@ +{ + "type": "commonjs", + "private": true, + "description": "模块格式标记(零依赖,无可安装内容):祖先目录 /home/xsl/AutoVideo/package.json 带 \"type\": \"module\",会让 Node 把本目录的 .js 误判为 ESM 而无法启动。本文件把 web/server/ 下的 .js 固定回 CommonJS(server.js/ws.js/hunt-room.js 均为 CJS;test/*.mjs 恒为 ESM 不受影响)。浏览器经