feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决 - 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏 - 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言 - 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步 - 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿 - 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描) - 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目 验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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 结局,
|
||||
|
||||
@@ -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 排行榜。
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# 23b · 《围猎》语音台词表(voice ids,录音依据)
|
||||
|
||||
> **给音频总监的录制说明**
|
||||
> - **旁白/系统音双性别组(60 句,预算打满)**:落 `web/hunt/audio/voice/female|male/<id>.{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_<char>_<line>.{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/<gender>/key/<id>.*` 变体,变体缺失回退主资产;与 `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/<gender>/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。
|
||||
@@ -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_<char>_<line>`) |
|
||||
| `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_<char>_<line>.{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°<az<270°)的 loop/呼啸音量 ×0.6("身后发闷",dungeon"背后=中央减半"先例);教学 tut_rule 讲明此约定。呼喊/喊话的 pan 取座位防区中心方位(§3.3)。
|
||||
|
||||
### 8.4 验证口径(harness/测试 agent 记录点)
|
||||
|
||||
- 逐事件记录:任一时刻 voice 在播句数、sfx 层人声句数、loop 路数、one-shot 并发数——断言不超 R1-R4 上限。
|
||||
- 警报可达性:第 5 波峰值剧本下,breach/danger_low/wave 播报零丢失(可被顶掉的状态类句不计)。
|
||||
- 实机听测 checklist(roadmap 真机轮):戴耳机分辨"左前狼 + 正右熊 + 旁白警报"三源。
|
||||
|
||||
## 9. 引擎改动(唯一一处,后向兼容)
|
||||
|
||||
`web/engine/audio.js`:
|
||||
|
||||
1. `_playSfx(id, { volume = 1, pan } = {})`:`typeof pan === 'number' && isFinite(pan)` 且 `pan !== 0` 时,clamp 到 [-1,1],链路改为 `source → gain → StereoPannerNode(pan) → sfxBus`;pan 缺省或 0 → **原路径逐字节不变**(不建 panner 节点)。`ctx.createStereoPanner` 不存在(老 Safari)→ 静默忽略 pan(不炸、不报错音)。
|
||||
2. `_playVoice` 同款:opts.pan 存在时把 panner 插在 voice gain 与 voiceBus 之间;缺省不变。(**本作旁白不使用 voice pan**——对称能力一次给足,后续游戏可用;见 §15 裁决 7。)
|
||||
3. 回归断言(harness 增补):全部既有游戏 play() 调用(无 pan)行为不变;pan=0 与缺省等价;pan=-1/1 时 panner 值正确。
|
||||
|
||||
本作使用面:bot 喊话、生成呼啸、击杀/突入/技能音全部 one-shot 带 pan;怪物 loop 走游戏层句柄(§8 R3)。
|
||||
|
||||
## 10. 音频三层资产
|
||||
|
||||
### 10.1 BGM(2 首,Mureka,-20 LUFS,ogg+m4a,90s 无缝循环)
|
||||
|
||||
| id | 状态映射 | prompt 草稿(英文) |
|
||||
|---|---|---|
|
||||
| `bgm/menu` | TITLE / MATCHING / LOBBY / END(结算 vol 0.5) | "Night camp ambient pad, distant crickets and low wind, warm fireplace crackle subtle, calm anticipation, no vocals, seamless loop" |
|
||||
| `bgm/hunt` | TEACH / WAVE | "Tense night hunt drums, low tribal percussion 100 bpm, ominous strings pulses, cold wind, no vocals, seamless loop" |
|
||||
|
||||
切波不改 BGM(波次张力靠号角+怪密度);失守/胜利走引擎 crossfade 到 menu。语音 duck 20% 引擎自动。
|
||||
|
||||
### 10.2 SFX(13 + 24 喊话;SYN=程序合成,SA3=Stable Audio 3;双格式、真峰值 ≤-3dBFS)
|
||||
|
||||
| id | 方式 | 说明 |
|
||||
|---|---|---|
|
||||
| `atk_whoosh` | SYN | 挥击风声 0.3s(带 pan=出击方位) |
|
||||
| `atk_swish` | SYN | 挥空空荡风声 0.3s(归因真话:没打到) |
|
||||
| `kill_wolf` / `kill_bear` | SA3 | 狼哀嚎倒地 0.5s / 熊闷重倒地 0.7s(带 pan) |
|
||||
| `skill_spin` | SA3 | 旋风斩气旋 0.8s(中央,大动态) |
|
||||
| `breach_thud` | SA3 | 营地被撞闷响+木栅裂 0.6s(中央) |
|
||||
| `horn_wave` | SA3 | 波次号角 0.9s(中央) |
|
||||
| `join_chime` | SYN | 队友加入 0.4s(双音上行) |
|
||||
| `call_whistle` | SYN | 求援哨音 0.6s(座 3 呼喊用,带 pan) |
|
||||
| `howl_wolf` / `howl_bear` | SA3 | 生成呼啸 0.8s / 低吼 1.0s(带 pan,后半平面 ×0.6) |
|
||||
| `loop_wolf` / `loop_bear` | SA3 | 移动循环:狼碎步+低呜 / 熊重步+喘息(4s 无缝循环源,游戏层 loop,pan+距离档音量) |
|
||||
| `npc_<char>_<line>` ×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": "<ISO>" },
|
||||
"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"不失误"语义保留(只打有目标的弧),弱化的是**反应速度与出手频率**(拟人迟钝),不是命中率。
|
||||
@@ -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)
|
||||
|
||||
|
||||
+11
-2
@@ -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(决定公网化时再启动)。
|
||||
|
||||
@@ -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 一遍。
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全项目音频资产终检(R5)。
|
||||
|
||||
检查 8 个游戏 web/<game>/audio/:
|
||||
检查 11 个游戏 web/<game>/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/<gender>/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:
|
||||
|
||||
@@ -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 <flac> <track> <LUFS>
|
||||
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 =="
|
||||
@@ -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/<id>.wav(44.1kHz 立体声);
|
||||
成品 → web/hunt/audio/sfx/<id>.{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())
|
||||
+140
-3
@@ -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/<id>.*
|
||||
# 角色 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 <game>/npc/ → audio/voice/<id>.*;radio 按 NPC_BANDPASS 带通)
|
||||
# NPC 平铺(staging <game>/npc/ → audio/voice/<id>.*;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,
|
||||
|
||||
@@ -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 先例)。
|
||||
两条任务一次成功无重试。
|
||||
|
||||
+36
-3
@@ -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);
|
||||
// 可选声像(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);
|
||||
// 可选声像(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 客户端侧全部断言
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user