HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、 Node 零依赖服务器(静态 + 注册登录 + 进度存档 API) - 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、 节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除 - 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、 震动反馈语言(10 种震型)、服务器进度同步 - 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、 Mureka BGM 4+3 首),终检零缺失 - 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、 web/server/README.md API 文档
This commit is contained in:
+11
@@ -19,6 +19,8 @@ tools/bin/
|
||||
tools/jdk17/
|
||||
# 用户态 Android SDK
|
||||
tools/android-sdk/
|
||||
# 用户态 Node.js(web 引擎语法校验用)
|
||||
tools/node/
|
||||
# 程序合成版 BGM 备份
|
||||
tools/bgm_backup/
|
||||
# Mureka 原始下载 flac
|
||||
@@ -27,6 +29,8 @@ tools/mureka_out/
|
||||
tools/samples/
|
||||
# flock GPU 串行锁文件
|
||||
.gpu.lock
|
||||
# Web 服务器运行时数据(用户/PIN/会话/进度,勿入库)
|
||||
web/server/data/
|
||||
|
||||
# ---- 生成中间产物(可由 gen/ 脚本复现) ----
|
||||
app/src/*/gen/wav/
|
||||
@@ -36,3 +40,10 @@ app/src/*/gen/*.otf
|
||||
# ---- 含密钥的环境脚本(只提交 .example.sh 模板) ----
|
||||
env.d/sfx.sh
|
||||
env.d/mureka.sh
|
||||
|
||||
# 资产生成中间产物 (staging wav)
|
||||
tools/__pycache__/
|
||||
tools/gen_voice_dual_wav/
|
||||
tools/gen_amb_wav/
|
||||
tools/gen_inst_wav/
|
||||
tools/gen_rpg_sfx_wav/
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
# SoundGame — 纯音频手机游戏
|
||||
|
||||
三款纯音频 Android 游戏:屏幕只有一张封面图,全部操作只有 6 种手势
|
||||
6 款纯音频游戏(HTML5 为主,3 款另有 Android 版):屏幕只有一张封面图,全部操作只有 6 种手势
|
||||
(上滑 / 下滑 / 左滑 / 右滑 / 单击 / 双击),所有内容靠音频呈现。
|
||||
对白由本地 Qwen3-TTS 生成,音效为程序合成(numpy/ffmpeg);
|
||||
《声命线》与《暗室》的 BGM 已用 Mureka API 生成替换(见"待办"一节),
|
||||
Stable Audio 3 音效环境已就绪,程序合成音效可逐步升级替换。
|
||||
对白由本地 Qwen3-TTS 生成(男声/女声双套资产),音效用 Stable Audio 3 + 程序合成,
|
||||
BGM 用 Mureka API 生成 + 程序合成。
|
||||
|
||||
## 三款游戏
|
||||
## HTML5 版(推荐,浏览器即玩)
|
||||
|
||||
无需安装 App,手机浏览器打开即玩(Android Chrome / iOS Safari / 鸿蒙 ArkWeb 均支持,
|
||||
音频提供 ogg + m4a 双格式)。
|
||||
|
||||
```bash
|
||||
./web/serve.sh # 启动 Node 服务器(静态 + 用户系统 API,默认端口 8000)
|
||||
```
|
||||
|
||||
打开后进入游戏大厅。大厅提供:
|
||||
|
||||
- **用户系统**:用户名 + PIN 注册/登录,游戏进度保存在服务器(`web/server/`)
|
||||
- **全局设置**:旁白音色(男声/女声)、语速(正常/快/特快)、背景音乐(正常/小/关闭)
|
||||
- 所有游戏用**震动反馈**交互(确认/错误/成功/警告各有震型;iOS 不支持震动,静默降级)
|
||||
|
||||
6 款游戏:
|
||||
|
||||
- **《剑与声》** `web/rpg/` — 纯音频回合制 RPG(新作):盲剑客题材,节点式场景探索 +
|
||||
回合制战斗(听前摇音双击格挡)+ 升级/装备/金币 + 服务器存档,第一章完整可玩
|
||||
- **《声命线》** `web/storyline/` — 末世互动广播剧:3 幕 8 抉择 4 结局,
|
||||
左右滑做抉择,单局 5-7 分钟
|
||||
- **《暗室》** `web/escape/` — 密室逃脱解谜(已重设计):手把手开场引导 +
|
||||
卡关分级提示(60s 暗示 / 120s 明示 / 180s 带路),3x3 房间 4 步谜题链
|
||||
- **《音序回响》** `web/simon/` — 音频记忆游戏(已重设计):无 BGM 纯交互音,
|
||||
交互式教学、重听机会机制、轮次整句播报
|
||||
- **《节拍行者》** `web/rhythm/` — 跟随领奏节奏游戏(已重构):系统奏一小节节奏型、
|
||||
玩家跟打复现,手势触发同调性打击乐器音,3 首曲目(90/105/120 BPM)解锁制 + S~C 评级
|
||||
- **《盲航》** `web/space/` — 太空盲飞躲避(已重构):同向闪避(左来左滑)、
|
||||
交互式演练教学、60 秒保护期、单击加速双击护盾
|
||||
|
||||
Web 引擎文档见 `docs/web-engine-api.md`;服务器 API 见 `web/server/README.md`;
|
||||
设计文档在 `docs/design/`;资产管线文档在 `docs/audio/`。
|
||||
|
||||
## 三款游戏(Android 版,已停止迭代)
|
||||
|
||||
注意:Android 版停留在初版,所有重设计/新功能(双音色、设置、震动、用户系统)
|
||||
只在 HTML5 版。鸿蒙(HarmonyOS NEXT)无法安装 APK,请用 HTML5 版。
|
||||
|
||||
### 1. 《声命线》(storyline) — 末世互动广播剧
|
||||
大停电后第 30 天,前急救员林晚靠一台旧收音机寻找妹妹,对抗抢掠者"猎狗"。
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 音频资产清单(R1,2026-08-29)
|
||||
|
||||
本文档是 5 个 web 游戏的 voice / sfx / bgm 资产总账,以及双性别语音的音色分配。
|
||||
|
||||
## 目录约定(R1 新增)
|
||||
|
||||
- 双性别旁白:`web/<game>/audio/voice/male/<id>.{ogg,m4a}` 和 `web/<game>/audio/voice/female/<id>.{ogg,m4a}`,两个目录内 id 集合完全一致。
|
||||
- 原有平铺文件 `web/<game>/audio/voice/<id>.{ogg,m4a}` **暂时保留**,等引擎切换读取 male/female 子目录后由后续轮次清理。
|
||||
- 所有 web 音频均为双格式:`.ogg`(libvorbis q3,44.1kHz)+ `.m4a`(aac 128k,44.1kHz,iOS 用)。
|
||||
- 生成管线:`tools/gen_voice_dual.py`(台词表内嵌,从各游戏原 gen 脚本只读提取),staging wav 在 `tools/gen_voice_dual_wav/<game>/<gender>/`。
|
||||
|
||||
## 音色分配表(游戏 × 性别 → speaker)
|
||||
|
||||
| 游戏 | male | female | 备注 |
|
||||
|---|---|---|---|
|
||||
| storyline | Uncle_Fu | Vivian | 仅旁白+系统音双性别;角色音色不动(见下) |
|
||||
| escape | Uncle_Fu | Serena | male 沿用原旁白音色 |
|
||||
| simon | Uncle_Fu | Serena | female 沿用原主持人音色 |
|
||||
| rhythm | Uncle_Fu | Vivian | female 沿用原解说音色 |
|
||||
| space | Uncle_Fu | Serena | female 沿用原导航音色 |
|
||||
| rpg | Uncle_Fu | Serena | 旁白;NPC 单音色平铺(见 rpg 章节) |
|
||||
|
||||
关于 Ryan:冒烟测试样本在 `tools/samples/ryan_zh_*.wav`(3 条中文句)。Ryan 是英语母语音色(见 `env.d/tts.md`),项目已有中文发音投诉(反馈⑥),男声统一采用中文母语的 Uncle_Fu;如需换成 Ryan,改 `tools/gen_voice_dual.py` 的 `SPEAKERS` 表重跑即可。
|
||||
|
||||
## instruct 模板(每性别全游戏内一致)
|
||||
|
||||
同一游戏的同一类台词,male/female 使用同一 instruct 模板(仅性别描述词不同),数字/短句(≤2 字)一整批同 instruct 连续生成:
|
||||
|
||||
- storyline 旁白:male「低沉醇厚的中年男声旁白,冷静、缓慢,带着末世废墟的苍凉感」/ female「冷静低沉的女声旁白,语速缓慢,带着末世废墟的苍凉感」(另有 WARM/COLD/EPIC 三个结局变体,见脚本)
|
||||
- escape(R2 起,全游戏同一常量):male「低沉平稳的男声,冷静、克制,带一点神秘感」/ female「沉稳平静的女声,冷静、克制,带一点神秘感」
|
||||
- simon(R2 起,全游戏同一常量,两性别同文):「平静、清晰,像一个耐心的游戏主持人」
|
||||
- rhythm(R3 起,全表统一):female(Vivian)「活力清晰的年轻女声,像节奏游戏主持人,情绪饱满但不夸张」/ male(Uncle_Fu)「低沉醇厚的男声,像节奏游戏主持人,平稳有力」
|
||||
- space(R3 起,全表统一):female(Serena)「平静清晰的女声,像飞船领航员,沉稳可信」/ male(Uncle_Fu)「低沉醇厚的男声,像飞船领航员,沉稳有力」
|
||||
- rpg(R4 起,旁白全表统一):female(Serena)「平静清晰的年轻女声,像在讲武侠故事」/ male(Uncle_Fu)「低沉醇厚的男声,像说书人,平静有韵味」;NPC 各角色 speaker+instruct 恒定(村长 Uncle_Fu·慈祥和缓的老者/老猎人 Eric·沙哑粗犷豪爽中带着暖意/头目 Dylan·凶狠粗鲁大声喝骂/寨主 Uncle_Fu·低沉阴狠慢条斯理)
|
||||
|
||||
## 各游戏资产清单
|
||||
|
||||
### storyline《声命线》
|
||||
|
||||
- voice 双性别版(male/ 与 female/ 各 36 条):旁白 + 系统音 = `help menu status1 status2 status3 p1–p8 n00_intro n01_wake n04_exit n05_signal n06_whistle n08_vent n09_dead n10_rain n11_child n13_leave n14_ignore n15_bridge n16_culvert n16b_culvert_c n17_cable n17b_cable_c n20_convoy n22_fight n23_tower e1_dawn e2_price e3_alone e4_static`
|
||||
- voice 角色台词(**不做双性别**,沿用平铺原文件):
|
||||
|
||||
| 角色 | speaker | 文件 id |
|
||||
|---|---|---|
|
||||
| 林晚(主角) | Serena | n02_linwan n03_radio n12_take n19_betray n21_redeem n24_open n25_secure |
|
||||
| 猎狗(反派) | Dylan | n07_dog n18_dog |
|
||||
| 小满(小男孩) | Vivian(稚嫩童声 instruct) | e1_child |
|
||||
| 林晨(妹妹,收音机录音) | Vivian(收音机 instruct) | n03_msg |
|
||||
| 广播女声 | Vivian(机械广播 instruct) | n05_bcast |
|
||||
|
||||
约定:角色台词在两个性别目录下使用**同一份文件**(由开发复制/链接或引擎回退到平铺目录,见 round1-report)。
|
||||
- sfx(12):`footsteps gunshot heartbeat radio_static rubble signal_beep struggle ui_confirm ui_menu water whistle wind`
|
||||
- bgm(1):`dark_drone`
|
||||
|
||||
### escape《暗室》(R2 重设计版,最终清单见 `round2-voice-ids.md`)
|
||||
|
||||
- voice 双性别版(male/ 与 female/ 各 74 条):引导 `g0_intro g1_move g1_done g2_wall g3_left g4_down g5_inspect g5_key g6_doubletap g6_done g7_goal`、引导纠错 `guide_wrong_* guide_blunt_* guide_offpath guide_skip reopen_prompt`、三级提示 `stuck_ask stuck_{key,cabinet,box,gate}_{1,2,3}`、导航 `nav_{up,down,left,right}`、房间 `room_{storage,cabinet,power,gate}`、用时 `time_band1–4`、保留句 `wall start_room found_key inspect_* unlock_cabinet box_fixed fix_box escape use_fail inv_* replay again empty_1–3`、菜单 `help_ops menu_*`
|
||||
- voice 已删除(R2):`n0–n9 nshi unit_min unit_sec time_prefix intro tutorial hint_key hint_cabinet hint_box hint_gate`(整句化替代);平铺 `voice/` 旧文件全部移除
|
||||
- sfx(16):`amb_drip amb_hum amb_wind`(保留)+ R2 新增 `amb_storage amb_cabinet amb_power amb_gate amb_gate_live`(Stable Audio 3,6s,统一 -25dB 均值,storage/power 偏左、cabinet 偏右 pan)+ `door_creak gate_open pickup power_on spark step unlock wall win_chime`;已删除 `ui_deny`(反馈改震动)
|
||||
- bgm(1):`drone`
|
||||
|
||||
### simon《音序回响》(R2 重设计版,最终清单见 `round2-voice-ids.md`)
|
||||
|
||||
- voice 双性别版(male/ 与 female/ 各 68 条):交互教学 `t0_welcome t0_goal t1_up–t6_double t_try t_ok t_wrong_* t_blunt_* t7_ready t7_retry t_skip`、轮次 `round_01–round_20 round_more`(整句)、成绩 `score_band1–6`(整句)、流程 `your_turn chances_out retry_use relisten replay rules_prompt rules gameover`、反馈 `correct wrong chance_plus new_record`、菜单 `menu_*`
|
||||
- voice 已删除(R2):`di lun num_shi num_0–num_9 score_pre best_pre`(拼接件)+ `intro teach_* listen`(旧连播教学);平铺 `voice/` 旧文件全部移除
|
||||
- sfx(7):`gameover tone_doubletap tone_down tone_left tone_right tone_tap tone_up`;已删除 `correct wrong newrecord`(反馈改震动)
|
||||
- bgm:**已于 R1 按用户要求移除**(原 `theme`,代码引用由开发删除)
|
||||
|
||||
### rhythm《节拍行者》(R3 重构版,台词依据 `docs/design/03-rhythm-voice-ids.md`)
|
||||
|
||||
- voice 双性别版(male/ 与 female/ 各 31 条):开场流程 `welcome welcome_back tut_1 tut_2 tut_3 tut_done tut_skip mid_song`、教学纠正 `retry_early retry_late retry_gesture retry_listen retry_skip`、选曲解锁 `select_hint song1_name song2_name song3_name song_locked unlock_song2 unlock_song3`、结算 `grade_s grade_a grade_b grade_c new_best_combo restart`、菜单规则 `rules settings_hint menu_main menu_resume menu_lobby`
|
||||
- voice 已删除(R3):`tut_start`(平铺旧 id 全部移除,其余旧 id 均在 R2 已替换)
|
||||
- voice 修订(R5):`menu_main` 改词重录为「菜单。单击切换选项,双击确认。」(对齐 round2-review 裁决②全球菜单约定;选曲界面非模态菜单不受影响)
|
||||
- sfx(7,R3 补齐):`inst_kick inst_snare inst_hat_l inst_hat_r inst_tom_hi inst_tom_lo inst_accent`(演奏乐器音,详见 round3-report §7;hat_l 闭合镲极左 / hat_r 开放镲极右,accent 为 A minor 合成亮音);已删除 `judge_* tick tick_accent`(R3)与 `cue_*`(R3 收尾,重构不再使用指令音)
|
||||
- bgm(3,R3 重制):`track90`(90.01 BPM,85.33s)`track105`(104.99 BPM,73.14s)`track120`(120.00 BPM,72.00s),A minor 电子乐,整数小节无缝循环,loudnorm -20 LUFS;旧 `groove` 已删除
|
||||
|
||||
### space《盲航》(R3 备产,台词依据 `docs/design/04-space-voice-ids.md`)
|
||||
- voice 双性别版(male/ 与 female/ 各 52 条):演练 `drill_*`(14)、保护期 `protect_*`(5)、功能引入 `feat_* shield_ready shield_block`(6)、被撞归因 `hit_*`(5)、里程碑 `ms_10 ms_25 ms_50 ms_100`、结算 `time_1–4 dodge_1–4 new_record restart_hint`、菜单系统 `second_run_ask menu_* help_recap settings_hint paused`
|
||||
- voice 修订(R4):`menu_main` 改词重录为「菜单。单击切换选项,双击确认。」(对齐 round2-review 裁决②全球菜单约定)
|
||||
- voice 已删除(R3):`num0–9 num_shi num_bai n_time n_sec n_dodge n_ge n_milestone intro1–4 hit1 hit2 gameover restart`(数字拼接与旧教学弃用);平铺旧文件全部移除
|
||||
- sfx(12):`alarm boost crash dodge hit shield shield_block whoosh_down whoosh_left whoosh_right whoosh_up`
|
||||
- bgm(1):`engine`
|
||||
|
||||
### rpg《剑与声》(R4 新游戏第一章,台词依据 `docs/design/05-rpg-ch1-voice-ids.md` + `05-rpg-addendum.md`)
|
||||
|
||||
- voice 旁白双性别版(male/ 与 female/ 各 108 条,male=Uncle_Fu 说书人 / female=Serena 武侠故事):序章 `intro_1–3`、系统状态 `sys_* help_* settings_hint st_*`、场景 `desc_*`(16)、选项 `opt_*`(18)、事件 `evt_*`(6)、战斗 `btl_* cmd_* tut_*`(27,含 R5 补录 `btl_half_bandit`)、商店 `shop_*`(7)、敌方档位 `foe_hp_high/mid/low`(addendum §6.2 补录)
|
||||
- voice NPC 单音色平铺(14 条,落 `voice/` 根,引擎平铺回退):村长 `npc_headman_1–4`(Uncle_Fu·慈祥和缓)、老猎人 `npc_hunter_1–4`(Eric·沙哑豪爽)、寨丁头目 `npc_elite_1/half/die`(Dylan·凶狠)、黑风寨主 `npc_boss1_1/2/die`(Uncle_Fu·低沉阴狠,与村长同 speaker 不同 instruct)
|
||||
- sfx(35):前摇 `tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2`(1.0s 格挡窗口);战斗 `atk_swing atk_hit parry skill_slash enemy_die potion` + SYN `hurt flee`;探索 `chest drink_water rest door_heavy` + SYN `step coin levelup`;环境 `amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak`(5s 无缝循环,均值 -25dB);`ui_deny` 按设计不做
|
||||
- bgm(4,Mureka):`village battle boss ending`(90s 无缝循环,loudnorm -20);探索期无音乐(addendum §3),`amb_*` 由游戏层手动 loop 接 sfx 总线
|
||||
- 再生成方法见 `web/rpg/gen/README.md`
|
||||
|
||||
## 发音质量
|
||||
|
||||
### 本轮已采取的规避措施
|
||||
|
||||
- 数字类语音(num_0~9、shi、bai、第、轮、分、秒、颗等)一整批同 instruct 连续生成,减少拼接 prosody 漂移(反馈⑥根因之一)。
|
||||
- 台词 >40 字按句拆分生成再拼接(句间 0.12s 静音),避免长句吞字。
|
||||
- 每条 assert 时长 >0.3s、峰值 >0.05,统一峰值归一 0.89;同句两性时长比 >3 判离群并重生成。
|
||||
|
||||
### 多音字/发音风险清单(待人工试听确认)
|
||||
|
||||
以下条目为文本层面的已知风险点,本轮生成保留原文;如试听发现误读,建议按右栏换写法重生成(改 `tools/gen_voice_dual.py` 台词表):
|
||||
|
||||
| 位置 | 原文 | 风险 | 建议换写法 |
|
||||
|---|---|---|---|
|
||||
| storyline help | 双击:重听刚才的台词 | 「重」可能读 zhòng | 「双击:重新听刚才的台词」 |
|
||||
| storyline n11_child / n13_leave / n16b | 数到一千、数数 | 「数」shǔ/shù | 误读时写「数一数,数到一千」 |
|
||||
| storyline n05_bcast / n19_betray | 三、七点、五 | 坐标需逐字读 | 误读时写「三,七,点,五」 |
|
||||
| storyline n16b | 数到一百七 | 可能读成「一百七十个」 | 写「数到一百七十」 |
|
||||
| 多处(escape/simon/space/storyline) | 还(hái)在/还有/还能 | 「还」hái/huán | 误读时改写句子避开 |
|
||||
| rhythm welcome | S / A / B / C 评级 | 字母发音 | 现行文本为「评级,S!」,试听确认字母读音 |
|
||||
|
||||
(本表为文本审查结果;时长离群自动检查已通过的部分见 round1-report,最终发音准确性需人工试听把关。)
|
||||
@@ -0,0 +1,20 @@
|
||||
# 全项目音频资产终检(R5,2026-08-29)
|
||||
|
||||
| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 |
|
||||
|---|---|---|---|---|---|
|
||||
| storyline | 133 | 133 | 0 | 0 | ✅ 各 36(平铺 48 个角色/NPC 句,豁免) |
|
||||
| escape | 166 | 166 | 0 | 0 | ✅ 各 74 |
|
||||
| simon | 143 | 143 | 0 | 0 | ✅ 各 68 |
|
||||
| rhythm | 72 | 72 | 0 | 0 | ✅ 各 31 |
|
||||
| space | 116 | 116 | 0 | 0 | ✅ 各 52 |
|
||||
| rpg | 269 | 269 | 0 | 0 | ✅ 各 108(平铺 14 个角色/NPC 句,豁免) |
|
||||
|
||||
## 结论
|
||||
|
||||
全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg 平铺角色句按约定豁免)。
|
||||
|
||||
## 附注
|
||||
|
||||
- storyline 平铺 48 个 = 12 个角色台词(林晚/猎狗/小满/林晨/广播,单音色永久保留)+ 36 个 R1 旁白旧文件(与 male/ 内容重复的遗产,待引擎切换验证后清理,见 round1-report §2)。
|
||||
- rpg 平铺 14 个 = NPC 句(设计如此,单音色)。
|
||||
- 终检脚本:`tools/check_all_assets.py`(可重复执行)。
|
||||
@@ -0,0 +1,72 @@
|
||||
# R1 音频轮次报告(2026-08-29,音乐音效总监)
|
||||
|
||||
本轮主题:双性别旁白资产管线 + simon BGM 移除。Mureka 花费 $0(无新 BGM,预算余额 $1.85)。
|
||||
|
||||
## 1. 新目录约定(给开发)
|
||||
|
||||
- 旁白/系统音按性别分目录:`web/<game>/audio/voice/male/<id>.{ogg,m4a}` 与 `web/<game>/audio/voice/female/<id>.{ogg,m4a}`。
|
||||
- 两个目录的 **id 集合完全一致**(每游戏核对过),引擎按用户选择整目录切换即可,无需按 id 特判。
|
||||
- 双格式:每个 id 都有 `.ogg`(libvorbis q3 44.1k)和 `.m4a`(aac 128k 44.1k),沿用现有格式协商逻辑。
|
||||
- **平铺的旧文件 `voice/<id>.*` 本轮保留未动**。引擎切到 male/female 子目录后,后续轮次再删除平铺文件(storyline 角色台词除外,见 §3)。
|
||||
- staging wav 保留在 `tools/gen_voice_dual_wav/<game>/<gender>/`,供后续轮次增量重生成(管线支持断点续跑,已有且合格的 wav 会跳过)。
|
||||
|
||||
## 2. 生成统计
|
||||
|
||||
| 游戏 | ids/性别 | male 总时长 | female 总时长 |
|
||||
|---|---|---|---|
|
||||
| storyline | 36 | 806.6s | 960.6s |
|
||||
| escape | 41 | 230.8s | 212.4s |
|
||||
| simon | 30 | 63.4s | 62.9s |
|
||||
| rhythm | 9 | 71.5s | 59.0s |
|
||||
| space | 27 | 78.2s | 65.1s |
|
||||
| **合计** | **143 × 2 = 286 条** | 1250.5s | 1360.0s |
|
||||
|
||||
落盘文件 286 × 2 格式 = 572 个。质量控制:全部通过 时长>0.3s / 峰值>0.05 / 峰值归一 0.89;数字类同 instruct 整批生成;>40 字台词拆句拼接。两性时长离群(>3 倍)初检 3 条,已全部重生成并复检通过:
|
||||
|
||||
- `simon/male/num_4`(8.00s → 0.48s)
|
||||
- `simon/female/teach_end`(29.04s → 14.80s)
|
||||
- `space/male/n_ge`(2.16s → 1.12s)
|
||||
|
||||
## 3. storyline 角色音色映射(角色台词不做双性别)
|
||||
|
||||
| 角色 | speaker | 文件 id(沿用平铺原文件) |
|
||||
|---|---|---|
|
||||
| 旁白/系统音 | male=Uncle_Fu / female=Vivian | 36 个双性别 id(见 asset-manifest.md) |
|
||||
| 林晚(主角) | Serena | n02_linwan n03_radio n12_take n19_betray n21_redeem n24_open n25_secure |
|
||||
| 猎狗(反派) | Dylan | n07_dog n18_dog |
|
||||
| 小满(小男孩) | Vivian(童声 instruct) | e1_child |
|
||||
| 林晨(妹妹录音) | Vivian(收音机 instruct) | n03_msg |
|
||||
| 广播女声 | Vivian(广播 instruct) | n05_bcast |
|
||||
|
||||
**需要开发配合**:角色台词在两个性别下使用同一份音频。建议引擎对 storyline 做回退:在 `voice/<gender>/` 找不到的 id 回退读平铺 `voice/<id>`;或由开发/后续轮次把 12 个角色 id 复制进两个子目录(资产侧未复制,避免重复文件进入仓库前等引擎方案定稿)。
|
||||
|
||||
注意:female 旁白选用 Vivian 而非 Serena,因为 Serena 是主角林晚的音色,旁白与主角同声会混淆;代价是旁白与小满/林晨/广播同 speaker,已用截然不同的 instruct(冷静低沉 vs 童声/收音机/机械广播)区分,请策划试听确认。
|
||||
|
||||
## 4. 音色分配表
|
||||
|
||||
| 游戏 | male | female |
|
||||
|---|---|---|
|
||||
| storyline | Uncle_Fu(沿用原旁白) | Vivian |
|
||||
| escape | Uncle_Fu(沿用) | Serena |
|
||||
| simon | Uncle_Fu | Serena(沿用) |
|
||||
| rhythm | Uncle_Fu | Vivian(沿用) |
|
||||
| space | Uncle_Fu | Serena(沿用) |
|
||||
|
||||
Ryan(英语母语音色)未采用:项目已有中文发音投诉(反馈⑥),男声统一中文母语 Uncle_Fu。冒烟样本在 `tools/samples/ryan_zh_*.wav`,如策划试听认为可用,改 `tools/gen_voice_dual.py` 的 `SPEAKERS` 重跑即可。
|
||||
|
||||
## 5. 需要游戏代码配合的点
|
||||
|
||||
1. **引擎支持 voice 性别目录切换**:按用户设置在 `audio/voice/male|female/` 之间切换;storyline 需平铺回退(§3)。
|
||||
2. **simon 删除 BGM 引用**:资产 `web/simon/audio/bgm/theme.{ogg,m4a}` 已按用户要求删除(bgm/ 目录现为空),请移除 game.js 中的 bgm 加载/播放逻辑及 preload 清单条目。
|
||||
3. **语音选择 UI 入口**:需要「男声/女声」设置项(全局或每游戏),默认建议 male=storyline/escape、female=simon/rhythm/space(即各游戏原音色性别),具体由策划定。
|
||||
4. 平铺旧 voice 文件待引擎切换完成后通知音频侧清理(R2+)。
|
||||
|
||||
## 6. 质量遗留问题
|
||||
|
||||
- 本轮 QC 为自动检查(时长/峰值/两性离群),**未做人工试听**;发音准确性需策划/QA 试听把关。文本层多音字风险清单在 `docs/audio/asset-manifest.md`(如 storyline help 的「重听」建议改「重新听」),确认误读后改台词表重跑单条即可。
|
||||
- `simon/female/teach_end` 重生成后 14.8s(male 7.8s),比值正常但偏长,建议试听确认无重复啰嗦。
|
||||
- 管线脚本:`tools/gen_voice_dual.py`(含全部台词表,支持 `--game/--gender/--only/--check/--convert`)。
|
||||
|
||||
## 7. Mureka 花费
|
||||
|
||||
本轮 $0(无新 BGM 需求;simon BGM 为删除而非重制)。累计 $0.15 / $2.00,未动 `tools/mureka_log.md`(无新条目)。
|
||||
@@ -0,0 +1,59 @@
|
||||
# R2 音频轮次报告(2026-08-29,音乐音效总监)
|
||||
|
||||
本轮主题:《暗室》《音序回响》重设计语音资产 + escape 环境音扩展。依据 `docs/design/01-escape-redesign.md`、`02-simon-redesign.md` 与 `00-global-ux.md`。Mureka 花费 $0。
|
||||
|
||||
## 1. 首要交付物
|
||||
|
||||
`docs/audio/round2-voice-ids.md` —— 两款游戏全部语音 id → 台词全文 → instruct 对照表,含新增/保留/删除标记和代码映射区间(time_band/score_band)。开发照此实现即可。
|
||||
|
||||
## 2. 新资产统计
|
||||
|
||||
| 游戏 | 性别 | id 数 | 总时长 | 文件数(ogg+m4a) |
|
||||
|---|---|---|---|---|
|
||||
| escape | male | 74 | 346.6s | 148 |
|
||||
| escape | female | 74 | 320.6s | 148 |
|
||||
| simon | male | 68 | 190.2s | 136 |
|
||||
| simon | female | 68 | 154.3s | 136 |
|
||||
|
||||
合计 142 id × 2 性别 = 284 条语音(568 文件)。全部按 R2 新规范重录:每游戏同一 speaker + 同一 instruct 常量(00 文档 §3.2),instruct 不含语速词;一句一文件整句生成,无数字拼接。escape=Uncle_Fu/Serena,simon=Uncle_Fu/Serena(沿用 R1 分配)。
|
||||
|
||||
环境音新增 5 条(Stable Audio 3,6s 循环,ogg+m4a):`amb_storage amb_cabinet amb_power amb_gate amb_gate_live`,统一归一到 -25dB 均值(对齐旧 amb_drip/-28.7、amb_wind/-21.3 之间),storage/power 轻微偏左、cabinet 轻微偏右 pan 增强房间辨识;staging wav 在 `tools/gen_amb_wav/`。
|
||||
|
||||
## 3. 删除清单(资产侧已执行,代码侧开发跟进)
|
||||
|
||||
**escape voice**(20 id × 2 性别 × 2 格式 + 平铺):
|
||||
- 数字拼接链:`n0–n9 nshi unit_min unit_sec time_prefix` → `time_band1–4` 替代
|
||||
- 旧开场:`intro tutorial` → GUIDE `g0–g7` 替代
|
||||
- 旧提示:`hint_key hint_cabinet hint_box hint_gate` → `stuck_*` 三级提示替代
|
||||
|
||||
**simon voice**(23 id × 2 性别 × 2 格式 + 平铺):
|
||||
- 拼接件:`di lun num_shi num_0–num_9 score_pre best_pre` → `round_*`/`score_band*` 整句替代
|
||||
- 旧教学:`intro teach_up teach_down teach_left teach_right teach_tap teach_doubletap teach_end listen` → 交互教学 `t*_*` 替代
|
||||
|
||||
**sfx**:`simon/correct wrong newrecord`、`escape/ui_deny` 删除(反馈改震动,00 文档 §2)。
|
||||
|
||||
**平铺目录**:escape/simon 的 `audio/voice/*.{ogg,m4a}` 平铺旧文件已全部移除(引擎已支持 gender 目录)。storyline/rhythm/space 的平铺文件未动。
|
||||
|
||||
## 4. QC 结果
|
||||
|
||||
- 284 条全部通过:时长 >0.3s、峰值 >0.05、峰值归一 0.89。
|
||||
- 两性时长离群检测(>3 倍):escape、simon 均 **ALL OK**,本轮零重生成。
|
||||
- 双格式逐条核对:male/female 各目录 id 数与清单精确一致(74/68),无缺失(convert 已改为按台词表驱动,staging 残留旧 id 不会误落盘)。
|
||||
- 人工试听仍未做:发音准确性需策划/QA 把关;多音字规避已在新台词写作时执行(如"重听"→"重新听一遍"(reopen_prompt)、避免"还/得/着"歧义写法)。
|
||||
|
||||
## 5. 给开发的配合说明
|
||||
|
||||
1. 语音 id 以 `round2-voice-ids.md` 为唯一权威清单;删除 id 不得再引用(文件已不存在,引擎容错会静默跳过,但请清理引用)。
|
||||
2. `time_band1–4` 区间映射:`<120s / 120–300s / 300–600s / ≥600s`;`score_band1–6` 映射:`0 / 1–3 / 4–6 / 7–9 / 10–14 / ≥15`。
|
||||
3. escape L3 提示 = `stuck_<阶段>_3` + 按当前坐标算路链播 `nav_up/down/left/right`(两句以内);单击检查 = 先重播当前房间 `amb_*` 再播描述。
|
||||
4. escape 大门通电后环境音切 `amb_gate_live`;空房 `amb_wind`/`amb_hum` 轮换。
|
||||
5. simon 无 BGM(`bgm/` 已空,勿加载);`sfx/gameover` 保留为结束音。
|
||||
6. 菜单句 `menu_open/menu_restart/menu_home/menu_back` 两款游戏已各备一份;设置值播报("当前:女声,正常语速")为跨游戏需求,本清单未覆盖,建议 R3 统一处理。
|
||||
|
||||
## 6. 管线变更
|
||||
|
||||
- `tools/gen_voice_dual.py`:escape/simon 台词表整体替换为 R2 新版(全常量 instruct);`--convert` 改为按台词表驱动;其余游戏(storyline/rhythm/space)台词表与资产未动,脚本仍可全量复跑。
|
||||
|
||||
## 7. Mureka 花费
|
||||
|
||||
本轮 $0(两款游戏均无新 BGM 需求,simon 为去 BGM)。累计 $0.15 / $2.00。
|
||||
@@ -0,0 +1,242 @@
|
||||
# R2 语音 id 清单:escape《暗室》/ simon《音序回响》
|
||||
|
||||
> 给开发:本文件是两款游戏重设计的**最终语音资产清单**。`✦` = 新增 id,`●` = 保留 id(文本不变或微调,全部按新 instruct 重录),`✗` = 删除 id(文件已从 voice/male|female/ 移除,代码不得再引用)。
|
||||
> 所有 id 在 `audio/voice/male/` 与 `audio/voice/female/` 下成对存在(.ogg + .m4a)。
|
||||
> 生成规范(00 文档 §3):每游戏同一 speaker 常量 + 同一 instruct 常量;instruct 不含语速词(语速由引擎 playbackRate 控制);一句一文件,禁止拼接。
|
||||
|
||||
## 音色与 instruct 常量
|
||||
|
||||
| 游戏 | 目录 | speaker | instruct(本游戏全部 id 共用) |
|
||||
|---|---|---|---|
|
||||
| escape | male | Uncle_Fu | 低沉平稳的男声,冷静、克制,带一点神秘感 |
|
||||
| escape | female | Serena | 沉稳平静的女声,冷静、克制,带一点神秘感 |
|
||||
| simon | male | Uncle_Fu | 平静、清晰,像一个耐心的游戏主持人 |
|
||||
| simon | female | Serena | 平静、清晰,像一个耐心的游戏主持人 |
|
||||
|
||||
---
|
||||
|
||||
## 一、escape《暗室》(共 74 id)
|
||||
|
||||
### 1. 开场引导 GUIDE(G0–G7,✦ 全部新增)
|
||||
|
||||
| id | 台词 | 对应设计步骤 |
|
||||
|---|---|---|
|
||||
| g0_intro ✦ | 黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。 | G0(单击继续) |
|
||||
| g1_move ✦ | 先活动一下。朝任意方向滑一下,走一小步。 | G1 |
|
||||
| g1_done ✦ | 很好,你迈出了第一步。 | G1 反馈 |
|
||||
| g2_wall ✦ | 这个房间不大,四面都是墙。再滑两下,试试碰碰墙。 | G2 |
|
||||
| g3_left ✦ | 跟着我指的路走。向左滑。 | G3 |
|
||||
| g4_down ✦ | 对。再向下滑。 | G3 反馈 + G4 指令(同一句) |
|
||||
| g5_inspect ✦ | 听到水声了吗?单击一下,仔细检查这个房间。 | G5 |
|
||||
| g5_key ✦ | 你摸到了一把钥匙! | G5 反馈(谜题链第一环) |
|
||||
| g6_doubletap ✦ | 钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。 | G6 |
|
||||
| g6_done ✦ | 对,就是这样。接下来,靠你自己了。 | G6 反馈 |
|
||||
| g7_goal ✦ | 找到能用这把钥匙打开的东西。需要帮助时,在原地双击。 | G7 → PLAY |
|
||||
|
||||
### 2. 引导纠错与跳过(✦)
|
||||
|
||||
| id | 台词 | 用途 |
|
||||
|---|---|---|
|
||||
| guide_wrong_up ✦ | 不是这个方向。向上滑。 | 做错方向纠正(4 向各一) |
|
||||
| guide_wrong_down ✦ | 不是这个方向。向下滑。 | 同上 |
|
||||
| guide_wrong_left ✦ | 不是这个方向。向左滑。 | 同上 |
|
||||
| guide_wrong_right ✦ | 不是这个方向。向右滑。 | 同上 |
|
||||
| guide_wrong_gesture ✦ | 不是这个操作。照我说的做。 | 手势类型错误(该滑动了却点按等) |
|
||||
| guide_blunt_up ✦ | 手指贴着屏幕,向上推一下。 | 同一步错 3 次的直白重述(4 向各一) |
|
||||
| guide_blunt_down ✦ | 手指贴着屏幕,向下推一下。 | 同上 |
|
||||
| guide_blunt_left ✦ | 手指贴着屏幕,向左推一下。 | 同上 |
|
||||
| guide_blunt_right ✦ | 手指贴着屏幕,向右推一下。 | 同上 |
|
||||
| guide_offpath ✦ | 先跟我走。 | 引导期间偏离脚本路线的温和拦回 |
|
||||
| guide_skip ✦ | 跳过引导,直接开始自由探索。 | 引导期间下滑跳过确认 |
|
||||
| reopen_prompt ✦ | 上滑,重新听一遍引导。单击,直接开始。 | 二周目开场(已完成过引导) |
|
||||
|
||||
### 3. 卡关三级提示(✦ 12 句 + 前置句 + 导航句 4 句)
|
||||
|
||||
L1/L2 每阶段自动播一次;L3 = `stuck_<阶段>_3` + 按当前位置算路链播 `nav_*`(两句以内)。
|
||||
|
||||
| id | 台词 | 级别/阶段 |
|
||||
|---|---|---|
|
||||
| stuck_ask ✦ | 需要帮助吗? | L1 前置句(播完接 L1 内容) |
|
||||
| stuck_key_1 ✦ | 铁锈味最重的地方,值得仔细看看。 | L1·找钥匙 |
|
||||
| stuck_key_2 ✦ | 去西南角的储物间,单击检查,找到钥匙。 | L2·找钥匙(双击兜底也播这条) |
|
||||
| stuck_key_3 ✦ | 跟我说的方向走,去西南角的储物间。 | L3·找钥匙(后接 nav_* 链) |
|
||||
| stuck_cabinet_1 ✦ | 钥匙就在你身上。它能打开某一把锁。 | L1·开铁柜 |
|
||||
| stuck_cabinet_2 ✦ | 去东边的铁柜房,双击,用钥匙打开铁柜。 | L2·开铁柜 |
|
||||
| stuck_cabinet_3 ✦ | 跟我说的方向走,去铁柜所在的房间。 | L3·开铁柜 |
|
||||
| stuck_box_1 ✦ | 保险丝属于电箱。有电流声的地方,就有电箱。 | L1·修电箱 |
|
||||
| stuck_box_2 ✦ | 去西北角的电箱房,双击,把保险丝装进电箱。 | L2·修电箱 |
|
||||
| stuck_box_3 ✦ | 跟我说的方向走,去电箱所在的房间。 | L3·修电箱 |
|
||||
| stuck_gate_1 ✦ | 电已经通了。该去找那扇铁门了。 | L1·开大门 |
|
||||
| stuck_gate_2 ✦ | 去东南角的大门,双击,打开它,离开这里。 | L2·开大门 |
|
||||
| stuck_gate_3 ✦ | 跟我说的方向走,去大门所在的房间。 | L3·开大门 |
|
||||
| nav_up ✦ | 向上滑。 | L3 导航词(算路链播,可两句连读) |
|
||||
| nav_down ✦ | 向下滑。 | 同上 |
|
||||
| nav_left ✦ | 向左滑。 | 同上 |
|
||||
| nav_right ✦ | 向右滑。 | 同上 |
|
||||
|
||||
### 4. 房间描述(✦ 首次进入关键房间时播,配合环境音)
|
||||
|
||||
| id | 台词 | 房间 |
|
||||
|---|---|---|
|
||||
| room_storage ✦ | 一间储物间。货架上堆满杂物,角落里传来水滴声。 | 储物间 (0,2),环境音 amb_storage |
|
||||
| room_cabinet ✦ | 这个房间里,立着一个铁柜。 | 铁柜房 (2,1),amb_cabinet |
|
||||
| room_power ✦ | 这个房间的墙上,有一只电箱。 | 电箱房 (0,0),amb_power |
|
||||
| room_gate ✦ | 面前,是一扇沉重的铁门。 | 大门 (2,2),amb_gate / 通电后 amb_gate_live |
|
||||
|
||||
### 5. 用时播报(✦ 4 段整句,替代全部数字拼接)
|
||||
|
||||
| id | 台词 | 区间(供代码映射) |
|
||||
|---|---|---|
|
||||
| time_band1 ✦ | 你逃出来了,用时不到两分钟。了不起! | t < 120s |
|
||||
| time_band2 ✦ | 你逃出来了,用时不到五分钟。 | 120s ≤ t < 300s |
|
||||
| time_band3 ✦ | 你逃出来了,用时不到十分钟。 | 300s ≤ t < 600s |
|
||||
| time_band4 ✦ | 你逃出来了,用时超过十分钟。 | t ≥ 600s |
|
||||
|
||||
### 6. 保留 id(● 文本不变,按新 instruct 双性别重录)
|
||||
|
||||
| id | 台词 |
|
||||
|---|---|
|
||||
| wall ● | 那边是墙,换个方向。(新文本;引导 G2 与 PLAY 撞墙共用,每次撞墙都播) |
|
||||
| start_room ● | 你站在设施中央的房间里。滑动移动,单击检查,双击使用物品。(新文本;跳过引导后的 PLAY 开场) |
|
||||
| found_key ● | 你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。 |
|
||||
| inspect_cabinet_locked ● | 这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。 |
|
||||
| unlock_cabinet ● | 钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。 |
|
||||
| inspect_box_dead ● | 墙上是一只电箱,箱盖敞开着,里面的保险丝座空空如也。没有电,这里的大门永远打不开。 |
|
||||
| box_fixed ● | 电箱运转正常,电流的嗡鸣声平稳而持续。 |
|
||||
| fix_box ● | 你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了! |
|
||||
| inspect_gate ● | 面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。 |
|
||||
| inspect_gate_ready ● | 电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。 |
|
||||
| escape ● | 你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。 |
|
||||
| use_fail ● | 这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。 |
|
||||
| inv_empty ● | 你摸了摸身上,什么也没有。 |
|
||||
| inv_key ● | 你身上带着一把钥匙。 |
|
||||
| inv_fuse ● | 你身上带着一枚保险丝。 |
|
||||
| inv_both ● | 你身上带着一把钥匙和一枚保险丝。 |
|
||||
| replay ● | 黑暗再次合拢。你回到了中央的房间。这一次,你会更快。 |
|
||||
| again ● | 单击屏幕,再来一次。下滑,打开菜单。(新文本;ENDING 提示) |
|
||||
| empty_1 ● | 房间里空空荡荡,只有你自己的回声。 |
|
||||
| empty_2 ● | 你伸出手,摸到的只有冰冷粗糙的墙壁。 |
|
||||
| empty_3 ● | 除了远处的水滴声,这里什么都没有。 |
|
||||
|
||||
### 7. 菜单与帮助(✦)
|
||||
|
||||
| id | 台词 | 用途 |
|
||||
|---|---|---|
|
||||
| help_ops ✦ | 滑动,在房间之间移动。单击,检查房间,或者重听房间的声音。双击,使用物品,或者查看背包。 | 菜单「重听帮助」/ 兜底说明 |
|
||||
| menu_open ✦ | 菜单。 | 菜单打开播报第一项前 |
|
||||
| menu_restart ✦ | 重新开始。 | 菜单选项 |
|
||||
| menu_home ✦ | 回大厅。 | 菜单选项(保存并回大厅) |
|
||||
| menu_back ✦ | 返回游戏。 | 菜单选项 |
|
||||
|
||||
### 8. escape 删除清单(✗ 文件已移除,代码禁止引用)
|
||||
|
||||
- 数字拼接链(14):`n0 n1 n2 n3 n4 n5 n6 n7 n8 n9 nshi unit_min unit_sec time_prefix` → 由 `time_band1..4` 替代
|
||||
- 旧开场(2):`intro tutorial` → 由 GUIDE(g0–g7)替代
|
||||
- 旧四级提示(4):`hint_key hint_cabinet hint_box hint_gate` → 由 `stuck_*` 三级提示替代
|
||||
- 平铺目录 `audio/voice/` 下的 escape 旧文件全部移除(引擎已切 gender 目录)
|
||||
|
||||
---
|
||||
|
||||
## 二、simon《音序回响》(共 68 id)
|
||||
|
||||
### 1. 交互式教学(✦ T0–T7;T8 即 round_01)
|
||||
|
||||
| id | 台词 | 用途 |
|
||||
|---|---|---|
|
||||
| t0_welcome ✦ | 欢迎来到音序回响。先认识六种声音。 | T0(与下句链播) |
|
||||
| t0_goal ✦ | 我会演奏一串声音,请你记住顺序,原样复现。 | T0 第二句 |
|
||||
| t1_up ✦ | 向上滑,是这个声音。 | T1(播完接 tone_up,再接 t_try) |
|
||||
| t2_down ✦ | 向下滑,是这个声音。 | T2 |
|
||||
| t3_left ✦ | 向左滑,是这个声音。 | T3 |
|
||||
| t4_right ✦ | 向右滑,是这个声音。 | T4 |
|
||||
| t5_tap ✦ | 单击,是这个声音。 | T5 |
|
||||
| t6_double ✦ | 双击,是这个声音。 | T6 |
|
||||
| t_try ✦ | 你试试。 | T1–T6 共用,等待玩家做该手势 |
|
||||
| t_ok ✦ | 对了。 | 做对,进下一个 |
|
||||
| t_wrong_up ✦ | 不是这个,向上滑试试。 | 做错纠正(6 手势各一) |
|
||||
| t_wrong_down ✦ | 不是这个,向下滑试试。 | 同上 |
|
||||
| t_wrong_left ✦ | 不是这个,向左滑试试。 | 同上 |
|
||||
| t_wrong_right ✦ | 不是这个,向右滑试试。 | 同上 |
|
||||
| t_wrong_tap ✦ | 不是这个,单击试试。 | 同上 |
|
||||
| t_wrong_double ✦ | 不是这个,双击试试。 | 同上 |
|
||||
| t_blunt_up ✦ | 手指贴着屏幕,向上推一下。 | 同一手势错 3 次的直白版(6 手势各一) |
|
||||
| t_blunt_down ✦ | 手指贴着屏幕,向下推一下。 | 同上 |
|
||||
| t_blunt_left ✦ | 手指贴着屏幕,向左推一下。 | 同上 |
|
||||
| t_blunt_right ✦ | 手指贴着屏幕,向右推一下。 | 同上 |
|
||||
| t_blunt_tap ✦ | 用指尖,轻轻点一下屏幕。 | 同上 |
|
||||
| t_blunt_double ✦ | 用指尖,快速点两下屏幕。 | 同上 |
|
||||
| t7_ready ✦ | 六种声音都记住了。后面的序列,会越来越长。 | T7(与下句链播) |
|
||||
| t7_retry ✦ | 答错可以消耗机会重听。双击,可以随时重听。 | T7 第二句 |
|
||||
| t_skip ✦ | 跳过教学,直接开始。 | 教学期间下滑跳过确认 |
|
||||
|
||||
### 2. 轮次播报(✦ 21 句整句)
|
||||
|
||||
| id | 台词 |
|
||||
|---|---|
|
||||
| round_01 … round_20 ✦ | 第一轮,听好了。/第二轮,听好了。/第三轮,听好了。/第四轮,听好了。/第五轮,听好了。/第六轮,听好了。/第七轮,听好了。/第八轮,听好了。/第九轮,听好了。/第十轮,听好了。/第十一轮,听好了。/第十二轮,听好了。/第十三轮,听好了。/第十四轮,听好了。/第十五轮,听好了。/第十六轮,听好了。/第十七轮,听好了。/第十八轮,听好了。/第十九轮,听好了。/第二十轮,听好了。 |
|
||||
| round_more ✦ | 新的一轮,听好了。 | round > 20 兜底 |
|
||||
|
||||
### 3. 成绩播报(✦ 6 段整句)
|
||||
|
||||
| id | 台词 | 区间(供代码映射,score=完成轮数) |
|
||||
|---|---|---|
|
||||
| score_band1 ✦ | 一轮都没坚持下来。别灰心。 | score = 0 |
|
||||
| score_band2 ✦ | 你坚持了三轮以内。 | 1–3 |
|
||||
| score_band3 ✦ | 你坚持了五轮左右。 | 4–6 |
|
||||
| score_band4 ✦ | 你坚持了将近十轮。 | 7–9 |
|
||||
| score_band5 ✦ | 你坚持了十轮以上,很厉害。 | 10–14 |
|
||||
| score_band6 ✦ | 你坚持了十五轮以上,大师级! | ≥ 15 |
|
||||
|
||||
### 4. 流程句(✦/●)
|
||||
|
||||
| id | 台词 | 用途 |
|
||||
|---|---|---|
|
||||
| your_turn ● | 轮到你了。 | 第 1 轮复现阶段(之后轮次仅震动) |
|
||||
| chances_out ✦ | 重听机会用完了,小心。 | 机会归 0 时提示 |
|
||||
| retry_use ✦ | 错了。用一次机会,再听一遍。 | 答错且有机会(自动重播序列) |
|
||||
| relisten ✦ | 再听一遍。 | 输入阶段双击主动重听(耗机会) |
|
||||
| replay ● | 单击屏幕,再来一局。 | 结算 |
|
||||
| rules_prompt ✦ | 上滑,重听规则。下滑,打开菜单。 | 结算/非输入阶段提示 |
|
||||
| rules ✦ | 上行音,向上滑。下行音,向下滑。左边的音,向左滑。右边的音,向右滑。一声轻响,单击。两声轻响,双击。 | 规则重听句(上滑) |
|
||||
| gameover ● | 游戏结束。 | 答错且无机会(sfx/gameover 之后链播成绩) |
|
||||
|
||||
### 5. 反馈短句(● 文本不变重录)
|
||||
|
||||
| id | 台词 | 用途 |
|
||||
|---|---|---|
|
||||
| correct ● | 答对了! | 整轮复现成功(每 4 轮播一次,其余仅震动) |
|
||||
| wrong ● | 错了。 | 答错(retry_use 已含"错了",此句供无机会外的即时反馈,可选) |
|
||||
| chance_plus ✦ | 机会加一。 | 每过 4 轮机会 +1 |
|
||||
| new_record ● | 新纪录! | 破纪录 |
|
||||
|
||||
### 6. 菜单(✦)
|
||||
|
||||
| id | 台词 |
|
||||
|---|---|
|
||||
| menu_open ✦ | 菜单。 |
|
||||
| menu_restart ✦ | 重新开始。 |
|
||||
| menu_home ✦ | 回大厅。 |
|
||||
| menu_back ✦ | 返回游戏。 |
|
||||
|
||||
### 7. simon 删除清单(✗ 文件已移除,代码禁止引用)
|
||||
|
||||
- 数字拼接件(14):`di lun num_shi num_0 num_1 num_2 num_3 num_4 num_5 num_6 num_7 num_8 num_9 score_pre best_pre` → 由 `round_*` / `score_band*` 整句替代
|
||||
- 旧教学连播(9):`intro teach_up teach_down teach_left teach_right teach_tap teach_doubletap teach_end listen` → 由交互式教学 `t*_*` 替代
|
||||
- sfx(3):`sfx/correct sfx/wrong sfx/newrecord` 已删除(反馈改震动);`sfx/gameover` 与 6 个 `tone_*` 保留
|
||||
- 平铺目录 `audio/voice/` 下的 simon 旧文件全部移除
|
||||
|
||||
---
|
||||
|
||||
## 三、escape 环境音(sfx,非语音)
|
||||
|
||||
| id | 状态 | 内容 |
|
||||
|---|---|---|
|
||||
| amb_drip | 保留 | 起点 (1,1):单一滴水 |
|
||||
| amb_wind | 保留 | 空房轮换:风声弱化版 |
|
||||
| amb_hum | 保留 | 空房轮换:远处嗡鸣弱化版 |
|
||||
| amb_storage ✦ | 新增 | 储物间:滴水 + 金属杂物轻响 |
|
||||
| amb_cabinet ✦ | 新增 | 铁柜房:嗡鸣 + 铁皮共振 |
|
||||
| amb_power ✦ | 新增 | 电箱房:电流嗡鸣 + 偶尔火花 |
|
||||
| amb_gate ✦ | 新增 | 大门:风声 + 门外空旷感 |
|
||||
| amb_gate_live ✦ | 新增 | 大门通电后:风声 + 电流声 |
|
||||
| ui_deny ✗ | 删除 | 反馈提示音退役(改震动) |
|
||||
@@ -0,0 +1,68 @@
|
||||
# R3 音频轮次报告(2026-08-29,音乐音效总监)
|
||||
|
||||
本轮主题:《节拍行者》重构语音 + 3 首 BPM 精确 BGM,《盲航》重构语音备产。依据 `docs/design/03-rhythm-voice-ids.md`、`03-rhythm-rebuild.md`、`04-space-voice-ids.md`。Mureka 本轮 $0.15 / $0.30,累计 $0.30 / $2.00。
|
||||
|
||||
## 1. rhythm 语音(重构版)
|
||||
|
||||
- 台词表照录 `docs/design/03-rhythm-voice-ids.md`(31 句),合入 `tools/gen_voice_dual.py` 全量替换 R1 版。female=Vivian / male=Uncle_Fu,全表同一 instruct 常量。
|
||||
- 生成统计:**male 31 条 / 116.6s,female 31 条 / 105.4s**(ogg+m4a 各 62 文件)。
|
||||
- QC:时长/峰值/两性离群(>3 倍)全部 **ALL OK,零重生成**。
|
||||
- 旧 id 清理:平铺 9 个旧 id 全部删除(`welcome rules tut_start tut_done grade_* restart`,其中 `grade_* restart` 按新文案重录覆盖,`welcome` 文案变更重录);`tut_start` 彻底弃用。目录现仅含新表 31 id × 2 性别。
|
||||
|
||||
## 2. rhythm BGM(3 首,Mureka)
|
||||
|
||||
| 曲目 | 选用候选(task/choice) | 源实测 BPM | 处理 | 成品实测 BPM | 循环时长 |
|
||||
|---|---|---|---|---|---|
|
||||
| track90《启程》 | 157963509563393 / 157963627134980(首提候选 1) | 98.75 | atempo=0.911392 拉伸 | **90.01** | 85.33s(32 小节) |
|
||||
| track105《追风》 | 157963652431875 / 157963780292611(首提候选 0) | **105.00 精确** | 无拉伸 | **104.99** | 73.14s(32 小节) |
|
||||
| track120《星爆》 | 157964212305921 / 157964317097985(重提候选 0) | **120.00 精确** | 无拉伸 | **120.00** | 72.00s(36 小节) |
|
||||
|
||||
- 后处理:首个强 onset 对齐小节头裁剪 → 整数小节 + 末 2 拍与开头 acrossfade 无缝循环 → loudnorm I=-20/TP=-2 → ogg q3 + m4a 128k。成品 mean -21.2/-21.3/-19.9dB。
|
||||
- **BPM 测量方法学(重要)**:`librosa.beat.beat_track` 在 22050Hz/hop512 下按整帧量化,105 BPM 附近一档=4.3 BPM(读数 103.36/107.67 都是量化伪影)。改用自写梳状扫描 `tools/bpm_precise.py`(0.05 BPM 步进 + 相位扫描 + 抛物线插值,合成 105.00 点击轨校准通过)。`tools/bpm_check.py`(粗筛)+ `bpm_precise.py`(判定)均留在 tools/。
|
||||
- 重试记录:track90 首提 121.0/98.75、重提 80.0/120.0 四候选均不达标,已达"每首最多重试 1 次"上限,按规则停止重提,选用 98.75 拉伸修正(电子乐 8.9% 减速,瞬态影响可接受,成品实测 90.01)。track120 首提不达标、重提即中 120.00。track105 首提即中。
|
||||
- 旧 `bgm/groove` 已删除。花费明细已追加 `tools/mureka_log.md`(5 任务 × $0.03 = $0.15)。
|
||||
|
||||
## 3. space 语音(备产,已完成)
|
||||
|
||||
`docs/design/04-space-voice-ids.md` 在本轮执行期间就绪(52 句),已全量生成:
|
||||
|
||||
- **male 52 条 / 188.0s,female 52 条 / 159.4s**(ogg+m4a 各 104 文件)。female=Serena / male=Uncle_Fu,全表同一 instruct。
|
||||
- QC:**ALL OK,零重生成**。
|
||||
- 旧 id 清理:`num0–9 num_shi num_bai n_* intro1–4 hit1 hit2 gameover restart` 全部弃用删除(含平铺);`shield_ready shield_block` 沿用 id 重录。
|
||||
- 注意:重构版闪避改为**同向**(左来左滑),新台词已按此写死,与 R1 旧台词(反向)语义相反——开发务必以新 game.js + 新语音为准,不可混用。
|
||||
|
||||
## 4. 删除清单(资产侧已执行)
|
||||
|
||||
- rhythm voice 平铺 9 id;rhythm sfx `judge_good judge_miss judge_perfect tick tick_accent`(反馈改震动、数拍融入 BGM);rhythm `bgm/groove`。
|
||||
- space voice 旧 27 id(平铺 + male/female 全部,新表 52 id 重录替换)。
|
||||
|
||||
## 5. 给开发的配合说明
|
||||
|
||||
1. **rhythm 乐器音 sfx(已于 R3 收尾补齐,见 §7)**:重构 §4 需要的 `inst_kick inst_snare inst_hat_l inst_hat_r inst_tom_hi inst_tom_lo inst_accent` 已生产落盘;`cue_*` 6 个旧提示音已确认不再使用并删除。
|
||||
2. BGM 对网格:三首成品 BPM 实测偏差 ≤0.01%,直接按设计 SPB(0.667/0.571/0.5s)排程即可;循环长度均为整数小节,`src.start(t0)` 从 0 播即是小节头。
|
||||
3. rhythm 结算评级句已含准确率档位文案(S=九成五/A=八成五/B=七成/C=不到七成),与 §2 判定阈值一致,代码直接按 rate 映射 grade_* 即可。
|
||||
4. space 里程碑触发点:10/25/50/100 颗(`ms_*`);结算 time 档 `<30/30–60/60–120/≥120s`,dodge 档 `<5/5–14/15–49/≥50`。
|
||||
|
||||
## 6. Mureka 花费
|
||||
|
||||
本轮 $0.15(5 任务,全部 succeeded);累计 **$0.30 / $2.00**。详见 `tools/mureka_log.md` R3 节。
|
||||
|
||||
## 7. 补产:rhythm 演奏乐器音 sfx(R3 收尾,2026-08-29)
|
||||
|
||||
重构 §4 的 7 个手势乐器音已补齐,落 `web/rhythm/audio/sfx/`(ogg q3 + m4a 128k):
|
||||
|
||||
| id | 内容 | 生成方式 | 时长 | 峰值 | 声像/调性 |
|
||||
|---|---|---|---|---|---|
|
||||
| inst_kick | 底鼓(tap) | Stable Audio 3("punchy deep EDM kick, dry, single hit") | 0.50s | -0.8dB | 中央 |
|
||||
| inst_snare | 军鼓(doubletap) | Stable Audio 3 | 0.45s | -1.1dB | 中央 |
|
||||
| inst_tom_hi | 高通鼓(up) | Stable Audio 3(high pitched synth tom) | 0.50s | -1.2dB | 中央,升调感 |
|
||||
| inst_tom_lo | 低通鼓(down) | Stable Audio 3(low pitched deep synth tom) | 0.60s | -1.0dB | 中央,降调感 |
|
||||
| inst_hat_l | 闭合镲(left) | Stable Audio 3(closed hi-hat) | 0.20s | -1.1dB | **极左**(L=-1.1dB / R=-22.7dB) |
|
||||
| inst_hat_r | 开放镲(right) | Stable Audio 3(open hi-hat) | 0.60s | -2.2dB | **极右**(R=-2.2dB / L=-23.3dB) |
|
||||
| inst_accent | Perfect 亮音叠加层 | **numpy 程序合成**(音高可控) | 0.38s | -1.8dB(0.8 峰值) | A minor 上行琶音 A5→E6→A6,三音 90ms 间隔快衰减 |
|
||||
|
||||
- 调性处理:三首 BGM 均 A minor(Mureka prompt 指定);accent 用 A 小调音级(A/E/A)程序合成,音高精确对齐;鼓件为伪音高打击乐,tom_hi/lo 在 prompt 里分了高低音区。staging wav 在 `tools/gen_inst_wav/`。
|
||||
- 后处理:去头静音(silenceremove -45dB)→ 定长裁剪 + 末端 40-100ms 淡出防爆音 → 峰值归一 -1dBFS → hat 先 downmix 再 pan 后归一(避免了 pan 叠加削波的一版返工)。
|
||||
- **id 映射注意**:设计文档 §4 写的叠加层 id 是 `sfx/accent`,本轮按主 agent 指令统一命名 `inst_accent`(与其他 6 件同前缀),开发以 `inst_accent` 为准。
|
||||
- **cue_* 处置:删除**。通读 03-rhythm-rebuild.md,全文无 cue_* 资产引用(仅 §8.2 有内部变量 `_cues` 指期望音符),§4 手势音全部改走 inst_* 乐器音——旧指令音 `cue_double cue_down cue_left cue_right cue_tap cue_up`(6 id × 2 格式)已删。
|
||||
- Mureka 花费不变(本轮 $0,累计 $0.30 / $2.00)。
|
||||
@@ -0,0 +1,102 @@
|
||||
# R4 音频轮次报告(2026-08-29,音乐音效总监)
|
||||
|
||||
本轮主题:新 RPG《剑与声》第一章全套音频资产 + space `menu_main` 改词重录。依据 `docs/design/05-rpg-ch1-voice-ids.md`、`05-rpg-chapter1.md`、`05-rpg-addendum.md`。Mureka 本轮 $0.12 / $0.30,**累计 $0.42 / $2.00**。
|
||||
|
||||
## 1. 语音(107 旁白 ×2 + 14 NPC)
|
||||
|
||||
| 类别 | 数量 | 总时长 | 落点 |
|
||||
|---|---|---|---|
|
||||
| 旁白 male(Uncle_Fu·说书人) | 107 | 282.8s | `web/rpg/audio/voice/male/` |
|
||||
| 旁白 female(Serena·武侠故事) | 107 | 242.6s | `web/rpg/audio/voice/female/` |
|
||||
| NPC 单音色(村长/老猎人/头目/寨主) | 14 | 50.9s | `web/rpg/audio/voice/` 平铺 |
|
||||
|
||||
- 台词表照录 05-rpg-ch1-voice-ids.md(104 句)+ addendum §6.2 补录 `foe_hp_high/mid/low`(3 句)= 107 句,已合入 `tools/gen_voice_dual.py`(新增 `rpg` 条目与 `NPCS` 平铺机制,管线断点续跑/`--only`/`--check`/`--convert` 全部适用)。
|
||||
- **计数勘误**:台词表头写"NPC 16 句",实表只有 14 句(headman 4 + hunter 4 + elite 3 + boss1 3);合计 107×2+14 = 228 条(×2 格式 = 456 文件),以此为准。
|
||||
- QC:时长/峰值/两性离群(>3 倍)**ALL OK,零重生成**。
|
||||
- 已知风险:村长与黑风寨主同 speaker(Uncle_Fu)不同 instruct(慈祥和缓 vs 低沉阴狠),按设计"从不在同场景出现"处理,**需人工试听确认区分度**。
|
||||
|
||||
## 2. BGM(4 首,Mureka)
|
||||
|
||||
| id | task/choice | 选用 | 成品 |
|
||||
|---|---|---|---|
|
||||
| village | 157968630808577 / 157968750477315 | 候选 1(229.2s) | 90.00s 循环,mean -23.0dB |
|
||||
| battle | 157968792551426 / 157968893214722 | 候选 0(212.8s) | 90.00s,mean -20.5dB |
|
||||
| boss | 157968932864001 / 157969029332994 | 候选 1(178.2s) | 90.00s,mean -22.6dB |
|
||||
| ending | 157969071407105 / 157969188913155 | 候选 1(248.4s) | 90.00s,mean -21.9dB |
|
||||
|
||||
- 4 任务一次成功无重试。处理:强 onset 起点 → 90s 裁剪 + 首尾 3s crossfade 无缝循环 → loudnorm -20 LUFS → ogg q3 + m4a。RPG 无节拍网格,BPM 不做硬校验。
|
||||
- addendum §3 裁定:`bgm/explore` 取消(探索期无音乐),amb 走 sfx 层。花费已追加 `tools/mureka_log.md`。
|
||||
|
||||
## 3. SFX(35 件)
|
||||
|
||||
- **SA3 29 件**(`tools/gen_rpg_sfx.sh`,staging `tools/gen_rpg_sfx_wav/`):前摇 `tel_*` ×9(含 ch2/3 备用 croc/shaman/puppet/boss2,统一裁 1.0s 格挡窗口)、战斗 `atk_swing atk_hit parry skill_slash enemy_die potion`、探索 `chest drink_water rest door_heavy`、环境 `amb_*` ×11(含备用 swamp/peak,6s→1s 首尾交叉淡化成 5s 循环)。
|
||||
- **SYN 5 件**(`tools/gen_rpg_syn.py`):`hurt`(60Hz 低频闷击,与 atk_hit 区分)、`flee`(渐远脚步)、`step`(单步)、`coin`(硬币叮当)、`levelup`(A 小调五声 A4-C5-D5-E5-A5 上行钟声琶音)。
|
||||
- 响度:one-shot 峰值 -1dBFS;amb 均值 -25dB(amb_hunter/amb_hall 因 crackle 峰值封顶初次仅 -31dB,已用 `alimiter level=false` 限幅重做至 -25.9dB——R2 学到的 level=true 陷阱的反向应用)。
|
||||
- `ui_deny` 按设计不做。`web/rpg/gen/README.md` 有完整清单与再生成方法。
|
||||
|
||||
## 4. space `menu_main` 改词重录
|
||||
|
||||
按 round2-review 裁决②(全球菜单约定:单击切换/双击确认),文本由「菜单。左右滑选择,单击确认。」改为「**菜单。单击切换选项,双击确认。**」,双性别重录覆盖 `web/space/audio/voice/male|female/menu_main.*`。
|
||||
**同类遗留**:rhythm `menu_main` 文本同为旧约定("左右滑选择,单击确认"),且 rpg 的 `sys_menu`/`shop_open` 按 RPG 豁免条款保持"左右滑+单击"不变(正确)。rhythm 那句建议 R5 一并处理(rhythm 选曲界面非模态菜单仍用左右滑+单击,仅模态菜单改新约定——需策划确认文案后我重录)。
|
||||
|
||||
## 5. 给开发的 id 总表(可直接 import)
|
||||
|
||||
语音 id 分组(旁白 male/female 同集,NPC 平铺单集):
|
||||
|
||||
```js
|
||||
const RPG_VOICE = {
|
||||
intro: ['intro_1','intro_2','intro_3'],
|
||||
system: ['sys_continue_ask','sys_new_game','sys_saved','sys_rest','sys_drink',
|
||||
'sys_menu','sys_menu_resume','sys_menu_save','sys_menu_help',
|
||||
'help_explore','help_battle','settings_hint'],
|
||||
status: ['st_hp_high','st_hp_mid','st_hp_low','st_gold_poor','st_gold_some','st_gold_rich',
|
||||
'st_lv_low','st_lv_mid','st_lv_high',
|
||||
'st_weapon_none','st_weapon_iron','st_weapon_steel','st_weapon_wind',
|
||||
'st_armor_none','st_armor_leather','st_armor_iron'],
|
||||
foeHp: ['foe_hp_high','foe_hp_mid','foe_hp_low'], // 敌方血量档位(addendum §6.2)
|
||||
desc: ['desc_village','desc_village_re','desc_path','desc_path_re','desc_fork','desc_fork_re',
|
||||
'desc_chest','desc_chest_re','desc_stream','desc_stream_re','desc_hunter','desc_hunter_re',
|
||||
'desc_deepwood','desc_deepwood_re','desc_gate','desc_hall'],
|
||||
option: ['opt_go_path','opt_go_fork','opt_go_chest','opt_go_stream','opt_go_hunter',
|
||||
'opt_go_deepwood','opt_go_gate','opt_back','opt_shop','opt_rest','opt_status',
|
||||
'opt_investigate','opt_open_chest','opt_drink','opt_talk_hunter','opt_assault',
|
||||
'opt_sneak','opt_leave'],
|
||||
event: ['evt_gold15','evt_nothing','evt_chest','evt_hunter_gift','evt_gate_open','evt_ch1_end'],
|
||||
battle: ['btl_start_wolf','btl_start_bandit','btl_start_spider','btl_start_elite','btl_start_boss1',
|
||||
'cmd_attack','cmd_skill','cmd_defend','cmd_item','btl_nomp','btl_noitem','btl_flee',
|
||||
'btl_noflee','btl_enemy_die','btl_win','btl_loot_small','btl_loot_mid','btl_loot_big',
|
||||
'btl_levelup','btl_lose','btl_lose_hint','tut_b1','tut_b2','tut_b3','tut_b4','tut_b5'],
|
||||
shop: ['shop_open','shop_price_sword','shop_price_armor','shop_price_potion',
|
||||
'shop_owned','shop_poor','shop_buy'],
|
||||
npc: ['npc_headman_1','npc_headman_2','npc_headman_3','npc_headman_4',
|
||||
'npc_hunter_1','npc_hunter_2','npc_hunter_3','npc_hunter_4',
|
||||
'npc_elite_1','npc_elite_half','npc_elite_die',
|
||||
'npc_boss1_1','npc_boss1_2','npc_boss1_die'], // 平铺 voice/,无性别目录
|
||||
};
|
||||
const RPG_BGM = ['village','battle','boss','ending'];
|
||||
const RPG_SFX = {
|
||||
telegraph: ['tel_wolf','tel_bandit','tel_spider','tel_elite','tel_boss1', // 1.0s 格挡窗口
|
||||
'tel_croc','tel_shaman','tel_puppet','tel_boss2'], // ch2/3 备用
|
||||
combat: ['atk_swing','atk_hit','parry','skill_slash','enemy_die','potion','hurt','flee'],
|
||||
explore: ['step','coin','chest','drink_water','rest','levelup','door_heavy'],
|
||||
ambient: ['amb_village','amb_path','amb_fork','amb_chest','amb_stream','amb_hunter',
|
||||
'amb_deepwood','amb_gate','amb_hall','amb_swamp','amb_peak'], // 5s 循环,手动 loop 接 sfx 总线
|
||||
};
|
||||
```
|
||||
|
||||
配合要点:
|
||||
1. `amb_*` 需手动 BufferSource loop 接 `_sfxBus`(addendum §3 对实现的唯一新要求;不受 bgm=off 设置影响)。
|
||||
2. 前摇音 `tel_<enemy>` 起点即格挡窗口起点(1.0s),Boss 二阶段 `tel_boss1` 以 playbackRate 1.2 重播(不加资产)。
|
||||
3. `foe_hp_*` 为战斗态上滑敌方档位句;降档才播 `st_hp_mid/low`。
|
||||
4. 场景 `desc_*` 首次/回访成对(`_re` 后缀为回访短句)。
|
||||
5. **设计缺口(请策划补)**:敌人表中山贼标了半血句 `btl_half_bandit`,但台词表无此 id(头目有 `npc_elite_half`)——按 chapter1 §3"无此语音的敌人跳过"可运行,如需补齐请发文案我重录。`npc_boss3_warn`/`btl_half_shaman`/`btl_half_shadow` 属 ch2/3 最小集(addendum §5),等 ch2 台词表。
|
||||
|
||||
## 6. 遗留问题
|
||||
|
||||
- 村长/寨主同 speaker 区分度、全部新语音发音准确性:未做人工试听(QC 均为自动检查)。
|
||||
- rhythm `menu_main` 旧菜单约定文案(§4)。
|
||||
- rhythm 乐器音 `inst_accent` 与设计文档 §4 的 `accent` 命名差异(R3 已按 inst_accent 落盘,开发以此为准)。
|
||||
|
||||
## 7. Mureka 花费
|
||||
|
||||
本轮 $0.12(4 任务全成功);累计 **$0.42 / $2.00**,剩 $1.58。明细见 `tools/mureka_log.md` R4 节。
|
||||
@@ -0,0 +1,39 @@
|
||||
# R5 音频轮次报告(2026-08-29,音乐音效总监)
|
||||
|
||||
最终轮收尾:两处小批量补录 + 全项目资产终检。Mureka 本轮 $0(累计 **$0.42 / $2.00**,未再变动)。
|
||||
|
||||
## 1. 补录明细
|
||||
|
||||
### rhythm `menu_main` 改词重录(与 space R4 同款问题)
|
||||
|
||||
- 旧文案「菜单。左右滑选择,单击确认。」→ 新文案「**菜单。单击切换选项,双击确认。**」(round2-review 裁决②全球菜单约定;rhythm 选曲界面非模态菜单,仍左右滑+单击,不受影响)。
|
||||
- 双性别重录(male=Uncle_Fu 4.00s / female=Vivian 4.16s,沿用 rhythm 统一 instruct),覆盖 `web/rhythm/audio/voice/male|female/menu_main.{ogg,m4a}`。台词表已同步更新(`tools/gen_voice_dual.py` RHYTHM_LINES)。
|
||||
|
||||
### rpg `btl_half_bandit` 补录(设计缺口)
|
||||
|
||||
- 敌人表(05-rpg-chapter1 §4.2)给山贼标了半血句但台词表漏发;按 B 组风格补写「**山贼的攻势缓了下来。**」,已合入 `tools/gen_voice_dual.py` RPG_LINES(B 组,27→含此句)。
|
||||
- 双性别生成(male=Uncle_Fu 2.64s / female=Serena 1.52s),落 `web/rpg/audio/voice/male|female/btl_half_bandit.{ogg,m4a}`。rpg 旁白现为 108 id ×2。
|
||||
- 触发点:`CHECK_ENEMY_DEAD` 前、山贼 HP 首次 ≤50% 时插播(chapter1 §3 半血叙事钩子)。
|
||||
|
||||
## 2. 全项目资产终检(`docs/audio/final-asset-check.md`)
|
||||
|
||||
终检脚本 `tools/check_all_assets.py`(可重复执行),检查 6 游戏:双格式配对、0 字节、ffprobe 全量解码、voice 性别目录 id 集合一致性(storyline/rpg 平铺角色句豁免):
|
||||
|
||||
| 游戏 | ogg | m4a | voice 性别一致性 |
|
||||
|---|---|---|---|
|
||||
| storyline | 133 | 133 | ✅ 各 36(平铺 48 = 12 角色 + 36 R1 旁白遗产) |
|
||||
| escape | 166 | 166 | ✅ 各 74 |
|
||||
| simon | 143 | 143 | ✅ 各 68 |
|
||||
| rhythm | 72 | 72 | ✅ 各 31 |
|
||||
| space | 116 | 116 | ✅ 各 52 |
|
||||
| rpg | 269 | 269 | ✅ 各 108(平铺 14 NPC) |
|
||||
| **合计** | **899** | **899** | 全部通过 |
|
||||
|
||||
零 0 字节、零解码失败、零格式缺失。
|
||||
|
||||
## 3. 五轮累计遗留(供后续迭代参考)
|
||||
|
||||
1. **人工试听从未执行**:全部 QC 为自动检查(时长/峰值/离群/BPM/电平)。多音字风险清单在 asset-manifest.md,村长 vs 寨主同 speaker 区分度(R4)为最高优先试听项。
|
||||
2. storyline 平铺 36 个 R1 旁白遗产文件待引擎切换验证后清理。
|
||||
3. ch2/3 台词表最小集(addendum §5:`npc_boss3_warn`、`btl_half_shaman`、`btl_half_shadow`)待策划补发。
|
||||
4. Ryan 音色未采用样本在 `tools/samples/ryan_zh_*.wav`(如策划想给某游戏换年轻男声,改 SPEAKERS 重跑即可)。
|
||||
@@ -0,0 +1,174 @@
|
||||
# 00 · 全局交互规范(Global UX)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 设置项(voice / speed / bgm)只在大厅页用 `<select>` 可视化 UI 修改,存 `localStorage["soundgame.settings"]`,由 `engine/settings.js` 管理;游戏启动时只读生效,游戏内不提供修改入口(菜单里只播报当前值)。**R1 已落地,本文 §1/§2/§4 已按实现修订(R2 修订版)。**
|
||||
> 2. 所有提示音反馈改为震动:`engine/haptics.js`(**已实现**,震型名小写:`confirm/error/success/warning/swipe/heartbeat`);iOS Safari 不支持时**静默降级,绝不回退到提示音**。
|
||||
> 3. 旁白全部整句预生成:禁止数字/碎片拼接(音色漂移根因),每款游戏旁白固定同一 `speaker` + 同一 `instruct` 常量。
|
||||
> 4. 手势全局约定:上滑=帮助/状态、下滑=菜单、单击=确认/继续、双击=重听、左右滑=选择/移动;以滑动为核心玩法的游戏可偏离(见 §5 豁免表),但必须在开场交互教学中讲清。
|
||||
> 5. 用户系统:大厅页登录,进度经 `web/server/server.js` REST API 存服务器(**已实现**),本地 localStorage 做离线缓存。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设置系统(已实现:engine/settings.js + 大厅设置面板)
|
||||
|
||||
### 1.1 三个设置项的精确语义
|
||||
|
||||
| 设置项 | 取值(实现值) | 精确语义 |
|
||||
|---|---|---|
|
||||
| `voice` | `"female"`(默认)/ `"male"` | 切换 **voice 资产目录**。`play('voice/xxx')` 实际解析为 `audio/voice/<gender>/xxx.{ogg,m4a,mp3,wav}`,找不到时**回退平铺的 `audio/voice/xxx.*`**(已实现;storyline 角色台词靠此回退)。两性别的 id 集合由资产侧保证完全一致(见 docs/audio/round1-report.md),因此不做"回退另一性别"的逻辑。 |
|
||||
| `speed` | `1.0`(默认)/ `1.35` / `1.7` | **只作用于 voice 层**的 `playbackRate`(创建 voice source 时实时读取,**从下一句开始生效**,正在播的句子不变速)。BGM、sfx 速率不变。TTS 一律按 1.0 语速生成;`instruct` 里禁止出现语速描述词。 |
|
||||
| `bgm` | `1.0`(默认)/ `0.3` / `0` | BGM 层独立增益总线系数。`0` = 不加载、不播放任何 `bgm/` 资产(`_playBgm` 短路),ducking 逻辑保持原样。改设置即时生效(已实现 subscribe 机制)。 |
|
||||
|
||||
存储:localStorage key = `soundgame.settings`,值形如:
|
||||
|
||||
```json
|
||||
{ "voice": "female", "speed": 1.0, "bgm": 1.0 }
|
||||
```
|
||||
|
||||
注意(与设计初稿的差异,**以实现为准**):`speed`/`bgm` 存**数值**(1.0/1.35/1.7、1.0/0.3/0),不是字符串枚举;`engine/settings.js` 对非法键/值逐项丢弃,永不炸。
|
||||
|
||||
### 1.2 修改入口:只在大厅页(已实现)
|
||||
|
||||
- `web/index.html` 大厅已有"设置"面板:三个 `<select>`(旁白音色:女声/男声;语速:正常/快/特快;背景音乐:正常/小/关闭),变更即 `settings.set()` 写 localStorage。
|
||||
- 试听句(设计有、R1 未做):变更后播一句样例语音演示 voice+speed,列为 R3 打磨项,非阻塞。
|
||||
- 游戏内:**只读**。`audio.js` 自动应用三项设置,游戏代码无需改动;下滑呼出的菜单里用语音播报当前设置(如"当前:女声,正常语速"),并提示"请回到大厅修改设置"。
|
||||
|
||||
### 1.3 服务器同步(已实现,形式与初稿不同)
|
||||
|
||||
- 实现:**没有独立的 `/api/settings`**。设置作为特殊游戏键 `_settings` 存进进度接口:大厅在变更时 `PUT /api/progress { game: '_settings', data: settings.get() }`;登录后 `GET /api/progress?game=_settings` 拉回覆盖本地(服务器为准)。
|
||||
- 离线/未登录:纯 localStorage,行为不变。
|
||||
|
||||
## 2. 震动语言规范(已实现:engine/haptics.js)
|
||||
|
||||
### 2.1 动机
|
||||
|
||||
用户反馈⑦:提示音(确认音、错误音)与游戏音效/线索音混在一起,盲玩时无法区分"这是反馈还是游戏内容"。震动是独占的触觉通道,不会与任何音频信息混淆。所有"操作反馈"一律用震动;音频只承载游戏内容与叙事。
|
||||
|
||||
### 2.2 引擎模块(以实现为准)
|
||||
|
||||
`web/engine/haptics.js`(R1 已落地):
|
||||
|
||||
```js
|
||||
import { vibrate, hapticsSupported, HAPTIC_PATTERNS } from '../engine/haptics.js';
|
||||
vibrate('error'); // 预定义名称(小写)
|
||||
vibrate([15, 40, 15]); // 自定义震型数组
|
||||
```
|
||||
|
||||
已实现预定义震型(R2 已补全,**以此 10 种为准**):
|
||||
|
||||
| 名称 | 震型 (ms) | 语义用途 |
|
||||
|---|---|---|
|
||||
| `confirm` | [20] | 确认:点按确认、菜单选中 |
|
||||
| `error` | [60, 40, 60] | 错误:两连震(做错手势、无效操作) |
|
||||
| `success` | [100] | 成功:长震(过关、解谜成功、升级) |
|
||||
| `warning` | [30, 50, 30, 50, 30] | 警告:三连短震(血量低、卡关提示) |
|
||||
| `swipe` | [10] | 滑动识别成功(最轻,引擎内置) |
|
||||
| `heartbeat` | [40, 100, 40] | 心跳/倒计时紧张感 |
|
||||
| `pickup` | [15] | 获得物品/机会(单轻震,不与 error 混淆) |
|
||||
| `hit` | [80] | 受击/终结性失败:单次重震 |
|
||||
| `beat` | [8] | 节拍锚点(simon/rhythm 领奏专用,极轻) |
|
||||
| `danger` | [25, 40, 25, 40, 25] | 危险逼近(可循环,每遍间隔 ≥400ms;RPG 无声前摇用它代替听觉提示) |
|
||||
|
||||
### 2.3 语义绑定规则(含引擎内置反馈的分工)
|
||||
|
||||
- **gesture.js 已内置基础反馈**(R1 实现):滑动识别成功→自动 `swipe`;tap/doubletap→自动 `confirm`。因此**游戏层不要再对"手势被识别"本身调 `confirm`/`swipe`,否则双重震动**。游戏层只在语义事件处调用:`error`(操作被判定为错)、`success`(过关/成就)、`warning`(警告)、以及自定义 PICKUP/HIT/BEAT/DANGER。
|
||||
- 震型按**语义**选用,不按游戏选用:任何游戏的"操作错误"都是 `error`,玩家在不同游戏间迁移时触觉语言一致。
|
||||
- 单次震动总时长 ≤ 500ms;DANGER 循环时每遍间隔 ≥ 400ms,事件结束立即 `navigator.vibrate(0)` 停止。
|
||||
- 震动的触发源只有两类:a) 玩家的手势被系统接收/拒绝(引擎内置或游戏层语义判定);b) 明确的离散游戏事件(受击、警告、节拍点)。**禁止**持续型/背景型震动。
|
||||
|
||||
### 2.4 绝不用震动的场景
|
||||
|
||||
1. **长对白播放中**(voice 层有音频在播):旁白叙事期间不震动,避免震动机理掩盖语音细节、也让玩家误以为需要操作。例外:教学流程里"现在请向左滑"这类等待玩家操作的指令句,在语音**播完后**允许一次轻震提示可以操作——震动发生在 voice 结束后,不重叠。注意:教学里玩家出手后引擎已自动震 swipe/confirm,游戏层通常无需再补。
|
||||
2. 序列/乐句播放中需要玩家纯聆听记音高的阶段(simon/节奏游戏领奏时例外:BEAT 本身就是节拍信息通道,见 02/03 文档)。
|
||||
3. 同一 200ms 窗口内多个事件只震一次(实现上去抖)。
|
||||
4. 开场遮罩、大厅页等可视化 UI 不震动。
|
||||
|
||||
### 2.5 iOS Safari 降级策略
|
||||
|
||||
iOS Safari 不支持 `navigator.vibrate()`。策略:**静默降级**——`haptics.js` 特性检测 `'vibrate' in navigator`,不支持即 no-op(已实现)。**不允许**回退到提示音(反馈⑦的根因就是提示音混淆;iOS 用户没有反馈音,好过错乱的反馈音)。iOS 玩家依赖已有的音频结果反馈(脚步、撞击、机关声),这些属于游戏内容音,保留。各游戏开场教学统一口径:"做对做错,手机会震动告诉你;iPhone 用户听结果声"。
|
||||
|
||||
## 3. 旁白质量规范
|
||||
|
||||
### 3.1 根因
|
||||
|
||||
用户反馈⑥的"音色漂移、发音不准"来自两类做法(旧版 escape/simon/space):
|
||||
|
||||
1. **碎片拼接**:`voice/di` + `voice/num_5` + `voice/lun` 拼"第 5 轮"。每个碎片独立生成,音色、气息、响度逐片漂移,接缝明显。
|
||||
2. **同游戏多 instruct / 长短不一**:有的句带情感指令有的不带,批次间模型状态不一致。
|
||||
|
||||
### 3.2 规范(所有游戏强制执行)
|
||||
|
||||
1. **一句一文件,整句生成**。凡要播报给玩家的信息,整句一次 TTS 生成,禁止运行时拼接。例外:>40 字的长台词由资产管线在生成期拆句拼接(R1 管线已支持,见 docs/audio/round1-report.md),运行时仍不拼。
|
||||
2. **数字信息改分段整句**。需要报数字的场景(轮数、成绩、存活时间、用时、准确率),按取值分段预生成整句:
|
||||
- 取值少且可数(如 simon 轮数):直接为每个值生成整句,"第一轮"…"第二十轮",超出范围用无数字兜底句。
|
||||
- 取值连续(如存活秒数、准确率):按区间分段整句,如"不到三十秒""超过一分钟""准确率超过八成五"。宁粗勿拼。
|
||||
- 各游戏的具体分段在各自设计文档中给出。
|
||||
3. **同游戏同音色**:每款游戏的旁白用固定 speaker 常量 + 固定 INSTRUCT 常量。R1 音色分配表(docs/audio/round1-report.md §4,**以此为准**):male 一律 Uncle_Fu;female:storyline=Vivian、escape=Serena、simon=Serena、rhythm=Vivian、space=Serena。角色音(storyline/RPG 的 NPC)可另有角色 speaker,但**同一角色**所有句子的 speaker+instruct 必须是同一常量,写死在 gen 脚本的台词表表头注释里;NPC 台词不做双性别,平铺存 `voice/` 根目录(引擎平铺回退会命中)。
|
||||
4. **短句优先**:单句 ≤ 25 个汉字为宜;长信息拆成多句链播(onComplete 串行),每句仍是完整句,不是碎片。
|
||||
5. **生成管线**:统一用 `tools/gen_voice_dual.py`(R1 已建,台词表驱动,female/male 双跑、断点续跑、ogg+m4a 双格式、峰值归一 0.89)。新台词表由策划以"voice-ids 文档"形式交付(id + 全文 + speaker + instruct),音频总监照录进该脚本。
|
||||
6. **验收**:自动 QC(时长/峰值/两性时长离群)+ 人工试听;多音字风险句在台词表中标注替代写法(如"重听"→"重新听")。
|
||||
|
||||
## 4. 用户系统(服务器进度,已实现:web/server/server.js)
|
||||
|
||||
### 4.1 形态(已实现)
|
||||
|
||||
- 大厅页"用户"面板:用户名 + 4-6 位数字 PIN 注册/登录;token 存 localStorage key `soundgame.auth`(形如 `{ username, token }`),请求头 `Authorization: Bearer <token>`。
|
||||
- 服务端 `web/server/server.js`(Node 零依赖,静态托管 + API 一体,`serve.sh` 拉起);数据落盘 `web/server/data/`。局域网玩具级认证,勿暴露公网。
|
||||
- 未登录/服务器不可达:静默降级为仅本地模式,进度只存 localStorage(key `soundgame.progress.<game>`)。
|
||||
|
||||
### 4.2 API 约定(以实现为准)
|
||||
|
||||
```
|
||||
POST /api/register { username, pin } -> 201 { ok, username }
|
||||
POST /api/login { username, pin } -> 200 { ok, token, username }
|
||||
GET /api/progress?game=<game> (Bearer) -> 200 { game, data } (无存档则 data=null)
|
||||
PUT /api/progress { game, data } (Bearer) -> 200 { ok, game }
|
||||
```
|
||||
|
||||
与初稿的差异(**以实现为准**):进度读写用 `?game=` 查询参数 / 请求体字段,不是路径段;`data` 是各游戏**自由格式**的 JSON(服务器不校验结构);没有统一的 `{schema, game, updatedAt, data}` 信封——游戏自行在 `data` 内放 `schema`/`updatedAt` 字段(建议保留,便于迁移)。
|
||||
|
||||
### 4.3 进度 data 的两条约定
|
||||
|
||||
1. **`summary` 字符串(必须)**:大厅游戏卡片会直接显示 `data.summary`(已实现,index.html `loadProgress()`)。每个游戏的存档 data 里必须维护一句人话摘要,如 `"第一章·溪流 · Lv3 · 金币120"`、`"最高纪录:12 轮"`。
|
||||
2. 保留键 `_settings`(设置同步用,见 §1.3),游戏不得占用。
|
||||
3. 写进度时机:局内关键节点(过关、死亡结算、退出菜单选"保存并回大厅")自动 PUT + 写 localStorage;不要求实时。
|
||||
|
||||
## 5. 手势一致性约定
|
||||
|
||||
### 5.1 全局默认映射
|
||||
|
||||
| 手势 | 全局语义 |
|
||||
|---|---|
|
||||
| 上滑 | 帮助 / 当前状态播报 |
|
||||
| 下滑 | 菜单(含:重听帮助、播报当前设置、重新开始、保存并回大厅) |
|
||||
| 单击 | 确认 / 继续 / 检查 |
|
||||
| 双击 | 重听(重播当前语音/线索/乐句) |
|
||||
| 左/右滑 | 选择 / 移动 |
|
||||
|
||||
### 5.2 模态菜单约定(R3 修订:采纳 R2 开发实现,round2-review 裁决②)
|
||||
|
||||
全球统一的模态菜单交互:**下滑呼出(模态)→ 单击循环切换选项(每切换播选项名)→ 双击确认执行 → 上滑=帮助/规则 → 下滑或"返回游戏"项关闭**。菜单打开时暂停计时类玩法(有计时器的游戏进入暂停态)。
|
||||
|
||||
- 标准选项集:重新开始 / 回大厅(存档游戏为"保存并回大厅")/ 返回游戏;打开时先播 `menu_open` + 第一项。
|
||||
- 为什么单击=切换而非确认:结算页"单击=再来一局"已被占用,菜单内单击若作确认会与重开冲突;单击循环 + 双击确认把"浏览"与"执行"分离,误操作成本为零(escape/simon R2 已一致落地)。
|
||||
- **RPG《剑与声》豁免**:探索态菜单保持"左右滑切换 + 单击确认"——RPG 探索本身就是"左右滑选项 + 单击确认"范式,菜单与探索同构反而少学一套;战斗态不设菜单(下滑=逃跑)。
|
||||
- 选曲/存档确认等**非模态**选项界面(如 rhythm 选曲)不属于菜单,仍用"左右滑切换 + 单击确认"。
|
||||
|
||||
### 5.3 豁免表(以滑动为核心玩法的游戏)
|
||||
|
||||
四向滑动被玩法占用的游戏,"上滑=帮助 / 下滑=菜单"让位,偏离必须在开场交互教学中明确讲出:
|
||||
|
||||
| 游戏 | 滑动用途 | 帮助入口 | 菜单入口 |
|
||||
|---|---|---|---|
|
||||
| escape 暗室 | 四向滑=移动 | PLAY 中双击无目标时兜底播报目标与提示;卡关计时器自动三级提示 | 仅结算后下滑=模态菜单(PLAY 中无菜单,R3 打磨拟加双击二级入口) |
|
||||
| simon 音序回响 | 六手势全是答题输入 | 非输入阶段上滑=重听规则 | 结算后下滑=模态菜单 |
|
||||
| rhythm 节拍行者 | 四向滑=演奏输入 | 乐句间隙上滑=重听规则 | 结算后下滑=模态菜单 |
|
||||
| space 盲航 | 四向滑=闪避 | 坠毁结算后上滑=重听教学要点 | 结算后下滑=模态菜单 |
|
||||
| rpg 剑与声 | 左右滑=选择(符合默认) | 上滑=角色状态(符合默认) | 下滑=菜单(豁免:左右滑+单击,见 §5.2) |
|
||||
| storyline 声命线 | 左右滑=抉择(符合默认) | 上滑=帮助+进度(符合默认) | 下滑=菜单(R3 打磨统一到 §5.2 模态约定) |
|
||||
|
||||
### 5.4 通则
|
||||
|
||||
- 任何游戏里,**做了一个手势 3 秒内必须有反馈**(引擎内置震动 + 内容音/语音),被判定无效的手势给 `error` 震动;教学阶段除外(教学里错误手势给纠正语音)。
|
||||
- 单击有 ~300ms 引擎级延迟:即时判定(格挡、节奏打击)一律绑滑动或双击,单击只用于非即时操作(确认、检查、菜单)。新设计继续遵守。
|
||||
- 结算画面统一:单击=再来一局,下滑=菜单(回大厅)。教学可跳过:任何游戏的引导阶段,下滑=跳过引导(首次强制完整引导的标志位存进度里)。
|
||||
@@ -0,0 +1,105 @@
|
||||
# 01 · 《暗室》重设计(escape redesign)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 保留 3x3 网格与谜题链骨架(钥匙→铁柜→保险丝→电箱→大门),重写开场:前 ~2 分钟为**引导式脚本**(带玩家走到储物间、教会移动/检查/双击使用),之后进入自由探索。
|
||||
> 2. 新增**卡关检测 + 三级提示**:以上一次"进展事件"起算,60s 无进展给暗示、120s 给明示、180s 给带路级提示;提示由计时器自动播出,不依赖玩家发现隐藏手势。
|
||||
> 3. 环境线索音**可重听**:单击检查总是先重播当前房间环境音再播描述;9 个房间环境音去重(关键房间专属线索音)。
|
||||
> 4. 操作反馈全部改震动(确认/撞墙/拾取/错误),iOS 静默降级;删除数字拼接播报,用时改为分段整句。
|
||||
> 5. 手势映射(偏离全局默认,教学中讲清):四向滑=移动,单击=检查/重听,双击=使用物品/背包与目标,引导阶段下滑=跳过引导。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现版诊断:"第一个房间都出不去"断在哪
|
||||
|
||||
读 `web/escape/game.js` 现版流程,新手断点有四:
|
||||
|
||||
1. **教学是两段可跳过的长旁白,零交互**(`voice/intro` → `voice/tutorial`,game.js:56-87)。玩家听完(或单击跳过)后被直接丢进 `PLAY`,没有任何一次"做对了一件事"的体验确认。移动、检查、使用物品三个核心动作都没有被验证过玩家真的会了。
|
||||
2. **进入自由探索后没有任何方向感支撑**。起点 (1,1) 播一句 `voice/start_room`,玩家不知道房间有 9 个、不知道自己在哪、不知道目标在西南角。环境音只有 3 种(hum/wind/drip)铺给 9 个房间(`_playAmbient`, game.js:134-145),相邻房间声音雷同,靠环境音导航不成立。
|
||||
3. **环境线索音只播一次**。进房间播一遍就没了,没听清就永远错过;`voice/wall` 撞墙提示也只播一次(`wallVoicePlayed`)。
|
||||
4. **提示系统存在但不可达**。`hint_*` 四级提示语音已生成,但唯一入口是"双击且无可用目标"的兜底(game.js:195-198)——玩家根本不知道双击这个手势存在,更不知道要在"错误的地方"双击才能听到提示。卡关玩家恰恰是不会操作的人,提示入口藏在操作里,死循环。
|
||||
|
||||
## 2. 新开场:引导式脚本(GUIDE 阶段)
|
||||
|
||||
目标:前 2 分钟是"手把手",玩家每做一个动作都得到确认,引导结束时已天然完成谜题链第一环(拿到钥匙)。
|
||||
|
||||
### 2.1 流程
|
||||
|
||||
阶段 `GUIDE`(脚本化,步骤由玩家手势推进,不是定时旁白):
|
||||
|
||||
| 步 | 旁白(整句,功能) | 等待的玩家动作 | 反馈 |
|
||||
|---|---|---|---|
|
||||
| G0 | 剧情引入(2 句以内,比现版 intro 短):醒来、漆黑、要逃出去 | 单击继续 | CONFIRM 震动 |
|
||||
| G1 | "先活动一下。试着朝任意方向滑一下,走一小步。" | 任意滑动 | 脚步声 + SWIPE_OK 震动 + "很好,你迈出了第一步。" |
|
||||
| G2 | "这个房间不大,边界是墙。再滑两下,试着碰碰墙。" | 滑动 2 次(其中撞墙也计数) | 撞墙:wall 闷响 + ERROR 震动 + "那边是墙,换个方向。"(每次撞墙都播,取消 wallVoicePlayed 一次性限制);未撞墙:脚步 + SWIPE_OK |
|
||||
| G3 | "跟着我指的路走。向左滑。" | 左滑(做错给纠正语音重等) | 脚步 + "对。再向下滑。" |
|
||||
| G4 | "再向下滑。" | 下滑 | 脚步 + 到达储物间,播储物间专属环境音 |
|
||||
| G5 | "听到水声和铁锈味了吗?单击一下,仔细检查这个房间。" | 单击 | pickup 音 + PICKUP 震动 + "你摸到了一把钥匙!"(谜题链第一环完成) |
|
||||
| G6 | "钥匙应该能开什么。双击是把物品用出去的用法。现在先记住:双击。" | 双击 | 兜底播报背包 + CONFIRM 震动 + "对,就是这样。接下来靠你自己了。" |
|
||||
| G7 | 目标陈述:"找到能用这把钥匙打开的东西。需要帮助时,在原地双击。" | — | 进入 PLAY |
|
||||
|
||||
- 引导期间任何步骤可**下滑跳过**(播"跳过引导"确认后直达 PLAY);跳过标志存 localStorage/进度,第二次进游戏开场直接问"上滑重听引导,单击直接开始"。
|
||||
- 引导期间做错手势:不惩罚,播一句短纠正("不是这个方向,向左滑")继续等待;同一个步骤错 3 次给 `WARNING` 震动 + 更直白的重述。
|
||||
- 引导脚本写死路线 (1,1)→(0,1)? 不,(1,1)→左=(0,1),再下滑=(0,2) 储物间。路线合法(现版图 (0,1) 是空房·风)。引导期间超出脚本路线的滑动被温和拦回("先跟我走")。
|
||||
|
||||
### 2.2 教学覆盖核对
|
||||
|
||||
引导结束时玩家已亲手做过:滑动移动(含撞墙)、单击检查、拾取、双击使用/查看。全部六个手势里只剩"上滑"未用——escape 不用上滑(豁免,见 00 文档 §5.2),无需教。
|
||||
|
||||
## 3. 卡关检测与三级提示
|
||||
|
||||
### 3.1 进展事件
|
||||
|
||||
以下任一发生即重置卡关计时器 `lastProgressAt`:拾取钥匙、打开铁柜、拾取保险丝、修好电箱、首次到达某关键房间(铁柜房/电箱房/大门,各记一次)、成功逃脱。普通移动/检查空房不算进展。
|
||||
|
||||
### 3.2 三级提示(计时器自动触发,无需手势)
|
||||
|
||||
| 无进展时长 | 级别 | 内容(整句预生成,每级每阶段一句) | 附带 |
|
||||
|---|---|---|---|
|
||||
| 60s | L1 暗示 | 面向当前目标的一句意象提示,如"铁锈味最重的地方,值得仔细看看" | WARNING 震动 + 语音提示前播"需要帮助吗?" |
|
||||
| 120s | L2 明示 | 直接点明目标房间与动作,如"去西南角的储物间,单击检查,找钥匙" | WARNING 震动 |
|
||||
| 180s | L3 带路 | 以玩家当前坐标算路,逐步导航:"从这里,向左滑,再向下滑"(每次触发按当前位置重算,滑动方向词为预生成的整句组合:方向指令句 4 句 × 顺序连读,非数字拼接,允许两句链播) | WARNING 震动,此后每 60s 重复 L3 直至有进展 |
|
||||
|
||||
- 提示级别按当前谜题链阶段(`_hintVoice()` 的 4 阶段:找钥匙/开铁柜/修电箱/开大门)× 3 级 = 12 句新增语音,全部是**整句**。
|
||||
- 提示播完不重复骚扰:同 level 对同阶段只自动播一次(L3 除外,可周期重复)。
|
||||
- 主动求提示保留:双击无目标时的兜底语音升级——先播背包,再播 L2 级明示(原来是 L1 级 hint_*)。
|
||||
|
||||
## 4. 环境线索音可重听 + 房间声音去重
|
||||
|
||||
1. **单击检查 = 重播环境音 + 描述**:`_inspect()` 改为先 `play(当前房间环境音)` 再链播描述语音(现版只播描述)。玩家随时可重听线索。
|
||||
2. **环境音从 3 种扩到 6 种**,关键房间各有专属签名(Stable Audio 3 生成或合成,双格式):
|
||||
- 储物间 (0,2):滴水 + 金属杂物轻响(`amb_storage`)
|
||||
- 铁柜房 (2,1):嗡鸣 + 铁皮共振(`amb_cabinet`)
|
||||
- 电箱房 (0,0):电流嗡鸣 + 偶尔火花(`amb_power`)
|
||||
- 大门 (2,2):风声 + 门外空旷感(`amb_gate`)
|
||||
- 起点 (1,1):单一滴水(`amb_drip`,保留)
|
||||
- 其余 4 间空房:风声/远处嗡鸣两种弱化版轮换(`amb_wind`/`amb_hum` 保留)
|
||||
3. 进房间自动播环境音(保留现行为),且**每次进入都播**——不再是"错过就没了",配合单击重听,线索可随时复核。
|
||||
4. 大门房间通电后环境音切换为带电流声的版本(`amb_gate_live`),听觉上确认"电通了"这条谜题状态。
|
||||
|
||||
## 5. 反馈改震动(对齐 00 文档 §2)
|
||||
|
||||
| 事件 | 原反馈 | 新反馈 |
|
||||
|---|---|---|
|
||||
| 移动成功 | 脚步声 | 脚步声 + SWIPE_OK |
|
||||
| 撞墙 | wall 闷响 | wall 闷响 + ERROR |
|
||||
| 拾取/开柜/修电箱 | sfx + 语音 | sfx + 语音 + PICKUP / SUCCESS |
|
||||
| 双击无目标(无效使用) | ui_deny 提示音 | **删除 ui_deny**,改 ERROR 震动(内容语音保留) |
|
||||
| 逃脱成功 | 音效+语音 | 音效+语音 + SUCCESS |
|
||||
| 提示出现 | 无 | WARNING |
|
||||
|
||||
`sfx/ui_deny` 这类"反馈提示音"全部退役;内容音(脚步、撞墙、机关)保留。
|
||||
|
||||
## 6. 语音重做清单(整句化 + 双音色)
|
||||
|
||||
- **删除**:`n0`-`n9`/`nshi`/`unit_min`/`unit_sec`/`time_prefix` 数字拼接链。用时播报改 4 段整句:"不到两分钟""五分钟内""十分钟内""超过十分钟"(`time_band1..4`),前缀"你逃出来了,用时"并入各段整句一次生成。
|
||||
- **新增**:引导脚本 8 步全部语音(每步 1-2 短句)、12 句三级提示、跳过引导/二周目开场句、储物间等 4 个关键房间的新描述。
|
||||
- **保留可用**:`empty_*`、`inspect_*`、`found_key`、`unlock_cabinet`、`fix_box`、`escape`、`replay`、`inv_*` 已是整句,重录一版 male 目录即可(文本不变,speaker=Uncle_Fu,instruct 与 female 版同款)。
|
||||
- 全部语音按 00 文档 §3 生成:同 speaker+instruct 常量、female/male 双目录、ogg+m4a 双格式。
|
||||
|
||||
## 7. 流程图(新)
|
||||
|
||||
```
|
||||
start → GUIDE(可下滑跳过)→ PLAY(自由探索 + 三级提示计时器)→ ENDING → 单击重开 / 下滑菜单
|
||||
```
|
||||
|
||||
PLAY 阶段手势:四向滑=移动 / 单击=重播环境音+检查 / 双击=使用物品(无目标→背包+L2 明示)。ENDING:单击=重开,下滑=菜单(回大厅)。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 02 · 《音序回响》重设计(simon redesign)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. **删除 BGM**:`bgm/theme` 不再播放,游戏只有交互音色 + 语音两条线(响应反馈⑥)。
|
||||
> 2. **旁白全部整句化**:删除 `di/lun/num_*/score_pre/best_pre` 拼接体系,轮次与成绩改预生成整句(轮数 1-20 逐句 + 无数字兜底;成绩分段整句)。
|
||||
> 3. **交互节奏**:播放序列时每个音前给 `BEAT` 轻震提供节拍感;复现阶段每个手势给 `CONFIRM` 震动确认;对/错用 SUCCESS/ERROR 震动,`sfx/correct`、`sfx/wrong` 提示音删除。
|
||||
> 4. **教学改交互式**:每教一个音色就让玩家亲手做一次该手势,做对才教下一个。
|
||||
> 5. **人性化与复活**:每局初始 1 次"重听机会"(输入阶段双击=重听当前序列,消耗机会;答错时若有机会则消耗一次机会重听而非直接结算),每过 4 轮 +1 次(上限 2);速度爬升放缓;前 5 轮避免同手势三连。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现版诊断
|
||||
|
||||
读 `web/simon/game.js`:
|
||||
|
||||
1. **BGM 是干扰项**(`bgm/theme` 音量 0.4 常驻,game.js:77)。记忆类游戏的工作记忆负载全在听觉通道,BGM 与六个手势音色同通道竞争,是纯负面。反馈⑥正确,整层去掉。
|
||||
2. **数字拼接是音色漂移重灾区**:报轮次 `di + num_5 + lun`、报成绩 `score_pre + num_* + lun`(game.js:47-57, 142, 219-222)。这正是反馈⑥"音色漂移发音不准"的来源。
|
||||
3. **教学是 8 句连续旁白**(`_runTutorial`, game.js:92-117),玩家只能听不能动,听完直接开考。
|
||||
4. **错一音即死**(`_onInput` → `_gameOver`),无缓冲;轮次越高挫败越强。
|
||||
5. **反馈音与内容音同质**:`sfx/correct`/`sfx/wrong` 与手势音色同为合成短音,盲听时"我按对了还是错了"要靠音色分辨,混淆。
|
||||
|
||||
## 2. 音频层重构
|
||||
|
||||
- BGM 层:不播放、不加载。开场静默 0.5s 后直接进教学语音。间隙的"空白感"由环境底噪填充?——**不填**。纯静更利于记忆,这是设计选择不是缺失。
|
||||
- 手势音色(`tone_*` 6 个):保留现有映射(上=上行琶音 / 下=下行低音 / 左=左声道弹拨 / 右=右声道弹拨 / 单击=底鼓 / 双击=双连响),这是游戏内容,不动。
|
||||
- 反馈提示音(`sfx/correct`、`sfx/wrong`、`sfx/newrecord`):**删除**,改震动(见 §4)。`sfx/gameover` 保留为内容性结束音(低沉闷响,标识一局终结,不与任何手势音色相似)。
|
||||
|
||||
## 3. 旁白整句化清单
|
||||
|
||||
### 3.1 删除的拼接件
|
||||
|
||||
`voice/di`、`voice/lun`、`voice/num_shi`、`voice/num_0..9`、`voice/score_pre`、`voice/best_pre` 全部退役(资产文件可留,代码不再引用)。
|
||||
|
||||
### 3.2 需要重录的句子类型(整句,female/male 双目录)
|
||||
|
||||
| 类型 | 句数 | 说明 |
|
||||
|---|---|---|
|
||||
| 轮次播报 | 21 | "第一轮,听好了。"…"第二十轮,听好了。"逐轮整句;第 21 句为无数字兜底:"新的一轮,听好了。"(round>20 时用,轮次仍可继续) |
|
||||
| 成绩播报 | 6 段 | 按完成轮数分段整句:"一轮都没坚持下来,别灰心。"/"你坚持了三轮以内。"/"你坚持了五轮左右。"/"你坚持了将近十轮。"/"你坚持了十轮以上,很厉害。"/"你坚持了十五轮以上,大师级。"(代码按 score 映射分段) |
|
||||
| 新纪录 | 1 | "新纪录!"(已是整句,保留文本重录) |
|
||||
| 教学句 | 8 | 见 §5,每句 ≤20 字 |
|
||||
| 流程句 | 6 | "轮到你了。"/"重听机会用完了,小心。"/"用一次机会,再听一遍。"/"单击屏幕,再来一局。"/"上滑重听规则,下滑打开菜单。"/规则重听句 |
|
||||
| 反馈短句 | 3 | "答对了!"/"错了。"/"机会加一。"(均已有文本,整句重录) |
|
||||
|
||||
生成规范按 00 文档 §3:同一 SPEAKER 常量(female=Serena / male=Uncle_Fu)+ 同一 INSTRUCT 常量(建议"平静清晰的女声/男声,语速平稳"——不写进 instruct 的语速词,instruct 只控情绪:"平静、清晰、像游戏主持人")。
|
||||
|
||||
## 4. 交互节奏与震动设计
|
||||
|
||||
### 4.1 播放序列阶段(playback)
|
||||
|
||||
- 每个手势音播放的**同时**给 `BEAT`(8ms 轻震):节拍锚点帮助玩家分段记忆(把 N 个音切成节拍组),这是 simon 类游戏经典的"chunking"辅助。
|
||||
- 播放间隔维持"逐个播放",节奏均匀(见 §6 速度曲线),最后一个音播完 400ms 后给一次 `CONFIRM` 震动,表示"轮到你了"(第 1 轮同时播语音,之后轮次只用震动,省时间)。
|
||||
- 播放阶段手势一律忽略(保留现行为),但给 `ERROR` 震动一次告知"现在不用操作"?——**不给**,播放期误触静默忽略,避免打断聆听(00 文档 §2.4:纯聆听阶段不震动,BEAT 除外因其本身是节拍信息)。
|
||||
|
||||
### 4.2 复现阶段(input)
|
||||
|
||||
| 事件 | 震动 | 音频 |
|
||||
|---|---|---|
|
||||
| 玩家做出手势 | CONFIRM | 立即回声放对应手势音色(保留现行为) |
|
||||
| 手势正确(序列推进) | (并入上面的 CONFIRM,不额外震) | — |
|
||||
| 整轮复现成功 | SUCCESS | "答对了!"(每轮都播太密——改为每 4 轮播一次,其余轮次仅震动) |
|
||||
| 答错且有机会 | ERROR + WARNING(间隔 300ms 先后) | "错了。用一次机会,再听一遍。" → 自动重播序列 |
|
||||
| 答错且无机会 | ERROR + HIT | `sfx/gameover` + 成绩播报 |
|
||||
| 双击(消耗机会主动重听) | PICKUP | "再听一遍。"+ 重播序列(inputIdx 重置为 0) |
|
||||
| 机会 +1(每过 4 轮) | PICKUP | "机会加一。" |
|
||||
|
||||
## 5. 交互式教学(tutorial)
|
||||
|
||||
现版 8 句连播改为"教一个、练一个":
|
||||
|
||||
| 步 | 语音(整句) | 机制 |
|
||||
|---|---|---|
|
||||
| T0 | "欢迎来到音序回响。我会演奏一串声音,请你记住顺序,原样复现。先认识六种声音。" | 播完进 T1 |
|
||||
| T1-T6 | 每个手势一句:"向上滑,是这个声音。"→ 播 tone → "你试试。" | **等待玩家做出该手势**;做对:tone 回响 + CONFIRM 震动 + "对了。"进下一个;做错:ERROR 震动 + "不是这个,向上滑试试。"重等;同一手势错 3 次给更直白描述("手指贴着屏幕,向上推一下") |
|
||||
| T7 | "六种声音都记住了。序列会越来越长,答错可以消耗机会重听。双击随时重听当前序列。" | 进 T8 |
|
||||
| T8 | "第一轮,听好了。" | 进正式对局 |
|
||||
|
||||
教学时长约 60-80s,全程玩家动手 6 次以上。教学期间下滑=跳过教学(播确认句后直达第一轮)。
|
||||
|
||||
## 6. 难度曲线与复活机制
|
||||
|
||||
### 6.1 速度曲线(放缓)
|
||||
|
||||
现版 `_interval()`: 720ms 起、每轮 -36ms、下限 340ms(game.js:152)——第 11 轮即触底。新版:
|
||||
|
||||
```
|
||||
interval(round) = max(420, 700 - (round - 1) * 25) // 第 12 轮才到 420ms 下限
|
||||
```
|
||||
|
||||
### 6.2 序列生成约束
|
||||
|
||||
- 长度 = round + 1(保留,从 2 开始)。
|
||||
- **前 5 轮**:禁止同一手势连续出现 3 次;第 1 轮两个音不同手势。
|
||||
- 全部轮次:禁止连续 4 个同手势(防恶意随机)。
|
||||
- 复活重听的序列**不变**(重听就是重听同一序列,不重新随机)。
|
||||
|
||||
### 6.3 机会(retry)规则
|
||||
|
||||
- 每局初始 `chances = 1`;每完成 4 轮 `chances = min(2, chances + 1)` 并播报。
|
||||
- 消耗途径二选一:a) 输入阶段**双击**主动重听当前序列(进度清零重输);b) 答错时自动消耗一次机会,重播序列后再试。
|
||||
- **双击语义冲突的裁定(R3 修订,round2-review 裁决①)**:输入阶段双击按上下文判定——当前期望输入恰为 doubletap 时双击=**答题**(序列必须永远可答,答题优先),否则双击=**主动重听**(耗机会)。机会为 0 时双击非期望手势:不判答错、不消耗,仅播 `chances_out` 提示。重听播报期间切到非输入阶段,避免手势被吞。
|
||||
- 无机会时答错 → gameover:成绩分段整句播报 + 新纪录判定 + "单击屏幕,再来一局"。
|
||||
- `best`(历史最高)从会话内存改为写进度存储(localStorage + 服务器同步,进度 data 带 `summary`,格式"最佳成绩:第 N 轮")。
|
||||
|
||||
## 7. 状态机与手势汇总
|
||||
|
||||
阶段:`tutorial → announce → playback → input → (replay) → roundend → … → gameover`
|
||||
|
||||
| 阶段 | 上滑 | 下滑 | 双击 | 单击 | 四向滑/点按 |
|
||||
|---|---|---|---|---|---|
|
||||
| tutorial | — | 跳过教学 | — | — | 教学输入 |
|
||||
| playback | — | — | — | — | 忽略 |
|
||||
| input | — | — | 重听(耗机会) | 答题输入 | 答题输入 |
|
||||
| gameover | 重听规则 | 菜单(重开/回大厅) | — | 重开 | — |
|
||||
|
||||
非 input 阶段上滑=重听规则(豁免见 00 文档 §5.2:六手势全是输入,帮助让位到非输入阶段)。
|
||||
|
||||
## 8. 资产清单变动
|
||||
|
||||
- 删:bgm/theme 引用(文件可保留不播)、sfx/correct、sfx/wrong、拼接数字件。
|
||||
- 新录:§3.2 全部(约 45 句 × 2 音色 = 90 条 × 2 格式)。
|
||||
- 保留:6 个 tone、sfx/gameover。
|
||||
- 更新 `web/simon/README.md` 玩法描述与 `gen/gen_voice.py` 的 `LINES` 表(文本表驱动,female/male 两跑)。
|
||||
@@ -0,0 +1,185 @@
|
||||
# 03 · 《节拍行者》重构施工稿(rhythm rebuild,R3 施工依据)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 玩法 = **跟随领奏(call & response)**:系统奏 1 小节节奏型(2-4 个手势乐器音),玩家在下一小节的相同拍点用相同手势跟打。3 首曲目(90/105/120 BPM)解锁制。完整谱面见 §3(每首前 8 句全部写出,后续句按 §3.5 规则生成)。
|
||||
> 2. 判定沿用现版常量:PERFECT ±0.12s / GOOD ±0.25s / TAP_DELAY 0.30s 补偿 / LOOKAHEAD 0.6s / 调度泵 40ms;tap 只允许出现在第 1、3 拍(强拍)。
|
||||
> 3. 判定反馈全改震动(`engine/haptics.js`):出手震动引擎 gesture.js 已内置(swipe/confirm),游戏层只加 Miss=`error`、16 连击=`success`、乐句全 Perfect=自定义 [20,40,20];领奏音符锚点自定义 BEAT=[8]。`judge_*` 提示音删除。
|
||||
> 4. 语音台词全表在 **[03-rhythm-voice-ids.md](03-rhythm-voice-ids.md)**(音频总监照录):旁白 female=Vivian / male=Uncle_Fu(R1 音色分配表),准确率分档整句播报。
|
||||
> 5. 进度 data:`{ summary, unlocked, grades, bestCombo }`(§7),大厅卡片显示 summary。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现版问题与重构方向(结论,不再展开)
|
||||
|
||||
现版"听令行动"(指令音→下一拍做手势)认知负荷四层(认音色→忆映射→换算拍点→精确出手),且 judge 提示音制造"考试感"。重构为跟随领奏:记忆负荷压到 ≤4 音短句,负荷转移到节奏精度;玩家动作触发与 BGM 同调性的**乐器音**,做对时听到自己在演奏。现版的 AudioContext 预排程泵(`_pump`/`_scheduleSfx`/tap 补偿)工程质量好,**保留复用**,只重写谱面与判定逻辑。
|
||||
|
||||
## 2. 常量与判定(施工契约,直接抄)
|
||||
|
||||
```js
|
||||
const PERFECT = 0.12; // Perfect 窗口 ±120ms
|
||||
const GOOD = 0.25; // Good 窗口 ±250ms(蓝牙延迟由它吸收)
|
||||
const TAP_DELAY = 0.30; // 引擎 tap 回调延迟补偿(沿用现版)
|
||||
const LOOKAHEAD = 0.6; // 调度前瞻(秒)
|
||||
const PUMP_MS = 40; // 调度泵周期
|
||||
```
|
||||
|
||||
- **tap 补偿**:判定时 `if (g === 'tap') t -= TAP_DELAY`;tap 音符的 Miss 超时检查放宽 `TAP_DELAY + 0.08`,其他手势放宽 `0.05`(现版 game.js:159-164 原样保留)。
|
||||
- **tap 位置约束**:谱面中 tap 只允许落在第 1、3 拍(强拍),给补偿留足余量;doubletap 不落在 x.5 反拍(曲目三之前)。
|
||||
- 判定结果:
|
||||
- 手势对 + |dt|≤0.12 → Perfect:玩家手势乐器音 + accent 亮音层叠加。
|
||||
- 手势对 + |dt|≤0.25 → Good:只播乐器音。
|
||||
- |dt|>0.25 或手势错 → Miss:**不播乐器音**(错过=没出声,本身就是反馈)+ `vibrate('error')`。
|
||||
- 手势对但窗口内找错音符归属:按现版"最近未判定音符"归属逻辑(game.js:278-293)保留。
|
||||
- 计分:Perfect=2 / Good=1 / Miss=0;`rate = 得分 / (2 × 总音符数)`;评级 S≥0.95 / A≥0.85 / B≥0.70 / C<0.70。不掉血、不中断,歌照唱完。
|
||||
- 连击:每 16 连击 `vibrate('success')`(不打断演奏,不播报);乐句全部 Perfect 时乐句末 `vibrate([20,40,20])`。
|
||||
|
||||
## 3. 谱面
|
||||
|
||||
### 3.1 记号与结构
|
||||
|
||||
- 拍点记号:`拍号:手势`,1 小节 = 4 拍(1, 2, 3, 4),`x.5` = 八分音符反拍。
|
||||
- 手势:`tap`(T) `doubletap`(D) `left`(L) `right`(R) `up`(U) `down`(N)。
|
||||
- 每个乐句 = **领奏 1 小节 + 应答 1 小节**(应答拍点 = 领奏拍点 + 4 拍)。
|
||||
- 曲目流程:`数拍 1 小节(BGM 内鼓点,无独立节拍器)→ 逐乐句 → 结算`。
|
||||
- 乐句间休止:曲目一前 4 句应答后各休 1 小节;曲目二前 2 句各休 1 小节;曲目三不休。
|
||||
|
||||
### 3.2 曲目一《启程》90 BPM(SPB=0.667s,10 句,≈70s)
|
||||
|
||||
定位:教学曲。全强拍,2→4 音,无反拍。
|
||||
|
||||
| 句 | 领奏节奏型 | 音符数 |
|
||||
|---|---|---|
|
||||
| 1 | `1:tap, 3:tap` | 2 |
|
||||
| 2 | `1:left, 3:right` | 2 |
|
||||
| 3 | `1:tap, 2:doubletap, 3:tap` | 3 |
|
||||
| 4 | `1:left, 2:doubletap, 3:right` | 3 |
|
||||
| 5 | `1:tap, 3:left, 4:doubletap` | 3 |
|
||||
| 6 | `1:right, 3:tap, 4:doubletap` | 3 |
|
||||
| 7 | `1:up, 3:down, 4:doubletap` | 3 |
|
||||
| 8 | `1:tap, 2:up, 3:right, 4:doubletap` | 4 |
|
||||
| 9 | `1:left, 2:right, 3:tap, 4:doubletap` | 4 |
|
||||
| 10 | `1:tap, 2:doubletap, 3:tap, 4:doubletap` | 4(终句,全鼓组收束) |
|
||||
|
||||
### 3.3 曲目二《追风》105 BPM(SPB=0.571s,12 句,≈75s)
|
||||
|
||||
定位:引入反拍(第 5 句起)与 4 音密集句。
|
||||
|
||||
| 句 | 领奏节奏型 | 音符数 |
|
||||
|---|---|---|
|
||||
| 1 | `1:tap, 3:right` | 2 |
|
||||
| 2 | `1:left, 2:doubletap, 3:tap` | 3 |
|
||||
| 3 | `1:up, 3:down, 4:doubletap` | 3 |
|
||||
| 4 | `1:tap, 2:right, 3:left, 4:doubletap` | 4 |
|
||||
| 5 | `1:tap, 2.5:right, 3:tap` | 3(首个反拍 2.5) |
|
||||
| 6 | `1:left, 2:up, 3.5:right, 4:doubletap` | 4 |
|
||||
| 7 | `1:tap, 2:doubletap, 3:down, 3.5:up` | 4 |
|
||||
| 8 | `1:right, 1.5:left, 3:tap, 4:doubletap` | 4 |
|
||||
| 9 | `1:tap, 2.5:left, 3:tap, 4:doubletap` | 4 |
|
||||
| 10 | `1:up, 2:right, 3:down, 3.5:left` | 4 |
|
||||
| 11 | `1:tap, 2:doubletap, 2.5:right, 3:tap` | 4 |
|
||||
| 12 | `1:left, 1.5:right, 2:doubletap, 3:tap, 4:doubletap` | 5(终句) |
|
||||
|
||||
### 3.4 曲目三《星爆》120 BPM(SPB=0.5s,12 句,≈70s)
|
||||
|
||||
定位:4-6 音密集句、切分、doubletap 上反拍。不设十六分音符(盲玩极限)。
|
||||
|
||||
| 句 | 领奏节奏型 | 音符数 |
|
||||
|---|---|---|
|
||||
| 1 | `1:tap, 2:doubletap, 3:tap, 4:doubletap` | 4 |
|
||||
| 2 | `1:left, 2:right, 3:left, 4:doubletap` | 4 |
|
||||
| 3 | `1:up, 2:down, 3:up, 4:doubletap` | 4 |
|
||||
| 4 | `1:tap, 2.5:left, 3:tap, 4:right` | 4 |
|
||||
| 5 | `1:left, 1.5:right, 2.5:left, 3:tap` | 4 |
|
||||
| 6 | `1:tap, 2:up, 2.5:down, 3:tap, 4:doubletap` | 5 |
|
||||
| 7 | `1:right, 2:doubletap, 3:left, 3.5:right, 4:up` | 5 |
|
||||
| 8 | `1:tap, 1.5:doubletap, 2.5:left, 3:tap, 3.5:right, 4:doubletap` | 6(高潮句) |
|
||||
| 9 | `1:left, 2:doubletap, 2.5:up, 3:right, 4:doubletap` | 5 |
|
||||
| 10 | `1:tap, 2.5:down, 3:tap, 3.5:up, 4:doubletap` | 5 |
|
||||
| 11 | `1:right, 1.5:left, 2:right, 3:tap, 4:doubletap` | 5 |
|
||||
| 12 | `1:tap, 2:doubletap, 2.5:left, 3:tap, 3.5:right, 4:doubletap` | 6(终句) |
|
||||
|
||||
### 3.5 难度曲线规则(谱面设计约束,续作/改谱遵守)
|
||||
|
||||
1. 乐句长度:曲目一 2→4 音,曲目二 2→5 音,曲目三 4→6 音;同曲内单调不减(终句允许 +1 爆发)。
|
||||
2. 反拍(x.5)引入:曲目一禁用;曲目二第 5 句起、每句至多 1 个反拍音;曲目三不限。
|
||||
3. tap 只落 1/3 拍(全曲目铁律);doubletap 曲目一/二只落整拍,曲目三可落反拍。
|
||||
4. 同一手势连续出现 ≤2 次(跨音符);同一乐句内手势种类 ≥2(第 1、10 句的全鼓句除外)。
|
||||
5. up/down 作为"音高对"尽量成对出现在同一乐句(↑↓ 或 ↓↑),利于听觉记忆。
|
||||
6. 左右声道手势不放在相邻反拍连续位(如 `1:left, 1.5:right` 只允许曲目三)——声像跳变过快在耳机上易混。
|
||||
|
||||
## 4. 手势 → 乐器音映射(sfx 资产)
|
||||
|
||||
| 手势 | 乐器音 | sfx id | 声像/音高 |
|
||||
|---|---|---|---|
|
||||
| tap | 底鼓 kick | `sfx/inst_kick` | 中央,低频 |
|
||||
| doubletap | 军鼓 snare | `sfx/inst_snare` | 中央偏上 |
|
||||
| left | 闭合镲 closed hat | `sfx/inst_hat_l` | 极左 |
|
||||
| right | 开放镲 open hat | `sfx/inst_hat_r` | 极右 |
|
||||
| up | 高通 tom | `sfx/inst_tom_hi` | 中央,升调感 |
|
||||
| down | 低通 tom | `sfx/inst_tom_lo` | 中央,降调感 |
|
||||
| (叠加层) | Perfect accent 亮音 | `sfx/accent` | 共用 1 个,叠在乐器音上 |
|
||||
|
||||
全部与 BGM 同调性(曲目一/二/三 BGM 根音需在 Mureka prompt 里统一,建议 A minor)。乐器音时长 ≤0.4s,起音要硬(盲玩靠起音对拍)。生成方式:Stable Audio 3(打击乐短采样质量足够)或程序合成兜底;accent 可用程序合成(高频短琶音)。
|
||||
|
||||
## 5. 流程与教学(30 秒上手)
|
||||
|
||||
### 5.1 状态机
|
||||
|
||||
```
|
||||
idle → intro(语音) → tut(3 步教学) → select(选曲,二周目起) → countin → main(逐乐句) → end(结算)
|
||||
↑_________________|
|
||||
```
|
||||
|
||||
- `select`:左右滑切曲目(播曲目名 + 已解锁状态),单击进入,下滑回大厅。未解锁曲目播报"上一首拿到 B 级以上解锁"。
|
||||
- 教学只在首次进游戏出现(`progress.data.tutorialDone`);之后 intro 缩减为一句"跟着音乐,我奏一句,你跟一句。"
|
||||
|
||||
### 5.2 教学 3 步(台词见 voice-ids 文档 T 组)
|
||||
|
||||
| 步 | 内容 | 机制 |
|
||||
|---|---|---|
|
||||
| T1 | 领奏 `1:tap`(第 1 拍单底鼓)→ 玩家在第 2 小节同位置跟打 | 做错:error 震动 + 纠正语音(早了/晚了/不是这个手势,按 dt 与手势选句)→ 重播领奏重试;2 次失败跳过进 T2 |
|
||||
| T2 | 领奏 `1:left, 3:right` → 跟打 | 同上 |
|
||||
| T3 | 领奏 `1:tap, 2:doubletap, 3:tap` → 跟打 | 通过后进曲目一 |
|
||||
|
||||
纠正判定复用正式判定窗口,只是不计分。教学期间 BGM 已在播(从 intro 第 6 秒起),数拍感来自 BGM 本身。
|
||||
|
||||
## 6. 震动与语音在流程中的落点
|
||||
|
||||
| 时机 | 震动 | 语音 |
|
||||
|---|---|---|
|
||||
| 领奏每个音符 | [8](BEAT,自定义) | — |
|
||||
| 玩家出手 | (引擎内置 swipe/confirm,游戏层不重复) | 乐器音 |
|
||||
| Miss | error | — |
|
||||
| 16 连击 | success | — |
|
||||
| 乐句全 Perfect | [20,40,20] | — |
|
||||
| 曲目过半 | — | `mid_song`("过半了,保持。") |
|
||||
| 结算 | success(S/A)/ 无(B/C) | `grade_*` + `restart_hint` |
|
||||
| 解锁新曲 | success + [20,40,20] | `unlock_song2/3` |
|
||||
|
||||
## 7. 进度存储
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"updatedAt": "2026-08-29T12:00:00Z",
|
||||
"summary": "《追风》B级 · 已解锁2/3首",
|
||||
"tutorialDone": true,
|
||||
"unlocked": 2,
|
||||
"grades": { "song1": "a", "song2": "b" },
|
||||
"bestCombo": 42
|
||||
}
|
||||
```
|
||||
|
||||
- 评级提升(如 b→a)、解锁变化、bestCombo 刷新时在结算后 PUT;`summary` 格式:`《曲名》X级 · 已解锁N/3首`(取最高评级的曲目)。
|
||||
- 解锁规则:曲目 n 拿到 B 级以上 → 解锁 n+1。
|
||||
|
||||
## 8. 实现要点(给开发 agent)
|
||||
|
||||
1. 保留现版:`AudioContext.currentTime` 预排程、40ms 泵、LOOKAHEAD、`TAP_DELAY` 补偿与 Miss 放宽(现 game.js:26-30, 149-176, 267-293)、BGM 手动 `src.start(t0)` 对网格(game.js:221-239)。
|
||||
2. 重写:谱面数据从 40 指令流改为 `phrases: [{ notes: [{ beat, gesture }] }]`(本文 §3 表格直接转 JSON);调度时领奏音符排第 k 小节、应答 `_cues` 期望时刻 = 领奏时刻 + 4 拍;间奏休止按曲目参数插入空小节。
|
||||
3. 玩家乐器音不走预排程:出手即播(sfx 即发即弃),Perfect 时叠播 `sfx/accent`。
|
||||
4. BGM:3 首重生成(Mureka,90/105/120 BPM 精确、鼓组清晰、A minor、60-90s 无缝循环,沿用现版 groove 归一化工艺);现版 `bgm/groove` 弃用。数拍融入 BGM,删独立 tick 节拍器。
|
||||
5. 震动接 `engine/haptics.js`;BEAT 用自定义数组 `vibrate([8])`,领奏音符在调度时同步排一个 `setTimeout` 对齐(或用 pump 在音符时刻触发,误差 <40ms 可接受)。
|
||||
6. 语音全表见 03-rhythm-voice-ids.md;现版 `voice/welcome/rules/tut_start/tut_done` 弃用,`grade_*`/`restart` id 沿用但文案变更(重录)。
|
||||
7. 结算画面:单击=再来一遍(同曲),右滑=下一首(若已解锁),下滑=回大厅。
|
||||
8. 更新 `web/rhythm/README.md`(玩法、手势表、进度结构)。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 03a · 《节拍行者》语音台词表(voice ids,R3 录音依据)
|
||||
|
||||
> **给音频总监的录制说明**
|
||||
> - 旁白双性别:**female = Vivian,male = Uncle_Fu**(R1 音色分配表,docs/audio/round1-report.md §4)。每句两个性别各录一条,分别落 `web/rhythm/audio/voice/female/<id>.{ogg,m4a}` 与 `.../male/`。
|
||||
> - instruct 全表统一(同游戏同 instruct,00 文档 §3.2):
|
||||
> - female(Vivian):`活力清晰的年轻女声,像节奏游戏主持人,情绪饱满但不夸张`
|
||||
> - male(Uncle_Fu):`低沉醇厚的男声,像节奏游戏主持人,平稳有力`
|
||||
> - 全表只用这两条 instruct 常量,逐句不换。特例单独标注。
|
||||
> - 管线:`tools/gen_voice_dual.py` 台词表照录本文件;多音字已规避(无"重听/银行/长短"类风险词)。
|
||||
> - 准确率按 S/A/B/C 四档直接写进评级句(设计定:档位即评级语义,不另设 90+/80+ 分档句,避免与评级割裂)。
|
||||
> - id 沿用现版的:`grade_s/a/b/c`、`restart`(文案变更需重录覆盖);其余全部新增。现版 `welcome/rules/tut_start/tut_done` 弃用。
|
||||
|
||||
## W 组 · 开场与流程(8 句)
|
||||
|
||||
| id | 台词全文 | 备注 |
|
||||
|---|---|---|
|
||||
| `welcome` | 欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。 | 首次进游戏 |
|
||||
| `welcome_back` | 欢迎回来。我奏一句,你跟一句。 | 二周目起 |
|
||||
| `tut_1` | 听,底鼓。在下一小节的同一个位置,单击。 | 教学步 1 |
|
||||
| `tut_2` | 这次是左和右。跟着打,左滑,右滑。 | 教学步 2 |
|
||||
| `tut_3` | 最后一句,三个音,中间是双击。 | 教学步 3 |
|
||||
| `tut_done` | 很好,演出开始。 | 教学通过,进曲目一 |
|
||||
| `tut_skip` | 跳过教学,直接开始。 | 下滑跳过 |
|
||||
| `mid_song` | 过半了,保持。 | 每首过半播一次 |
|
||||
|
||||
## C 组 · 教学纠正(5 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `retry_early` | 早了,等鼓点再出手。 | 教学/练习中 dt < -GOOD |
|
||||
| `retry_late` | 晚了,跟着鼓点走。 | dt > +GOOD |
|
||||
| `retry_gesture` | 不是这个手势,再听一遍。 | 手势错 |
|
||||
| `retry_listen` | 再听一遍,跟着来。 | 重播领奏前 |
|
||||
| `retry_skip` | 没关系,我们先走下一句。 | 同一步失败 2 次跳过 |
|
||||
|
||||
## S 组 · 选曲与解锁(7 句)
|
||||
|
||||
| id | 台词全文 | 备注 |
|
||||
|---|---|---|
|
||||
| `select_hint` | 左右滑选曲目,单击开始,下滑回大厅。 | 进选曲界面 |
|
||||
| `song1_name` | 第一首,启程,九十拍。 | 曲目一 |
|
||||
| `song2_name` | 第二首,追风,一百零五拍。 | 曲目二 |
|
||||
| `song3_name` | 第三首,星爆,一百二十拍。 | 曲目三 |
|
||||
| `song_locked` | 这首还没解锁。上一首拿到 B 级以上就能解锁。 | 选中未解锁曲 |
|
||||
| `unlock_song2` | 新曲目解锁,追风! | 曲目一 ≥B |
|
||||
| `unlock_song3` | 新曲目解锁,星爆! | 曲目二 ≥B |
|
||||
|
||||
## G 组 · 结算(6 句)
|
||||
|
||||
| id | 台词全文 | 备注 |
|
||||
|---|---|---|
|
||||
| `grade_s` | S 级!准确率超过九成五,完美演出。 | 沿用 id,文案变更重录 |
|
||||
| `grade_a` | A 级!准确率超过八成五,非常出色。 | 同上 |
|
||||
| `grade_b` | B 级,准确率超过七成。不错,还能更好。 | 同上 |
|
||||
| `grade_c` | C 级,准确率不到七成。别灰心,再来一遍。 | 同上 |
|
||||
| `new_best_combo` | 新连击纪录! | bestCombo 刷新时插在评级句后 |
|
||||
| `restart_hint` | 单击再来一遍,右滑下一首,下滑回大厅。 | 沿用 id `restart`,文案变更重录 |
|
||||
|
||||
## M 组 · 菜单与规则(5 句)
|
||||
|
||||
| id | 台词全文 | 备注 |
|
||||
|---|---|---|
|
||||
| `rules` | 规则:我奏一句,你在下一小节原样跟打。音要对,拍点也要对。 | 上滑重听规则 |
|
||||
| `settings_hint` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内播设置 |
|
||||
| `menu_main` | ~~菜单。左右滑选择,单击确认。~~ → **勘误(R 终审)**:菜单约定已改(00 §5.2),以音频重录版"菜单。单击切换,双击确认。"为准。 | 下滑呼出 |
|
||||
| `menu_resume` | 继续演奏。 | 菜单项 |
|
||||
| `menu_lobby` | 回大厅。 | 菜单项 |
|
||||
|
||||
---
|
||||
|
||||
**合计 31 句 × 2 性别 = 62 条**(其中 grade_*、restart 为重录覆盖)。无数字拼接:拍数(九十/一百零五/一百二十)与准确率档位全部写死在整句里。
|
||||
@@ -0,0 +1,112 @@
|
||||
# 04 · 《盲航》重构方案(space rebuild)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 47 秒连续旁白教学(intro1-4 链播)改为**交互式演练**:每次只教一件事,玩家立刻实操一颗慢速陨石,做错不惩罚、当场重教。四个方向各练一次,单击/双击在开局后第 20s / 45s 分批引入。
|
||||
> 2. **闪避方向改为同向**:听到左边的呼啸就向左滑——"把陨石拨开"。反向映射(现版左来右滑)是"没搞懂就死了"的最大认知负担,同向映射让方位声直接映射到同侧手势。
|
||||
> 3. **前 60 秒保护期**:陨石慢(逼近 4s)、稀(间隔 4s)、撞了不掉血(训练护盾吸收,语音解释)。60s 后语音宣告"保护解除",难度曲线从现版起点再降一档重新爬坡。
|
||||
> 4. **死亡即教学**:每次被撞,语音说清陨石从哪来、该怎么做;坠毁结算播报分因果。
|
||||
> 5. 数字拼接全部改整句:里程碑 5 句固定整句,存活时间分 4 段整句。撞击/护盾等反馈改震动。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现版诊断:"没搞懂就死了"
|
||||
|
||||
读 `web/space/game.js`:
|
||||
|
||||
1. **教学是 4 句连播旁白**(`voice/intro1..4`, game.js:84-87),约 47 秒。玩家零操作听完,然后第一颗陨石 1.2 秒后就来(`_scheduleSpawn(1200)`, game.js:111)。方位声→反向手势的映射只在旁白里说过一遍,没有任何一次实操验证。
|
||||
2. **反向闪避违反直觉**(`ESCAPE_SWIPE = {left:'right',...}`, game.js:26)。声音从左边来,手却要往右滑——玩家在前几秒的生存窗口里要完成"定位→取反→出手"三步推理,取反这一步是死亡率主因。
|
||||
3. **三个机制同时压上来**:闪避、单击加速、双击护盾,47 秒旁白里全部讲完,开局全部可用。玩家还没分清 whoosh 的方位,护盾冷却的播报已经来了。
|
||||
4. **难度起点过高**:首颗逼近时长 2.8s、刷怪间隔 3.2s(game.js:42-44),对没建立映射的新手等于即死。
|
||||
5. **撞击无教学价值**:`voice/hit1/hit2` 只报损,不说陨石从哪来、该怎么躲。玩家死了都不知道为什么。
|
||||
|
||||
## 2. 核心决策:同向闪避
|
||||
|
||||
**听到哪边来,就向哪边滑**(`PUSH_SWIPE = {left:'left', right:'right', up:'up', down:'down'}`),叙事包装为"挥手把陨石拨开/推引擎避开"。
|
||||
|
||||
- 方位声(左声道)→ 同侧手势(左滑):零推理,反射直达,这是盲玩能成立的前提。
|
||||
- 上=高频呼啸→上滑,下=低频轰隆→下滑,保留现有 sfx 方位设计(whoosh_* 四个资产全部复用,只改映射表和文案)。
|
||||
- 判定窗口沿用现版结构(撞击前 1.5s 内滑动有效 + 250ms 宽限),仅 `need` 从 `ESCAPE_SWIPE[dir]` 改为 `dir` 本身。
|
||||
- 单击加速、双击护盾机制保留(现版"持续增益规避 tap 延迟"的设计是对的,game.js:146-153 注释写得很清楚),只是引入时机后移(§3.3)。
|
||||
|
||||
## 3. 新开场:交互式演练(DRILL 阶段)
|
||||
|
||||
### 3.1 演练流程
|
||||
|
||||
每步 = 一句短旁白 → 一颗**演练陨石**(逼近 4.5s,撞了不掉血)→ 玩家操作 → 反馈。做错/超时:ERROR 震动 + 一句纠正 + 同一颗重来,**最多重教 2 次后自动演示通过**(不卡流程)。
|
||||
|
||||
| 步 | 旁白(整句,≤20字/句) | 演练内容 |
|
||||
|---|---|---|
|
||||
| D0 | "欢迎登上盲航。你只有一双耳朵。" + "陨石来了,会有声音告诉你方位。" | 无陨石,建立预期 |
|
||||
| D1 | "听,左边有陨石。向左滑,把它拨开!" | 左来,需左滑 |
|
||||
| D2 | "这次在右边。" | 右来,需右滑 |
|
||||
| D3 | "上面有东西,声音又尖又高。向上滑!" | 上来,需上滑 |
|
||||
| D4 | "下面是低频的轰隆。向下滑!" | 下来,需下滑 |
|
||||
| D5 | "四个方向你都会了。引擎点火,出发!" | 进保护期 |
|
||||
|
||||
- 每次成功拨开:dodge 音 + SWIPE_OK 震动 + 短肯定("漂亮。"仅 D1/D3 播,避免啰嗦)。
|
||||
- 每步旁白在**演练陨石的 whoosh 响起之前**播完(语音先、音效后,不叠放),whoosh 响起时玩家已知该做什么。
|
||||
- 教学期间下滑=跳过演练(二周目直接问"上滑重听演练,单击出发")。
|
||||
|
||||
### 3.2 前 60 秒保护期
|
||||
|
||||
演练结束进入 `playing` 后启动保护期(`protectionUntil = runStart + 60s`):
|
||||
|
||||
| 参数 | 保护期 | 保护期后 |
|
||||
|---|---|---|
|
||||
| 逼近时长 | 4.0s(恒定) | 3.2s → 2.0s 爬坡(比现版 2.8→1.8 各抬 0.4s) |
|
||||
| 刷怪间隔 | 4.0s(恒定) | 3.2s → 1.4s 爬坡 |
|
||||
| 撞击 | 不掉血:训练护盾吸收,播 shield_block 音 + WARNING 震动 + 语音解释("护盾接住了。那颗在左边,听到左声道的呼啸就向左滑。"按实际方位选句) | 正常掉血 |
|
||||
| 单击/双击 | 第 20s / 45s 才解锁(见 §3.3) | 可用 |
|
||||
|
||||
第 60s 播:"训练护盾关闭,接下来靠你自己了。" + WARNING 震动。之后难度从低档开始 120s 爬满(沿用 RAMP_MS=120s)。
|
||||
|
||||
### 3.3 单击/双击分批引入
|
||||
|
||||
- **第 20 秒**(保护期内):暂停刷怪 6 秒,旁白:"试试单击,引擎会短促加速,直接冲过空隙。" → 玩家单击 → boost 音 + CONFIRM 震动 + "感觉到了吗?加速期间撞不上你。" → 恢复刷怪。
|
||||
- **第 45 秒**:同样暂停 6 秒:"双击展开护盾,能挡一次撞击,用完要 30 秒充能。" → 玩家双击 → shield 音 + CONFIRM 震动 → 恢复。
|
||||
- 若玩家 8 秒内没做,不等待直接继续(机制仍在,之后用上也正常生效)。
|
||||
- 护盾充能完毕语音(`voice/shield_ready`)保留。
|
||||
|
||||
## 4. 死亡即教学 + 结算
|
||||
|
||||
### 4.1 撞击反馈(保护期后)
|
||||
|
||||
每次被撞:`sfx/hit` + HIT 震动 + 一句归因语音(按方位 4 句预生成:"左边撞上的。听到左声道呼啸,向左滑。"),替换现版 `voice/hit1/hit2` 的纯报损。第二次被撞额外加半句船体状态:"船体只剩最后一次了。"
|
||||
|
||||
### 4.2 坠毁结算(整句化)
|
||||
|
||||
删除 `numClips` 拼接体系(`voice/num*`/`n_time`/`n_sec`/`n_dodge`/`n_ge`, game.js:47-65):
|
||||
|
||||
- **存活时间** 4 段整句:"不到三十秒,再试一次。""超过三十秒,有进步。""超过一分钟,合格的飞行员。""超过两分钟,王牌飞行员。"
|
||||
- **躲避数** 4 段整句(合并进上句的第二句):"只拨开几颗。""拨开了十来颗。""拨开了几十颗。""拨开了一大群,漂亮。"
|
||||
- 每局结算 = 时间段整句 + 躲避段整句 + "单击再飞一次,下滑回大厅。"
|
||||
- **里程碑播报**:`dodges % 10` 的数字拼接改为 5 句固定整句(10/20/30/40/50 颗各一句:"十颗了,稳。"…"五十颗,传奇。"),超出 50 不再播报。
|
||||
|
||||
### 4.3 最高分
|
||||
|
||||
存活时间最高纪录存进度(`data = { bestSec: n, totalRuns: n }`),结算时若破纪录加一句"新纪录!"(SUCCESS 震动)。
|
||||
|
||||
## 5. 震动映射(对齐 00 文档)
|
||||
|
||||
| 事件 | 震动 |
|
||||
|---|---|
|
||||
| 成功闪避 | SWIPE_OK(dodge 音保留) |
|
||||
| 警报渐强(撞击前 1s / 0.5s 两段) | DANGER 渐强脉冲(随 alarm 两档,第二档更密) |
|
||||
| 撞击 | HIT |
|
||||
| 护盾挡下 | CONFIRM |
|
||||
| 加速/护盾激活 | CONFIRM |
|
||||
| 坠毁 | ERROR + HIT(间隔 300ms) |
|
||||
| 演练做对 | SWIPE_OK;演练做错 ERROR |
|
||||
|
||||
现版 `sfx/alarm` 两档警报音**保留**(它是方位+距离的内容信息,不是反馈提示音),震动 DANGER 与其同步叠加。
|
||||
|
||||
## 6. 实现要点(给开发 agent)
|
||||
|
||||
1. `ESCAPE_SWIPE` 改同向映射(一行改动),`DODGE_WINDOW_MS`/`DODGE_GRACE_MS` 判定结构不动。
|
||||
2. 新增 `phase='drill'`:复用 `_spawnObstacle` 但参数化(approachMs/spawnInterval/harmless 标志),演练陨石带 `harmless: true` 与指定 `dir`。
|
||||
3. `_resolve()` 增加保护期分支:`now < protectionUntil` 时撞击走"训练护盾"路径(不掉血、播归因语音)。
|
||||
4. 暂停刷怪:`_clearTimers()` 后重排 `_scheduleSpawn`(现版已有清晰的 timer 管理,game.js:250-261)。
|
||||
5. 归因语音按方位选句:`voice/hit_left/hit_right/hit_up/hit_down` + `voice/hit_last`。
|
||||
6. 语音全部整句重录:演练 8 句、保护期 3 句、单击/双击引入 4 句、归因 5 句、结算 10 句(时间段 4 + 躲避段 4 + 新纪录 + 重开)、里程碑 5 句 ≈ 35 句 × 2 音色。删除全部 num 拼接件。
|
||||
7. 教学语音与 whoosh 的时序:语音 onComplete 后再 spawn 演练陨石,保证不叠放。
|
||||
8. 更新 `web/space/README.md` 手势表(同向闪避是玩家可见的最大变化,README 必须同步)。
|
||||
@@ -0,0 +1,105 @@
|
||||
# 04a · 《盲航》重构版语音台词表(voice ids,R4 录音依据)
|
||||
|
||||
> **给音频总监的录制说明**
|
||||
> - 设计依据:[04-space-rebuild.md](04-space-rebuild.md)。闪避为**同向**(左来左滑),台词已全部按此写死,不存在反向表述。
|
||||
> - 旁白双性别:**female = Serena,male = Uncle_Fu**(R1 音色分配表)。落 `web/space/audio/voice/female|male/<id>.{ogg,m4a}`。
|
||||
> - instruct 全表统一(同游戏同 instruct,00 文档 §3.2):
|
||||
> - female(Serena):`平静清晰的女声,像飞船领航员,沉稳可信`
|
||||
> - male(Uncle_Fu):`低沉醇厚的男声,像飞船领航员,沉稳有力`
|
||||
> - 演练的紧迫感靠台词文案表达,instruct 不变。
|
||||
> - 全部整句、无数字拼接:方位/次数/存活时间/躲避数全部按句写死或分档。
|
||||
> - 多音字已规避("重新听"不写"重听";无"撞着/着陆"类风险)。
|
||||
> - 管线:`tools/gen_voice_dual.py` 照录本表。**本表全量替换 R1 生成的 space 语音**(旧 id 见表末映射)。
|
||||
|
||||
## D 组 · 交互式演练(14 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `drill_welcome` | 欢迎登上盲航。你只有一双耳朵。 | D0 |
|
||||
| `drill_rule` | 陨石来了,会有声音告诉你方位。 | D0 |
|
||||
| `drill_left` | 听,左边有陨石。向左滑,把它拨开! | D1 |
|
||||
| `drill_right` | 这次在右边。向右滑! | D2 |
|
||||
| `drill_up` | 上面有东西,声音又尖又高。向上滑! | D3 |
|
||||
| `drill_down` | 下面是低沉的轰隆。向下滑! | D4 |
|
||||
| `drill_good` | 漂亮。 | 演练做对(D1/D3 播) |
|
||||
| `drill_retry_left` | 那是左边来的,向左滑。 | D1 做错重教 |
|
||||
| `drill_retry_right` | 那是右边来的,向右滑。 | D2 做错重教 |
|
||||
| `drill_retry_up` | 声音又高又尖,是上面,向上滑。 | D3 做错重教 |
|
||||
| `drill_retry_down` | 轰隆声在下面,向下滑。 | D4 做错重教 |
|
||||
| `drill_moveon` | 没关系,跟着我,我们继续。 | 同一步失败 2 次自动带过 |
|
||||
| `drill_done` | 四个方向你都会了。引擎点火,出发! | D5 |
|
||||
| `drill_skip` | 跳过演练,直接出发。 | 下滑跳过 |
|
||||
|
||||
## P 组 · 保护期(5 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `protect_left` | 训练护盾接住了。那颗在左边,听到左边的呼啸,向左滑。 | 保护期被撞(左) |
|
||||
| `protect_right` | 训练护盾接住了。那颗在右边,听到右边的呼啸,向右滑。 | 保护期被撞(右) |
|
||||
| `protect_up` | 训练护盾接住了。那颗在上面,听到高高的呼啸,向上滑。 | 保护期被撞(上) |
|
||||
| `protect_down` | 训练护盾接住了。那颗在下面,听到低沉的轰隆,向下滑。 | 保护期被撞(下) |
|
||||
| `protect_end` | 训练护盾关闭。接下来,靠你自己了。 | 第 60 秒 |
|
||||
|
||||
## F 组 · 单击/双击分批引入(6 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `feat_boost` | 试试单击,引擎会短促加速,直接冲过空隙。 | 第 20 秒引入 |
|
||||
| `feat_boost_done` | 感觉到了吗?加速的时候,陨石撞不上你。 | 单击后确认 |
|
||||
| `feat_shield` | 双击展开护盾,能挡下一次撞击。 | 第 45 秒引入 |
|
||||
| `feat_shield_done` | 护盾展开后会自动充能,好了我会告诉你。 | 双击后确认 |
|
||||
| `shield_ready` | 护盾充能完毕。 | 30 秒冷却结束(沿用 R1 id,重录) |
|
||||
| `shield_block` | 护盾挡下了撞击。 | 护盾生效(沿用 R1 id,重录) |
|
||||
|
||||
## H 组 · 被撞归因(5 句,保护期后)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `hit_left` | 左边撞上的。听到左边的呼啸,向左滑。 | 第 1/2 次被撞(左) |
|
||||
| `hit_right` | 右边撞上的。听到右边的呼啸,向右滑。 | 右 |
|
||||
| `hit_up` | 上面撞上的。听到高高的呼啸,向上滑。 | 上 |
|
||||
| `hit_down` | 下面撞上的。听到低沉的轰隆,向下滑。 | 下 |
|
||||
| `hit_last` | 船体只剩最后一次了,小心! | 第 2 次被撞后接在方位句后 |
|
||||
|
||||
## M 组 · 里程碑(4 句)
|
||||
|
||||
| id | 台词全文 | 触发(躲过数) |
|
||||
|---|---|---|
|
||||
| `ms_10` | 十颗了,稳。 | 10 |
|
||||
| `ms_25` | 二十五颗,老练的飞行员。 | 25 |
|
||||
| `ms_50` | 五十颗,传奇。 | 50 |
|
||||
| `ms_100` | 一百颗!天空属于你。 | 100 |
|
||||
|
||||
## R 组 · 结算(10 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `time_1` | 不到三十秒,再试一次。 | 存活 <30s |
|
||||
| `time_2` | 超过三十秒,有进步。 | 30~60s |
|
||||
| `time_3` | 超过一分钟,合格的飞行员。 | 60~120s |
|
||||
| `time_4` | 超过两分钟,王牌飞行员。 | ≥120s |
|
||||
| `dodge_1` | 只拨开几颗。 | 躲过 <5 |
|
||||
| `dodge_2` | 拨开了十来颗。 | 5~14 |
|
||||
| `dodge_3` | 拨开了几十颗。 | 15~49 |
|
||||
| `dodge_4` | 拨开了一大群,漂亮。 | ≥50 |
|
||||
| `new_record` | 新纪录! | 破纪录(插在 time_* 后) |
|
||||
| `restart_hint` | 单击再飞一次,下滑回大厅。 | 结算尾 |
|
||||
|
||||
## Y 组 · 菜单与系统(8 句)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `second_run_ask` | 上滑重新听演练,单击直接出发。 | 二周目开场 |
|
||||
| `menu_main` | ~~菜单。左右滑选择,单击确认。~~ → **勘误(R 终审)**:菜单约定已改(00 §5.2),以音频重录版"菜单。单击切换,双击确认。"为准。 | 下滑呼出 |
|
||||
| `menu_resume` | 继续飞行。 | 菜单项 |
|
||||
| `menu_lobby` | 回大厅。 | 菜单项 |
|
||||
| `menu_help` | 重新听教学要点。 | 菜单项 |
|
||||
| `help_recap` | 左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。 | 帮助重听(>25 字,生成期拆两句拼接:句 1 四方位,句 2 单击双击) |
|
||||
| `settings_hint` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内 |
|
||||
| `paused` | 已暂停。 | 菜单打开时 |
|
||||
|
||||
---
|
||||
|
||||
**合计 52 句 × 2 性别 = 104 条**(×2 格式 = 208 文件)。
|
||||
|
||||
旧 id 处置:R1 生成的 `num*/n_*/intro1-4/hit1/hit2/gameover/restart` 全部弃用(数字拼接与旧教学);`shield_ready`/`shield_block` 沿用 id 重录。平铺旧文件按 R1 约定在切换验证后统一清理。
|
||||
@@ -0,0 +1,106 @@
|
||||
# 05c · 《剑与声》增补规范(rpg addendum,R3 补漏)
|
||||
|
||||
> 本文件补 [05-rpg-chapter1.md](05-rpg-chapter1.md) 的施工遗漏:①战斗事件 × 音效 × 震动配合总表;②Boss 无声前摇的震动协议;③音频层叠规则修正(amb 与 BGM 冲突);④若干流程细节。与主文档冲突处以本文件为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 战斗事件 × 音效 × 震动配合总表(施工对照表)
|
||||
|
||||
震动一律用 `engine/haptics.js` 预定义名;手势识别震动(swipe/confirm)由 gesture.js 内置,下表不再重复列出。
|
||||
|
||||
| 战斗事件 | sfx | 震动 | 语音 |
|
||||
|---|---|---|---|
|
||||
| 遭遇开始 | `tel_<enemy>`(登场即第一次前摇不播,登场用 `enemy_die` 无——登场无专属 sfx,直接语音) | — | `btl_start_<enemy>` |
|
||||
| 指令切换(左右滑) | — | (内置 swipe) | 指令名 `cmd_*` |
|
||||
| 攻击命中 | `atk_swing` + `atk_hit` | — | —(敌方半血/死亡见下) |
|
||||
| 技能·破空斩 | `skill_slash` | — | mp 不足时 `btl_nomp` |
|
||||
| 防御指令 | — | — | —(标记生效,无播报,下回合减伤自明) |
|
||||
| 使用金疮药 | `potion` | `pickup` | 无药时 `btl_noitem` |
|
||||
| **敌方前摇(有声敌人)** | `tel_<enemy>`(1.0s) | 窗口开启同时 `beat` 一次(提示"现在可以格挡",不干扰听觉辨音) | — |
|
||||
| **格挡成功** | `parry` | `confirm`(即时,双击无延迟) | — |
|
||||
| 格挡窗口内做错手势 | — | `error` | — |
|
||||
| 受击(未格挡) | `hurt` | `hit` | 血量档位降档时 `st_hp_mid` / `st_hp_low`(降档才播,不每击都播) |
|
||||
| 敌方半血 | — | — | `npc_*_half`(有半血句的敌人)/ Boss 见 §4 |
|
||||
| 击杀 | `enemy_die` | `success` | `btl_enemy_die` |
|
||||
| 战斗胜利 | — | — | `btl_win` + `btl_loot_*`(分档) |
|
||||
| 升级 | `levelup` | `success` | `btl_levelup` |
|
||||
| 逃跑成功 | `flee` | — | `btl_flee` |
|
||||
| 逃跑失败(Boss/剧情战) | — | `error` | `btl_noflee` |
|
||||
| 战败 | `hurt`(最后一击) | `hit` + 300ms 后 `error` | `btl_lose` → `btl_lose_hint` |
|
||||
|
||||
要点:**前摇窗口的 `beat` 轻震是"格挡窗口已开"的通道标识**——有声敌人靠前摇音辨招式、靠 beat 确认窗口时机,两者分工。iOS 无震动时靠前摇音本身即可(前摇音起始即窗口起始),无信息损失。
|
||||
|
||||
## 2. Boss 无声前摇 · 震动协议(影卫 / 白无瞳三阶段)
|
||||
|
||||
无声敌人是第三章核心机制(耳朵最强的剑客遇到没有声音的敌人),触觉成为唯一提示通道。协议:
|
||||
|
||||
| 时刻 | 震动 | 说明 |
|
||||
|---|---|---|
|
||||
| 窗口开启(t=0) | `danger` [25,40,25,40,25] | 代替前摇音——**无声前摇的"声音"就是这段震型** |
|
||||
| 窗口中点(t=0.5s) | `heartbeat` [40,100,40] | 催促:窗口过半,不出手就来不及了 |
|
||||
| 格挡成功 | `confirm` | 同有声敌人 |
|
||||
| 窗口关闭未格挡 | `hit` | 受击 |
|
||||
|
||||
规则:
|
||||
|
||||
1. **只有无声敌人**用这套协议;有声敌人前摇窗口只震一次 `beat`(§1 表),绝不用 danger——danger 语义专属于"无声威胁",玩家一听(一感)就知道是影卫/白无瞳。
|
||||
2. iOS 无震动降级:**静默降级,不回退提示音**(00 §2.5)。无声敌人在 iOS 上不可格挡是已知代价,设计上用数值兜住——影卫 ATK16 对 lv7+ 铁鳞甲玩家 dmg≈10,52+ HP 可吃 5 击,不格挡也能硬打过普通影卫;白无瞳三阶段前由 NPC 台词明示(见 §5),iOS 玩家知情后可用"防御"指令(减伤 50%,不需要时机)作为等效手段。**防御指令因此对 iOS 是必要兜底,不是可选项。**
|
||||
3. 听风剑(格挡窗口 1.0s→1.3s)对无声敌人同样生效:heartbeat 顺延到 t=0.65s。
|
||||
4. 无声敌人的 `_resolve` 判定窗口与有声一致(1.0s/1.3s 内双击有效),仅提示通道不同——代码上前摇 = `{ sfx: tel_<enemy> | null, haptic: 'beat' | 'danger+heartbeat' }` 二元组。
|
||||
|
||||
## 3. 音频层叠规则修正(amb 与 BGM 冲突)
|
||||
|
||||
05-rpg-chapter1.md §7.2 让 `amb_*` 走 bgm 层 loop,与 `bgm/explore` 等同层冲突(引擎 BGM 层同时只有一首)。裁定为:
|
||||
|
||||
| 场景/状态 | bgm 层播放 | 说明 |
|
||||
|---|---|---|
|
||||
| village | `bgm/village`(音乐) | 安全区给音乐,情绪锚点 |
|
||||
| 其他探索场景 | `amb_<scene>`(环境音循环,volume 0.6) | **探索期不放音乐**——纯环境音,战斗音乐进来才有对比 |
|
||||
| 普通战斗 | `bgm/battle` | 进战斗 crossfade 替换 amb;战后 crossfade 回当前场景 amb |
|
||||
| Boss 战 | `bgm/boss` | 同上 |
|
||||
| 章结算/剧情(evt_ch1_end 等) | `bgm/ending` | 播完回场景 amb |
|
||||
|
||||
资产清单变动:`bgm/explore` **取消**(探索期用 amb,不需要探索音乐),BGM 从 5 首减为 4 首(village/battle/boss/ending);`amb_village` 保留(village 的音乐若停播设置 bgm=0 时用 amb_village 垫底——bgm=0 时 bgm 层整体静默,village 也播 amb_village)。sfx 总数 35 不变。
|
||||
|
||||
`bgm=0`(设置关闭音乐)时的行为:village/battle/boss/ending 全部静默,探索场景 amb 照播(amb 属内容环境音不是 BGM——**例外裁定**:amb_* 虽走 bgm 层,但不受 bgm 设置关闭影响;实现上 amb 用 `play(id, {volume})` 时引擎按 bgm 层处理会被设置关停,因此 **amb 改走 sfx 层 loop**——sfx 层即发即弃不支持 loop,需要开发在 game.js 里用 `audio._load` + 手动 BufferSource loop 接 `_sfxBus`(rhythm 现版手动接总线已有先例)。这是本增补对实现的唯一新要求。)
|
||||
|
||||
## 4. Boss 阶段转换表现
|
||||
|
||||
| Boss | 阶段 | 触发 | 表现 |
|
||||
|---|---|---|---|
|
||||
| 黑风寨主 | 二阶段(ATK 10→12) | HP ≤50% | `warning` 震动 + `npc_boss1_2` + 前摇音 `tel_boss1` 从单声双刀变急促(同一 sfx 以 playbackRate 1.2 重播即可,不加资产) |
|
||||
| 腐沼祭主 | 二阶段 | HP ≤50% | `warning` + 召唤 2 毒蛛(连战队列插入),播专属半血句(R4 补录) |
|
||||
| 白无瞳 | 三阶段(完全无声) | HP ≤50% / ≤25% | 每阶段 `warning`;三阶段进场播 §5 教学句,此后前摇走 §2 震动协议 |
|
||||
|
||||
## 5. 补充台词需求(R4 录不进 ch1 表的,列入 ch2/3 台词表)
|
||||
|
||||
| id | 角色 | 台词全文 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `npc_boss3_warn` | 旁白 | 他没有声音。闭上眼睛没用,竖起耳朵也没用。这一次,相信你的手。 | 白无瞳三阶段进场(同时向 iOS 玩家暗示:防御指令是兜底) |
|
||||
| `btl_half_shaman` | 蛊师 | 蛊师半血句(R4 写 ch2 台词表时补全文) | 半血钩子 |
|
||||
| `btl_half_shadow` | 影卫 | 影卫半血句 | 同上 |
|
||||
| `menu_hint_escape` | — | (属 escape,见 round2-review §5.1,非本游戏) | — |
|
||||
|
||||
规则:ch2/3 台词表沿用 05-rpg-ch1-voice-ids.md 格式,R4 由策划补发;上表是必须包含的最小集。
|
||||
|
||||
## 6. 流程细节补遗
|
||||
|
||||
1. **上滑状态播报链顺序**(探索态与战斗态共用):`st_lv_*` → `st_hp_*` → `st_gold_*` → `st_weapon_*` → `st_armor_*`,链播可被打断(再次上滑重播,下滑进菜单)。
|
||||
2. **战斗态上滑**:`st_hp_*` + 敌方血量档位(敌方档位句复用 st_hp_* 文案?——**不复用**,敌人档位用:`foe_hp_high`"敌人气焰正盛。"/`foe_hp_mid`"敌人露出疲态。"/`foe_hp_low`"敌人撑不住了。"——R4 补录 3 句 ×2 性别,列入上表最小集)。
|
||||
3. **连战转换**(enc_bandits 等):第一只死亡 → `btl_enemy_die` → 不停 BGM → 直接播下一只 `btl_start_<enemy>` + 前摇,玩家 HP/MP 不回、指令重置为攻击。
|
||||
4. **`stats.playSec` 口径**:累计探索态+战斗态实际时长(菜单/结算暂停不计),存档时累加。
|
||||
5. **存档 schema 迁移**:R5 内允许丢弃重来(读档 schema≠1 时播 `sys_new_game` 重开,stats 保留)。
|
||||
6. **菜单豁免确认**:RPG 探索态菜单=左右滑切换+单击确认(00 §5.2 豁免条款,round2-review 裁决②);战斗态下滑=逃跑,不设菜单——与 05-rpg.md §2.2 一致,不变。
|
||||
|
||||
## 7. R4/R5 施工就绪核对单
|
||||
|
||||
- [x] 场景图 JSON / 遭遇战表 / 战斗状态机(05-rpg-chapter1.md §1-3)
|
||||
- [x] 全数值表 + 装备节点(05-rpg-chapter1.md §4-5)
|
||||
- [x] 存档字段 + summary(05-rpg-chapter1.md §6)
|
||||
- [x] 第一章 120 句台词表(05-rpg-ch1-voice-ids.md)
|
||||
- [x] 战斗音效/震动配合(本文件 §1)
|
||||
- [x] 无声前摇协议 + iOS 兜底(本文件 §2)
|
||||
- [x] 音频层叠规则(本文件 §3,含 amb 走 sfx 层的实现要求)
|
||||
- [x] BGM 4 首 + sfx 35 个 prompt 草稿(05-rpg-chapter1.md §7 + 本文件 §3 修正)
|
||||
- [ ] ch2/3 台词表(R4 策划补发,最小集见本文件 §5)
|
||||
- [ ] ch2/3 场景正文(R5 开发按 ch1 模板扩写)
|
||||
@@ -0,0 +1,176 @@
|
||||
# 05b · 《剑与声》第一章语音台词表(voice ids,R4 录音依据)
|
||||
|
||||
> **给音频总监的录制说明**
|
||||
> - **旁白**(A/S/D/O/E/B/SH 组,104 句):双性别。**female = Serena,male = Uncle_Fu**(新游戏,沿用 R1 分配惯例)。落 `web/rpg/audio/voice/female|male/<id>.{ogg,m4a}`。
|
||||
> - female instruct 常量:`平静清晰的年轻女声,像在讲武侠故事`
|
||||
> - male instruct 常量:`低沉醇厚的男声,像说书人,平静有韵味`
|
||||
> - **NPC**(N 组,16 句):单音色、不做双性别,平铺落 `web/rpg/audio/voice/<id>.{ogg,m4a}`(引擎平铺回退命中,同 storyline 角色台词模式)。同一角色 speaker+instruct 恒定:
|
||||
> - 村长:`Uncle_Fu` + `慈祥和缓的老者`
|
||||
> - 老猎人:`Eric` + `沙哑粗犷,豪爽中带着暖意`
|
||||
> - 寨丁头目:`Dylan` + `凶狠粗鲁,大声喝骂`
|
||||
> - 黑风寨主:`Uncle_Fu` + `低沉阴狠,慢条斯理`(与村长同 speaker 不同 instruct,参照 storyline 先例;两者从不在同场景出现,请试听确认区分度)
|
||||
> - 主角沈听澜**无台词**:旁白第二人称("你")叙述,省一套主角音色。
|
||||
> - 数字规避:等级/血量/金币全部分档整句;价格写死在商店句里;无运行时拼接。
|
||||
> - 管线:`tools/gen_voice_dual.py` 照录;多音字已规避("重听"写作"重新听")。
|
||||
|
||||
## A 组 · 序章(旁白 ×3)
|
||||
|
||||
| id | 台词全文 |
|
||||
|---|---|
|
||||
| `intro_1` | 你的眼睛看不见了。但你的耳朵,比谁都灵。 |
|
||||
| `intro_2` | 黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。 |
|
||||
| `intro_3` | 先记住,怎么用耳朵走路。 |
|
||||
|
||||
## S 组 · 系统/菜单/状态(旁白 ×28)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `sys_continue_ask` | 检测到你的旅程记录。单击继续,右滑开始新的旅程。 | 有存档进游戏 |
|
||||
| `sys_new_game` | 新的旅程,开始了。 | 新游戏 |
|
||||
| `sys_saved` | 进度已保存。 | 存档写入后 |
|
||||
| `sys_rest` | 你在篝火边睡了一觉,状态全满。 | village 休息 |
|
||||
| `sys_drink` | 溪水清冽,你喝了个饱,伤势尽消。 | stream 饮水 |
|
||||
| `sys_menu` | 菜单。左右滑选择,单击确认。 | 下滑呼出 |
|
||||
| `sys_menu_resume` | 继续旅程。 | 菜单项 |
|
||||
| `sys_menu_save` | 保存并回大厅。 | 菜单项 |
|
||||
| `sys_menu_help` | 重新听帮助。 | 菜单项 |
|
||||
| `help_explore` | 左右滑切换选项,单击确认。双击重新听场景,上滑查看状态,下滑打开菜单。 | 帮助 |
|
||||
| `help_battle` | 战斗中,左右滑选招式,单击出手。听到敌人的前摇,双击格挡。 | 帮助 |
|
||||
| `settings_hint` | 音色、语速、背景音乐,请回大厅修改。 | 菜单内 |
|
||||
| `st_hp_high` | 你状态很好。 | 血量 >70% |
|
||||
| `st_hp_mid` | 你受了伤。 | 30~70% |
|
||||
| `st_hp_low` | 你伤势很重,命悬一线。 | <30%(附带 warning 震动) |
|
||||
| `st_gold_poor` | 口袋空空。 | 金币 <30 |
|
||||
| `st_gold_some` | 有些积蓄。 | 30~150 |
|
||||
| `st_gold_rich` | 腰包鼓鼓。 | >150 |
|
||||
| `st_lv_low` | 你初出茅庐。 | lv 1-2 |
|
||||
| `st_lv_mid` | 你小有名气。 | lv 3-5 |
|
||||
| `st_lv_high` | 你已是高手。 | lv 6+ |
|
||||
| `st_weapon_none` | 你赤手空拳。 | 无武器 |
|
||||
| `st_weapon_iron` | 你手持铁剑。 | iron_sword |
|
||||
| `st_weapon_steel` | 你手持青钢剑。 | steel_sword |
|
||||
| `st_weapon_wind` | 你手持听风剑。 | wind_sword |
|
||||
| `st_armor_none` | 你身无片甲。 | 无护甲 |
|
||||
| `st_armor_leather` | 你身着皮甲。 | leather |
|
||||
| `st_armor_iron` | 你身着铁鳞甲。 | iron_scale |
|
||||
|
||||
## D 组 · 场景描述(旁白 ×16)
|
||||
|
||||
| id | 台词全文 |
|
||||
|---|---|
|
||||
| `desc_village` | 雾口村。篝火噼啪作响,村民低声议论着林子里的土匪。 |
|
||||
| `desc_village_re` | 你回到了雾口村。 |
|
||||
| `desc_path` | 林间小路。风穿过树叶,脚下的落叶沙沙作响。 |
|
||||
| `desc_path_re` | 你回到林间小路。 |
|
||||
| `desc_fork` | 岔路口。风声在左右两边,听起来不太一样。 |
|
||||
| `desc_fork_re` | 你回到岔路口。 |
|
||||
| `desc_chest` | 一个枯树洞。洞里似乎藏着什么。 |
|
||||
| `desc_chest_re` | 你回到枯树洞前。 |
|
||||
| `desc_stream` | 溪流。水声潺潺,是个歇脚的好地方。 |
|
||||
| `desc_stream_re` | 你回到溪流边。 |
|
||||
| `desc_hunter` | 猎人小屋。柴火正旺,还有磨刀的声音。 |
|
||||
| `desc_hunter_re` | 你回到猎人小屋。 |
|
||||
| `desc_deepwood` | 密林深处。虫鸣急促,远处有狼嚎,杀气很重。 |
|
||||
| `desc_deepwood_re` | 你回到密林深处。 |
|
||||
| `desc_gate` | 黑风寨门前。火把熊熊,人声嘈杂。 |
|
||||
| `desc_hall` | 聚义厅。火盆熊熊,大厅空旷,回声冰冷。 |
|
||||
|
||||
## O 组 · 选项名(旁白 ×18)
|
||||
|
||||
| id | 台词全文 |
|
||||
|---|---|
|
||||
| `opt_go_path` | 前进,去林间小路。 |
|
||||
| `opt_go_fork` | 前进,去岔路口。 |
|
||||
| `opt_go_chest` | 往左,看看树洞。 |
|
||||
| `opt_go_stream` | 往右,去溪流。 |
|
||||
| `opt_go_hunter` | 前进,去猎人小屋。 |
|
||||
| `opt_go_deepwood` | 前进,进密林深处。 |
|
||||
| `opt_go_gate` | 前进,去寨门。 |
|
||||
| `opt_back` | 返回。 |
|
||||
| `opt_shop` | 去商店。 |
|
||||
| `opt_rest` | 休息一晚。 |
|
||||
| `opt_status` | 查看状态。 |
|
||||
| `opt_investigate` | 调查四周。 |
|
||||
| `opt_open_chest` | 打开树洞。 |
|
||||
| `opt_drink` | 饮水休息。 |
|
||||
| `opt_talk_hunter` | 和老猎人谈谈。 |
|
||||
| `opt_assault` | 强攻寨门。 |
|
||||
| `opt_sneak` | 绕行潜入。 |
|
||||
| `opt_leave` | 离开。 |
|
||||
|
||||
## E 组 · 事件(旁白 ×6)
|
||||
|
||||
| id | 台词全文 |
|
||||
|---|---|
|
||||
| `evt_gold15` | 你在落叶下摸到十五枚金币。 |
|
||||
| `evt_nothing` | 这里什么都没有了。 |
|
||||
| `evt_chest` | 树洞里有两瓶金疮药,你收好了。 |
|
||||
| `evt_hunter_gift` | 老猎人塞给你一瓶金疮药,还说商店会给你算便宜些。 |
|
||||
| `evt_gate_open` | 寨门缓缓打开。 |
|
||||
| `evt_ch1_end` | 寨主倒了,但徒弟不在这里。一张字条,指向南方的腐沼。 |
|
||||
|
||||
## B 组 · 战斗(旁白 ×26)
|
||||
|
||||
| id | 台词全文 | 触发 |
|
||||
|---|---|---|
|
||||
| `btl_start_wolf` | 野狼拦路! | 遭遇狼 |
|
||||
| `btl_start_bandit` | 山贼围上来了! | 遭遇山贼 |
|
||||
| `btl_start_spider` | 毒蛛从树上垂了下来! | 遭遇毒蛛 |
|
||||
| `btl_start_elite` | 寨丁头目挡住了去路! | 遭遇头目 |
|
||||
| `btl_start_boss1` | 黑风寨主,就在你面前。 | Boss 战 |
|
||||
| `cmd_attack` | 攻击。 | 指令 |
|
||||
| `cmd_skill` | 破空斩。 | 指令 |
|
||||
| `cmd_defend` | 防御。 | 指令 |
|
||||
| `cmd_item` | 金疮药。 | 指令 |
|
||||
| `btl_nomp` | 内劲不足。 | mp<3 选技能 |
|
||||
| `btl_noitem` | 没有金疮药了。 | 无药选道具 |
|
||||
| `btl_flee` | 你抽身退出了战斗。 | 逃跑成功 |
|
||||
| `btl_noflee` | 避无可避! | Boss/剧情战逃跑 |
|
||||
| `btl_enemy_die` | 敌人倒下了。 | 击杀 |
|
||||
| `btl_win` | 战斗胜利。 | 胜利 |
|
||||
| `btl_loot_small` | 小有收获。 | 战利 exp+gold <15 |
|
||||
| `btl_loot_mid` | 收获不少。 | 15~40 |
|
||||
| `btl_loot_big` | 大获全胜,收获颇丰。 | >40 |
|
||||
| `btl_levelup` | 你的修为精进了。 | 升级(success 震动) |
|
||||
| `btl_lose` | 你倒下了,黑暗吞没了一切。 | 战败 |
|
||||
| `btl_lose_hint` | 记住,听清前摇,双击格挡。 | 战败提示 |
|
||||
| `tut_b1` | 它要扑了,听前摇! | 教学战 |
|
||||
| `tut_b2` | 就是现在,双击格挡! | 教学战 |
|
||||
| `tut_b3` | 挡住了!轮到你了。左右滑选攻击,单击出剑。 | 教学战 |
|
||||
| `tut_b4` | 没挡住也没关系,再来一次。 | 教学战格挡失败 |
|
||||
| `tut_b5` | 好剑法!接下来,靠你自己了。 | 教学战结束 |
|
||||
|
||||
## SH 组 · 商店(旁白 ×7)
|
||||
|
||||
| id | 台词全文 |
|
||||
|---|---|
|
||||
| `shop_open` | 商店。左右滑看看货,单击购买。 |
|
||||
| `shop_price_sword` | 铁剑,一百金。 |
|
||||
| `shop_price_armor` | 皮甲,八十金。 |
|
||||
| `shop_price_potion` | 金疮药,三十金。 |
|
||||
| `shop_owned` | 这件你已经有了。 |
|
||||
| `shop_poor` | 钱不够。 |
|
||||
| `shop_buy` | 买下了。 |
|
||||
|
||||
## N 组 · NPC(单音色平铺 ×16)
|
||||
|
||||
| id | 角色 | 台词全文 |
|
||||
|---|---|---|
|
||||
| `npc_headman_1` | 村长 | 外乡人,林子里不太平。这五十金,拿着防身。 |
|
||||
| `npc_headman_2` | 村长 | 商店就在村里,缺什么就去看看。 |
|
||||
| `npc_headman_3` | 村长 | 听说你是用耳朵走路的剑客?保重啊。 |
|
||||
| `npc_headman_4` | 村长 | 救救那些被掳走的孩子吧。 |
|
||||
| `npc_hunter_1` | 老猎人 | 站住。……哦,是个用耳朵的。 |
|
||||
| `npc_hunter_2` | 老猎人 | 寨子里有我旧识。带上这个,别死在路上。 |
|
||||
| `npc_hunter_3` | 老猎人 | 黑风寨主的双刀很快,听清他出刀前的动静。 |
|
||||
| `npc_hunter_4` | 老猎人 | 去吧。让我听听,你的传说。 |
|
||||
| `npc_elite_1` | 寨丁头目 | 哪儿来的瞎子,敢闯黑风寨! |
|
||||
| `npc_elite_half` | 寨丁头目 | 你……你到底是什么人! |
|
||||
| `npc_elite_die` | 寨丁头目 | 寨主……会替我报仇…… |
|
||||
| `npc_boss1_1` | 黑风寨主 | 盲剑客?正好,省得挖你的眼睛。 |
|
||||
| `npc_boss1_2` | 黑风寨主 | 有两下子。可惜,你听不懂死期的声音。 |
|
||||
| `npc_boss1_die` | 黑风寨主 | 白无瞳……会找到你…… |
|
||||
|
||||
---
|
||||
|
||||
**合计:旁白 104 句 × 2 性别 + NPC 16 句 = 224 条音频**(×2 格式 = 448 文件)。对话功能与语气大纲对照见 [05-rpg.md](05-rpg.md) §5.3,场景/战斗触发逻辑见 [05-rpg-chapter1.md](05-rpg-chapter1.md)。
|
||||
@@ -0,0 +1,238 @@
|
||||
# 05a · 《剑与声》第一章施工稿(rpg chapter 1,R4 资产 / R5 开发依据)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 本文件 + [05-rpg-ch1-voice-ids.md](05-rpg-ch1-voice-ids.md)(台词表)+ [05-rpg.md](05-rpg.md)(总设计)三者齐全即可施工。场景图、战斗状态机、数值表、存档结构全部定死,不允许实现时自由发挥。
|
||||
> 2. 战斗是**定时器驱动的回合状态机**(§3 状态转换表):敌方前摇 1.0s 内双击=格挡(双击即时回调,规避 tap 延迟);指令循环 攻击→技能→防御→道具。
|
||||
> 3. 第一章 8 场景、5 场固定遭遇战(明雷)、2 件装备 + 道具在商店/宝箱/支线获取(§5)。
|
||||
> 4. 存档 data 结构 §6(含大厅卡片要显示的 `summary` 字符串);保存时机:场景切换/战斗结束/商店交易后。
|
||||
> 5. 资产清单 §7:5 首 BGM(Mureka prompt 草稿)+ 35 个 sfx(Stable Audio 3 prompt 草稿 / 程序合成标注)。语音 ~114 句见台词表文件。
|
||||
|
||||
---
|
||||
|
||||
## 1. 第一章场景图(数据表,直接转 JSON)
|
||||
|
||||
```js
|
||||
const CH1 = {
|
||||
start: 'village',
|
||||
scenes: {
|
||||
village: { name: '雾口村', amb: 'amb_village', options: ['go_path', 'shop', 'rest', 'status'] },
|
||||
path: { name: '林间小路', amb: 'amb_path', options: ['go_fork', 'investigate', 'back'] },
|
||||
fork: { name: '岔路口', amb: 'amb_fork', options: ['go_chest', 'go_stream', 'back'] },
|
||||
chest: { name: '枯树洞', amb: 'amb_chest', options: ['open_chest', 'back'] },
|
||||
stream: { name: '溪流', amb: 'amb_stream', options: ['drink', 'go_hunter', 'back'],
|
||||
encounter: 'enc_teach_wolf' }, // 首次进入先触发教学战
|
||||
hunter: { name: '猎人小屋', amb: 'amb_hunter', options: ['talk_hunter', 'go_deepwood', 'back'] },
|
||||
deepwood: { name: '密林深处', amb: 'amb_deepwood', options: ['go_gate', 'back'],
|
||||
encounter: 'enc_bandits' }, // 首次进入触发山贼连战
|
||||
gate: { name: '黑风寨门前', amb: 'amb_gate', options: ['assault', 'sneak', 'back'] },
|
||||
boss1: { name: '聚义厅', amb: 'amb_hall', options: [],
|
||||
encounter: 'enc_boss1' }, // 由 gate 两个选项分别进入
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
选项语义(选项 id → 行为):
|
||||
|
||||
| 选项 id | 播报语音 | 行为 |
|
||||
|---|---|---|
|
||||
| `go_*` | `opt_go_<场景>` | 切换场景(播脚步 sfx + 新场景 desc) |
|
||||
| `back` | `opt_back` | 回上一场景 |
|
||||
| `shop` | `opt_shop` | 进商店子菜单(§4) |
|
||||
| `rest` | `opt_rest` | HP/MP 回满,播 `sys_rest`;仅 village |
|
||||
| `status` | `opt_status` | 角色状态播报(同上滑) |
|
||||
| `investigate` | `opt_investigate` | 一次性:+15 金币(flag `chest_path`),播 `evt_gold15`;之后播 `evt_nothing` |
|
||||
| `go_chest` | `opt_go_chest` | 岔路左支线 |
|
||||
| `open_chest` | `opt_open_chest` | 一次性:金疮药×2(flag `chest_fork`) |
|
||||
| `drink` | `opt_drink` | HP 回满,播 `sys_drink` |
|
||||
| `talk_hunter` | `opt_talk_hunter` | 支线对话(flag `hunter_quest`:金疮药×1 + 商店九折 flag `shop_discount`) |
|
||||
| `assault` | `opt_assault` | 战 `enc_elite` → 胜后进 boss1 |
|
||||
| `sneak` | `opt_sneak` | 战 `enc_sneak`(较弱)→ 进 boss1 且 HP/MP 回满 |
|
||||
|
||||
## 2. 遭遇战表(第一章,全部明雷固定)
|
||||
|
||||
| 遭遇 id | 敌人 | 触发 | 特殊 |
|
||||
|---|---|---|---|
|
||||
| `enc_teach_wolf` | 教学野狼 ×1 | stream 首次进入 | 教学战:玩家 HP 锁底 1(不败),分步教学(台词表 B 组) |
|
||||
| `enc_wolf` | 野狼 ×1 | 教学战后 stream 再入 50% 刷新一次 | 可逃 |
|
||||
| `enc_bandits` | 山贼 ×2(连战两场) | deepwood 首次进入 | 不可逃;两场间不回血 |
|
||||
| `enc_elite` | 寨丁头目 ×1 | gate 选 assault | 不可逃 |
|
||||
| `enc_sneak` | 山贼 ×1 + 毒蛛 ×1(连战) | gate 选 sneak | 不可逃;胜后进 boss 前回满 |
|
||||
| `enc_boss1` | 黑风寨主 ×1 | boss1 进入 | Boss 战,不可逃;半血进二阶段 |
|
||||
|
||||
## 3. 战斗状态机(完整状态转换表)
|
||||
|
||||
状态:`B_START → (回合循环) → VICTORY | DEFEAT | FLED`
|
||||
|
||||
| 状态 | 进入时副作用 | 出口转换 |
|
||||
|---|---|---|
|
||||
| `B_START` | 播 `btl_start_<enemy>` + 敌方登场 sfx; init 回合数=1 | 自动 → `ENEMY_TELEGRAPH`(敌方先攻的蛊师类)或 `PLAYER_SELECT`(默认玩家先攻)。第一章全部玩家先攻 |
|
||||
| `PLAYER_SELECT` | 播当前指令名(`cmd_attack`),左右滑循环切换(攻击→技能→防御→道具),单击执行;上滑播战场状态;下滑请求逃跑 | 单击 → `PLAYER_EXECUTE`;下滑 → `FLEE_CHECK` |
|
||||
| `PLAYER_EXECUTE` | 按指令结算:攻击=伤害公式;技能=atk×2 耗 mp3(mp 不足播 `btl_nomp` 留在本状态重选);防御=置 halfDamage 标记;道具=金疮药回 40%maxHp(无药播 `btl_noitem` 重选)。播对应 sfx + 敌方受击音 | 结算完 → `CHECK_ENEMY_DEAD` |
|
||||
| `CHECK_ENEMY_DEAD` | 敌 HP≤0:播 `btl_enemy_die`;连战则拉下一个敌人回 `B_START`(不重置玩家状态) | 敌死 → `VICTORY`(无下一只);敌活 → `ENEMY_TELEGRAPH` |
|
||||
| `ENEMY_TELEGRAPH` | 播敌方前摇音(`tel_<enemy>`,1.0s);启动格挡窗口 1.0s | 窗口内双击 → 格挡标记=true(`vibrate('confirm')` + `sfx/parry`),→ `ENEMY_RESOLVE`;窗口结束 → `ENEMY_RESOLVE`;窗口内其他手势 → `vibrate('error')`,留在窗口 |
|
||||
| `ENEMY_RESOLVE` | 结算敌方伤害:`dmg×0.3`(格挡)/`×0.5`(防御)/全值;播受击音 + `vibrate([80])`(格挡成功不震 HIT,已震 confirm);血量档位变化时播 `btl_hp_*` | 玩家 HP≤0 → `DEFEAT`;否则回合数+1 → `PLAYER_SELECT` |
|
||||
| `FLEE_CHECK` | 非 Boss 战:播 `btl_flee` 脱离;Boss/剧情战:播 `btl_noflee` 回 `PLAYER_SELECT` | → `FLED`(回场景,遭遇不消失)或 `PLAYER_SELECT` |
|
||||
| `VICTORY` | 播 `btl_win` + 经验/金币获得句(整句分档,见台词表)→ 结算升级(升级播 `btl_levelup` + `vibrate('success')`)→ 写存档 | → 场景探索态 |
|
||||
| `DEFEAT` | 播 `btl_lose` + 死亡归因句 → 章首(village)重生:HP/MP 回满,等级/金币/装备/flags 保留,本章非 Boss 遭遇重置 | → village 探索态 |
|
||||
|
||||
半血叙事钩子:敌人 HP 首次 ≤50% 时在 `CHECK_ENEMY_DEAD` 前插播 `btl_half_<enemy>`(无此语音的敌人跳过)。
|
||||
|
||||
## 4. 数值表
|
||||
|
||||
### 4.1 玩家
|
||||
|
||||
```
|
||||
maxHp = 20 + 8*(lv-1) // lv1=20, lv3=36, lv5=52
|
||||
atk = 5 + 2*(lv-1) + 武器atk
|
||||
def = 护甲def
|
||||
mp = 3 + lv // 破空斩耗 3
|
||||
expToNext = lv * 20
|
||||
dmg = max(1, round((atk攻 - def守) * (0.85~1.15)))
|
||||
```
|
||||
|
||||
### 4.2 敌人(全游戏 8 种 + 3 Boss;第一章用前 4 种 + Boss1)
|
||||
|
||||
| 敌人 | 章 | HP | ATK | DEF | EXP | 金币 | 前摇音 sfx id | 前摇特征 | 半血句 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 教学野狼 | 1 | 6 | 3 | 0 | 5 | 0 | `tel_wolf` | 扑击前低吼 | 无 |
|
||||
| 野狼 | 1 | 10 | 4 | 0 | 8 | 5 | `tel_wolf` | 低吼 | 无 |
|
||||
| 山贼 | 1 | 16 | 6 | 1 | 12 | 12 | `tel_bandit` | 脚步+拔刀 | `btl_half_bandit` |
|
||||
| 毒蛛 | 1 | 12 | 7 | 0 | 12 | 8 | `tel_spider` | 高频肢节摩擦 | 无 |
|
||||
| 寨丁头目 | 1 | 24 | 8 | 2 | 20 | 25 | `tel_elite` | 重甲碰撞 | `btl_half_elite` |
|
||||
| **Boss 黑风寨主** | 1 | 45 | 10 | 2 | 50 | 100 | `tel_boss1` | 双刀互击两声 | 二阶段:ATK→12,播 `boss1_phase2` |
|
||||
| 沼泽鳄 | 2 | 30 | 11 | 2 | 22 | 18 | `tel_croc` | 水花+低吼 | 无 |
|
||||
| 蛊师 | 2 | 22 | 12 | 1 | 26 | 30 | `tel_shaman` | 吟唱(敌方先攻) | 有 |
|
||||
| 剑傀 | 3 | 38 | 14 | 3 | 40 | 45 | `tel_puppet` | 机括咔哒 | 无 |
|
||||
| 影卫 | 3 | 30 | 16 | 2 | 45 | 50 | 无声(震动 DANGER 代替) | 寂静 | 有 |
|
||||
| **Boss 腐沼祭主** | 2 | 70 | 14 | 3 | 90 | 200 | `tel_boss2` | 人声哼鸣 | 二阶段召唤 2 毒蛛 |
|
||||
| **Boss 白无瞳** | 3 | 110 | 18 | 5 | 150 | 500 | 三阶段完全无声 | 寂静+DANGER | 三次阶段转换 |
|
||||
|
||||
经验校验:第一章可得 exp = 5+8+12×2+20+12+12+50 = 131+(教学狼 5 + 野狼 8 + 山贼 24 + 头目/偷袭线 20~24 + Boss 50)≈ 110-135 → 打完第一章玩家 lv3(20+40+60=120 可到 lv4 门槛前),数值节奏成立。
|
||||
|
||||
### 4.3 装备与道具(获取节点)
|
||||
|
||||
| 物品 | 效果 | 获取 |
|
||||
|---|---|---|
|
||||
| 铁剑 | atk+3 | village 商店 100 金(开局 50 金 + 小路调查 15 + 若干战斗;猎人支线后九折 90) |
|
||||
| 皮甲 | def+2 | village 商店 80 金(九折 72) |
|
||||
| 金疮药 | 回 40% maxHp | 商店 30 金;枯树洞宝箱 ×2;猎人支线 ×1;叠加上限 5 |
|
||||
| 青钢剑 | atk+6 | 第二章商店 300 |
|
||||
| 铁鳞甲 | def+4 | 第二章商店 250 |
|
||||
| 听风剑 | atk+9,格挡窗口 1.0s→1.3s | 第三章商店 600 |
|
||||
|
||||
### 4.4 商店子菜单(village)
|
||||
|
||||
进入播 `shop_open` → 左右滑循环:铁剑/皮甲/金疮药/离开 → 单击购买(播物品名+价格档句 `shop_price_<item>`;钱不够播 `shop_poor`;已购装备播 `shop_owned`)→ 购买成功 `sfx/coin` + `vibrate([15,40,15])` → 写存档。
|
||||
|
||||
## 5. 探索态手势(复读总设计,施工口径)
|
||||
|
||||
左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述+环境音 / 上滑=状态播报(`st_lv`、`st_hp_*`、`st_gold_*`、`st_equip` 链播,全整句分档)/ 下滑=菜单(继续 / 保存并回大厅 / 重听帮助)。血量档位:>70% `btl_hp_high`("你状态很好")、30-70% `btl_hp_mid`("你受了伤")、<30% `btl_hp_low`("你伤势很重")。
|
||||
|
||||
## 6. 存档 JSON(精确定义)
|
||||
|
||||
`PUT /api/progress { game: 'rpg', data: ... }` 的 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"updatedAt": "2026-08-29T12:00:00Z",
|
||||
"summary": "第一章·溪流 · Lv3 · 金币120",
|
||||
"chapter": 1,
|
||||
"scene": "stream",
|
||||
"player": {
|
||||
"lv": 3, "exp": 45,
|
||||
"hp": 28, "maxHp": 36, "mp": 6,
|
||||
"gold": 120,
|
||||
"weapon": "iron_sword",
|
||||
"armor": "leather",
|
||||
"potions": 2
|
||||
},
|
||||
"flags": {
|
||||
"tutorial_battle_done": false,
|
||||
"chest_path": false,
|
||||
"chest_fork": false,
|
||||
"hunter_quest": false,
|
||||
"shop_discount": false,
|
||||
"enc_bandits_done": false,
|
||||
"enc_elite_done": false,
|
||||
"enc_sneak_done": false,
|
||||
"boss1_done": false
|
||||
},
|
||||
"chapters": { "1": "open", "2": "locked", "3": "locked" },
|
||||
"stats": { "kills": 0, "deaths": 0, "parries": 0, "playSec": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `schema` | 1 | 升版时 R5+ 写迁移或丢弃重来 |
|
||||
| `summary` | `"新的旅程"` | 大厅卡片显示;格式 `第X章·场景名 · LvN · 金币M` |
|
||||
| `chapter`/`scene` | 1 / `village` | 继续游戏落点 |
|
||||
| `player.*` | lv1 exp0 hp20 maxHp20 mp4 gold50 weapon null armor null potions 1 | 开局 50 金为村长赠送剧情款 |
|
||||
| `weapon`/`armor` | null | `iron_sword`/`steel_sword`/`wind_sword`;`leather`/`iron_scale` |
|
||||
| `flags.*` | false | 一次性事件锁 |
|
||||
| `chapters` | 见上 | `locked`/`open`/`cleared` |
|
||||
| `stats.*` | 0 | 只增不减,新游戏也保留 |
|
||||
|
||||
- 保存时机:场景切换后、战斗结算后、商店交易后、菜单"保存并回大厅"。localStorage `soundgame.progress.rpg` 与服务器双写。
|
||||
- 进游戏有存档时:播 `sys_continue_ask`("继续上次的旅程?单击继续,右滑开始新游戏。");新游戏清空 data 保留 `stats` 累计。
|
||||
|
||||
## 7. 资产需求清单
|
||||
|
||||
### 7.1 BGM(5 首,Mureka instrumental,60-90s 无缝循环,沿用归一化工艺)
|
||||
|
||||
| id | 用途 | Mureka prompt 草稿(英文) |
|
||||
|---|---|---|
|
||||
| `bgm/explore` | 第一章探索底 | "Atmospheric Chinese wuxia ambient, bamboo forest mist, sparse guzheng plucks over low drone, slow tempo 60 bpm, mysterious but calm, no vocals, seamless loop" |
|
||||
| `bgm/battle` | 普通战斗 | "Tense wuxia battle music, driving taiko drums 100 bpm, sharp erhu stabs, urgent but not chaotic, no vocals, seamless loop" |
|
||||
| `bgm/boss` | Boss 战 | "Epic dark wuxia boss theme, heavy taiko 120 bpm, dissonant strings, dramatic tension, no vocals, seamless loop" |
|
||||
| `bgm/village` | 村庄/安全区 | "Warm peaceful Chinese village ambiance music, soft dizi flute melody, gentle plucked strings, cozy 70 bpm, no vocals, seamless loop" |
|
||||
| `bgm/ending` | 章结算/剧情 | "Melancholic wuxia emotional theme, solo erhu over soft pads, bittersweet, slow, no vocals, seamless loop" |
|
||||
|
||||
### 7.2 SFX(35 个;SA3 = Stable Audio 3 生成,SYN = 程序合成)
|
||||
|
||||
| 类别 | id | 方式 | prompt 草稿 / 说明 |
|
||||
|---|---|---|---|
|
||||
| 敌方前摇 | `tel_wolf` | SA3 | "wolf growl then lunge, short, dry, close mic" |
|
||||
| | `tel_bandit` | SA3 | "footsteps on dirt then sword unsheathing shing, 1 second" |
|
||||
| | `tel_spider` | SA3 | "insect legs skittering, high frequency chitin clicks, eerie, 1 second" |
|
||||
| | `tel_elite` | SA3 | "heavy armor plates clanking, menacing, 1 second" |
|
||||
| | `tel_boss1` | SA3 | "two metal blades striking each other twice, dramatic ring" |
|
||||
| | `tel_croc` | SA3 | "water splash with low reptile growl" |
|
||||
| | `tel_shaman` | SA3 | "low guttural chant humming, ritualistic, dark" |
|
||||
| | `tel_puppet` | SA3 | "wooden mechanism clicks and winding gears" |
|
||||
| 战斗 | `atk_swing` | SA3 | "sword whoosh through air, fast, clean" |
|
||||
| | `atk_hit` | SA3 | "sword hitting flesh, dull impact, not gory" |
|
||||
| | `parry` | SA3 | "metal parry clang, bright ring, short" |
|
||||
| | `skill_slash` | SA3 | "powerful sword slash with air rip, dramatic" |
|
||||
| | `hurt` | SYN | 低频闷击(程序合成,避免与 atk_hit 混淆) |
|
||||
| | `enemy_die` | SA3 | "body falling to ground with weapon clatter" |
|
||||
| | `potion` | SA3 | "drinking liquid gulps, glass bottle" |
|
||||
| | `flee` | SYN | 渐远脚步 |
|
||||
| 探索/系统 | `step` | SYN | 单步脚步(复用 escape 工艺) |
|
||||
| | `coin` | SYN | 硬币叮当(短合成) |
|
||||
| | `chest` | SA3 | "old wooden chest creaking open" |
|
||||
| | `drink_water` | SA3 | "scooping and drinking stream water" |
|
||||
| | `rest` | SA3 | "settling down by campfire, fabric rustle" |
|
||||
| | `levelup` | SYN | 上行钟声琶音(内容音非提示音,允许) |
|
||||
| | `door_heavy` | SA3 | "heavy wooden gate slowly opening, deep creak" |
|
||||
| | `ui_deny` | — | **不做**(反馈走震动 error) |
|
||||
| 场景环境 | `amb_village` | SA3 | "night village ambience, campfire crackle, distant chatter, crickets, loop" |
|
||||
| | `amb_path` | SA3 | "forest path ambience, wind through leaves, birds distant, loop" |
|
||||
| | `amb_fork` | SA3 | "forest crossroads ambience, wind split feel, crows, loop" |
|
||||
| | `amb_chest` | SA3 | "hollow tree hole ambience, drips, muffled wind, loop" |
|
||||
| | `amb_stream` | SA3 | "flowing stream over rocks, gentle water, loop" |
|
||||
| | `amb_hunter` | SA3 | "wood cabin ambience, firewood crackle, knife sharpening occasionally, loop" |
|
||||
| | `amb_deepwood` | SA3 | "deep forest ambience, insects, distant wolf howl, ominous, loop" |
|
||||
| | `amb_gate` | SA3 | "bandit camp outside gate ambience, torches burning, rowdy distant voices, loop" |
|
||||
| | `amb_hall` | SA3 | "large hall ambience, fire brazier, echoing space, tense, loop" |
|
||||
| 二/三章备用 | `tel_croc/tel_shaman/tel_puppet`(已列) + `amb_swamp`、`amb_peak`、`tel_boss2` | SA3 | 沼气泡/山峰风/哼鸣,R4 一起生成 |
|
||||
| 格挡无声敌人 | (无 sfx,震动 DANGER) | — | 影卫/白无瞳三阶段 |
|
||||
|
||||
计数:前摇 8 + 战斗 9 + 探索系统 8 + 环境 9 + Boss2 前摇 1 = **35**(`ui_deny` 不做,无声敌人不占 sfx)。环境音 amb_* 走 bgm 层 loop(低音量 volume 0.5),不占 sfx 并发。
|
||||
|
||||
## 8. 与台词表的对应
|
||||
|
||||
语音 id 共 114 个(旁白双性别 ~70 + NPC 平铺单音色 ~44),见 [05-rpg-ch1-voice-ids.md](05-rpg-ch1-voice-ids.md)。场景 desc、选项名、战斗播报、商店、状态档位、教学全部覆盖;开发 agent 只许引用表内 id。
|
||||
@@ -0,0 +1,209 @@
|
||||
# 05 · 新 RPG《剑与声》完整设计(rpg / Sword & Sound)
|
||||
|
||||
> **给开发者的实现要点摘要**
|
||||
> 1. 纯音频回合制 RPG:节点式场景图(不是网格),左右滑=切换选项、单击=确认/前进、双击=重听、上滑=角色状态、下滑=菜单。回合制战斗:左右滑选指令、单击执行;敌方攻击有 1s 前摇音,前摇内**双击=格挡**(减伤 70%)——这是唯一的即时操作,绑双击规避 tap 延迟。
|
||||
> 2. 规模按"一轮开发(R5)可完成"控制:3 章、每章 6-8 个场景节点、共 8 种敌人 + 3 个 Boss、装备 6 件、数值一套线性公式。复杂度对标 escape(单文件 game.js + ~120 条语音 ×2 音色)。
|
||||
> 3. 进度存服务器:存档 JSON 结构见 §7,在场景切换、战斗结束、商店交易后自动保存。
|
||||
> 4. 第一章《迷雾林》内容全部给出(§5):8 场景、敌人表、对白功能大纲。第二、三章只给结构骨架与 Boss 设定,正文沿用第一章模式由开发 agent 扩写。
|
||||
> 5. 语音全部整句(禁止数字拼接):血量/等级/金币的播报用分段整句("毫发无损/受了轻伤/伤势很重/命悬一线"),数值细节进上滑状态播报的档位句。
|
||||
>
|
||||
> **R2 增补**:施工级文档已就位,以这两份为准——
|
||||
> - [05-rpg-chapter1.md](05-rpg-chapter1.md):第一章场景图 JSON、战斗状态机转换表、全数值表、存档字段定义、BGM/sfx 资产清单(含 Mureka/SA3 prompt 草稿)。
|
||||
> - [05-rpg-ch1-voice-ids.md](05-rpg-ch1-voice-ids.md):第一章 120 句台词表(旁白 104×2 性别 + NPC 16 单音色)。
|
||||
> 本文 §7 的存档结构、§9 的资产数量为设计初稿,若与施工稿不一致,**以 05-rpg-chapter1.md 为准**(主要差异:存档 data 为服务器自由格式 + 必带 `summary` 字段;语音总量 104×2+16)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 游戏定位与核心循环
|
||||
|
||||
- **题材**:武侠/奇幻。玩家是"盲剑客"沈听澜——眼盲,耳即眼。题材与纯音频玩法互为叙事 justification,敌人方位、招式前摇全部天然用声音呈现。
|
||||
- **单局时长**:一章 15-25 分钟;全三章 60-80 分钟。死亡从章首重生(背包/等级保留),鼓励反复推图。
|
||||
- **核心循环**:探索场景 → 听环境音与描述 → 选择行动(前进/调查/战斗/商店/休息)→ 回合制战斗(格挡是技巧点)→ 获得经验金币 → 升级换装备 → 打 Boss → 下一章。
|
||||
|
||||
## 2. 手势映射(符合全局默认,无需豁免)
|
||||
|
||||
### 2.1 探索态
|
||||
|
||||
| 手势 | 作用 |
|
||||
|---|---|
|
||||
| 左/右滑 | 在当前场景的选项间切换(每切换播选项名 + SWIPE_OK 震动)。选项循环 |
|
||||
| 单击 | 确认执行当前选项(CONFIRM 震动) |
|
||||
| 双击 | 重听当前场景描述 + 环境音(重听是探索游戏的生命线) |
|
||||
| 上滑 | 角色状态播报(等级档位、血量档位、金币档位、装备名,全整句) |
|
||||
| 下滑 | 菜单:继续 / 保存并回大厅 / 重听帮助 |
|
||||
|
||||
### 2.2 战斗态
|
||||
|
||||
| 手势 | 作用 |
|
||||
|---|---|
|
||||
| 左/右滑 | 切换指令:攻击 → 技能(破空斩,耗内劲)→ 防御(本回合受伤减半)→ 道具(金疮药,回 40% HP)→ 循环 |
|
||||
| 单击 | 执行当前指令 |
|
||||
| **双击** | **格挡**:仅在敌方攻击前摇音播放的 1.0s 窗口内有效,成功减伤 70% 并播格挡音;窗口外双击无效(ERROR 震动) |
|
||||
| 上滑 | 战场状态(己方血量档位 + 敌方血量档位) |
|
||||
| 下滑 | 尝试逃跑(Boss 战无效,播"避无可避") |
|
||||
|
||||
格挡窗口的 300ms tap 延迟问题不存在——格挡绑双击(即时回调),这是全局规范"即时反馈绑滑动/双击"的应用。
|
||||
|
||||
## 3. 数值框架(简单可平衡)
|
||||
|
||||
### 3.1 玩家
|
||||
|
||||
```
|
||||
等级 lv: 1..10
|
||||
maxHp = 20 + 8*(lv-1) // lv1=28? 不——lv1=20, lv10=92
|
||||
atk = 5 + 2*(lv-1) + 武器atk // lv1 裸装=5
|
||||
def = 护甲def
|
||||
内劲 mp = 3 + lv // 破空斩耗 3,伤害 = atk*2
|
||||
升级所需 exp = lv * 20 // lv1→2 需 20,lv9→10 需 180;全三章总 exp ≈ 900
|
||||
```
|
||||
|
||||
伤害公式:`dmg = max(1, round((攻方atk - 守方def) * (0.85 ~ 1.15 随机)))`
|
||||
格挡:`dmg = max(0, round(dmg * 0.3))`;防御指令:`dmg = round(dmg * 0.5)`(与格挡不叠加)。
|
||||
|
||||
### 3.2 敌人表(三章通用模板 + 第一章实例)
|
||||
|
||||
| 敌人 | 章 | HP | ATK | DEF | EXP | 金币 | 前摇音特征 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 野狼 | 1 | 10 | 4 | 0 | 8 | 5 | 扑击前低吼 |
|
||||
| 山贼 | 1 | 16 | 6 | 1 | 12 | 12 | 挥刀前的脚步 |
|
||||
| 毒蛛 | 1 | 12 | 7 | 0 | 12 | 8 | 肢节摩擦(高频) |
|
||||
| 精英·寨丁头目 | 1 | 24 | 8 | 2 | 20 | 25 | 重甲碰撞 |
|
||||
| **Boss 黑风寨主** | 1 | 45 | 10 | 2 | 50 | 100 | 双刀互击(两下金属声) |
|
||||
| 沼泽鳄 / 蛊师 / … | 2 | 按 lv4-6 玩家数值 +30% 调 | | | 18-30 | | |
|
||||
| **Boss 腐沼祭主** | 2 | 70 | 14 | 3 | 90 | 200 | 吟唱(人声哼鸣) |
|
||||
| 剑傀 / 影卫 / … | 3 | 按 lv7-9 玩家数值 +30% 调 | | | 35-50 | | |
|
||||
| **Boss 盲剑宿敌·白无瞳** | 3 | 110 | 18 | 5 | 150 | 500 | 无声!前摇是"寂静"(特殊机制:格挡窗口用震动 DANGER 提示,教学点在 Boss 战开场由旁白讲明) |
|
||||
|
||||
第三章 Boss"无声前摇"是全游戏叙事+玩法高潮:耳朵最强的剑客遇到没有声音的敌人,格挡提示从听觉转触觉——呼应全局震动语言。
|
||||
|
||||
### 3.3 装备与道具
|
||||
|
||||
| 物品 | 类型 | 效果 | 价格 |
|
||||
|---|---|---|---|
|
||||
| 铁剑 | 武器 | atk+3 | 100(一章商店) |
|
||||
| 青钢剑 | 武器 | atk+6 | 300(二章商店) |
|
||||
| 听风剑 | 武器 | atk+9,格挡窗口 +0.3s | 600(三章商店,特色装) |
|
||||
| 皮甲 | 护甲 | def+2 | 80 |
|
||||
| 铁鳞甲 | 护甲 | def+4 | 250 |
|
||||
| 金疮药 | 道具 | 回复 40% maxHp | 30,可叠 5 瓶 |
|
||||
|
||||
平衡校验:lv3 玩家(atk≈9+3=12 铁剑)打野狼 dmg≈11-13 → 一刀秒;打山贼 2 刀;Boss 黑风寨主 45HP 需 ~5 刀,Boss dmg 对皮甲玩家 ≈ 8-10/刀,玩家 36HP 可吃 4 刀,格挡成功只吃 2-3 → 不格挡会死、会格挡能过,技巧有真实收益。
|
||||
|
||||
## 4. 场景与探索结构
|
||||
|
||||
- 每章 = **有向节点图**(非网格):节点 = 场景,边 = 选项。探索时当前场景播环境音循环底(长底噪用 bgm 层 loop、低音量)+ 首次进入播描述语音。
|
||||
- 每章结构:`起点(教学/商店) → 主线节点 ×4~5 → Boss`,其中 2 个节点带岔路(一条主线、一条奖励支线:宝箱/支线 NPC/休息点)。
|
||||
- 战斗触发:进入战斗节点即触发(暗雷不做了——纯音频里"突然遇敌"没有视觉游戏的信息缓冲,全部明雷:场景描述里明说"有杀气")。
|
||||
- 场景环境音要求:每场景一个专属 amb 循环(同 escape 重设计的做法,Stable Audio 3 生成)。
|
||||
|
||||
## 5. 第一章《迷雾林》详细内容
|
||||
|
||||
### 5.1 场景列表(8 节点)
|
||||
|
||||
| # | 场景 id | 名称 | 环境音 | 选项 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `village` | 雾口村 | 篝火+人语 | 前进 / 商店 / 休息(回满 HP) / 状态 | 起点兼教学关;商店卖铁剑/皮甲/金疮药 |
|
||||
| 2 | `path` | 林间小路 | 风+落叶 | 前进 / 调查(找到 15 金币) | 教学:双击重听、上滑状态 |
|
||||
| 3 | `fork` | 岔路口 | 风分两路(立体声) | 左(宝箱:金疮药×2) / 右(主线) | 教学:左右滑选择 |
|
||||
| 4 | `stream` | 溪流 | 流水 | 饮水(回满) / 前进 | 回复点;此处触发教学战:野狼×1(教学格挡,必不死) |
|
||||
| 5 | `hunter` | 老猎人小屋 | 柴火+磨刀 | 对话(支线:获金疮药×1+铁剑九折) / 前进 | NPC 角色音首登场 |
|
||||
| 6 | `deepwood` | 密林深处 | 虫鸣+远处狼嚎 | 前进(遭遇山贼×2 连战) | 本章难度峰值前 |
|
||||
| 7 | `gate` | 黑风寨门前 | 火把+人声嘈杂 | 强攻(战寨丁头目) / 绕行(多打一场但 Boss 战前满状态) | 战术选择 |
|
||||
| 8 | `boss1` | 聚义厅 | 大厅混响+火盆 | Boss 黑风寨主 | 章 Boss;胜利→第二章解锁 |
|
||||
|
||||
### 5.2 教学编排(探索与战斗各自交互式)
|
||||
|
||||
- 场景 1:旁白教"左右滑选、单击确认",让玩家亲手在商店完成一次"听价→选→买"(给 50 金币启动金)。
|
||||
- 场景 3:教左右滑选择岔路 + 双击重听。
|
||||
- 场景 4 教学战(野狼,HP 锁 1 不死保护):旁白分步——"它要扑了,听前摇!"→ 低吼响起 → "双击格挡!"→ 成功/失败都继续(失败只掉 1 血),然后教"左右滑选攻击,单击出剑"。玩家亲手砍出第一刀后教学结束。
|
||||
- 之后不再教学,上滑随时可重听帮助。
|
||||
|
||||
### 5.3 对白大纲(每句注明功能与语气;正文由开发 agent 按语气扩写,整句、≤25字/句)
|
||||
|
||||
**序章旁白**(旁白音:Serena/Uncle_Fu,平静叙述)
|
||||
1. "你的眼睛看不见了。但你的耳朵,比谁都灵。"——立人设,沉稳
|
||||
2. "黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。"——给动机,冷
|
||||
3. "先记住怎么用耳朵走路。"——转教学,缓和
|
||||
|
||||
**村长**(village,长者音:Uncle_Fu 同款 speaker 不同 instruct?"同一角色 speaker+instruct 固定"——村长=Uncle_Fu+慈和 instruct;旁白 male 版=Uncle_Fu+平静 instruct。同 speaker 不同角色时 instruct 不同是允许的,规范约束的是"同一角色恒定")
|
||||
4. 问候+送 50 金币:"外乡人,林子里不太平。这点钱拿着防身。"——慈和
|
||||
5. 商店引导 2 句——功能性,慢速清晰
|
||||
|
||||
**老猎人**(hunter,沙哑老者 instruct)
|
||||
6. 戒备开场:"站住。……哦,是个用耳朵的。"——先冷后奇
|
||||
7. 支线给予:"寨子里有我旧识。带上这个,别死在路上。"——粗中有暖(功能:给药+折扣 flag)
|
||||
|
||||
**寨丁头目**(deepwood/gate,粗哑)
|
||||
8. 遭遇喝骂 1 句——凶
|
||||
9. 半血狠话 1 句——凶转慌(功能:血量反馈的叙事化)
|
||||
|
||||
**黑风寨主**(boss1,低沉反派)
|
||||
10. 登场宣言:"盲剑客?正好,省得挖你眼睛。"——狂
|
||||
11. 半血:"有两下子。可惜你听不懂死期的声音。"——狠(功能:阶段转换信号)
|
||||
12. 败亡遗言——惊愕→不甘(功能:章结束钩子,暗示白无瞳存在)
|
||||
|
||||
**章尾旁白**
|
||||
13. "寨主倒了。但徒弟不在这里——一张字条,指向南方的腐沼。"——收束+引第二章
|
||||
|
||||
## 6. 第二、三章骨架
|
||||
|
||||
- **第二章《腐沼泽》**:7 节点。新机制:毒状态(每回合 -3 HP,金疮药可解)、蛊师敌人(先攻)。Boss 腐沼祭主二阶段(半血召唤 2 小蛛)。解锁青钢剑/铁鳞甲。
|
||||
- **第三章《无瞳峰》**:7 节点。新机制:黑暗战斗(部分敌人无声前摇,靠震动格挡——为终 Boss 铺垫)、听风剑。终 Boss 白无瞳三阶段,终阶段完全无声前摇。
|
||||
- 两章正文沿用第一章模板(场景表+敌人表+对白功能大纲)由开发 agent 扩写,规模与第一章相当。
|
||||
|
||||
## 7. 存档结构(progress JSON)
|
||||
|
||||
信封按 00 文档 §4.2(`{schema, game:"rpg", updatedAt, data}`),`data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"chapter": 1,
|
||||
"scene": "stream",
|
||||
"player": {
|
||||
"lv": 3, "exp": 45,
|
||||
"hp": 28, "maxHp": 36, "mp": 6,
|
||||
"gold": 120,
|
||||
"weapon": "iron_sword", "armor": "leather",
|
||||
"potions": 2
|
||||
},
|
||||
"flags": {
|
||||
"tutorial_explore_done": true,
|
||||
"tutorial_battle_done": true,
|
||||
"hunter_quest": true,
|
||||
"chest_fork": false,
|
||||
"shop_discount": true
|
||||
},
|
||||
"chapters": { "1": "cleared", "2": "unlocked", "3": "locked" },
|
||||
"stats": { "kills": 12, "deaths": 1, "parries": 9, "playSec": 1500 }
|
||||
}
|
||||
```
|
||||
|
||||
- **保存时机**:场景切换后、战斗结算后、商店交易后、菜单选"保存并回大厅"。写 localStorage 并 PUT 服务器(未登录只写本地)。
|
||||
- **读取**:进游戏检测到存档 → 语音问"继续上次的旅程?单击继续,右滑开始新游戏"。新游戏清空 data 保留 stats 累计。
|
||||
- **版本**:`schema` 升版时开发 agent 写迁移或丢弃重来(R 轮内允许丢弃,文档标注即可)。
|
||||
|
||||
## 8. 战斗回合时序(实现契约)
|
||||
|
||||
```
|
||||
回合开始 → 若敌人存活:播敌人行动意图音(前摇 1.0s,期间双击=格挡判定)
|
||||
→ 前摇结束结算敌方攻击(应用格挡/防御减伤)→ 播受击/格挡音 + 震动(HIT / CONFIRM)
|
||||
→ 玩家指令阶段:左右滑切指令(播指令名)→ 单击执行 → 播攻击音 + 敌方受击音
|
||||
→ 敌方血量档位播报仅在上滑查询时播;半血/死亡自动播叙事短句
|
||||
→ 循环。玩家 HP≤0 → 战败旁白 → 章首重生(等级金币保留,hp 回满)
|
||||
```
|
||||
|
||||
- 敌方前摇音 = 敌人表"前摇音特征"列的 sfx(每敌人一个专属前摇音,这是"听声辨招"的核心资产,优先级高于环境音)。
|
||||
- 内劲技能"破空斩":atk*2,mp-3,mp 不足时指令播报"内劲不足"并跳过该选项。
|
||||
|
||||
## 9. 资产清单(规模核对)
|
||||
|
||||
| 类别 | 数量 | 备注 |
|
||||
|---|---|---|
|
||||
| bgm | 5 | 3 章环境底各 1 + 战斗 1 + Boss 1(Mureka,循环) |
|
||||
| sfx | ~35 | 8 敌人前摇 + 3 Boss 前摇 + 攻击/受击/格挡/技能/买药/开箱/升级 + 8 场景 amb 循环(或 amb 走 bgm 层) |
|
||||
| voice | ~120 句 ×2 音色 | 旁白 ~30、NPC ~40、教学 ~15、战斗播报 ~20、状态/菜单 ~15;全部整句、角色 speaker+instruct 常量化 |
|
||||
| 对标 | escape 现版约 40 句语音 + 15 sfx;《剑与声》约为其 3 倍,R4 备资产 + R5 开发一轮可行(场景/敌人/指令全部数据驱动,game.js 单文件预计 800-1000 行) |
|
||||
|
||||
## 10. 开发次序建议(R5 一轮内)
|
||||
|
||||
1. 数据表(场景图/敌人/物品/对话 id)→ 2. 探索循环 + 菜单 + 状态播报 → 3. 战斗回合机(含格挡窗口)→ 4. 商店/道具/升级 → 5. 存档读写 → 6. 第一章内容灌入 → 7. 第二、三章灌入 → 8. 教学关与打磨。资产(R4 已生成)缺失时用 console.error 静默跳过的引擎特性兜底,先通流程后补音。
|
||||
@@ -0,0 +1,45 @@
|
||||
# SoundGame 设计文档(5 轮迭代全记录)
|
||||
|
||||
本目录是纯音频游戏合集的设计规范与审查档案。开发 agent 开工前必读对应文档开头的
|
||||
"给开发者的实现要点摘要";全局规范(00)对任何游戏改动都生效。
|
||||
后续路线见 [roadmap.md](roadmap.md)。
|
||||
|
||||
## 设计规范(施工依据)
|
||||
|
||||
| 文档 | 内容 | 对应用户反馈 |
|
||||
|---|---|---|
|
||||
| [00-global-ux.md](00-global-ux.md) | 全局交互规范(终审修订版):设置系统、haptics 震动语言(10 震型)、旁白整句化、手势约定与模态菜单、用户系统 API | ⑥⑦ + 全局需求 |
|
||||
| [01-escape-redesign.md](01-escape-redesign.md) | 《暗室》重设计:引导式开场、三级卡关提示、线索音可重听 | ⑤ |
|
||||
| [02-simon-redesign.md](02-simon-redesign.md) | 《音序回响》重设计:去 BGM、旁白整句化、交互式教学、复活机制(含双击语义裁定) | ⑥⑦ |
|
||||
| [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 句台词表 + 增补(战斗震动配合、无声前摇协议、层叠规则) | ⑩ |
|
||||
|
||||
## 审查档案(各轮落实度与裁定记录)
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [round1-review.md](round1-review.md) | R1 基建审查:6 处出入裁定(haptics 命名、voice 平铺回退、`_settings` 保留键等) |
|
||||
| [round2-review.md](round2-review.md) | R2 暗室/音序审查:落实度 ~95%;裁决① simon 双击上下文判定、裁决② 全球模态菜单约定 |
|
||||
| [round3-4-review.md](round3-4-review.md) | R3 节拍/R4 盲航审查:4 项裁定(结算末位语音、menu_main 文案、space 难度不回调、震动总量批准)+ 附裁 A(玩法手势优先的菜单折中模式) |
|
||||
| [round5-notes.md](round5-notes.md) | 给 RPG 开发 agent 的返工注意级提示(看门狗/会话令牌/末位语境/震动去抖/层叠实现) |
|
||||
| [roadmap.md](roadmap.md) | 后续路线:RPG 二三章扩展、storyline 打磨(菜单统一/knowTrap 死标记/角色音色)、真机听测 checklist、商业化前置 |
|
||||
|
||||
## 5 轮迭代计划总表(已执行完毕)
|
||||
|
||||
| 轮次 | 主题 | 结果 |
|
||||
|---|---|---|
|
||||
| **R1** | 基建 + 设计 | 设计文档集;引擎 settings/haptics/voice 目录;大厅设置+登录;服务器 API;删 reflex |
|
||||
| **R2** | 暗室 + 音序重设计 | 两游戏重写落地,语音 142 id ×2 性别;审查 round2-review |
|
||||
| **R3** | 节拍重构 | 跟随领奏落地(3 曲目解锁制);审查 round3-4-review §1 |
|
||||
| **R4** | 盲航重构 + RPG 资产 | 盲航重写落地(演练/保护期/同向);RPG 台词表与资产清单交付;审查 round3-4-review §2 |
|
||||
| **R5** | RPG 开发 + 总打磨 | 进行中;开发提示 round5-notes;后续路线 roadmap |
|
||||
|
||||
横切约束(每轮验收都要查):
|
||||
|
||||
- 旁白:整句预生成、同角色 speaker+instruct 恒定、female/male 双目录、ogg+m4a 双格式(00 §3)。
|
||||
- 反馈:操作反馈一律震动(`engine/haptics.js` 10 预定义震型),iOS 静默降级不回退提示音(00 §2)。
|
||||
- 手势:默认映射 + 豁免表 + 模态菜单约定(00 §5),任何偏离必须在开场交互教学中讲清。
|
||||
- 教学:禁止"连续旁白讲完就开考";一切教学交互式,做错不惩罚、当场重教。
|
||||
- 设置:游戏内只读,改动只在大厅页(00 §1)。
|
||||
- 进度:data 自由格式 + 必带 `summary`,localStorage 兜底 + 服务器同步,`_settings` 为保留键(00 §4)。
|
||||
@@ -0,0 +1,114 @@
|
||||
# SoundGame 后续路线图(策划,最终轮)
|
||||
|
||||
范围:R5(RPG 收尾)之后的事。分四块:RPG 内容扩展、storyline 打磨、真机听测、商业化前置。
|
||||
设计基准文档索引见 [README.md](README.md);审查结论见 round1/round2/round3-4-review。
|
||||
|
||||
---
|
||||
|
||||
## 1. RPG《剑与声》第二/三章扩展计划
|
||||
|
||||
第一章模式已定型(场景图 JSON + 战斗状态机 + 台词表三件套),二三章是**内容灌入**而非新开发。
|
||||
|
||||
### 1.1 第二章《腐沼泽》(7 节点)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 场景 | 沼口村(商店:青钢剑 300/铁鳞甲 250)→ 腐叶径 → 浅滩(岔路:鳄巢宝箱)→ 瘴气林 → 蛊师祭坛(NPC 支线)→ 祭主殿门 → Boss 腐沼祭主 |
|
||||
| 新机制 | 毒状态(中毒后每回合 -3 HP,金疮药可解;中毒播报走整句分档);蛊师敌方先攻(B_START → ENEMY_TELEGRAPH,状态机已支持) |
|
||||
| 敌人 | 沼泽鳄(HP30 ATK11 DEF2)、蛊师(HP22 ATK12 DEF1,先攻)+ 一章怪复用降档 |
|
||||
| Boss | 腐沼祭主 HP70 ATK14 DEF3;半血二阶段召唤 2 毒蛛(连战队列插入,机制已有) |
|
||||
| 解锁 | 一章 Boss 胜后 `chapters.2 = open`;章首旁白承接 `evt_ch1_end` 字条 |
|
||||
|
||||
### 1.2 第三章《无瞳峰》(7 节点)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 场景 | 山道驿站(商店:听风剑 600,格挡窗口 1.0→1.3s)→ 风啸崖 → 断桥(岔路)→ 剑冢(机关战斗)→ 影廊(影卫首战,无声前摇教学)→ 峰顶 → Boss 白无瞳 |
|
||||
| 新机制 | 无声前摇(震动协议见 05-rpg-addendum §2,影卫铺垫 → 白无瞳三阶段完全无声);iOS 兜底=防御指令 |
|
||||
| 敌人 | 剑傀(HP38 ATK14 DEF3)、影卫(HP30 ATK16 DEF2,无声) |
|
||||
| Boss | 白无瞳 HP110 ATK18 DEF5,三阶段(≤50% / ≤25% 转换,三阶段进场播 `npc_boss3_warn`) |
|
||||
| 完结 | 终章结算用专属完结句(不复用"前往下一章",round5-notes §3 教训) |
|
||||
|
||||
### 1.3 排期与资产
|
||||
|
||||
- 台词表:ch2/ch3 各一份(沿用 05-rpg-ch1-voice-ids.md 格式,每章约 60-80 句;最小集见 05-rpg-addendum §5)。策划补发 → 音频总监录制(各 1 轮)。
|
||||
- BGM:复用现有 4 首 + 每章新增场景 amb(`amb_swamp*`、`amb_peak*`,05-rpg-chapter1 §7.2 已含)。
|
||||
- 建议节奏:内容扩展 A 轮(ch2 台词+灌入)→ B 轮(ch3 台词+灌入+终章完结句)→ 与真机听测并行。
|
||||
|
||||
## 2. storyline《声命线》打磨项(一直没重构,逐项跟进)
|
||||
|
||||
| # | 事项 | 现状 | 动作 |
|
||||
|---|---|---|---|
|
||||
| 1 | 菜单约定统一 | 现版 down=菜单(左滑重开/右滑单击返回/上滑帮助),是裁决②之前的私有约定 | 统一到 00 §5.2:下滑呼出 → 单击循环 → 双击确认 → 上滑帮助;菜单句 `voice/menu` 需按新口径重录(×2 性别) |
|
||||
| 2 | knowTrap 死标记修复 | `game.js:229` 只写不读:n09 右滑获得"北区陷阱情报"后无任何分支消费它 | 先读 `docs/games/storyline.md` 剧情树定接入点:在北区相关节点读取 knowTrap,解锁"绕开陷阱"分支或改变死亡结局判定;预计需补录 1-2 句分支台词(角色音:林晚 Serena / 旁白双性别)。若剧情树无合适节点,降级方案=删除该标记与台词里的伏笔句 |
|
||||
| 3 | 角色音色在双性别目录的处理 | R1 裁定:旁白双性别(male=Uncle_Fu/female=Vivian),角色台词单音色平铺靠引擎回退(round1-report §3),资产侧未复制 | 终案确认:**维持平铺回退**(引擎已支持,零成本);把"平铺回退是正式机制"写进 web-engine-api 的目录约定(开发顺手改)。试听确认点:female 旁白(Vivian)与小满/林晨/广播(同 Vivian 不同 instruct)的区分度 |
|
||||
| 4 | 提示音替换排查 | `sfx/ui_confirm`、`sfx/ui_menu` 仍在选择确认/菜单处使用 | 按反馈⑦替换:选择确认删 ui_confirm(引擎内置震动已覆盖),菜单打开删 ui_menu;剧情氛围 sfx(water/whistle/wind 等内容音)保留 |
|
||||
| 5 | 进度 summary | storyline 无存档(单局 5-7 分钟,一次性剧情) | 轻量方案:记录"已达成结局 X/4",summary="已见 2/4 结局";结局解锁存 localStorage+服务器,大厅卡片有内容可显示 |
|
||||
| 6 | 双性别 QC | 36 id ×2 已生成 | 抽听 female 版旁白与角色音的衔接(旁白切角色时音色跳跃是否出戏) |
|
||||
|
||||
## 3. 各游戏存量打磨小项(来自各轮审查遗留)
|
||||
|
||||
- rhythm:结算右滑末位修正 + 补录 `no_more_song`;震动 200ms 去抖(round3-4-review §3 裁定①④)。
|
||||
- escape:PLAY 中双击兜底链末尾加"再双击一次回大厅"二级入口(补录 `menu_hint` ×2 性别,round2-review §5.1)。
|
||||
- simon:`voice/wrong` 冗余资产清理或启用(round2-review §5.2)。
|
||||
- 大厅:设置变更试听句(00 §1.2,一直未做)。
|
||||
- 全合集:平铺旧 voice 文件清理(storyline/rhythm/space 的残留,音频侧执行)。
|
||||
|
||||
## 4. 真机听测 checklist(用户拿手机逐项打钩)
|
||||
|
||||
通用前置:安卓 Chrome + iOS Safari 各过一遍;戴有线耳机测一轮、蓝牙耳机测一轮(延迟差异);iOS 项注意"无震动"是预期行为。
|
||||
|
||||
### 声命线 storyline
|
||||
- [ ] 旁白切角色音时不出戏(重点:female 旁白 vs 小满/广播的 Vivian 家族)
|
||||
- [ ] 8 个抉择点左/右滑方向与选项播报一致
|
||||
- [ ] 4 结局至少打通 2 条,结局后单击重开正常
|
||||
- [ ] (打磨后)菜单单击循环/双击确认,无误触发重开
|
||||
|
||||
### 暗室 escape
|
||||
- [ ] 引导 G0-G7 全流程不卡(含故意做错 3 次触发直白重述)
|
||||
- [ ] 9 个房间听环境音能区分储物间/铁柜/电箱/大门(闭眼走一遍)
|
||||
- [ ] 卡关提示计时手感:60s/120s/180s 出现时机不烦人(真机挂一次 3 分钟)
|
||||
- [ ] 单击重听环境音:amb 与描述语音的 800ms 间隔自然
|
||||
- [ ] 用时 4 段播报与实际耗时匹配
|
||||
|
||||
### 音序回响 simon
|
||||
- [ ] 无 BGM 的静默不尴尬(开场 0.5s + 轮次间隙)
|
||||
- [ ] 播放序列的 beat 轻震与音色对齐(安卓机能感到节拍锚点)
|
||||
- [ ] 双击主动重听:期望输入是 doubletap 时按答题处理(裁定①场景实测)
|
||||
- [ ] 第 10 轮后的速度(420ms 下限)可跟
|
||||
- [ ] 成绩分档句与实际轮数匹配
|
||||
|
||||
### 节拍行者 rhythm
|
||||
- [ ] Perfect ±120ms 窗口:有线耳机能稳定 Perfect,蓝牙耳机 Good 为主(不挫败)
|
||||
- [ ] tap 补偿:底鼓音(1/3 拍)判定不冤
|
||||
- [ ] 下滑菜单折中:演奏中误开菜单 / 想开开不了 各试 5 次
|
||||
- [ ] 菜单暂停后 BGM 断点续播节拍不漂
|
||||
- [ ] 三首 BGM 与乐器音调性协和、响度平衡(乐器音不被 BGM 淹没)
|
||||
|
||||
### 盲航 space
|
||||
- [ ] 同向闪避直觉性:不测老玩家,找一个没玩过的人 30 秒内能否理解
|
||||
- [ ] 演练 D1-D4 每步"语音先、whoosh 后"不叠放
|
||||
- [ ] 保护期 60s 内被撞不掉血且归因句听清
|
||||
- [ ] 满级段(存活 90s+)密度:过密则把 spawn 下限 1.2→1.35(round3-4 裁定③旋钮)
|
||||
- [ ] 警报两档渐强 + danger 震动的时间感(撞击前 1s/0.5s)
|
||||
|
||||
### 剑与声 rpg(R5 完成后)
|
||||
- [ ] 格挡窗口:8 种敌人前摇音都能听出并双击成功(重点:头目重甲 vs 野狼低吼的区分)
|
||||
- [ ] 无声敌人(若 ch3 已做):danger+heartbeat 震动节奏可格挡;iOS 上确认防御指令兜底
|
||||
- [ ] 战斗 BGM 与场景 amb 切换不突兀;bgm=0 设置下 amb 照播
|
||||
- [ ] 商店购买/缺钱/已购播报语境正确
|
||||
- [ ] 存档继续:杀进程重进,summary 与落点正确
|
||||
|
||||
## 5. 商业化前要做的事
|
||||
|
||||
1. **Stable Audio 3 许可确认**:核对当前账号/模型的商用授权条款(训练数据与生成物的商用权利、署名要求);所有 SA3 生成 sfx 的资产清单(id → 生成日期 → prompt)落盘存档(`docs/audio/asset-manifest.md` 扩展)。
|
||||
2. **Mureka 曲目版权落盘**:确认 API 生成音乐的商用授权范围与归属条款;把 `tools/mureka_log.md` 整理为正式曲目登记表(曲目 → 生成时间 → 授权凭证/订单号);离线保存原始生成文件与凭证截图。
|
||||
3. **Qwen3-TTS 许可**:核对模型协议(Apache 2.0 系/通义许可)对生成语音商用的要求;在 About/致谢页列模型名。
|
||||
4. **字体许可**:封面生成用 NotoSansSC(OFL),保留 OFL 许可证副本,允许商用但需随附声明。
|
||||
5. **隐私政策**:用户系统当前是局域网玩具级(PIN 明文、token 不过期——web/server/README 已自声明)。公网化前必须:PIN 加盐哈希、token 过期机制、HTTPS、最小化收集声明、账号删除入口;写隐私政策页(收集:用户名+游戏进度;不收集:设备信息/位置)。
|
||||
6. **付费墙设计预留**(如需):进度 API 加 `entitlements` 字段的位置先留好;不要在现有 data 自由格式里埋付费标记。
|
||||
7. **合规扫尾**:无年龄分级素材问题(武侠战斗无血腥音效——atk_hit prompt 已注明 not gory,保持);商店/内购若上 iOS 需走 IAP 不能用网页支付(届时再议)。
|
||||
|
||||
## 6. 建议顺序
|
||||
|
||||
1. R5 收尾(RPG 第一章上线)→ 2. §3 存量打磨小项(半天级)→ 3. 真机听测 §4(用户执行,反馈回来后定参数旋钮)→ 4. storyline §2(与听测并行)→ 5. RPG ch2/ch3 §1 → 6. 商业化 §5(决定公网化时再启动)。
|
||||
@@ -0,0 +1,68 @@
|
||||
# R1 落地一致性审查报告(策划,R2 轮)
|
||||
|
||||
审查对象:`docs/web-engine-api.md`(R1 变更)、`docs/audio/round1-report.md`、
|
||||
`web/index.html`(大厅新 UI)、`web/engine/{settings,haptics,audio}.js`、`web/server/server.js`。
|
||||
对照基准:`docs/design/00-global-ux.md`(设计规范初稿)。
|
||||
|
||||
**总体结论:R1 落地质量高,核心语义全部符合设计。发现 6 处出入,均已裁定"以谁为准";
|
||||
00-global-ux.md 已按本报告完成修订(R2 修订版),后续各轮以修订版 00 文档为唯一基准。**
|
||||
|
||||
---
|
||||
|
||||
## 出入清单与裁定
|
||||
|
||||
### 1. 震动模块命名与震型表 —— 以实现为准
|
||||
|
||||
- 设计:`engine/vibrate.js`,常量 `VIB.CONFIRM/ERROR/...`(大写),9 种震型。
|
||||
- 实现:`engine/haptics.js`,`HAPTIC_PATTERNS` 小写命名(`confirm/error/success/warning/swipe/heartbeat`),6 种预定义 + 自定义数组调用;数值微调(error=[60,40,60] vs 设计 [40,60,40];warning=[30,50,30,50,30] vs [30,40,...])。
|
||||
- **裁定:以实现为准。** 数值差异在感知阈内。设计侧 PICKUP/HIT/BEAT/DANGER 四种语义震型引擎未预定义——**R3 起游戏层先用自定义数组([15,40,15]/[80]/[8]/[20,50,30,50,40]),建议引擎 R3 补为预定义**(请开发排期)。
|
||||
- 00 文档 §2 已修订。
|
||||
|
||||
### 2. gesture.js 内置基础震动 —— 以实现为准,设计规范随之修订
|
||||
|
||||
- 实现:滑动识别成功自动 `swipe`、tap/doubletap 自动 `confirm`(web-engine-api.md:222-224 已写明"游戏层不要重复调用,否则双重震动")。
|
||||
- 设计初稿要求游戏层对"滑动生效/确认"调 SWIPE_OK/CONFIRM——按实现会导致双重震动。
|
||||
- **裁定:以实现为准。** 震动分工:手势识别反馈=引擎内置;语义事件(error/success/warning/PICKUP/HIT/BEAT/DANGER)=游戏层。00 文档 §2.3 已修订,01/02/04/05 各篇中"单击确认给 CONFIRM 震动""滑动给 SWIPE_OK"的表述按此映射理解,不逐一改文。
|
||||
|
||||
### 3. voice 资产回退逻辑 —— 以实现为准(比设计更优)
|
||||
|
||||
- 设计:`voice/<gender>/` 缺失时回退另一性别。
|
||||
- 实现:`voice/<gender>/` → 回退**平铺** `voice/<id>`(web-engine-api.md:145-148),不做性别回退。
|
||||
- **裁定:以实现为准。** 音频总监已保证两性别 id 集合一致(round1-report §1),性别回退无必要;平铺回退恰好解决 storyline 角色台词(单音色)的加载问题,设计更简洁。00 文档 §1.1 已修订。
|
||||
- 附带确认:voice 缓存键带性别前缀(audio.js:239),运行时切换性别不会串缓存——处理正确。
|
||||
|
||||
### 4. 设置值格式与服务器同步形式 —— 以实现为准
|
||||
|
||||
- 设计:speed/bgm 字符串枚举("normal"/"fast"/"veryfast");独立 `/api/settings` 端点。
|
||||
- 实现:数值(1.0/1.35/1.7、1.0/0.3/0),`settings.js` 非法值逐项丢弃;设置走进度接口保留键 **`game='_settings'`**(index.html:288)。
|
||||
- **裁定:以实现为准。** `_settings` 为全局保留键,任何游戏存档不得占用(00 §4.3 已写明)。数值格式更利于引擎直接乘算。
|
||||
- speed 生效时机为"下一句起"(audio.js:343,创建 source 时读值)——可接受,文档已注明;正在播放的长句不变速不算问题(句子均 ≤25 字短句)。
|
||||
|
||||
### 5. 进度 API 形态与存档信封 —— 以实现为准
|
||||
|
||||
- 设计:`GET/PUT /api/progress/<game>` 路径段 + 统一信封 `{schema, game, updatedAt, data}`;token 存 `soundgame.token`。
|
||||
- 实现:`GET /api/progress?game=xxx`、`PUT {game, data}`;data 自由格式(服务器不校验);token 存 `soundgame.auth`(含 username);大厅卡片约定读 **`data.summary`** 字符串。
|
||||
- **裁定:以实现为准。** 游戏侧 `data` 内仍建议自放 `schema`/`updatedAt` 字段(便于迁移),`summary` 为**必须**(RPG 存档结构已按此定义,05-rpg-chapter1.md §6)。00 文档 §4 已修订。
|
||||
|
||||
### 6. 各游戏默认音色性别 —— 以实现为准(全局统一默认 female)
|
||||
|
||||
- 音频总监建议(round1-report §5.3):按游戏默认原音色性别(storyline/escape=male,其余=female)。
|
||||
- 实现:全局单一设置项,默认 female,不分游戏。
|
||||
- **裁定:以实现为准。** 全局单项更符合"设置系统"的用户心智(用户选一次全合集生效);各游戏原音色差异已由双性别资产覆盖,用户可自切。此条无需改动,仅记录在案。
|
||||
|
||||
## 需要转达的事项
|
||||
|
||||
1. **给引擎开发**:R3 请在 `haptics.js` 补 4 个预定义震型:`pickup=[15,40,15]`、`hit=[80]`、`beat=[8]`、`danger=[20,50,30,50,40]`(R3 节奏游戏与 R5 RPG 都要用;未补前游戏层传自定义数组,不阻塞)。
|
||||
2. **给音频总监**:
|
||||
- R3 录制依据 `docs/design/03-rhythm-voice-ids.md`(31 句 ×2 性别,rhythm 旁白 female=Vivian/male=Uncle_Fu 沿用 R1 分配;grade_*/restart 为重录覆盖)。
|
||||
- R4 录制依据 `docs/design/05-rpg-ch1-voice-ids.md`(旁白 104×2 + NPC 16 平铺单音色;黑风寨主与村长同为 Uncle_Fu 不同 instruct,请优先试听这两条确认区分度,若混淆寨主改 Dylan 阴狠 instruct)。
|
||||
- Mureka/SA3 需求:`docs/design/05-rpg-chapter1.md` §7(5 BGM + 35 sfx,含英文 prompt 草稿)与 03-rhythm-rebuild.md §8.4(3 首 BGM 重做 + 7 个乐器音)。
|
||||
3. **给大厅/UI 开发**:设置变更后的试听句(00 §1.2)列为 R3 打磨项,非阻塞。
|
||||
4. **资产清理**:平铺旧 voice 文件待 R2 各游戏切换验证完成后由音频侧清理(round1-report §5.4 已排)。
|
||||
|
||||
## 无出入确认项(抽查通过)
|
||||
|
||||
- 音频解锁流程、三层架构、onComplete 打断语义未变,设计文档依赖的契约完好。
|
||||
- bgm=0 时 `_playBgm` 短路不加载——与设计"不是静音播放"一致。
|
||||
- simon BGM 资产已删除(round1-report §5.2),与 02 文档一致;game.js 的 bgm 引用移除属 R2 开发任务。
|
||||
- 服务器安全声明(局域网玩具级、PIN 明文)已在 web/server/README.md 显著标注,符合家庭内网定位,设计无异议。
|
||||
@@ -0,0 +1,89 @@
|
||||
# R2 落地一致性审查报告(策划,R3 轮)
|
||||
|
||||
审查对象:`web/escape/game.js`、`web/simon/game.js`(R2 重写版)、`docs/audio/round2-voice-ids.md`、`docs/audio/round2-report.md`、`web/engine/haptics.js`(R2 增补)。
|
||||
对照基准:`docs/design/01-escape-redesign.md`、`02-simon-redesign.md`、`00-global-ux.md`(R2 修订版)。
|
||||
|
||||
**总体结论:两款游戏落实度约 95%,核心设计(引导式开场、三级提示、可重听环境音 / 去 BGM、整句化、交互教学、复活机制)全部落地且工程质量高于设计预期(语音链看门狗、会话令牌、进度 summary 同步)。发现 5 处偏差,全部裁定;两个开发疑问的裁决见 §3。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 《暗室》落实度核查(对照 01 文档)
|
||||
|
||||
| 设计条目 | 落实情况 | 结论 |
|
||||
|---|---|---|
|
||||
| G0-G7 引导式脚本(带路到储物间、教移动/撞墙/检查/双击) | 完整实现(g0-g7 + `_advance` 步骤机 + 20s 看门狗防卡死) | ✅ |
|
||||
| 引导期做错纠正(错 3 次直白重述 + warning 震动)、下滑跳过 | 完整实现(guide_wrong_*/guide_blunt_*/guide_offpath/guide_skip) | ✅ |
|
||||
| 二周目开场询问(上滑重听引导/单击直接开始) | 实现(REOPEN + reopen_prompt + guideDone localStorage) | ✅ |
|
||||
| 卡关三级提示 60/120/180s、L3 每 60s 重复、按坐标算路 nav_* | 完整实现(5s 轮询、阶段×级别 12 句 + stuck_ask 前置 + nav 4 句) | ✅ |
|
||||
| 环境音可重听(单击=重播环境音+描述)、每次进房都播 | 实现(`_inspect` 先播 amb、800ms 后播描述;`_playAmbient` 每次进房播) | ✅ |
|
||||
| 环境音 3→6 种 + 大门通电切 amb_gate_live | 实现(KEY_ROOMS 4 专属 + 2 空房轮换 + gate_live) | ✅ |
|
||||
| 撞墙每次播 wall(取消一次性限制) | 实现(含引导 G2) | ✅ |
|
||||
| 用时 4 段整句替代数字拼接 | 实现(time_band1..4,区间与语音清单一致) | ✅ |
|
||||
| ui_deny 退役改 error 震动 | 实现 | ✅ |
|
||||
| 双击兜底:背包 + L2 级明示 | **偏差 1**:关键房间内播 `use_fail` 替代背包播报(非关键房间仍播背包 inv_* + stuck_*_2) | 以实现为准(更合语境),设计文档视为已修订 |
|
||||
| 菜单并入双击兜底链(00 豁免表原表述) | **偏差 2**:PLAY 阶段无菜单入口;菜单仅在 ENDING 下滑呼出 | 以实现为准(PLAY 中帮助需求已被双击兜底 + 自动卡关提示覆盖),00 豁免表已修订 |
|
||||
| G2"滑两下碰碰墙(撞墙也计数)" | **偏差 3**:引导期位置固定,两次滑动必然撞墙 | 以实现为准(教学目标就是撞墙反馈,更可靠) |
|
||||
|
||||
另确认:震动语义正确(撞墙 error、拾取 pickup、机关 success、提示 warning;gesture.js 内置识别反馈未重复调用);进度存档带 summary 同步服务器(超出设计要求,符合 00 §4.3)。
|
||||
|
||||
## 2. 《音序回响》落实度核查(对照 02 文档)
|
||||
|
||||
| 设计条目 | 落实情况 | 结论 |
|
||||
|---|---|---|
|
||||
| 去 BGM(不加载不播放) | 实现(prewarm 无 bgm,开场静默 0.5s 进教学) | ✅ |
|
||||
| 轮次整句 round_01..20 + round_more 兜底 | 实现 | ✅ |
|
||||
| 成绩 6 段整句(0/≤3/≤6/≤9/10-14/≥15) | 实现(score_band1..6 区间一致) | ✅ |
|
||||
| 交互式教学 T0-T7(教一个练一个、错 3 次直白版、下滑跳过) | 完整实现(t_wrong_*/t_blunt_* 六手势各一 + t_try 等待 + 跳过兜底) | ✅ |
|
||||
| 播放序列 BEAT 节拍锚点 + 播完 400ms confirm "轮到你了" | 实现(第 1 轮同播 your_turn,之后仅震动) | ✅ |
|
||||
| 复活机制:初始 1 次、每 4 轮 +1 上限 2、答错自动消耗重播同一序列、双击主动重听 | 完整实现(chances_out/retry_use/relisten/chance_plus 语音齐全) | ✅ |
|
||||
| 速度曲线 max(420, 700-(r-1)*25) | 实现 | ✅ |
|
||||
| 序列约束(第 1 轮两音不同 / 前 5 轮禁三连 / 全局禁四连) | 实现(`_violates`,20 次重试抽样) | ✅ |
|
||||
| 答对每 4 轮才播"答对了",其余仅 success 震动 | 实现 | ✅ |
|
||||
| 结算:error+hit 震动、sfx/gameover 保留、best 持久化+服务器 summary | 实现 | ✅ |
|
||||
| 播放/播报阶段手势静默忽略 | 实现(phase 守卫 default 分支) | ✅ |
|
||||
| 双击语义(输入阶段=主动重听) | **偏差 4**:与序列含 doubletap 冲突,开发按上下文判定(见 §3 裁决①) | 裁决采纳 |
|
||||
|
||||
另确认:`wrong` 语音在语音清单中标注"可选",实现未引用(retry_use 已含"错了")——不算缺失,清单口径一致。
|
||||
|
||||
## 3. 两个开发疑问的裁决
|
||||
|
||||
### 裁决①:simon 双击语义冲突 —— **采纳实现的上下文判定**
|
||||
|
||||
实现的规则(game.js:294-297):输入阶段,若当前期望输入恰为 doubletap,双击=答题;否则双击=主动重听(耗机会;机会为 0 时仅提示 chances_out,不判错)。
|
||||
|
||||
裁决理由:
|
||||
|
||||
1. **序列必须永远可答**。任何让"双击=重听"优先的方案都会使含 doubletap 的序列无法复现,破坏玩法完整性——答题优先是唯一可接受的解释。
|
||||
2. **边缘情况不自锁**。玩家想重听而当前期望恰是 doubletap 时:双击被当答题——答对则推进(玩家本来就会背到这一位,损失为零);答错则自动消耗机会重听,殊途同归。不存在"想重听却永远做不到"的死锁。
|
||||
3. **宽容性正确**:机会为 0 时双击非期望手势不判答错、只提示——避免了"想重听却被判错直接结算"的最坏体验。
|
||||
|
||||
补充规定(写入 02 文档):重听播报期间 phase 切 roundend 暂停接收输入(实现已做),防止重听语音播完前的手势被吞。
|
||||
|
||||
### 裁决②:菜单手势 —— **采纳进 00-global-ux.md,作为全球菜单约定(RPG 豁免)**
|
||||
|
||||
开发自拟方案:菜单内 **单击=循环切换选项(播选项名)/ 双击=确认执行 / 上滑=帮助**,下滑或"返回"项关闭。
|
||||
|
||||
裁决:**采纳**。理由:
|
||||
|
||||
1. 解决了全局约定"单击=确认"与结算页"单击=重开"的冲突——结算页单击已被占用,菜单内单击若仍是确认,玩家想重开时会误触发菜单项。单击循环 + 双击确认把"浏览"与"执行"分离,误操作成本归零。
|
||||
2. 两款游戏已一致落地,交互范式统一(菜单项 3 个:重新开始/回大厅/返回游戏,菜单打开时先播 menu_open + 第一项)。
|
||||
3. 单击循环有 ~300ms 引擎延迟,但菜单非即时场景,可接受;双击确认即时,关键的"执行"动作不拖。
|
||||
|
||||
配套修订(00 文档 §5.1 已更新):
|
||||
|
||||
- 新全球菜单约定:**下滑呼出(模态)→ 单击循环切换(播选项名)→ 双击确认执行 → 上滑=帮助/规则 → 下滑或"返回游戏"项关闭**。菜单打开时暂停计时类玩法。
|
||||
- **RPG《剑与声》豁免**:探索态菜单保持"左右滑切换 + 单击确认"——RPG 的探索本身就是这个范式(选项列表即游戏本体),菜单与探索同构反而少学一套;战斗态不设菜单。
|
||||
- **storyline**:R3 打磨轮统一到本约定(现状未审,列入 R3 检查项)。
|
||||
- **rhythm / space**:R3/R4 施工直接按新约定(03/04 文档中"左右滑切曲目/菜单"的表述以此为准——选曲界面不是模态菜单,仍用左右滑+单击;模态菜单用新约定)。
|
||||
|
||||
## 4. 偏差 5:haptics.js 震型增补(引擎侧,R2 已做)
|
||||
|
||||
- 引擎已补 `pickup=[15]`、`hit=[80]`、`beat=[8]`、`danger=[25,40,25,40,25]`——正是 round1-review 转达事项①,但 pickup/danger 数值与设计不同(设计 [15,40,15] / [20,50,30,50,40])。
|
||||
- **裁定:以实现为准**(pickup 单震更轻、不与 error 混淆;danger 节奏更密更接近"逼近"感)。00 文档 §2.2 已更新为实现的 10 种震型全表。
|
||||
|
||||
## 5. 遗留问题(转达开发/音频)
|
||||
|
||||
1. **escape PLAY 阶段无法回大厅**:结算前想退出只能刷新页面。建议 R3 打磨:PLAY 中双击兜底链末尾追加"要回大厅?再双击一次"二级入口(语音 id `menu_hint` 需补录 1 句 ×2 性别)。非阻塞。
|
||||
2. **simon `voice/wrong` 已生成但代码未引用**:保留无妨(资产冗余 1 句),或 R3 清理清单标注。
|
||||
3. **storyline 菜单约定统一**:列入 R3 检查项(裁决②配套)。
|
||||
4. 两游戏真机听测仍未做(沿用 R1 遗留):escape 提示计时阈值(60/120/180s)与 simon 教学节奏需真机验证手感。
|
||||
@@ -0,0 +1,97 @@
|
||||
# R3/R4 落地一致性审查报告(策划,最终轮)
|
||||
|
||||
审查对象:`web/rhythm/game.js`(R3 重构)、`web/space/game.js`(R4 重构)。
|
||||
对照基准:`docs/design/03-rhythm-rebuild.md`(+03-rhythm-voice-ids.md)、`docs/design/04-space-rebuild.md`(+04-space-voice-ids.md)、`00-global-ux.md`。
|
||||
|
||||
**总体结论:两款游戏落实度均约 95%+。谱面三首逐音直转零偏差;盲航的演练/保护期/分批引入全部落地且健壮性超设计(`_rearmRunTimers` 断点恢复)。开发上报的 4 个问题裁定见 §3——无推翻性意见,仅 1 处需要一行代码修正(问题①)。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 《节拍行者》落实度核查(对照 03 文档)
|
||||
|
||||
| 设计条目 | 落实 | 结论 |
|
||||
|---|---|---|
|
||||
| 谱面三首(90/105/120 BPM,逐乐句拍点) | 逐音核对一致(含 restAfter 休止:曲一前 4 句、曲二前 2 句、曲三不休) | ✅ 零偏差 |
|
||||
| 判定契约 PERFECT 0.12 / GOOD 0.25 / TAP_DELAY 0.30 / LOOKAHEAD 0.6 / 泵 40ms | 常量一致,Miss 放宽逻辑保留 | ✅ |
|
||||
| tap 限 1/3 拍、doubletap 曲三前不上反拍 | 谱面核对无违规 | ✅ |
|
||||
| 教学 T1-T3(做错纠正分"手势错/早/晚/没出手"、2 次失败跳过) | 实现且纠正优先级正确(手势错 > 过早 > 过晚 > 未出手) | ✅ |
|
||||
| 首次 welcome+BGM 第 6s 起;二周目 welcome_back → 选曲 | 实现(tutorialDone 分支) | ✅ |
|
||||
| 选曲界面(非模态:左右滑+单击,未解锁播 song_locked) | 实现 | ✅ |
|
||||
| 计分/评级 S≥0.95 A≥0.85 B≥0.70、解锁规则 B 级解锁下一首 | 实现 | ✅ |
|
||||
| Miss=不播乐器音(错过=没出声)+ error 震动;Perfect 叠 accent | 实现 | ✅ |
|
||||
| mid_song 过半播报 | 实现(`_timed` 定时动作) | ✅ |
|
||||
| 模态菜单(裁决②约定)+ 计时玩法暂停 | 实现且超出设计:BGM 断点续播 + 全部绝对时刻平移暂停时长,节拍网格不漂 | ✅ 超设计 |
|
||||
| **下滑=菜单 vs 下滑=低音 tom 冲突** | **开发自拟折中**:仅当无悬置/临近(1s 内)的 down 音符时下滑才呼出菜单(`_downNotePending`) | **偏差 1:采纳**(见 §3 附裁 A) |
|
||||
| 结算单击=再来一遍/右滑=下一首/下滑=回大厅 | 实现,但曲目三右滑播 song_locked 语义错位 | **问题①,见 §3 裁定①** |
|
||||
| 进度 data 带 summary 同步服务器 | 实现(格式"曲目2 评级A / 已解锁 2/3 首",与设计"《追风》A级 · 已解锁2/3首"措辞略异) | 以实现为准(summary 自由文本) |
|
||||
| 乐器音 6 + accent 1、BGM 3 首(track90/105/120) | 资产 id 与实现一致 | ✅ |
|
||||
|
||||
## 2. 《盲航》落实度核查(对照 04 文档)
|
||||
|
||||
| 设计条目 | 落实 | 结论 |
|
||||
|---|---|---|
|
||||
| 同向闪避(`s.dir === ob.dir` 判定,窗口/宽限结构不变) | 实现 | ✅ |
|
||||
| D0-D5 演练:语音先播完再 spawn(不叠放)、做错当场重教、2 次失败 moveon、下滑跳过 | 实现(跳过仅限无演练陨石悬置时,防误触) | ✅ 超设计 |
|
||||
| 二周目 second_run_ask(上滑重听/单击出发) | 实现 | ✅ |
|
||||
| 保护期 60s:逼近 4.0s/刷怪 4.0s 恒定、撞击不掉血(训练护盾+protect_<dir> 归因)、protect_end 宣告 | 实现(含菜单/分批引入打断后 `_rearmRunTimers` 补播,过期立即补播) | ✅ 超设计 |
|
||||
| 单击/双击 20s/45s 分批引入(暂停刷怪、8s 不等待) | 实现(feature 阶段完整,引入前对应手势锁定不可用) | ✅ |
|
||||
| 死亡即教学:hit_<dir> + 第 2 次 hit_last | 实现 | ✅ |
|
||||
| 里程碑 10/25/50/100(ms_* 整句) | 实现(confirm 震动,设计未指定震型,合理) | ✅ |
|
||||
| 结算 time_/dodge_ 各 4 档 + new_record + restart_hint | 实现(区间与设计一致:<30/30-60/60-120/≥120;<5/5-14/15-49/≥50) | ✅ |
|
||||
| 菜单新约定 + paused 播报 + menu_help 项 | 实现 | ✅ |
|
||||
| **下滑=闪避 vs 下滑=菜单冲突** | **开发自拟折中**:有下方陨石悬置时下滑永远是闪避,否则呼出菜单 | **偏差 2:采纳**(同附裁 A) |
|
||||
| 警报两档渐强 + danger 震动同步 | 实现 | ✅ |
|
||||
| 护盾:格挡 success 震动、shield_ready 充能播报 + pickup | 实现 | ✅ |
|
||||
| 难度参数(保护期后) | **实现 3.2→1.2s 刷怪 / 2.8→1.8s 逼近,比设计 3.2→1.4 / 3.2→2.0 更陡** | **问题③,见 §3 裁定③** |
|
||||
| 进度 summary 同步 | 实现("最长存活 M:SS / 躲避 N 颗") | ✅ |
|
||||
|
||||
## 3. 开发上报问题的裁定
|
||||
|
||||
### 裁定①:rhythm 结算页曲目三右滑播 song_locked —— **确认是语义错位,一行修正 + 低优先补录**
|
||||
|
||||
- 设计(03 §8.7):结算"右滑=下一首(若已解锁)"。
|
||||
- 实现(game.js:429-431):曲目三已是最后一首,`songIdx+1 < unlocked` 恒 false → 播 `song_locked`("这首还没解锁。上一首拿到 B 级以上就能解锁。")——玩家会误以为存在曲目四,**语义错误**。
|
||||
- **裁定(定论)**:曲目三右滑**不得**播 `song_locked`。修正方案(一行代码,零新资产):曲目三右滑时改播 `restart`("单击再来一遍,右滑下一首,下滑回大厅"——虽提到"右滑下一首"仍不完美,但不误导)。**同时**把 `no_more_song`("这已经是最后一首了。单击再来一遍,下滑回大厅。"×2 性别)列入打磨补录清单,录好后替换临时方案。同类问题通查:所有"下一段/下一首"导航语音都要做末位语境检查(教训写入 round5-notes)。
|
||||
|
||||
### 裁定②:space `menu_main` 重录文案"单击切换,双击确认" —— **确认 OK,以重录版为准**
|
||||
|
||||
- 我的 04-space-voice-ids.md 原文案是"菜单。左右滑选择,单击确认。"——那是 round2 裁决②**之前**的旧菜单约定,是设计文档过时,不是音频错。
|
||||
- 重录版与全球菜单约定(00 §5.2)一致,**批准**。已对 03/04 两份台词表做勘误注记。rhythm 的 `menu_main` 同理以重录版为准。
|
||||
- 附带要求:后续所有台词表在"菜单/系统"组的文案以 00 §5.2 最新约定为唯一来源(RPG ch1 台词表已核对:sys_menu "菜单。左右滑选择,单击确认。"——**RPG 属豁免游戏(探索态菜单=左右滑+单击),该句正确,无需改**。)
|
||||
|
||||
### 裁定③:space 难度 3.2→1.2s(比设计 1.4s 陡)—— **定论:不回调,以实现为准**
|
||||
|
||||
- 设计(04 §3.2):保护期后刷怪 3.2→1.4s、逼近 3.2→2.0s(我在旧版基础上各抬 0.4s/0.2s)。
|
||||
- 实现:沿用旧版 3.2→1.2s / 2.8→1.8s,更陡。
|
||||
- **定论:接受实现,不回调。** 理由:
|
||||
1. 反馈⑨"没搞懂就死"的病灶在前 60 秒,已由演练+保护期+同向闪避三道防线解决;满级难度影响的是 90 秒后的高手区,不是新手区。
|
||||
2. 满级 1.2s/1.8s 是"传奇段"的挑战上限,老玩家留存需要它;120s 爬升过程本身是平缓的。
|
||||
3. 真机听测兜底:把"满级段(存活 90s+)密度手感"列入真机 checklist(roadmap §4);若普遍反映过密,把 spawn 下限 1.2→1.35s 作为唯一旋钮(approach 不动)。
|
||||
- 04 文档参数表以此裁定为最终值(文档不再改,本审查报告即修订记录)。
|
||||
|
||||
### 裁定④:rhythm 震动总量 —— **批准,不算过多;两条收紧项**
|
||||
|
||||
对照 00 §2 逐条审:
|
||||
|
||||
| 震动点 | 合规性 |
|
||||
|---|---|
|
||||
| 领奏每音 `beat`(8ms) | ✅ 00 §2.4.2 明确豁免:BEAT 是节拍信息通道,不是反馈。曲三最快处间隔 250ms(120BPM 八分),不违反 200ms 去抖规则 |
|
||||
| 玩家出手(gesture.js 内置 swipe/confirm) | ✅ 游戏层未重复调用 |
|
||||
| Miss `error` | ✅ 语义正确(错过=没出声时的唯一反馈) |
|
||||
| 16 连击 `success` / 全 Perfect 乐句 [20,40,20] | ✅ 语义正确,频率低 |
|
||||
| 结算 S/A `success`、解锁 success+[20,40,20] | ✅ 在语音链开始前触发,不违反"长对白播放中不震动" |
|
||||
|
||||
**收紧项(打磨级,非阻塞)**:
|
||||
1. 全 Perfect 乐句 [20,40,20] 与 16 连击 success 同刻触发时会双震——按 00 §2.4.3 的 200ms 去抖规则应只震一次,建议游戏层补一个最小去抖(记录上次震动时刻)。
|
||||
2. 解锁处 success + [20,40,20] 连震两次同理合并为 [20,40,20] 一次。
|
||||
|
||||
## 4. 附裁(开发自拟折中的追认)
|
||||
|
||||
**附裁 A:玩法手势与"下滑=菜单"冲突的折中模式 —— 采纳为全球惯例。** rhythm(无临近 down 音符才开菜单)与 space(无下方陨石才开菜单)各自实现了同一思想:**玩法手势优先,菜单让位到"当前语境下该手势无玩法含义"时**。这符合 00 §5 豁免精神且比"固定豁免"更顺滑。写入 00 文档精神(不另改文):后续游戏遇同类冲突按此模式处理;RPG 战斗态"下滑=逃跑"本身就是语境判定,天然兼容。
|
||||
|
||||
## 5. 遗留事项汇总(转达)
|
||||
|
||||
1. rhythm 结算右滑末位修正(裁定①,一行 + 补录 `no_more_song`)。
|
||||
2. rhythm 震动去抖两处(裁定④收紧项)。
|
||||
3. 03/04 台词表 menu_main 勘误已落(本论附带完成)。
|
||||
4. 真机听测:rhythm 的 Perfect 窗口 ±120ms 蓝牙耳机实测、space 满级段密度(裁定③旋钮)、两者 BGM/sfx 响度平衡——进 roadmap §4 checklist。
|
||||
@@ -0,0 +1,47 @@
|
||||
# R5 开发提示(给《剑与声》开发 agent · 返工注意级)
|
||||
|
||||
> 定位:RPG 开发已在并行进行。以下**不是推翻性意见**,是 R3/R4 审查中发现的共性问题在 RPG 语境下的对应检查点——如果你的实现已经正确处理,忽略即可;如果需要返工,按对应条目修。设计依据:05-rpg.md / 05-rpg-chapter1.md / 05-rpg-ch1-voice-ids.md / 05-rpg-addendum.md / 00-global-ux.md。
|
||||
|
||||
## 1. 已验证可照抄的健壮性模式(escape/simon/rhythm/space 四款一致落地)
|
||||
|
||||
1. **语音链看门狗**:所有"语音播完才推进流程"的地方都要双通道(onComplete + 超时兜底,二者先到先触发、只触发一次),参考 simon `_speakGuarded`。RPG 的场景切换、战斗回合开场、商店对话都依赖语音推进,缺看门狗会在资产缺失/解码失败时卡死整局。
|
||||
2. **会话令牌**:重开/跳场景时 `session++` 使旧计时器与语音链回调失效。RPG 状态多(探索/战斗/商店/菜单),比前四款更需要。
|
||||
3. **打断即作废**:状态切换入口统一 `stopVoice()`,不要依赖逐条清理。
|
||||
|
||||
## 2. 菜单与手势冲突(round2 裁决② + round3-4 附裁 A)
|
||||
|
||||
- RPG 是**菜单豁免游戏**:探索态菜单=左右滑切换+单击确认(00 §5.2),战斗态下滑=逃跑不设菜单。不要套用 escape/simon 的"单击循环+双击确认"。
|
||||
- ch1 台词表 `sys_menu` 文案("菜单。左右滑选择,单击确认。")与豁免口径一致,**不用改**。
|
||||
- 若你在菜单里加了"保存并回大厅"项:保存成功的反馈=`sys_saved` 语音 + `pickup` 震动,不要用提示音。
|
||||
|
||||
## 3. 导航/播报语音的末位语境检查(rhythm 裁定①的教训)
|
||||
|
||||
rhythm 在"最后一首"右滑播了"还没解锁"造成语义错位。RPG 同类风险点:
|
||||
|
||||
- **章末**:第三章打完 Boss 后播"下一章"类语音会错位——终章结算需单独的完结句(ch3 台词表补发时会含 `evt_ch3_end`,若你先行实现,先用 `btl_win` + `evt_ch1_end` 模式临时顶替,勿复用"前往下一章"句)。
|
||||
- **场景图边界**:village 的 `back`、boss1 无选项等死端,播报选项时不要念出"返回"之外不存在的去向。
|
||||
- **商店已购/缺钱的播报**已在台词表(shop_owned/shop_poor),按语境选句,别统一播 shop_poor。
|
||||
|
||||
## 4. 震动落点(按 05-rpg-addendum §1 总表 + 两条新收紧)
|
||||
|
||||
- 前摇窗口开启震 `beat`(有声敌人)/ `danger`+`heartbeat`(无声敌人)的通道分工**不要混用**:danger 专属于无声威胁。
|
||||
- 新增收紧(round3-4 裁定④同款):200ms 去抖——格挡成功的 `confirm` 若与 gesture.js 内置双击 confirm 同刻,只震一次;升级 success 与战斗胜利播报前震动同理。
|
||||
- iOS 无震动:无声敌人不可格挡是已知设计代价(addendum §2.2),**防御指令必须可用且有效**(减伤 50% 不依赖时机),这是 iOS 兜底,不要在任何分支里把防御禁用。
|
||||
|
||||
## 5. 音频层叠(addendum §3 的实现要求)
|
||||
|
||||
- 场景 amb_* **不走 bgm 层**(会被 bgm=0 设置误杀,且与战斗 BGM 冲突):用 `audio._load` + 手动 BufferSource loop 接 `_sfxBus`,rhythm `_startBgm`/`_scheduleSfx` 的手动总线接法可直接参考。
|
||||
- 战斗 BGM(bgm/battle、bgm/boss)走正常 bgm 层,受 bgm 设置控制;进出战斗 crossfade 切换即可。
|
||||
- 注意 decodeAudioData 内存:amb 循环 6-10s、BGM 60-90s,全章节资产控制在 ~30MB 压缩内(web-engine-api §移动浏览器的坑 7)。
|
||||
|
||||
## 6. 存档(05-rpg-chapter1.md §6 + 审查共性)
|
||||
|
||||
- `summary` 必须随每次保存更新(大厅卡片直接显示);格式"第X章·场景名 · LvN · 金币M"。
|
||||
- 保存时机四件套:场景切换/战斗结算/商店交易/菜单保存——**战败重生也算战斗结算**,要存(否则死亡丢进度,比死亡本身更劝退)。
|
||||
- localStorage 与服务器双写,离线静默(四款游戏的 `_putProgress` 模式照抄)。
|
||||
- 继续/新开二选一(sys_continue_ask):右滑=新游戏是**破坏性操作**,建议加一次确认(单击确认句可用现有 `sys_new_game` 前的自答:"右滑确定重新开始?再右滑确认。"——如需新台词列入 ch2/3 补录,先用现有句拼接流程也行)。
|
||||
|
||||
## 7. 教学战(05-rpg-chapter1.md §5.2)
|
||||
|
||||
- 锁血保护(HP 最低 1)别忘了在 `_resolve` 类函数里实现为 clamp,不是免疫——教学战要让玩家**看到**掉血才理解格挡的价值。
|
||||
- 教学句 tut_b1-b5 的触发点:tut_b1 在首次 ENEMY_TELEGRAPH 前、tut_b2 与前摇音**同时**(这是唯一允许语音与前摇叠放的位置,设计如此)、tut_b3 格挡成功后。
|
||||
@@ -0,0 +1,319 @@
|
||||
# SoundGame Web 引擎 API 手册
|
||||
|
||||
本文档面向在 `web/` 下开发新游戏的 agent/开发者。Web 引擎是 Android 引擎
|
||||
(`docs/engine-api.md`、`docs/architecture.md`)的移植:同一套 6 手势、
|
||||
同一套三层音频、同一套 Game 契约。纯 vanilla JS ES module,**无构建步骤、
|
||||
无 npm 依赖**,任何静态 HTTP 服务器托管即可运行。
|
||||
|
||||
总览:
|
||||
|
||||
```
|
||||
web/
|
||||
├── index.html # 游戏大厅(深色风格,5 个游戏入口 + 用户/设置区)
|
||||
├── serve.sh # 本地托管脚本(node web/server/server.js)
|
||||
├── engine/ # 共享引擎 —— 所有游戏复用,勿修改
|
||||
│ ├── gesture.js # 6 手势识别(触摸+鼠标,内置基础震动反馈)
|
||||
│ ├── audio.js # Web Audio 三层音频引擎 + 启动遮罩(自动应用用户设置)
|
||||
│ ├── settings.js # 全局用户设置(voice/speed/bgm,localStorage 持久化)
|
||||
│ ├── haptics.js # navigator.vibrate 震动封装(预定义震型)
|
||||
│ ├── game.js # Game / StateMachineGame 基类
|
||||
│ └── style.css # 全屏黑底 + 封面铺满 + 屏蔽系统手势
|
||||
├── server/ # 用户系统服务器(静态托管 + 注册/登录/进度 API)
|
||||
│ ├── server.js
|
||||
│ └── README.md # API 文档
|
||||
├── template/ # 最小可运行模板游戏(复制它开工)
|
||||
│ ├── index.html
|
||||
│ ├── game.js
|
||||
│ ├── cover.png
|
||||
│ └── audio/{bgm,voice,sfx}/ # 含一个测试音 sfx/beep.ogg
|
||||
└── <游戏名>/ # 每个游戏一个目录(目录约定见下)
|
||||
```
|
||||
|
||||
## 目录约定(每个游戏必须遵守)
|
||||
|
||||
```
|
||||
web/<name>/
|
||||
├── index.html # 入口页,引用 ../engine/ 下的引擎
|
||||
├── game.js # 游戏逻辑(导出继承 Game 的类)
|
||||
├── cover.png # 封面图,1080x1920 竖图,文件名固定
|
||||
└── audio/
|
||||
├── bgm/*.ogg # 背景音乐(循环)
|
||||
├── voice/*.ogg # 对白/语音(平铺,过渡期兼容布局)
|
||||
├── voice/male/*.ogg # 男声版(settings.voice='male' 时优先加载)
|
||||
├── voice/female/*.ogg # 女声版(默认,优先加载)
|
||||
└── sfx/*.ogg # 音效(短促)
|
||||
```
|
||||
|
||||
- 音频 id 形如 `'bgm/theme'`、`'voice/intro'`、`'sfx/hit'`,
|
||||
`play()` 自动拼 `audio/<id>.ogg`(相对游戏页面)。
|
||||
- **iOS 兼容**:`.ogg` 在部分 iOS Safari 上无法解码(见下文"移动浏览器的坑")。
|
||||
id 不带扩展名时引擎依次尝试 `.ogg → .m4a → .mp3 → .wav`,
|
||||
所以只要给同名文件放一份 `.m4a` 或 `.mp3` 副本即可自动回退,代码不用改。
|
||||
- 资产缺失只 `console.error` 静默跳过,**不会抛异常**(对齐 Android)。
|
||||
拼错 id 不会崩,只能靠浏览器 DevTools 控制台发现——调试时务必开着控制台。
|
||||
|
||||
## 快速开始
|
||||
|
||||
复制模板开工:
|
||||
|
||||
```bash
|
||||
cp -r web/template web/<name>
|
||||
```
|
||||
|
||||
然后改 `web/<name>/index.html` 的标题和 import 类名,替换 `game.js` 逻辑,
|
||||
放入自己的 `cover.png` 和 `audio/` 资产。入口页的标准接线(模板已写好):
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="../engine/style.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1,
|
||||
maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
|
||||
<div id="game-root"><img class="cover" src="cover.png" alt=""></div>
|
||||
|
||||
<script type="module">
|
||||
import { WebAudioEngine } from '../engine/audio.js';
|
||||
import { attachGestures } from '../engine/gesture.js';
|
||||
import { MyGame } from './game.js';
|
||||
|
||||
async function main() {
|
||||
const root = document.getElementById('game-root');
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
const game = new MyGame(audio, root);
|
||||
await audio.showStartOverlay({ cover: 'cover.png', text: '轻触开始' });
|
||||
game.start(); // 此刻音频已解锁
|
||||
attachGestures(root, (g) => game.onGesture(g)); // 解锁后才接管手势
|
||||
}
|
||||
main();
|
||||
</script>
|
||||
```
|
||||
|
||||
## 音频解锁流程(必须遵守,否则无声)
|
||||
|
||||
iOS Safari 和 Chrome 都要求 AudioContext 必须在一次真实用户手势中创建/恢复。
|
||||
引擎用启动遮罩解决:
|
||||
|
||||
1. 页面加载后 `showStartOverlay()` 显示全屏封面 + "轻触开始"(呼吸动画);
|
||||
2. 用户第一次点按遮罩 → 引擎在手势回调里 `new AudioContext()` + `resume()`;
|
||||
3. 遮罩移除,Promise resolve → 你调 `game.start()` 接管。
|
||||
|
||||
**任何 `play()` 在解锁前调用只会 console.warn 并跳过。**
|
||||
解锁后再 `attachGestures`,这样开始那次点按不会被游戏误收。
|
||||
|
||||
## engine/audio.js — `WebAudioEngine`
|
||||
|
||||
```js
|
||||
new WebAudioEngine(basePath = 'audio/', opts?)
|
||||
```
|
||||
|
||||
| 构造 opts | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `bgmVolume` / `voiceVolume` / `sfxVolume` | 1 | 各层基础音量 0~1 |
|
||||
| `duckVolume` | 0.2 | 对白播放时 BGM duck 到的比例 |
|
||||
| `fadeMs` | 800 | BGM 交叉淡入时长 ms |
|
||||
| `duckMs` | 300 | duck / 恢复时长 ms |
|
||||
|
||||
方法:
|
||||
|
||||
- `unlock(): Promise<void>` — 创建/恢复 AudioContext。已在用户手势中时可用;
|
||||
一般用 `showStartOverlay()` 代替。
|
||||
- `showStartOverlay({ cover, text = '轻触开始' }): Promise<void>` —
|
||||
启动遮罩,resolve 时音频已解锁。
|
||||
- `play(id, opts?)` — 统一入口,按 id 前缀(`bgm/` `voice/` `sfx/`)分发:
|
||||
- 公共:`opts.volume`(0~1,乘层音量)
|
||||
- BGM:`opts.fadeMs`、`opts.loop = true`;同一首重复 `play` 不重启
|
||||
- voice:`opts.onComplete`(**自然播完**才触发;被 `stopVoice()` 或新 voice
|
||||
顶掉时**不触发**,对齐 Android——用这一点实现"打断即作废整条语音链");
|
||||
`opts.queue = true` 排队等当前对白播完(默认 false 直接顶掉)
|
||||
- sfx:即发即弃,天然支持并发叠加
|
||||
- `prewarm(ids: string[])` — 预解码一批音频,消除首次播放的网络+解码延迟。
|
||||
建议 `start()` 里把所有短音效 prewarm 一遍。
|
||||
(与 Android 差异:Web 端**不需要**音量 0 假播预热,解码缓存即可。)
|
||||
- `stopBgm(fadeOutMs = 800)` — 淡出后释放 BGM。
|
||||
- `stopVoice()` — 打断当前对白并清空排队,不触发 onComplete,BGM 恢复。
|
||||
- `stopAll()` — stopBgm(200) + stopVoice()(sfx 很短,不主动停)。
|
||||
|
||||
三层行为(对齐 Android DefaultAudioEngine):
|
||||
|
||||
| 层 | 播放器 | 特性 |
|
||||
|---|---|---|
|
||||
| BGM | AudioBufferSourceNode(双实例轮换) | 循环;切换时新源 0→满、旧源 →0 交叉淡入,淡出后 stop |
|
||||
| voice | 单通道 | duck BGM 到 20%(300ms 渐降),播完 300ms 恢复;onComplete 驱动链播 |
|
||||
| sfx | AudioBufferSourceNode 即发即弃 | 预解码 AudioBuffer 缓存,低延迟,可叠加 |
|
||||
|
||||
**用户设置自动生效(settings.js,游戏代码无需改动):**
|
||||
|
||||
- `settings.voice`(`'male'|'female'`,默认 `'female'`):voice 层路径解析——
|
||||
`play('voice/intro')` 依次尝试 `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,
|
||||
全部找不到再回退平铺的 `audio/voice/intro.*`(过渡期资产未分性别也能播)。
|
||||
缓存键含性别,游戏中途切换音色下一句即用新性别资产。
|
||||
- `settings.speed`(`1.0|1.35|1.7`,默认 `1.0`):voice 层
|
||||
`source.playbackRate`,在创建 source 时读取,**从下一句生效**
|
||||
(进行中的句子不变速)。
|
||||
- `settings.bgm`(`1.0|0.3|0`,默认 `1.0`):BGM 层独立增益总线。
|
||||
改动对**进行中的 BGM 立即生效**(150ms 渐变);调到 `0` 立即淡出停止
|
||||
且此后 `play('bgm/...')` 只记意图不加载不播放;从 `0` 调回非零时,
|
||||
若此前有想播的 BGM 会自动补播。
|
||||
|
||||
## engine/settings.js — `settings`
|
||||
|
||||
全局用户设置单例,localStorage 持久化(key: `'soundgame.settings'`),
|
||||
同域下大厅与所有游戏页共享;跨标签页通过 `storage` 事件同步。
|
||||
|
||||
```js
|
||||
import { settings } from '../engine/settings.js';
|
||||
|
||||
settings.get(); // -> { voice: 'male'|'female', speed: 1|1.35|1.7, bgm: 1|0.3|0 }
|
||||
settings.set({ speed: 1.35 }); // 部分更新;非法键/值被忽略(console.warn)
|
||||
const off = settings.subscribe((snapshot, changed) => { ... }); // off() 退订
|
||||
```
|
||||
|
||||
- 非法值永远不会进入存储——读入时逐项校验,坏数据自动回退默认值。
|
||||
- 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置。
|
||||
需要自定义响应(如设置页 UI)时才 `subscribe`。
|
||||
- 大厅会在用户登录后把设置同步到服务器(`PUT /api/progress {game:'_settings'}`)。
|
||||
|
||||
## engine/haptics.js — `vibrate`
|
||||
|
||||
`navigator.vibrate` 封装,预定义震型:
|
||||
|
||||
| 名称 | 震型 (ms) | 建议用途 |
|
||||
|---|---|---|
|
||||
| `confirm` | `[20]` | 点按确认 |
|
||||
| `error` | `[60,40,60]` | 操作错误 |
|
||||
| `success` | `[100]` | 过关/成功 |
|
||||
| `warning` | `[30,50,30,50,30]` | 警告/危险逼近 |
|
||||
| `swipe` | `[10]` | 滑动识别成功 |
|
||||
| `heartbeat` | `[40,100,40]` | 心跳/倒计时紧张感 |
|
||||
| `pickup` | `[15]` | 拾取/获得物品 |
|
||||
| `hit` | `[80]` | 受击/终结性失败 |
|
||||
| `beat` | `[8]` | 节拍锚点(最轻,如 simon 序列逐音) |
|
||||
| `danger` | `[25,40,25,40,25]` | 危险逼近 |
|
||||
|
||||
```js
|
||||
import { vibrate, hapticsSupported } from '../engine/haptics.js';
|
||||
vibrate('error'); // 预定义名称
|
||||
vibrate([50, 30, 50]); // 自定义震型
|
||||
```
|
||||
|
||||
- 特性检测 `'vibrate' in navigator`;不支持的平台(iOS Safari)**静默 no-op**。
|
||||
- gesture.js 已内置基础反馈:滑动→`swipe`,tap→`confirm`,doubletap→`confirm`。
|
||||
**游戏逻辑层只需在语义事件(error/success/warning/heartbeat 等)处自行调用**,
|
||||
不要对手势本身重复调 `vibrate('confirm')`/`vibrate('swipe')`,否则会双重震动。
|
||||
|
||||
## engine/gesture.js — `attachGestures`
|
||||
|
||||
```js
|
||||
const detach = attachGestures(element, onGesture, config?)
|
||||
// onGesture: (g: 'up'|'down'|'left'|'right'|'tap'|'doubletap') => void
|
||||
// detach(): 解除绑定
|
||||
```
|
||||
|
||||
判定参数(`DEFAULT_GESTURE_CONFIG`,与 Android `GestureConfig` 对齐):
|
||||
|
||||
| 参数 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `swipeMinDistance` | 64 px | 累积位移阈值(≈ Android 64dp,按 160dpi 等效) |
|
||||
| `swipeMinVelocity` | 400 px/s | 平均速度阈值(位移 / 拖动时长) |
|
||||
| `doubleTapWindow` | 300 ms | 双击判定窗口 |
|
||||
| `tapSlop` | 24 px | 位移小于此才算点按 |
|
||||
|
||||
- 方向取位移较大的轴,斜滑归一到最近主轴(防斜滑误判)。
|
||||
- 触摸 + 鼠标双支持(桌面可用鼠标拖拽/点击调试);多指触摸忽略。
|
||||
- 元素上的 touchstart/touchmove/touchend 全部 `preventDefault`
|
||||
(防滚动/双指缩放/长按菜单),元素需有 `touch-action: none`
|
||||
(`style.css` 的 `#game-root` 已带)。
|
||||
- 手势绑定是全屏捕获层级的:绑在 `#game-root` 上即可覆盖整个屏幕。
|
||||
- **内置基础震动反馈**(haptics.js):滑动识别成功→`swipe`、tap→`confirm`、
|
||||
doubletap→`confirm`;不支持震动的平台自动静默。游戏语义反馈
|
||||
(error/success/warning 等)由游戏层调用 `vibrate()`。
|
||||
|
||||
**单击有约 300ms 延迟**:引擎要等双击窗口关闭才能确定是单击,`tap`
|
||||
回调因此延迟 ~300ms 触发(`doubletap` 和滑动是即时的)。这是引擎级行为,
|
||||
游戏设计必须绕开——即时反馈(格挡、击打)绑滑动或双击,单击只用于
|
||||
"继续剧情""检查房间"这类非即时操作(Android 端 reflex/storyline/escape
|
||||
均如此设计)。
|
||||
|
||||
## engine/game.js — `Game` / `StateMachineGame`
|
||||
|
||||
```js
|
||||
import { Game, StateMachineGame } from '../engine/game.js';
|
||||
|
||||
class Game {
|
||||
constructor(audio, root) // audio: WebAudioEngine;root: #game-root 元素
|
||||
onGesture(g) {} // 手势入口(子类实现)
|
||||
start() {} // 音频解锁后调用一次(子类实现)
|
||||
stop() {} // 可选;默认实现 audio.stopAll()
|
||||
}
|
||||
```
|
||||
|
||||
`StateMachineGame` 是推荐起点(对齐 Android `GameEngine<S>`,线性剧情类适用):
|
||||
|
||||
```js
|
||||
class MyGame extends StateMachineGame {
|
||||
initialState() { return 'INTRO'; } // 初始状态
|
||||
transition(state, g) { ...; return state; } // 纯函数,返回原状态 = 无转移
|
||||
onEnterState(state, prev) { ... } // 副作用:播音频;prev 初始为 null
|
||||
// setState(next) 可强制切换(计时器、onComplete 驱动)
|
||||
}
|
||||
```
|
||||
|
||||
`onGesture` 是 final 逻辑:算 `transition`,状态变了才更新并回调
|
||||
`onEnterState`。`start()` 以 `prev = null` 进一次 `initialState()`。
|
||||
状态复杂(多维状态、计时器驱动)的游戏直接继承 `Game` 即可——Android 端
|
||||
三个游戏实际都没用状态机基类。
|
||||
|
||||
## 与 Android 引擎的行为差异
|
||||
|
||||
| 项 | Android | Web |
|
||||
|---|---|---|
|
||||
| sfx 预热 | SoundPool 异步加载,必须音量 0 预播否则首播丢失 | 按需解码 + 缓存,不需要假播;`prewarm()` 只是提前解码 |
|
||||
| 音频格式 | ogg 全覆盖 | **iOS Safari 可能无法解码 ogg**,引擎自动回退 m4a/mp3/wav;建议双格式 |
|
||||
| 音频解锁 | 无限制 | 必须先经用户手势(启动遮罩已内置) |
|
||||
| 滑动阈值 | 64dp(物理密度无关) | 64 CSS px(≈64dp@160dpi,高分屏实际手感略小,可接受) |
|
||||
| BGM/voice 播放 | ExoPlayer 流式 | 整段 fetch + 解码进内存,**大文件有解码延迟和内存占用**——BGM 建议压到几分钟内、voice 单句切分 |
|
||||
| 资源缺失 | Log.e 静默跳过 | console.error 静默跳过(行为一致) |
|
||||
|
||||
## 移动浏览器的坑(务必读)
|
||||
|
||||
1. **自动播放限制**:iOS Safari / Chrome 要求 AudioContext 在用户手势中
|
||||
resume。引擎的启动遮罩已解决,但不要绕过它——解锁前 `play()` 是无声 no-op。
|
||||
2. **iOS 不支持 ogg**:Safari 对 Ogg Vorbis 的解码支持很差(随版本变化,
|
||||
老版本完全不行)。引擎已做扩展名回退,但资产侧要保证有 `.m4a`(AAC)
|
||||
或 `.mp3` 副本,否则 iOS 上全程无声。生成:`ffmpeg -i in.ogg -c:a aac out.m4a`。
|
||||
3. **iOS 静音开关**:iPhone 静音模式下 Web Audio 默认**仍然出声**
|
||||
(AudioContext 不受静音开关控制,这点比 `<audio>` 标签好),
|
||||
但部分老版本 Safari 行为不一致,真机测试为准。
|
||||
4. **后台/锁屏**:页面切后台 AudioContext 会被系统挂起,回到前台
|
||||
需要 `ctx.resume()`(引擎在 unlock 后如被挂起,下次手势时不会自动恢复——
|
||||
如果游戏需要,可在 `visibilitychange` 里重新 `audio.unlock()`)。
|
||||
5. **页面滚动/缩放手势干扰**:已用 `touch-action: none` + `preventDefault` +
|
||||
viewport meta 锁住;iOS Safari 双指捏合缩放在少数版本仍可能触发,
|
||||
属系统行为无法完全屏蔽。
|
||||
6. **蓝牙音频延迟**:蓝牙耳机有 100~300ms 输出延迟,节奏类游戏(rhythm)
|
||||
判定窗口要留余量,并优先建议用户用有线/扬声器。
|
||||
7. **decodeAudioData 内存**:所有音频解码成 PCM 常驻内存(约为原始压缩
|
||||
文件的 10 倍体积)。单个游戏音频总量建议控制在 ~30MB 压缩体积内。
|
||||
|
||||
## 本地验证
|
||||
|
||||
```bash
|
||||
# 1. JS 语法校验(node 装在 tools/node/,ES module 语法可被识别)
|
||||
/home/ubuntu/SoundGame/tools/node/bin/node --check web/<name>/game.js
|
||||
|
||||
# 2. 起本地服务器(Node 静态 + 用户系统 API,见 web/server/README.md)
|
||||
bash web/serve.sh
|
||||
# 打印局域网地址,如 http://192.168.x.x:8000/
|
||||
|
||||
# 3. 桌面浏览器打开 http://localhost:8000/<name>/ 用鼠标调试
|
||||
# (DevTools → Toggle device toolbar 模拟手机尺寸;控制台看引擎日志)
|
||||
|
||||
# 4. 手机与电脑连同一 WiFi,浏览器打开局域网地址
|
||||
# WSL2 需要先在 Windows 做端口转发(serve.sh 注释里有命令)
|
||||
|
||||
# 5. curl 冒烟(资源 200 + Content-Type 正确)
|
||||
curl -sI http://localhost:8000/<name>/index.html
|
||||
curl -sI http://localhost:8000/<name>/audio/sfx/xxx.ogg
|
||||
```
|
||||
|
||||
听感参数(反应窗口、响度平衡、立体声分离度)必须在真机上戴耳机验证——
|
||||
这一点和 Android 版一样,模拟器/桌面听不出手感。
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""实测音频 BPM 并找出首个强拍起点(librosa)。
|
||||
|
||||
用法: .venv-tts/bin/python tools/bpm_check.py <audio> <target_bpm>
|
||||
输出 JSON: {"measured_bpm": ..., "octave_ok": bool, "first_beat_sec": ..., "n_beats": ...}
|
||||
octave_ok: 实测值与目标偏差 <=2%(含半速/倍速的节拍八度等价)。
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
path, target = sys.argv[1], float(sys.argv[2])
|
||||
y, sr = librosa.load(path, sr=22050, mono=True)
|
||||
onset = librosa.onset.onset_strength(y=y, sr=sr)
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr, onset_envelope=onset, start_bpm=target)
|
||||
tempo = float(np.atleast_1d(tempo)[0])
|
||||
|
||||
def dev(a, b):
|
||||
return abs(a - b) / b
|
||||
|
||||
ok = any(dev(tempo, target * k) <= 0.02 for k in (0.5, 1.0, 2.0))
|
||||
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
first_beat = 0.0
|
||||
if len(beat_times):
|
||||
# 第一个 onset 强度足够高的拍(跳过 intro 弱起)
|
||||
thresh = 0.4 * float(np.max(onset))
|
||||
for bf, bt in zip(beats, beat_times):
|
||||
if bf < len(onset) and onset[bf] >= thresh:
|
||||
first_beat = float(bt)
|
||||
break
|
||||
else:
|
||||
first_beat = float(beat_times[0])
|
||||
|
||||
print(json.dumps({
|
||||
"file": path,
|
||||
"measured_bpm": round(tempo, 2),
|
||||
"target": target,
|
||||
"deviation_pct": round(dev(tempo, target) * 100, 2),
|
||||
"octave_ok": ok,
|
||||
"first_beat_sec": round(first_beat, 3),
|
||||
"n_beats": int(len(beats)),
|
||||
}))
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""高精度 BPM 实测:onset 包络 + 梳状滤波扫描(分数精度,亚帧分辨率)。
|
||||
|
||||
用法: .venv-tts/bin/python tools/bpm_precise.py <audio> [bpm_min] [bpm_max]
|
||||
输出 JSON: {"bpm": ..., "score": ..., "harmonics": {...}}
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
path = sys.argv[1]
|
||||
bpm_lo = float(sys.argv[2]) if len(sys.argv) > 2 else 70.0
|
||||
bpm_hi = float(sys.argv[3]) if len(sys.argv) > 3 else 140.0
|
||||
|
||||
y, sr = librosa.load(path, sr=22050, mono=True)
|
||||
onset = librosa.onset.onset_strength(y=y, sr=sr)
|
||||
onset = onset - onset.mean()
|
||||
fps = sr / 512.0 # hop 512
|
||||
|
||||
|
||||
def comb_score(bpm):
|
||||
period = fps * 60.0 / bpm
|
||||
n_teeth = int((len(onset) - 1) / period)
|
||||
if n_teeth < 16:
|
||||
return 0.0
|
||||
# 相位扫描取最优
|
||||
best = 0.0
|
||||
for phase in np.linspace(0, period, 24, endpoint=False):
|
||||
idx = np.round(phase + period * np.arange(n_teeth)).astype(int)
|
||||
idx = idx[idx < len(onset)]
|
||||
s = float(onset[idx].sum()) / len(idx)
|
||||
if s > best:
|
||||
best = s
|
||||
return best
|
||||
|
||||
|
||||
cands = np.arange(bpm_lo, bpm_hi, 0.05)
|
||||
scores = np.array([comb_score(b) for b in cands])
|
||||
best_i = int(np.argmax(scores))
|
||||
best_bpm = float(cands[best_i])
|
||||
|
||||
# 抛物线插值细化
|
||||
if 0 < best_i < len(cands) - 1:
|
||||
y0, y1, y2 = scores[best_i - 1], scores[best_i], scores[best_i + 1]
|
||||
denom = (y0 - 2 * y1 + y2)
|
||||
if abs(denom) > 1e-12:
|
||||
best_bpm += 0.05 * 0.5 * (y0 - y2) / denom
|
||||
|
||||
harm = {}
|
||||
for k, name in ((0.5, "half"), (2.0, "double")):
|
||||
b = best_bpm * k
|
||||
if bpm_lo <= b <= bpm_hi:
|
||||
harm[name] = round(float(scores[np.argmin(np.abs(cands - b))]), 4)
|
||||
|
||||
print(json.dumps({
|
||||
"file": path,
|
||||
"bpm": round(best_bpm, 2),
|
||||
"score": round(float(scores[best_i]), 4),
|
||||
"harmonics": harm,
|
||||
}))
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全项目音频资产终检(R5)。
|
||||
|
||||
检查 6 个游戏 web/<game>/audio/:
|
||||
1. 每个 .ogg 都有同名 .m4a(反之亦然)
|
||||
2. voice/male 与 voice/female 的 id 集合一致(storyline/rpg 的平铺 NPC/角色句除外)
|
||||
3. 无 0 字节文件;ogg/m4a 均可被 ffprobe 解码且时长 > 0
|
||||
输出 Markdown 报告到 stdout(重定向进 docs/audio/final-asset-check.md)。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = "/home/ubuntu/SoundGame/web"
|
||||
GAMES = ["storyline", "escape", "simon", "rhythm", "space", "rpg"]
|
||||
# 平铺 voice 文件(角色/NPC,单音色,不做性别集合一致性要求)
|
||||
FLAT_OK = {"storyline", "rpg"}
|
||||
|
||||
|
||||
def ids(d, ext):
|
||||
if not os.path.isdir(d):
|
||||
return set()
|
||||
return {f[: -len(ext)] for f in os.listdir(d) if f.endswith(ext)}
|
||||
|
||||
|
||||
def main():
|
||||
problems = []
|
||||
print("# 全项目音频资产终检(R5,2026-08-29)\n")
|
||||
print("| 游戏 | ogg | m4a | 0字节 | 解码失败 | voice male/female id 一致 |")
|
||||
print("|---|---|---|---|---|---|")
|
||||
for g in GAMES:
|
||||
base = os.path.join(ROOT, g, "audio")
|
||||
oggs, m4as, zero, bad = [], [], [], []
|
||||
for dirpath, _dirs, files in os.walk(base):
|
||||
for f in files:
|
||||
p = os.path.join(dirpath, f)
|
||||
if os.path.getsize(p) == 0:
|
||||
zero.append(p)
|
||||
if f.endswith(".ogg"):
|
||||
oggs.append(p)
|
||||
elif f.endswith(".m4a"):
|
||||
m4as.append(p)
|
||||
# 双格式配对
|
||||
o_set = {p[:-4] for p in oggs}
|
||||
m_set = {p[:-4] for p in m4as}
|
||||
for p in sorted(o_set - m_set):
|
||||
problems.append(f"{g}: 缺 m4a —— {p}.ogg")
|
||||
for p in sorted(m_set - o_set):
|
||||
problems.append(f"{g}: 缺 ogg —— {p}.m4a")
|
||||
# 解码校验(抽查全量 ffprobe)
|
||||
for p in oggs + m4as:
|
||||
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", p], capture_output=True, text=True)
|
||||
try:
|
||||
if float(r.stdout.strip()) <= 0:
|
||||
bad.append(p)
|
||||
except ValueError:
|
||||
bad.append(p)
|
||||
for p in zero:
|
||||
problems.append(f"{g}: 0 字节 —— {p}")
|
||||
for p in bad:
|
||||
problems.append(f"{g}: 解码失败/时长为 0 —— {p}")
|
||||
# voice 性别集合一致性
|
||||
vm = ids(os.path.join(base, "voice", "male"), ".ogg")
|
||||
vf = ids(os.path.join(base, "voice", "female"), ".ogg")
|
||||
if vm == vf and vm:
|
||||
pair = f"✅ 各 {len(vm)}"
|
||||
else:
|
||||
pair = f"❌ male={len(vm)} female={len(vf)}"
|
||||
for i in sorted(vm - vf):
|
||||
problems.append(f"{g}: voice 缺 female/{i}")
|
||||
for i in sorted(vf - vm):
|
||||
problems.append(f"{g}: voice 缺 male/{i}")
|
||||
flat = ids(os.path.join(base, "voice"), ".ogg")
|
||||
flat_note = f"(平铺 {len(flat)} 个角色/NPC 句,豁免)" if g in FLAT_OK and flat else \
|
||||
(f"(⚠️ 非豁免游戏存在平铺 {len(flat)} 个)" if flat and g not in FLAT_OK else "")
|
||||
print(f"| {g} | {len(oggs)} | {len(m4as)} | {len(zero)} | {len(bad)} | {pair}{flat_note} |")
|
||||
print()
|
||||
if problems:
|
||||
print(f"## 问题清单({len(problems)} 条)\n")
|
||||
for p in problems:
|
||||
print(f"- {p}")
|
||||
return 1
|
||||
print("## 结论\n\n全部通过:双格式配对完整、无 0 字节、全部可解码、voice 性别目录 id 集合一致(storyline/rpg 平铺角色句按约定豁免)。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# R4 RPG《剑与声》SA3 音效批量生成(29 件,SYN 的 5 件不在此列)。
|
||||
# 用法: bash tools/gen_rpg_sfx.sh (内部 flock 串行 GPU;输出 tools/gen_rpg_sfx_wav/)
|
||||
set -u
|
||||
source /home/ubuntu/SoundGame/env.d/sfx.sh
|
||||
cd /home/ubuntu/SoundGame/tools/stable-audio-3
|
||||
OUT=/home/ubuntu/SoundGame/tools/gen_rpg_sfx_wav
|
||||
mkdir -p "$OUT"
|
||||
|
||||
declare -A P D
|
||||
# ---- 敌方前摇(1.5s 生成,后裁 1.0s 格挡窗口) ----
|
||||
P[tel_wolf]="wolf growl then lunge, short, dry, close mic"; D[tel_wolf]=1.5
|
||||
P[tel_bandit]="footsteps on dirt then sword unsheathing shing, short, dry"; D[tel_bandit]=1.5
|
||||
P[tel_spider]="insect legs skittering, high frequency chitin clicks, eerie, short"; D[tel_spider]=1.5
|
||||
P[tel_elite]="heavy armor plates clanking, menacing, short"; D[tel_elite]=1.5
|
||||
P[tel_boss1]="two metal blades striking each other twice, dramatic ring, short"; D[tel_boss1]=1.5
|
||||
P[tel_croc]="water splash with low reptile growl, short"; D[tel_croc]=1.5
|
||||
P[tel_shaman]="low guttural chant humming, ritualistic, dark, short"; D[tel_shaman]=1.5
|
||||
P[tel_puppet]="wooden mechanism clicks and winding gears, short"; D[tel_puppet]=1.5
|
||||
P[tel_boss2]="low human voice humming a dark ritual melody, ominous, short"; D[tel_boss2]=1.5
|
||||
# ---- 战斗 ----
|
||||
P[atk_swing]="sword whoosh through air, fast, clean, single swing"; D[atk_swing]=0.8
|
||||
P[atk_hit]="sword hitting flesh, dull impact, not gory, single hit"; D[atk_hit]=0.8
|
||||
P[parry]="metal parry clang, bright ring, short, single hit"; D[parry]=0.8
|
||||
P[skill_slash]="powerful sword slash with air rip, dramatic, single strike"; D[skill_slash]=1.0
|
||||
P[enemy_die]="body falling to ground with weapon clatter, short"; D[enemy_die]=1.2
|
||||
P[potion]="drinking liquid gulps from glass bottle, short"; D[potion]=1.5
|
||||
# ---- 探索/系统 ----
|
||||
P[chest]="old wooden chest creaking open, short"; D[chest]=1.5
|
||||
P[drink_water]="scooping and drinking stream water, gentle, short"; D[drink_water]=1.5
|
||||
P[rest]="settling down by campfire, fabric rustle, calm, short"; D[rest]=2.0
|
||||
P[door_heavy]="heavy wooden gate slowly opening, deep creak"; D[door_heavy]=3.0
|
||||
# ---- 环境(6s 循环素材) ----
|
||||
P[amb_village]="night village ambience, campfire crackle, distant chatter, crickets, loopable"; D[amb_village]=6
|
||||
P[amb_path]="forest path ambience, wind through leaves, distant birds, loopable"; D[amb_path]=6
|
||||
P[amb_fork]="forest crossroads ambience, gusty wind, crows cawing, loopable"; D[amb_fork]=6
|
||||
P[amb_chest]="hollow tree hole ambience, water drips, muffled wind, loopable"; D[amb_chest]=6
|
||||
P[amb_stream]="flowing stream over rocks, gentle water babbling, loopable"; D[amb_stream]=6
|
||||
P[amb_hunter]="wood cabin ambience, firewood crackle, occasional knife sharpening, loopable"; D[amb_hunter]=6
|
||||
P[amb_deepwood]="deep forest ambience at night, urgent insects, distant wolf howl, ominous, loopable"; D[amb_deepwood]=6
|
||||
P[amb_gate]="bandit camp outside wooden gate ambience, torches burning, rowdy distant voices, loopable"; D[amb_gate]=6
|
||||
P[amb_hall]="large empty hall ambience, fire brazier crackle, echoing space, tense, loopable"; D[amb_hall]=6
|
||||
P[amb_swamp]="swamp ambience, bubbling mud, frogs, insects, murky, loopable"; D[amb_swamp]=6
|
||||
P[amb_peak]="mountain peak ambience, strong cold wind, thin air, howling gusts, loopable"; D[amb_peak]=6
|
||||
|
||||
ORDER="tel_wolf tel_bandit tel_spider tel_elite tel_boss1 tel_croc tel_shaman tel_puppet tel_boss2 atk_swing atk_hit parry skill_slash enemy_die potion chest drink_water rest door_heavy amb_village amb_path amb_fork amb_chest amb_stream amb_hunter amb_deepwood amb_gate amb_hall amb_swamp amb_peak"
|
||||
|
||||
flock /home/ubuntu/SoundGame/.gpu.lock bash -c '
|
||||
'"$(declare -p P D OUT)"'
|
||||
for k in '"$ORDER"'; do
|
||||
if [ -s "$OUT/$k.wav" ]; then echo "skip $k"; continue; fi
|
||||
uv run stable-audio --model small-sfx -p "${P[$k]}" --duration "${D[$k]}" -o "$OUT/$k.wav" 2>&1 | grep -i "saved\|error"
|
||||
done'
|
||||
echo "SA3 batch done: $(ls "$OUT" | wc -l) files"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RPG《剑与声》程序合成音效(5 件):hurt flee step coin levelup。
|
||||
|
||||
输出 /tmp/rpg_syn/*.wav(44.1kHz 立体声,峰值归一 0.89),随后 ffmpeg 转 ogg+m4a。
|
||||
音高敏感件用合成保证可控:levelup 为 A 小调五声(A4 C5 D5 E5 A5)上行钟声琶音。
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
sr = 44100
|
||||
OUT = "/tmp/rpg_syn"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def norm(y, peak=0.89):
|
||||
return (y / np.max(np.abs(y)) * peak).astype(np.float32)
|
||||
|
||||
|
||||
def stereo(y):
|
||||
return np.stack([y, y], axis=1)
|
||||
|
||||
|
||||
def place(y, start_s, sig):
|
||||
s = int(start_s * sr)
|
||||
n = min(len(sig), len(y) - s)
|
||||
y[s:s + n] += sig[:n]
|
||||
|
||||
|
||||
# hurt:低频闷击(60Hz 轰 + 平滑噪声团),与 atk_hit 的金属/肉质听感区分
|
||||
t = np.arange(int(sr * 0.35)) / sr
|
||||
thud = np.sin(2 * np.pi * 60 * t) * np.exp(-t * 18)
|
||||
noise = np.random.default_rng(7).standard_normal(len(t))
|
||||
noise = np.convolve(noise, np.ones(64) / 64, mode="same") * np.exp(-t * 25) * 0.6
|
||||
sf.write(f"{OUT}/hurt.wav", stereo(norm(thud + noise)), sr)
|
||||
|
||||
# flee:渐远脚步(4 步,幅度逐次衰减、越走越闷)
|
||||
y = np.zeros(int(sr * 1.2), dtype=np.float32)
|
||||
for i in range(4):
|
||||
L = int(0.12 * sr)
|
||||
tt = np.arange(L) / sr
|
||||
w = 32 + i * 24
|
||||
st = np.convolve(np.random.default_rng(i).standard_normal(L), np.ones(w) / w, mode="same")
|
||||
st *= np.exp(-tt * 30) * (0.9 ** i)
|
||||
place(y, i * 0.28, st)
|
||||
sf.write(f"{OUT}/flee.wav", stereo(norm(y)), sr)
|
||||
|
||||
# step:单步脚步(短噪声团 + 90Hz 触点)
|
||||
t = np.arange(int(sr * 0.22)) / sr
|
||||
n = np.random.default_rng(3).standard_normal(len(t))
|
||||
body = np.convolve(n, np.ones(24) / 24, mode="same") * np.exp(-t * 35)
|
||||
toe = np.sin(2 * np.pi * 90 * t) * np.exp(-t * 30) * 0.5
|
||||
sf.write(f"{OUT}/step.wav", stereo(norm(body + toe)), sr)
|
||||
|
||||
# coin:硬币叮当(非谐波高频泛音簇,两次碰撞)
|
||||
y = np.zeros(int(sr * 0.45), dtype=np.float32)
|
||||
for start, dec in [(0.0, 28), (0.12, 32)]:
|
||||
seg = np.arange(int(sr * (0.45 - start))) / sr
|
||||
sig = sum(a * np.sin(2 * np.pi * f * seg) * np.exp(-seg * dec) * 0.3
|
||||
for f, a in [(5200, 1.0), (6900, 0.7), (9100, 0.45), (11400, 0.3)])
|
||||
place(y, start, sig)
|
||||
sf.write(f"{OUT}/coin.wav", stereo(norm(y)), sr)
|
||||
|
||||
# levelup:上行钟声琶音(A 小调五声 A4 C5 D5 E5 A5;钟 = 基音 + 非谐波泛音快衰减)
|
||||
y = np.zeros(int(sr * 1.4), dtype=np.float32)
|
||||
for i, f in enumerate([440.0, 523.25, 587.33, 659.25, 880.0]):
|
||||
seg = np.arange(int(sr * 1.0)) / sr
|
||||
bell = sum(a * np.sin(2 * np.pi * f * r * seg) * np.exp(-seg * d)
|
||||
for r, a, d in [(1, 1.0, 4), (2.4, 0.35, 7), (3.9, 0.15, 10)])
|
||||
place(y, i * 0.18, bell * 0.4)
|
||||
sf.write(f"{OUT}/levelup.wav", stereo(norm(y)), sr)
|
||||
print("SYN done:", sorted(os.listdir(OUT)))
|
||||
@@ -0,0 +1,781 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""双性别语音资产管线(R1):为 5 个 web 游戏生成 male/female 两版旁白 voice。
|
||||
|
||||
约定:
|
||||
- 台词表从各游戏已有 gen 脚本原样提取(storyline/escape 的原始表在 app/src/<flavor>/gen/,
|
||||
本脚本只做只读引用,不改 app/)。
|
||||
- storyline 只生成旁白 + 系统音(NARR* 角色)的双性别版本;角色台词(林晚/猎狗/小满等)
|
||||
不在本脚本范围内,沿用 web/storyline/audio/voice/ 平铺原文件。
|
||||
- 输出 staging wav: tools/gen_voice_dual_wav/<game>/<gender>/<id>.wav
|
||||
--convert 后落地: web/<game>/audio/voice/<gender>/<id>.{ogg,m4a}
|
||||
(原有平铺 voice/*.ogg 保留不动,等引擎切换后由后续轮次清理。)
|
||||
|
||||
质量控制:
|
||||
- 每条 assert 时长 > 0.3s、峰值 > 0.05;统一峰值归一 0.89。
|
||||
- 台词 > 40 字按句拆分生成再拼接(句间 0.12s 静音),避免长句吞字。
|
||||
- 短句/数字类(文本 ≤ 2 字)同 instruct 一整批连续生成,减少 prosody 漂移。
|
||||
- --check 对比同句两性时长,相差 3 倍以上判异常(删掉异常 wav 后重跑本脚本即可)。
|
||||
|
||||
用法(GPU 必须串行):
|
||||
source env.d/tts.sh
|
||||
flock /home/ubuntu/SoundGame/.gpu.lock .venv-tts/bin/python tools/gen_voice_dual.py --game simon --gender both
|
||||
.venv-tts/bin/python tools/gen_voice_dual.py --game all --check # 无需 GPU
|
||||
source env.d/android.sh && .venv-tts/bin/python tools/gen_voice_dual.py --game all --convert
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf")
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
|
||||
ROOT = "/home/ubuntu/SoundGame"
|
||||
STAGING = os.path.join(ROOT, "tools", "gen_voice_dual_wav")
|
||||
SPLIT_LIMIT = 40 # 超过 40 字拆句
|
||||
GAP_SEC = 0.12 # 拼接句间静音
|
||||
PEAK_NORM = 0.89
|
||||
BATCH_SHORT = 16 # 数字/短句一批
|
||||
BATCH_LONG = 6
|
||||
|
||||
# ---------------------------------------------------------------- 音色分配
|
||||
# 游戏 × 性别 -> speaker。说明见 docs/audio/asset-manifest.md。
|
||||
# Ryan(英语母语音色)经冒烟测试未采用:项目已有中文发音投诉,男声统一用
|
||||
# 中文母语的 Uncle_Fu;测试样本在 tools/samples/ryan_zh_*.wav 供人工试听。
|
||||
SPEAKERS = {
|
||||
"storyline": {"male": "Uncle_Fu", "female": "Vivian"},
|
||||
"escape": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
"simon": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
"rhythm": {"male": "Uncle_Fu", "female": "Vivian"},
|
||||
"space": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
"rpg": {"male": "Uncle_Fu", "female": "Serena"},
|
||||
}
|
||||
|
||||
# instruct 对:(male, female)。None 表示两性别共用同一 instruct。
|
||||
def _pair(m, f=None):
|
||||
return (m, f if f is not None else m)
|
||||
|
||||
# ---------------------------------------------------------------- storyline
|
||||
_SL_NARR = _pair("低沉醇厚的中年男声旁白,冷静、缓慢,带着末世废墟的苍凉感",
|
||||
"冷静低沉的女声旁白,语速缓慢,带着末世废墟的苍凉感")
|
||||
_SL_WARM = _pair("低沉醇厚的中年男声旁白,由紧张转为温暖,结尾带希望",
|
||||
"女声旁白,由紧张转为温暖,结尾带希望")
|
||||
_SL_COLD = _pair("低沉醇厚的中年男声旁白,冷漠、空洞,越说越轻",
|
||||
"女声旁白,冷漠、空洞,越说越轻")
|
||||
_SL_EPIC = _pair("低沉醇厚的中年男声旁白,悲壮,尾音下沉",
|
||||
"女声旁白,悲壮,尾音下沉")
|
||||
|
||||
STORYLINE_LINES = [
|
||||
# ---- 系统语音 ----
|
||||
("help", _SL_NARR, "操作说明。单击屏幕:继续剧情。双击:重听刚才的台词。向上滑:听这段说明和当前进度。向下滑:呼出菜单,菜单里可以重新开始。当旁白说“请选择”的时候,向左滑或者向右滑,做出你的决定。选择无法撤销。"),
|
||||
("status1", _SL_NARR, "当前进度:第一幕,城南地铁站附近。你的目标:活着离开这片废墟,前往北区的广播塔,寻找妹妹林晨的下落。"),
|
||||
("status2", _SL_NARR, "当前进度:第二幕,酸雨笼罩的街区与断桥。你的目标:渡过江水,抵达广播塔。小心,猎狗的人,就在附近。"),
|
||||
("status3", _SL_NARR, "当前进度:第三幕,广播塔下。你的目标:把警报发出去,找到林晨。这是最后一段路了。"),
|
||||
("menu", _SL_NARR, "菜单。向左滑:重新开始整个故事。向右滑,或者单击:回到故事。向上滑:听操作说明。"),
|
||||
# ---- 选择提示 ----
|
||||
("p1", _SL_NARR, "请选择。向左滑:先检查背包里的收音机。向右滑:先摸黑寻找出口。"),
|
||||
("p2", _SL_NARR, "请选择。向左滑:钻进旁边的通风管道。向右滑:躺进路边的尸体堆里装死。"),
|
||||
("p3", _SL_NARR, "请选择。向左滑:循着哭声过去看看。向右滑:不去管它,直接去北区。"),
|
||||
("p4", _SL_NARR, "请选择。向左滑:带上小满一起走。向右滑:留下食物,独自上路。"),
|
||||
("p5", _SL_NARR, "请选择。向左滑:走桥下的污水涵洞。向右滑:爬桥上悬着的钢索。"),
|
||||
("p6", _SL_NARR, "请选择。向左滑:交出坐标,保命要紧。向右滑:拒绝他,想办法周旋。"),
|
||||
("p7", _SL_NARR, "请选择。向左滑:追上去,警告避难所。向右滑:自己逃命,越远越好。"),
|
||||
("p8", _SL_NARR, "请选择。向左滑:用公开频道广播,让全城都听见。向右滑:用加密频段,只发给避难所。"),
|
||||
# ---- 第一幕 ----
|
||||
("n00_intro", _SL_NARR, "大停电后的第三十天,城市陷入彻底的黑暗。没有电,没有网络,只有收音机里偶尔闪过的沙沙声,像垂死者的呼吸。你是林晚,一名前急救员。在这个故事里,你用声音活下去。请记住:单击屏幕,继续剧情;双击,重听上一句;向上滑,听你的状态和帮助;向下滑,呼出菜单。当我说“请选择”的时候,向左滑,或者向右滑,做出你的决定。选择一旦做出,就无法回头。现在,单击屏幕,睁开眼睛。"),
|
||||
("n01_wake", _SL_NARR, "后脑一阵钝痛,嘴里全是铁锈味。你在废弃的地铁站里醒来,四周黑得像沉在水底。背包还压在身下,手电筒却不知去向。远处滴水的声音,一下,一下,像某种倒计时。"),
|
||||
("n04_exit", _SL_NARR, "你扶着潮湿的墙,一步一步往外摸。第五十步,脚尖踢到一个硬物——是一支手电,还剩半格电。光柱劈开黑暗的那一瞬,你看见墙上用红漆刷着一行大字:别去北区。"),
|
||||
("n05_signal", _SL_NARR, "就在这时,背包侧袋里的收音机,沙沙声忽然变得清晰。一个平稳的女声,一遍又一遍地重复着:"),
|
||||
("n06_whistle", _SL_NARR, "你刚走上街面,黑暗里忽然响起一声口哨。接着是第二声,第三声,从不同的方向,把你围在中间。有人的手电光,扫过你的脸。"),
|
||||
("n08_vent", _SL_NARR, "你一矮身,钻进了通风管道。铁皮贴着脊背,窄得几乎无法呼吸。身后传来咒骂和铁棍敲击管道口的声音,越来越近,又渐渐远去。十分钟后,你从两条街外的井口爬出来,肺里像塞满了沙子。"),
|
||||
("n09_dead", _SL_NARR, "你屏住呼吸,把自己埋进路边冰冷的尸体堆里。手电、罐头、半瓶水,被他们一样一样摸走。有人踢了你一脚,你纹丝不动。就在他们离开时,你听见一句不该听见的话——“北区那个陷阱,今晚就收网。”"),
|
||||
# ---- 第二幕 ----
|
||||
("n10_rain", _SL_NARR, "酸雨开始下了。街面上升起细密的嘶嘶声,像无数条小蛇在游。雨幕那头,隐约传来一阵哭声——细细的,是个孩子。"),
|
||||
("n11_child", _SL_NARR, "哭声来自一间塌了一半的便利店。一个小男孩抱着膝盖坐在货架底下,身边躺着一个再也不会醒来的女人。他抬起头,脸上全是灰,小声说:“我叫小满。妈妈说,数到一千,她就会醒。我已经数到……数到好多个一千了。”"),
|
||||
("n13_leave", _SL_NARR, "你把仅剩的一罐罐头放在他身边,转身走进雨里。身后,孩子重新开始数数:一……二……三……你告诉自己,别回头。千万别回头。"),
|
||||
("n14_ignore", _SL_NARR, "哭声被你甩在身后,越来越远,最后混进雨声里,再也听不见了。你走得很快,快得像在逃跑。快一点,再快一点。你对自己说,只要到了北区,这一切就都值了。"),
|
||||
("n15_bridge", _SL_NARR, "两个小时后,江水拦住了去路。跨江大桥断成两截,桥身斜插进黑沉沉的江面。要去广播塔,只剩两条路:桥下的污水涵洞,黑得看不见底;或者,桥上悬着的钢索,在风里荡来荡去。"),
|
||||
("n16_culvert", _SL_NARR, "涵洞里,污水没到胸口,又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西,擦着你的腿滑了过去。你没有叫出声,只是走,一步,一步,直到看见洞口那点灰白的天光。"),
|
||||
("n16b_culvert_c", _SL_NARR, "你让小满骑在肩上,自己蹚进没到胸口的污水里。又冷又黏,带着腐烂的甜味。走到一半,水面下有什么东西擦过你的腿,小满吓得一抖,你抬手按住他的膝盖:“数数。”他小声数起来,一,二,三……数到一百七,你们看见了洞口的天光。"),
|
||||
("n17_cable", _SL_NARR, "钢索比想象中更滑。酸雨把它泡得发黏,你每挪一步,整根钢索就晃一下。江风从断桥的缺口灌进来,像有无数只手在水底下拽你的脚。你不往下看。你只知道,前面就是北岸。"),
|
||||
("n17b_cable_c", _SL_NARR, "钢索比想象中更滑。小满趴在你背上,一声不吭,两条小胳膊勒得你生疼。走到一半,钢索猛地一沉,你听见身后钢丝崩断的脆响。“闭眼。”你说。然后你用尽全身力气,向前,向前,直到膝盖磕上北岸的石头。"),
|
||||
# ---- 第三幕 ----
|
||||
("n20_convoy", _SL_NARR, "猎狗的车队朝北区开走了,探照灯在雨幕里划出一道道口子。你蹲在原地,胃里一阵一阵地泛酸。晨晨在北区。而刚才,是你,亲手把狼,引到了她的门口。"),
|
||||
("n22_fight", _SL_NARR, "你往后退了半步,忽然笑了:“坐标在我脑子里。杀了我,你什么都拿不到。”猎狗愣了半秒。就是这半秒,你抓起一把沙土扬在他脸上,转身就跑。子弹擦着肩膀打进墙里,火辣辣地疼。但你在跑,你在跑——活着的感觉,从来没有这么清楚过。"),
|
||||
("n23_tower", _SL_NARR, "广播塔就立在头顶,红色的信号灯在雨里一闪一闪,像一颗还在跳动的心脏。塔底的中控台居然还能用。只要爬上去,你就能接入城市广播网,把警报发给北区的每一个人。问题是——用哪个频道。"),
|
||||
# ---- 结局 ----
|
||||
("e1_dawn", _SL_WARM, "加密频道那头沉默了三秒。然后,一个你等了三十天的声音响了起来:“姐?姐!是你吗?”天亮之前,避难所的救援队打开了大门。猎狗的人,在北区的高墙下溃散了。你抱着晨晨,听见广播塔里,重新响起了音乐。后来人们说,这座城市,是从一个女人的声音开始,重新活过来的。——好结局:黎明。感谢你,陪林晚走完了这条路。单击屏幕,可以回到故事开始的地方。"),
|
||||
("e2_price", _SL_EPIC, "你赶在车队前面,撞进了避难所的大门。“关掉广播!关灯!猎狗来了——!”那一夜,北区守住了。探照灯熄灭的时候,晨晨从人群里扑出来抱住你,才发现你胳膊上的血,已经把半边袖子浸透了。坐标是你交出去的,命,是你抢回来的。你赢了,用一条胳膊的代价。——结局:代价。单击屏幕,重新开始。"),
|
||||
("e3_alone", _SL_COLD, "你逃了。一路向南,没有回头。三天后,收音机里关于北区的广播,停了。又过了七天,你在一个废弃的加油站里,听见最后一个频段,也变成了沙沙声。你活下来了。只是从此以后的每一个夜里,你都会想起那声口哨,和你报出坐标时,自己发抖的声音。——坏结局:孤声。单击屏幕,重新开始。"),
|
||||
("e4_static", _SL_EPIC, "公开频道,把所有人都引来了——幸存者,还有猎狗。塔顶的风很大,你看见山下火把连成的长龙,也看见避难所方向亮起的防御火光。猎狗的刀抵上来的时候,你对着麦克风笑了:“听见了吗,晨晨?姐姐的声音,全城都听见了。”信号中断了。但那晚之后,北区,再没有人投降。——结局:静默。单击屏幕,重新开始。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- escape
|
||||
# R2 重设计(docs/design/01):全游戏同一 instruct 常量(00 文档 §3.2,不含语速词)。
|
||||
_ESC = _pair("低沉平稳的男声,冷静、克制,带一点神秘感",
|
||||
"沉稳平静的女声,冷静、克制,带一点神秘感")
|
||||
|
||||
ESCAPE_LINES = [
|
||||
# ---- 开场引导 G0-G7 ----
|
||||
("g0_intro", _ESC, "黑暗。你在废弃的地下设施里醒来,什么都想不起来。你必须找到出路。"),
|
||||
("g1_move", _ESC, "先活动一下。朝任意方向滑一下,走一小步。"),
|
||||
("g1_done", _ESC, "很好,你迈出了第一步。"),
|
||||
("g2_wall", _ESC, "这个房间不大,四面都是墙。再滑两下,试试碰碰墙。"),
|
||||
("g3_left", _ESC, "跟着我指的路走。向左滑。"),
|
||||
("g4_down", _ESC, "对。再向下滑。"),
|
||||
("g5_inspect", _ESC, "听到水声了吗?单击一下,仔细检查这个房间。"),
|
||||
("g5_key", _ESC, "你摸到了一把钥匙!"),
|
||||
("g6_doubletap", _ESC, "钥匙能开锁。双击,是把物品用出去的用法。现在,双击试试。"),
|
||||
("g6_done", _ESC, "对,就是这样。接下来,靠你自己了。"),
|
||||
("g7_goal", _ESC, "找到能用这把钥匙打开的东西。需要帮助时,在原地双击。"),
|
||||
# ---- 引导纠错与跳过 ----
|
||||
("guide_wrong_up", _ESC, "不是这个方向。向上滑。"),
|
||||
("guide_wrong_down", _ESC, "不是这个方向。向下滑。"),
|
||||
("guide_wrong_left", _ESC, "不是这个方向。向左滑。"),
|
||||
("guide_wrong_right", _ESC, "不是这个方向。向右滑。"),
|
||||
("guide_wrong_gesture", _ESC, "不是这个操作。照我说的做。"),
|
||||
("guide_blunt_up", _ESC, "手指贴着屏幕,向上推一下。"),
|
||||
("guide_blunt_down", _ESC, "手指贴着屏幕,向下推一下。"),
|
||||
("guide_blunt_left", _ESC, "手指贴着屏幕,向左推一下。"),
|
||||
("guide_blunt_right", _ESC, "手指贴着屏幕,向右推一下。"),
|
||||
("guide_offpath", _ESC, "先跟我走。"),
|
||||
("guide_skip", _ESC, "跳过引导,直接开始自由探索。"),
|
||||
("reopen_prompt", _ESC, "上滑,重新听一遍引导。单击,直接开始。"),
|
||||
# ---- 卡关三级提示 + 导航 ----
|
||||
("stuck_ask", _ESC, "需要帮助吗?"),
|
||||
("stuck_key_1", _ESC, "铁锈味最重的地方,值得仔细看看。"),
|
||||
("stuck_key_2", _ESC, "去西南角的储物间,单击检查,找到钥匙。"),
|
||||
("stuck_key_3", _ESC, "跟我说的方向走,去西南角的储物间。"),
|
||||
("stuck_cabinet_1", _ESC, "钥匙就在你身上。它能打开某一把锁。"),
|
||||
("stuck_cabinet_2", _ESC, "去东边的铁柜房,双击,用钥匙打开铁柜。"),
|
||||
("stuck_cabinet_3", _ESC, "跟我说的方向走,去铁柜所在的房间。"),
|
||||
("stuck_box_1", _ESC, "保险丝属于电箱。有电流声的地方,就有电箱。"),
|
||||
("stuck_box_2", _ESC, "去西北角的电箱房,双击,把保险丝装进电箱。"),
|
||||
("stuck_box_3", _ESC, "跟我说的方向走,去电箱所在的房间。"),
|
||||
("stuck_gate_1", _ESC, "电已经通了。该去找那扇铁门了。"),
|
||||
("stuck_gate_2", _ESC, "去东南角的大门,双击,打开它,离开这里。"),
|
||||
("stuck_gate_3", _ESC, "跟我说的方向走,去大门所在的房间。"),
|
||||
("nav_up", _ESC, "向上滑。"),
|
||||
("nav_down", _ESC, "向下滑。"),
|
||||
("nav_left", _ESC, "向左滑。"),
|
||||
("nav_right", _ESC, "向右滑。"),
|
||||
# ---- 房间描述 ----
|
||||
("room_storage", _ESC, "一间储物间。货架上堆满杂物,角落里传来水滴声。"),
|
||||
("room_cabinet", _ESC, "这个房间里,立着一个铁柜。"),
|
||||
("room_power", _ESC, "这个房间的墙上,有一只电箱。"),
|
||||
("room_gate", _ESC, "面前,是一扇沉重的铁门。"),
|
||||
# ---- 用时播报 4 段整句 ----
|
||||
("time_band1", _ESC, "你逃出来了,用时不到两分钟。了不起!"),
|
||||
("time_band2", _ESC, "你逃出来了,用时不到五分钟。"),
|
||||
("time_band3", _ESC, "你逃出来了,用时不到十分钟。"),
|
||||
("time_band4", _ESC, "你逃出来了,用时超过十分钟。"),
|
||||
# ---- 保留句(文本沿用,按新 instruct 重录) ----
|
||||
("wall", _ESC, "那边是墙,换个方向。"),
|
||||
("start_room", _ESC, "你站在设施中央的房间里。滑动移动,单击检查,双击使用物品。"),
|
||||
("found_key", _ESC, "你蹲下身在储物架的角落里摸索,指尖碰到一个冰凉的金属物件——是一把钥匙!钥匙到手了。"),
|
||||
("inspect_cabinet_locked", _ESC, "这个房间里立着一个铁柜,柜门上挂着一把锁。也许,有什么钥匙能打开它。"),
|
||||
("unlock_cabinet", _ESC, "钥匙插进锁孔,缓缓转动。咔哒一声,柜门开了。柜子深处,躺着一枚保险丝。保险丝到手了。"),
|
||||
("inspect_box_dead", _ESC, "墙上是一只电箱,箱盖敞开着,里面的保险丝座空空如也。没有电,这里的大门永远打不开。"),
|
||||
("box_fixed", _ESC, "电箱运转正常,电流的嗡鸣声平稳而持续。"),
|
||||
("fix_box", _ESC, "你把保险丝用力按进卡座。火花一闪,嗡鸣声响起——头顶的线路恢复供电了!"),
|
||||
("inspect_gate", _ESC, "面前是一扇沉重的铁门。门边的电子锁一片漆黑,没有任何反应。它需要电力。"),
|
||||
("inspect_gate_ready", _ESC, "电子锁的指示灯亮了,发出微弱的绿光。大门已经通电。双击,打开它,离开这里。"),
|
||||
("escape", _ESC, "你扳动开关。电机轰鸣,沉重的铁门缓缓升起,刺眼的光线涌了进来。你眯起眼睛,向前迈出了第一步。自由了。你逃出来了。"),
|
||||
("use_fail", _ESC, "这里没有用得上东西的地方。换个房间试试,或者先单击检查一下周围。"),
|
||||
("inv_empty", _ESC, "你摸了摸身上,什么也没有。"),
|
||||
("inv_key", _ESC, "你身上带着一把钥匙。"),
|
||||
("inv_fuse", _ESC, "你身上带着一枚保险丝。"),
|
||||
("inv_both", _ESC, "你身上带着一把钥匙和一枚保险丝。"),
|
||||
("replay", _ESC, "黑暗再次合拢。你回到了中央的房间。这一次,你会更快。"),
|
||||
("again", _ESC, "单击屏幕,再来一次。下滑,打开菜单。"),
|
||||
("empty_1", _ESC, "房间里空空荡荡,只有你自己的回声。"),
|
||||
("empty_2", _ESC, "你伸出手,摸到的只有冰冷粗糙的墙壁。"),
|
||||
("empty_3", _ESC, "除了远处的水滴声,这里什么都没有。"),
|
||||
# ---- 菜单与帮助 ----
|
||||
("help_ops", _ESC, "滑动,在房间之间移动。单击,检查房间,或者重听房间的声音。双击,使用物品,或者查看背包。"),
|
||||
("menu_open", _ESC, "菜单。"),
|
||||
("menu_restart", _ESC, "重新开始。"),
|
||||
("menu_home", _ESC, "回大厅。"),
|
||||
("menu_back", _ESC, "返回游戏。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- simon
|
||||
# R2 重设计(docs/design/02):全游戏同一 instruct 常量,轮次/成绩全部整句化。
|
||||
_SIMON = _pair("平静、清晰,像一个耐心的游戏主持人")
|
||||
_SIMON_ROUNDS = ["第一", "第二", "第三", "第四", "第五", "第六", "第七", "第八", "第九", "第十",
|
||||
"第十一", "第十二", "第十三", "第十四", "第十五", "第十六", "第十七", "第十八", "第十九", "第二十"]
|
||||
SIMON_LINES = [
|
||||
# ---- 交互式教学 T0-T7 ----
|
||||
("t0_welcome", _SIMON, "欢迎来到音序回响。先认识六种声音。"),
|
||||
("t0_goal", _SIMON, "我会演奏一串声音,请你记住顺序,原样复现。"),
|
||||
("t1_up", _SIMON, "向上滑,是这个声音。"),
|
||||
("t2_down", _SIMON, "向下滑,是这个声音。"),
|
||||
("t3_left", _SIMON, "向左滑,是这个声音。"),
|
||||
("t4_right", _SIMON, "向右滑,是这个声音。"),
|
||||
("t5_tap", _SIMON, "单击,是这个声音。"),
|
||||
("t6_double", _SIMON, "双击,是这个声音。"),
|
||||
("t_try", _SIMON, "你试试。"),
|
||||
("t_ok", _SIMON, "对了。"),
|
||||
("t_wrong_up", _SIMON, "不是这个,向上滑试试。"),
|
||||
("t_wrong_down", _SIMON, "不是这个,向下滑试试。"),
|
||||
("t_wrong_left", _SIMON, "不是这个,向左滑试试。"),
|
||||
("t_wrong_right", _SIMON, "不是这个,向右滑试试。"),
|
||||
("t_wrong_tap", _SIMON, "不是这个,单击试试。"),
|
||||
("t_wrong_double", _SIMON, "不是这个,双击试试。"),
|
||||
("t_blunt_up", _SIMON, "手指贴着屏幕,向上推一下。"),
|
||||
("t_blunt_down", _SIMON, "手指贴着屏幕,向下推一下。"),
|
||||
("t_blunt_left", _SIMON, "手指贴着屏幕,向左推一下。"),
|
||||
("t_blunt_right", _SIMON, "手指贴着屏幕,向右推一下。"),
|
||||
("t_blunt_tap", _SIMON, "用指尖,轻轻点一下屏幕。"),
|
||||
("t_blunt_double", _SIMON, "用指尖,快速点两下屏幕。"),
|
||||
("t7_ready", _SIMON, "六种声音都记住了。后面的序列,会越来越长。"),
|
||||
("t7_retry", _SIMON, "答错可以消耗机会重听。双击,可以随时重听。"),
|
||||
("t_skip", _SIMON, "跳过教学,直接开始。"),
|
||||
# ---- 轮次播报(整句,round_01..round_20 + 兜底) ----
|
||||
*[(f"round_{i + 1:02d}", _SIMON, f"{w}轮,听好了。") for i, w in enumerate(_SIMON_ROUNDS)],
|
||||
("round_more", _SIMON, "新的一轮,听好了。"),
|
||||
# ---- 成绩播报 6 段整句 ----
|
||||
("score_band1", _SIMON, "一轮都没坚持下来。别灰心。"),
|
||||
("score_band2", _SIMON, "你坚持了三轮以内。"),
|
||||
("score_band3", _SIMON, "你坚持了五轮左右。"),
|
||||
("score_band4", _SIMON, "你坚持了将近十轮。"),
|
||||
("score_band5", _SIMON, "你坚持了十轮以上,很厉害。"),
|
||||
("score_band6", _SIMON, "你坚持了十五轮以上,大师级!"),
|
||||
# ---- 流程句 ----
|
||||
("your_turn", _SIMON, "轮到你了。"),
|
||||
("chances_out", _SIMON, "重听机会用完了,小心。"),
|
||||
("retry_use", _SIMON, "错了。用一次机会,再听一遍。"),
|
||||
("relisten", _SIMON, "再听一遍。"),
|
||||
("replay", _SIMON, "单击屏幕,再来一局。"),
|
||||
("rules_prompt", _SIMON, "上滑,重听规则。下滑,打开菜单。"),
|
||||
("rules", _SIMON, "上行音,向上滑。下行音,向下滑。左边的音,向左滑。右边的音,向右滑。一声轻响,单击。两声轻响,双击。"),
|
||||
("gameover", _SIMON, "游戏结束。"),
|
||||
# ---- 反馈短句 ----
|
||||
("correct", _SIMON, "答对了!"),
|
||||
("wrong", _SIMON, "错了。"),
|
||||
("chance_plus", _SIMON, "机会加一。"),
|
||||
("new_record", _SIMON, "新纪录!"),
|
||||
# ---- 菜单 ----
|
||||
("menu_open", _SIMON, "菜单。"),
|
||||
("menu_restart", _SIMON, "重新开始。"),
|
||||
("menu_home", _SIMON, "回大厅。"),
|
||||
("menu_back", _SIMON, "返回游戏。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- rhythm
|
||||
# R3 重构(docs/design/03-rhythm-voice-ids.md):31 句,全表同一 instruct 常量。
|
||||
_RHY = _pair("低沉醇厚的男声,像节奏游戏主持人,平稳有力",
|
||||
"活力清晰的年轻女声,像节奏游戏主持人,情绪饱满但不夸张")
|
||||
|
||||
RHYTHM_LINES = [
|
||||
# ---- W 组 · 开场与流程 ----
|
||||
("welcome", _RHY, "欢迎来到节拍行者。跟着音乐,我奏一句,你跟一句。"),
|
||||
("welcome_back", _RHY, "欢迎回来。我奏一句,你跟一句。"),
|
||||
("tut_1", _RHY, "听,底鼓。在下一小节的同一个位置,单击。"),
|
||||
("tut_2", _RHY, "这次是左和右。跟着打,左滑,右滑。"),
|
||||
("tut_3", _RHY, "最后一句,三个音,中间是双击。"),
|
||||
("tut_done", _RHY, "很好,演出开始。"),
|
||||
("tut_skip", _RHY, "跳过教学,直接开始。"),
|
||||
("mid_song", _RHY, "过半了,保持。"),
|
||||
# ---- C 组 · 教学纠正 ----
|
||||
("retry_early", _RHY, "早了,等鼓点再出手。"),
|
||||
("retry_late", _RHY, "晚了,跟着鼓点走。"),
|
||||
("retry_gesture", _RHY, "不是这个手势,再听一遍。"),
|
||||
("retry_listen", _RHY, "再听一遍,跟着来。"),
|
||||
("retry_skip", _RHY, "没关系,我们先走下一句。"),
|
||||
# ---- S 组 · 选曲与解锁 ----
|
||||
("select_hint", _RHY, "左右滑选曲目,单击开始,下滑回大厅。"),
|
||||
("song1_name", _RHY, "第一首,启程,九十拍。"),
|
||||
("song2_name", _RHY, "第二首,追风,一百零五拍。"),
|
||||
("song3_name", _RHY, "第三首,星爆,一百二十拍。"),
|
||||
("song_locked", _RHY, "这首还没解锁。上一首拿到 B 级以上就能解锁。"),
|
||||
("unlock_song2", _RHY, "新曲目解锁,追风!"),
|
||||
("unlock_song3", _RHY, "新曲目解锁,星爆!"),
|
||||
# ---- G 组 · 结算 ----
|
||||
("grade_s", _RHY, "S 级!准确率超过九成五,完美演出。"),
|
||||
("grade_a", _RHY, "A 级!准确率超过八成五,非常出色。"),
|
||||
("grade_b", _RHY, "B 级,准确率超过七成。不错,还能更好。"),
|
||||
("grade_c", _RHY, "C 级,准确率不到七成。别灰心,再来一遍。"),
|
||||
("new_best_combo", _RHY, "新连击纪录!"),
|
||||
("restart", _RHY, "单击再来一遍,右滑下一首,下滑回大厅。"),
|
||||
# ---- M 组 · 菜单与规则 ----
|
||||
("rules", _RHY, "规则:我奏一句,你在下一小节原样跟打。音要对,拍点也要对。"),
|
||||
("settings_hint", _RHY, "音色、语速、背景音乐,请回大厅修改。"),
|
||||
("menu_main", _RHY, "菜单。单击切换选项,双击确认。"),
|
||||
("menu_resume", _RHY, "继续演奏。"),
|
||||
("menu_lobby", _RHY, "回大厅。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- space
|
||||
# R3 备产(docs/design/04-space-voice-ids.md):52 句全量替换 R1 版,全表同一 instruct 常量。
|
||||
_SPC = _pair("低沉醇厚的男声,像飞船领航员,沉稳有力",
|
||||
"平静清晰的女声,像飞船领航员,沉稳可信")
|
||||
SPACE_LINES = [
|
||||
# ---- D 组 · 交互式演练 ----
|
||||
("drill_welcome", _SPC, "欢迎登上盲航。你只有一双耳朵。"),
|
||||
("drill_rule", _SPC, "陨石来了,会有声音告诉你方位。"),
|
||||
("drill_left", _SPC, "听,左边有陨石。向左滑,把它拨开!"),
|
||||
("drill_right", _SPC, "这次在右边。向右滑!"),
|
||||
("drill_up", _SPC, "上面有东西,声音又尖又高。向上滑!"),
|
||||
("drill_down", _SPC, "下面是低沉的轰隆。向下滑!"),
|
||||
("drill_good", _SPC, "漂亮。"),
|
||||
("drill_retry_left", _SPC, "那是左边来的,向左滑。"),
|
||||
("drill_retry_right", _SPC, "那是右边来的,向右滑。"),
|
||||
("drill_retry_up", _SPC, "声音又高又尖,是上面,向上滑。"),
|
||||
("drill_retry_down", _SPC, "轰隆声在下面,向下滑。"),
|
||||
("drill_moveon", _SPC, "没关系,跟着我,我们继续。"),
|
||||
("drill_done", _SPC, "四个方向你都会了。引擎点火,出发!"),
|
||||
("drill_skip", _SPC, "跳过演练,直接出发。"),
|
||||
# ---- P 组 · 保护期 ----
|
||||
("protect_left", _SPC, "训练护盾接住了。那颗在左边,听到左边的呼啸,向左滑。"),
|
||||
("protect_right", _SPC, "训练护盾接住了。那颗在右边,听到右边的呼啸,向右滑。"),
|
||||
("protect_up", _SPC, "训练护盾接住了。那颗在上面,听到高高的呼啸,向上滑。"),
|
||||
("protect_down", _SPC, "训练护盾接住了。那颗在下面,听到低沉的轰隆,向下滑。"),
|
||||
("protect_end", _SPC, "训练护盾关闭。接下来,靠你自己了。"),
|
||||
# ---- F 组 · 单击/双击分批引入 ----
|
||||
("feat_boost", _SPC, "试试单击,引擎会短促加速,直接冲过空隙。"),
|
||||
("feat_boost_done", _SPC, "感觉到了吗?加速的时候,陨石撞不上你。"),
|
||||
("feat_shield", _SPC, "双击展开护盾,能挡下一次撞击。"),
|
||||
("feat_shield_done", _SPC, "护盾展开后会自动充能,好了我会告诉你。"),
|
||||
("shield_ready", _SPC, "护盾充能完毕。"),
|
||||
("shield_block", _SPC, "护盾挡下了撞击。"),
|
||||
# ---- H 组 · 被撞归因 ----
|
||||
("hit_left", _SPC, "左边撞上的。听到左边的呼啸,向左滑。"),
|
||||
("hit_right", _SPC, "右边撞上的。听到右边的呼啸,向右滑。"),
|
||||
("hit_up", _SPC, "上面撞上的。听到高高的呼啸,向上滑。"),
|
||||
("hit_down", _SPC, "下面撞上的。听到低沉的轰隆,向下滑。"),
|
||||
("hit_last", _SPC, "船体只剩最后一次了,小心!"),
|
||||
# ---- M 组 · 里程碑 ----
|
||||
("ms_10", _SPC, "十颗了,稳。"),
|
||||
("ms_25", _SPC, "二十五颗,老练的飞行员。"),
|
||||
("ms_50", _SPC, "五十颗,传奇。"),
|
||||
("ms_100", _SPC, "一百颗!天空属于你。"),
|
||||
# ---- R 组 · 结算 ----
|
||||
("time_1", _SPC, "不到三十秒,再试一次。"),
|
||||
("time_2", _SPC, "超过三十秒,有进步。"),
|
||||
("time_3", _SPC, "超过一分钟,合格的飞行员。"),
|
||||
("time_4", _SPC, "超过两分钟,王牌飞行员。"),
|
||||
("dodge_1", _SPC, "只拨开几颗。"),
|
||||
("dodge_2", _SPC, "拨开了十来颗。"),
|
||||
("dodge_3", _SPC, "拨开了几十颗。"),
|
||||
("dodge_4", _SPC, "拨开了一大群,漂亮。"),
|
||||
("new_record", _SPC, "新纪录!"),
|
||||
("restart_hint", _SPC, "单击再飞一次,下滑回大厅。"),
|
||||
# ---- Y 组 · 菜单与系统 ----
|
||||
("second_run_ask", _SPC, "上滑重新听演练,单击直接出发。"),
|
||||
("menu_main", _SPC, "菜单。单击切换选项,双击确认。"),
|
||||
("menu_resume", _SPC, "继续飞行。"),
|
||||
("menu_lobby", _SPC, "回大厅。"),
|
||||
("menu_help", _SPC, "重新听教学要点。"),
|
||||
("help_recap", _SPC, "左边来向左滑,右边来向右滑,上面来向上滑,下面来向下滑。单击加速,双击护盾。"),
|
||||
("settings_hint", _SPC, "音色、语速、背景音乐,请回大厅修改。"),
|
||||
("paused", _SPC, "已暂停。"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------- rpg
|
||||
# R4 新游戏《剑与声》第一章(docs/design/05-rpg-ch1-voice-ids.md + 05-rpg-addendum.md §6.2)。
|
||||
# 旁白全表同一 instruct 常量;NPC 单音色平铺见 RPG_NPC。
|
||||
_RPG = _pair("低沉醇厚的男声,像说书人,平静有韵味",
|
||||
"平静清晰的年轻女声,像在讲武侠故事")
|
||||
RPG_LINES = [
|
||||
# ---- A 组 · 序章 ----
|
||||
("intro_1", _RPG, "你的眼睛看不见了。但你的耳朵,比谁都灵。"),
|
||||
("intro_2", _RPG, "黑风寨掳走了你唯一的徒弟。追进迷雾林,把他们连根拔起。"),
|
||||
("intro_3", _RPG, "先记住,怎么用耳朵走路。"),
|
||||
# ---- S 组 · 系统/菜单/状态 ----
|
||||
("sys_continue_ask", _RPG, "检测到你的旅程记录。单击继续,右滑开始新的旅程。"),
|
||||
("sys_new_game", _RPG, "新的旅程,开始了。"),
|
||||
("sys_saved", _RPG, "进度已保存。"),
|
||||
("sys_rest", _RPG, "你在篝火边睡了一觉,状态全满。"),
|
||||
("sys_drink", _RPG, "溪水清冽,你喝了个饱,伤势尽消。"),
|
||||
("sys_menu", _RPG, "菜单。左右滑选择,单击确认。"),
|
||||
("sys_menu_resume", _RPG, "继续旅程。"),
|
||||
("sys_menu_save", _RPG, "保存并回大厅。"),
|
||||
("sys_menu_help", _RPG, "重新听帮助。"),
|
||||
("help_explore", _RPG, "左右滑切换选项,单击确认。双击重新听场景,上滑查看状态,下滑打开菜单。"),
|
||||
("help_battle", _RPG, "战斗中,左右滑选招式,单击出手。听到敌人的前摇,双击格挡。"),
|
||||
("settings_hint", _RPG, "音色、语速、背景音乐,请回大厅修改。"),
|
||||
("st_hp_high", _RPG, "你状态很好。"),
|
||||
("st_hp_mid", _RPG, "你受了伤。"),
|
||||
("st_hp_low", _RPG, "你伤势很重,命悬一线。"),
|
||||
("st_gold_poor", _RPG, "口袋空空。"),
|
||||
("st_gold_some", _RPG, "有些积蓄。"),
|
||||
("st_gold_rich", _RPG, "腰包鼓鼓。"),
|
||||
("st_lv_low", _RPG, "你初出茅庐。"),
|
||||
("st_lv_mid", _RPG, "你小有名气。"),
|
||||
("st_lv_high", _RPG, "你已是高手。"),
|
||||
("st_weapon_none", _RPG, "你赤手空拳。"),
|
||||
("st_weapon_iron", _RPG, "你手持铁剑。"),
|
||||
("st_weapon_steel", _RPG, "你手持青钢剑。"),
|
||||
("st_weapon_wind", _RPG, "你手持听风剑。"),
|
||||
("st_armor_none", _RPG, "你身无片甲。"),
|
||||
("st_armor_leather", _RPG, "你身着皮甲。"),
|
||||
("st_armor_iron", _RPG, "你身着铁鳞甲。"),
|
||||
# ---- D 组 · 场景描述 ----
|
||||
("desc_village", _RPG, "雾口村。篝火噼啪作响,村民低声议论着林子里的土匪。"),
|
||||
("desc_village_re", _RPG, "你回到了雾口村。"),
|
||||
("desc_path", _RPG, "林间小路。风穿过树叶,脚下的落叶沙沙作响。"),
|
||||
("desc_path_re", _RPG, "你回到林间小路。"),
|
||||
("desc_fork", _RPG, "岔路口。风声在左右两边,听起来不太一样。"),
|
||||
("desc_fork_re", _RPG, "你回到岔路口。"),
|
||||
("desc_chest", _RPG, "一个枯树洞。洞里似乎藏着什么。"),
|
||||
("desc_chest_re", _RPG, "你回到枯树洞前。"),
|
||||
("desc_stream", _RPG, "溪流。水声潺潺,是个歇脚的好地方。"),
|
||||
("desc_stream_re", _RPG, "你回到溪流边。"),
|
||||
("desc_hunter", _RPG, "猎人小屋。柴火正旺,还有磨刀的声音。"),
|
||||
("desc_hunter_re", _RPG, "你回到猎人小屋。"),
|
||||
("desc_deepwood", _RPG, "密林深处。虫鸣急促,远处有狼嚎,杀气很重。"),
|
||||
("desc_deepwood_re", _RPG, "你回到密林深处。"),
|
||||
("desc_gate", _RPG, "黑风寨门前。火把熊熊,人声嘈杂。"),
|
||||
("desc_hall", _RPG, "聚义厅。火盆熊熊,大厅空旷,回声冰冷。"),
|
||||
# ---- O 组 · 选项名 ----
|
||||
("opt_go_path", _RPG, "前进,去林间小路。"),
|
||||
("opt_go_fork", _RPG, "前进,去岔路口。"),
|
||||
("opt_go_chest", _RPG, "往左,看看树洞。"),
|
||||
("opt_go_stream", _RPG, "往右,去溪流。"),
|
||||
("opt_go_hunter", _RPG, "前进,去猎人小屋。"),
|
||||
("opt_go_deepwood", _RPG, "前进,进密林深处。"),
|
||||
("opt_go_gate", _RPG, "前进,去寨门。"),
|
||||
("opt_back", _RPG, "返回。"),
|
||||
("opt_shop", _RPG, "去商店。"),
|
||||
("opt_rest", _RPG, "休息一晚。"),
|
||||
("opt_status", _RPG, "查看状态。"),
|
||||
("opt_investigate", _RPG, "调查四周。"),
|
||||
("opt_open_chest", _RPG, "打开树洞。"),
|
||||
("opt_drink", _RPG, "饮水休息。"),
|
||||
("opt_talk_hunter", _RPG, "和老猎人谈谈。"),
|
||||
("opt_assault", _RPG, "强攻寨门。"),
|
||||
("opt_sneak", _RPG, "绕行潜入。"),
|
||||
("opt_leave", _RPG, "离开。"),
|
||||
# ---- E 组 · 事件 ----
|
||||
("evt_gold15", _RPG, "你在落叶下摸到十五枚金币。"),
|
||||
("evt_nothing", _RPG, "这里什么都没有了。"),
|
||||
("evt_chest", _RPG, "树洞里有两瓶金疮药,你收好了。"),
|
||||
("evt_hunter_gift", _RPG, "老猎人塞给你一瓶金疮药,还说商店会给你算便宜些。"),
|
||||
("evt_gate_open", _RPG, "寨门缓缓打开。"),
|
||||
("evt_ch1_end", _RPG, "寨主倒了,但徒弟不在这里。一张字条,指向南方的腐沼。"),
|
||||
# ---- B 组 · 战斗 ----
|
||||
("btl_start_wolf", _RPG, "野狼拦路!"),
|
||||
("btl_start_bandit", _RPG, "山贼围上来了!"),
|
||||
("btl_start_spider", _RPG, "毒蛛从树上垂了下来!"),
|
||||
("btl_start_elite", _RPG, "寨丁头目挡住了去路!"),
|
||||
("btl_start_boss1", _RPG, "黑风寨主,就在你面前。"),
|
||||
("cmd_attack", _RPG, "攻击。"),
|
||||
("cmd_skill", _RPG, "破空斩。"),
|
||||
("cmd_defend", _RPG, "防御。"),
|
||||
("cmd_item", _RPG, "金疮药。"),
|
||||
("btl_nomp", _RPG, "内劲不足。"),
|
||||
("btl_noitem", _RPG, "没有金疮药了。"),
|
||||
("btl_flee", _RPG, "你抽身退出了战斗。"),
|
||||
("btl_noflee", _RPG, "避无可避!"),
|
||||
("btl_half_bandit", _RPG, "山贼的攻势缓了下来。"),
|
||||
("btl_enemy_die", _RPG, "敌人倒下了。"),
|
||||
("btl_win", _RPG, "战斗胜利。"),
|
||||
("btl_loot_small", _RPG, "小有收获。"),
|
||||
("btl_loot_mid", _RPG, "收获不少。"),
|
||||
("btl_loot_big", _RPG, "大获全胜,收获颇丰。"),
|
||||
("btl_levelup", _RPG, "你的修为精进了。"),
|
||||
("btl_lose", _RPG, "你倒下了,黑暗吞没了一切。"),
|
||||
("btl_lose_hint", _RPG, "记住,听清前摇,双击格挡。"),
|
||||
("tut_b1", _RPG, "它要扑了,听前摇!"),
|
||||
("tut_b2", _RPG, "就是现在,双击格挡!"),
|
||||
("tut_b3", _RPG, "挡住了!轮到你了。左右滑选攻击,单击出剑。"),
|
||||
("tut_b4", _RPG, "没挡住也没关系,再来一次。"),
|
||||
("tut_b5", _RPG, "好剑法!接下来,靠你自己了。"),
|
||||
# ---- SH 组 · 商店 ----
|
||||
("shop_open", _RPG, "商店。左右滑看看货,单击购买。"),
|
||||
("shop_price_sword", _RPG, "铁剑,一百金。"),
|
||||
("shop_price_armor", _RPG, "皮甲,八十金。"),
|
||||
("shop_price_potion", _RPG, "金疮药,三十金。"),
|
||||
("shop_owned", _RPG, "这件你已经有了。"),
|
||||
("shop_poor", _RPG, "钱不够。"),
|
||||
("shop_buy", _RPG, "买下了。"),
|
||||
# ---- addendum §6.2 补录:敌方血量档位(旁白) ----
|
||||
("foe_hp_high", _RPG, "敌人气焰正盛。"),
|
||||
("foe_hp_mid", _RPG, "敌人露出疲态。"),
|
||||
("foe_hp_low", _RPG, "敌人撑不住了。"),
|
||||
]
|
||||
|
||||
# NPC 单音色平铺(id, speaker, instruct, 台词)→ web/rpg/audio/voice/<id>.*
|
||||
# 角色 speaker+instruct 恒定(村长与寨主同 speaker 不同 instruct,从不在同场景出现)。
|
||||
RPG_NPC = [
|
||||
("npc_headman_1", "Uncle_Fu", "慈祥和缓的老者", "外乡人,林子里不太平。这五十金,拿着防身。"),
|
||||
("npc_headman_2", "Uncle_Fu", "慈祥和缓的老者", "商店就在村里,缺什么就去看看。"),
|
||||
("npc_headman_3", "Uncle_Fu", "慈祥和缓的老者", "听说你是用耳朵走路的剑客?保重啊。"),
|
||||
("npc_headman_4", "Uncle_Fu", "慈祥和缓的老者", "救救那些被掳走的孩子吧。"),
|
||||
("npc_hunter_1", "Eric", "沙哑粗犷,豪爽中带着暖意", "站住。……哦,是个用耳朵的。"),
|
||||
("npc_hunter_2", "Eric", "沙哑粗犷,豪爽中带着暖意", "寨子里有我旧识。带上这个,别死在路上。"),
|
||||
("npc_hunter_3", "Eric", "沙哑粗犷,豪爽中带着暖意", "黑风寨主的双刀很快,听清他出刀前的动静。"),
|
||||
("npc_hunter_4", "Eric", "沙哑粗犷,豪爽中带着暖意", "去吧。让我听听,你的传说。"),
|
||||
("npc_elite_1", "Dylan", "凶狠粗鲁,大声喝骂", "哪儿来的瞎子,敢闯黑风寨!"),
|
||||
("npc_elite_half", "Dylan", "凶狠粗鲁,大声喝骂", "你……你到底是什么人!"),
|
||||
("npc_elite_die", "Dylan", "凶狠粗鲁,大声喝骂", "寨主……会替我报仇……"),
|
||||
("npc_boss1_1", "Uncle_Fu", "低沉阴狠,慢条斯理", "盲剑客?正好,省得挖你的眼睛。"),
|
||||
("npc_boss1_2", "Uncle_Fu", "低沉阴狠,慢条斯理", "有两下子。可惜,你听不懂死期的声音。"),
|
||||
("npc_boss1_die", "Uncle_Fu", "低沉阴狠,慢条斯理", "白无瞳……会找到你……"),
|
||||
]
|
||||
NPCS = {"rpg": RPG_NPC}
|
||||
|
||||
GAMES = {
|
||||
"storyline": STORYLINE_LINES,
|
||||
"escape": ESCAPE_LINES,
|
||||
"simon": SIMON_LINES,
|
||||
"rhythm": RHYTHM_LINES,
|
||||
"space": SPACE_LINES,
|
||||
"rpg": RPG_LINES,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具函数
|
||||
def split_text(text, limit=SPLIT_LIMIT):
|
||||
"""按句末标点拆句并合并到 <=limit 字;超长单句再按逗号拆。"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
sents = [s for s in re.split(r"(?<=[。!?;…])", text) if s]
|
||||
chunks, cur = [], ""
|
||||
for s in sents:
|
||||
if cur and len(cur) + len(s) > limit:
|
||||
chunks.append(cur)
|
||||
cur = s
|
||||
else:
|
||||
cur += s
|
||||
if cur:
|
||||
chunks.append(cur)
|
||||
out = []
|
||||
for c in chunks:
|
||||
if len(c) <= limit:
|
||||
out.append(c)
|
||||
else: # 单句仍超限,按逗号硬拆
|
||||
parts = [p for p in re.split(r"(?<=[,、:])", c) if p]
|
||||
buf = ""
|
||||
for p in parts:
|
||||
if buf and len(buf) + len(p) > limit:
|
||||
out.append(buf)
|
||||
buf = p
|
||||
else:
|
||||
buf += p
|
||||
if buf:
|
||||
out.append(buf)
|
||||
return [o for o in out if o.strip()]
|
||||
|
||||
|
||||
def staging_path(game, gender, fid):
|
||||
return os.path.join(STAGING, game, gender, fid + ".wav")
|
||||
|
||||
|
||||
def valid_wav(path):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
data, sr = sf.read(path)
|
||||
except Exception:
|
||||
return False
|
||||
if len(data) == 0:
|
||||
return False
|
||||
dur = len(data) / sr
|
||||
peak = float(np.max(np.abs(data)))
|
||||
return dur > 0.3 and peak > 0.05
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 生成
|
||||
def generate(games, genders, only):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
device_map="cuda:0",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
print("model loaded", flush=True)
|
||||
|
||||
for game in games:
|
||||
lines = GAMES[game]
|
||||
if only:
|
||||
lines = [x for x in lines if x[0] in only]
|
||||
for gender in genders:
|
||||
speaker = SPEAKERS[game][gender]
|
||||
ins_idx = 0 if gender == "male" else 1
|
||||
outdir = os.path.join(STAGING, game, gender)
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
|
||||
# 展开成 (fid, chunk_idx, n_chunks, text, instruct),跳过已完成
|
||||
items = []
|
||||
for fid, ins_pair, text in lines:
|
||||
path = staging_path(game, gender, fid)
|
||||
if valid_wav(path):
|
||||
continue
|
||||
chunks = split_text(text)
|
||||
for ci, ctext in enumerate(chunks):
|
||||
items.append((fid, ci, len(chunks), ctext, ins_pair[ins_idx]))
|
||||
if not items:
|
||||
print(f"[{game}/{gender}] nothing to do", flush=True)
|
||||
continue
|
||||
|
||||
# 短句(数字类)优先、同 instruct 聚合成整批,减少 prosody 漂移
|
||||
shorts = [it for it in items if len(it[3]) <= 2]
|
||||
longs = [it for it in items if len(it[3]) > 2]
|
||||
queue = ([("short", shorts[i:i + BATCH_SHORT]) for i in range(0, len(shorts), BATCH_SHORT)]
|
||||
+ [("long", longs[i:i + BATCH_LONG]) for i in range(0, len(longs), BATCH_LONG)])
|
||||
|
||||
results = {} # fid -> {chunk_idx: wav}
|
||||
for bi, (kind, batch) in enumerate(queue):
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=[b[3] for b in batch],
|
||||
language=["Chinese"] * len(batch),
|
||||
speaker=[speaker] * len(batch),
|
||||
instruct=[b[4] for b in batch],
|
||||
max_new_tokens=2048,
|
||||
)
|
||||
for (fid, ci, _n, _t, _i), wav in zip(batch, wavs):
|
||||
results.setdefault(fid, {})[ci] = np.asarray(wav, dtype=np.float32)
|
||||
print(f"[{game}/{gender}] batch {bi + 1}/{len(queue)} ({kind}, n={len(batch)})", flush=True)
|
||||
|
||||
gap = np.zeros(int(GAP_SEC * sr), dtype=np.float32)
|
||||
for fid, ins_pair, text in lines:
|
||||
path = staging_path(game, gender, fid)
|
||||
if valid_wav(path):
|
||||
continue
|
||||
got = results.get(fid, {})
|
||||
n_chunks = len(split_text(text))
|
||||
assert len(got) == n_chunks, f"{game}/{gender}/{fid}: 缺 chunk {len(got)}/{n_chunks}"
|
||||
wav = got[0]
|
||||
for ci in range(1, n_chunks):
|
||||
wav = np.concatenate([wav, gap, got[ci]])
|
||||
peak = float(np.max(np.abs(wav)))
|
||||
assert peak > 0.05, f"{game}/{gender}/{fid}: peak {peak:.3f}"
|
||||
wav = wav / peak * PEAK_NORM
|
||||
dur = len(wav) / sr
|
||||
assert dur > 0.3, f"{game}/{gender}/{fid}: dur {dur:.2f}s"
|
||||
sf.write(path, wav, sr)
|
||||
print(f"[ok] {game}/{gender}/{fid} {dur:.2f}s chunks={n_chunks}", flush=True)
|
||||
|
||||
# NPC 单音色平铺(staging <game>/npc/,convert 落 flat voice/)
|
||||
npc_lines = NPCS.get(game)
|
||||
if npc_lines:
|
||||
outdir = os.path.join(STAGING, game, "npc")
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
todo = [x for x in npc_lines if not valid_wav(os.path.join(outdir, x[0] + ".wav"))]
|
||||
for i in range(0, len(todo), BATCH_LONG):
|
||||
batch = todo[i:i + BATCH_LONG]
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=[b[3] for b in batch],
|
||||
language=["Chinese"] * len(batch),
|
||||
speaker=[b[1] for b in batch],
|
||||
instruct=[b[2] for b in batch],
|
||||
max_new_tokens=2048,
|
||||
)
|
||||
for (fid, _sp, _ins, _t), wav in zip(batch, wavs):
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
peak = float(np.max(np.abs(wav)))
|
||||
assert peak > 0.05, f"{game}/npc/{fid}: peak {peak:.3f}"
|
||||
wav = wav / peak * PEAK_NORM
|
||||
dur = len(wav) / sr
|
||||
assert dur > 0.3, f"{game}/npc/{fid}: dur {dur:.2f}s"
|
||||
sf.write(os.path.join(outdir, fid + ".wav"), wav, sr)
|
||||
print(f"[ok] {game}/npc/{fid} {dur:.2f}s", flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 检查
|
||||
def check(games):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
problems = []
|
||||
for game in games:
|
||||
print(f"=== {game} ===")
|
||||
for fid, _ins, _text in GAMES[game]:
|
||||
durs = {}
|
||||
for gender in ("male", "female"):
|
||||
path = staging_path(game, gender, fid)
|
||||
if not os.path.exists(path):
|
||||
durs[gender] = None
|
||||
continue
|
||||
data, sr = sf.read(path)
|
||||
peak = float(np.max(np.abs(data)))
|
||||
durs[gender] = len(data) / sr
|
||||
if durs[gender] <= 0.3 or peak <= 0.05:
|
||||
problems.append(f"{game}/{gender}/{fid}: 质量不达标 dur={durs[gender]:.2f} peak={peak:.3f}")
|
||||
m, f = durs["male"], durs["female"]
|
||||
if m is None or f is None:
|
||||
problems.append(f"{game}/{fid}: 缺 {'male' if m is None else 'female'}")
|
||||
continue
|
||||
ratio = max(m, f) / min(m, f)
|
||||
flag = " <-- OUTLIER" if ratio > 3 else ""
|
||||
if ratio > 3:
|
||||
problems.append(f"{game}/{fid}: 时长离群 m={m:.2f}s f={f:.2f}s ratio={ratio:.1f}")
|
||||
print(f" {fid:24s} m={m:6.2f}s f={f:6.2f}s{flag}")
|
||||
print()
|
||||
if problems:
|
||||
print("PROBLEMS:")
|
||||
for p in problems:
|
||||
print(" ", p)
|
||||
return 1
|
||||
print("ALL OK")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 转码
|
||||
def convert(games):
|
||||
# 只按台词表转换(不扫目录),staging 里残留的旧 id wav 不会落盘
|
||||
total = 0
|
||||
for game in games:
|
||||
for gender in ("male", "female"):
|
||||
dst_dir = os.path.join(ROOT, "web", game, "audio", "voice", gender)
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
for fid, _ins, _text in GAMES[game]:
|
||||
src = staging_path(game, gender, fid)
|
||||
if not os.path.exists(src):
|
||||
print(f"[convert] WARN missing {game}/{gender}/{fid}.wav")
|
||||
continue
|
||||
base = os.path.join(dst_dir, fid)
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
|
||||
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
|
||||
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
|
||||
total += 1
|
||||
# NPC 平铺(staging <game>/npc/ → audio/voice/<id>.*)
|
||||
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)
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
|
||||
"-ar", "44100", "-c:a", "libvorbis", "-q:a", "3", base + ".ogg"], check=True)
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src,
|
||||
"-ar", "44100", "-c:a", "aac", "-b:a", "128k", base + ".m4a"], check=True)
|
||||
total += 1
|
||||
print(f"[convert] {game} done")
|
||||
print(f"converted {total} files (ogg+m4a each)")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--game", required=True,
|
||||
choices=list(GAMES.keys()) + ["all"])
|
||||
ap.add_argument("--gender", default="both", choices=["male", "female", "both"])
|
||||
ap.add_argument("--only", default="", help="逗号分隔的 id 列表,只生成这些")
|
||||
ap.add_argument("--check", action="store_true")
|
||||
ap.add_argument("--convert", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
games = list(GAMES.keys()) if args.game == "all" else [args.game]
|
||||
genders = ["male", "female"] if args.gender == "both" else [args.gender]
|
||||
only = set(x for x in args.only.split(",") if x)
|
||||
|
||||
if args.check:
|
||||
sys.exit(check(games))
|
||||
if args.convert:
|
||||
convert(games)
|
||||
return
|
||||
generate(games, genders, only)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,3 +29,66 @@ ffmpeg:裁剪前 120s → 首尾 3s acrossfade(tri)无缝循环化 → lou
|
||||
I=-20/TP=-2/LRA=11 → aresample 44.1kHz 立体声 → libvorbis `-q:a 3`。
|
||||
成品均 117.00s(storyline mean -22.3dB,escape mean -21.1dB),保持原文件名
|
||||
直接替换 assets,游戏代码零改动。原合成版备份在 `tools/bgm_backup/`。
|
||||
| 3 | 157922006990851 | rhythm《节拍行者》groove.ogg | constant tempo 120 bpm electronic dance music, steady four-on-the-floor kick drum beat, driving synth bass, crisp hi-hats, energetic, no tempo changes, seamless loop, instrumental | succeeded | $0.03 |
|
||||
|
||||
- 任务 3(rhythm):候选 0 = 158.35s(choice 157922107719682);候选 1 = 194.23s(choice 157922107719683)。flac 存 `tools/mureka_out/rhythm/`。
|
||||
| 3 | 157922351054851 | space《盲航》engine.ogg | deep spaceship engine hum ambient, continuous low drone with subtle pulsing thruster texture, calm tension, dark sci-fi atmosphere, seamless loop, instrumental | succeeded | $0.03 |
|
||||
|
||||
space:选用候选 0(180.03s,choice 157922441101313),后处理同前(120s 裁剪+3s 首尾 crossfade+loudnorm -20LUFS),成品 117.00s mean -21.5dB。space 预算线:$0.03/$0.15。
|
||||
|
||||
simon:任务 157922504015873,一次成功(首次 429 重试 1 次),候选 0 = 202.7s 选用(choice 157922638299138),裁剪 117s 循环化 + loudnorm -20。估 $0.03。
|
||||
|
||||
## 2026-08-29 R3:rhythm《节拍行者》重构 BGM(track90/105/120)
|
||||
|
||||
预算线:本轮 $0.30;总预算 $2.00(5 轮)。
|
||||
|
||||
| # | task id | 用途 | 状态 | 花费 |
|
||||
|---|---|---|---|---|
|
||||
| 6 | 157963509563393 | track90 首提(2 候选) | succeeded | $0.03 |
|
||||
| 7 | 157963652431875 | track105 首提(2 候选) | succeeded | $0.03 |
|
||||
| 8 | 157963818106881 | track120 首提(2 候选) | succeeded | $0.03 |
|
||||
| 9 | 157964073893889 | track90 重提(第 2 次,2 候选) | succeeded | $0.03 |
|
||||
| 10 | 157964212305921 | track120 重提(第 2 次,2 候选) | succeeded | $0.03 |
|
||||
|
||||
**本轮花费:$0.15 / $0.30。累计:$0.30 / $2.00。**
|
||||
|
||||
### BPM 实测与选用(librosa 梳状扫描法 tools/bpm_precise.py,合成 105 点击轨校准=105.00 精确)
|
||||
|
||||
注意:`librosa.beat.beat_track` 在 22050/512 下按整帧量化(105 附近一档=4.3 BPM),
|
||||
读数 103.36/107.67 均为量化伪影,不作为判据;以梳状扫描(0.05 BPM 步进+抛物线插值)为准。
|
||||
|
||||
- track90:首提 121.0 / 98.75,重提 80.0 / 120.0 —— 4 候选全部不达标。已达"每首最多重试 1 次"上限,
|
||||
选用首提候选 1(98.75,choice 157963627134980)以 atempo=0.911392 拉伸至 90,
|
||||
成品实测 **90.01 BPM**(循环 85.33s = 32 小节)。
|
||||
- track105:首提候选 0 实测 **105.00 精确**(choice 157963780292611,168.03s),无需重提/拉伸;
|
||||
成品实测 **104.99 BPM**(循环 73.14s = 32 小节)。
|
||||
- track120:首提 123.05 / 117.45(beat_track 量化读数)不达标,重提候选 0 实测 **120.00 精确**
|
||||
(choice 157964317097985,208.59s);成品实测 **120.00 BPM**(循环 72.00s = 36 小节)。
|
||||
|
||||
### 后处理
|
||||
|
||||
强拍起点裁剪(首个强 onset 对齐小节头)→ 整数小节裁剪 + 末 2 拍与开头 acrossfade(tri)
|
||||
无缝循环 → loudnorm I=-20/TP=-2/LRA=11 → 44.1kHz → ogg q:a 3 + m4a aac 128k。
|
||||
成品:track90=85.33s / track105=73.14s / track120=72.00s,mean -21.2/-21.3/-19.9dB。
|
||||
旧 `bgm/groove`(R2 的 120bpm 单曲)已删除。flac 原件存 `tools/mureka_out/rhythm_track*/`。
|
||||
|
||||
## 2026-08-29 R4:rpg《剑与声》BGM(village/battle/boss/ending,4 首)
|
||||
|
||||
预算线:本轮 $0.30;总预算 $2.00(5 轮)。addendum 裁定探索期无音乐(amb 走 sfx 层),bgm/explore 取消。
|
||||
|
||||
| # | task id | 用途 | 状态 | 花费 |
|
||||
|---|---|---|---|---|
|
||||
| 11 | 157968630808577 | rpg village(2 候选) | succeeded | $0.03 |
|
||||
| 12 | 157968792551426 | rpg battle(2 候选) | succeeded | $0.03 |
|
||||
| 13 | 157968932864001 | rpg boss(2 候选) | succeeded | $0.03 |
|
||||
| 14 | 157969071407105 | rpg ending(2 候选) | succeeded | $0.03 |
|
||||
|
||||
**本轮花费:$0.12 / $0.30。累计:$0.42 / $2.00。**
|
||||
|
||||
选用:village=候选 1(choice 157968750477315,229.2s);battle=候选 0(choice 157968893214722,212.8s);
|
||||
boss=候选 1(choice 157969029332994,178.2s);ending=候选 1(choice 157969188913155,248.4s)。
|
||||
一次成功无重试。RPG 无节拍网格玩法,BPM 不做硬校验(prompt 已带 70/100/120 bpm 特征词)。
|
||||
|
||||
后处理:首个强 onset 起点 → 90s 裁剪 + 首尾 3s acrossfade(tri) 无缝循环 → loudnorm I=-20/TP=-2/LRA=11
|
||||
→ 44.1kHz → ogg q:a 3 + m4a 128k。成品均 90.00s(mean -23.0/-20.5/-22.6/-21.9dB)。
|
||||
flac 原件存 tools/mureka_out/rpg_*/。
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Ryan 音色中文发音冒烟测试:决定 simon/rhythm 男声旁白选型。"""
|
||||
import os
|
||||
os.environ.setdefault("HF_HOME", "/home/ubuntu/SoundGame/models/hf")
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
model = Qwen3TTSModel.from_pretrained(
|
||||
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
device_map="cuda:0",
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
texts = [
|
||||
"欢迎来到音序回响。我会用六种声音演奏一段序列,请你记住顺序。",
|
||||
"听好了:上行音,向上滑;下行音,向下滑。预备,开始!",
|
||||
"零一二三四五六七八九十",
|
||||
]
|
||||
wavs, sr = model.generate_custom_voice(
|
||||
text=texts,
|
||||
language=["Chinese"] * len(texts),
|
||||
speaker=["Ryan"] * len(texts),
|
||||
instruct=["热情活力的中文游戏解说语气,吐字清晰"] * len(texts),
|
||||
)
|
||||
os.makedirs("/home/ubuntu/SoundGame/tools/samples", exist_ok=True)
|
||||
for i, wav in enumerate(wavs):
|
||||
wav = np.asarray(wav, dtype=np.float32)
|
||||
sf.write(f"/home/ubuntu/SoundGame/tools/samples/ryan_zh_{i}.wav", wav, sr)
|
||||
print(f"ryan_zh_{i}: {len(wav)/sr:.2f}s peak={np.max(np.abs(wav)):.3f}")
|
||||
print("done")
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* audio.js — Web Audio 三层音频引擎,对齐 Android DefaultAudioEngine 行为。
|
||||
*
|
||||
* 三层架构(id 前缀即层名):
|
||||
* | 层 | id 前缀 | 特性 |
|
||||
* |------|-----------|------------------------------------------------------------|
|
||||
* | BGM | 'bgm/' | 循环播放,切换时双 BufferSource 交叉淡入淡出(默认 800ms) |
|
||||
* | 对白 | 'voice/' | 单通道;播放期间 BGM duck 到 20%,播完恢复; |
|
||||
* | | | onComplete 回调驱动链播;被打断(stopVoice/新 voice 顶掉) |
|
||||
* | | | 时**不触发** onComplete——游戏层可依赖"打断即作废语音链" |
|
||||
* | 音效 | 'sfx/' | 预解码缓存,低延迟即时播放,可并发叠加 |
|
||||
*
|
||||
* 统一入口 play(id, opts),id 形如 'bgm/theme' 'voice/intro' 'sfx/hit',
|
||||
* 自动拼路径 `${basePath}${id}.ogg`;也可带扩展名('sfx/hit.mp3')。
|
||||
* id 不带扩展名时依次尝试 .ogg → .m4a → .mp3 → .wav(对齐 Android 的
|
||||
* resolveAssetPath 容错;**.ogg 在 iOS Safari 上无法解码,建议给 iOS 准备
|
||||
* .m4a/.mp3 副本**,详见 docs/web-engine-api.md)。
|
||||
* 资产缺失只 console.error 静默跳过,不抛异常(对齐 Android)。
|
||||
*
|
||||
* **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。
|
||||
* 标准流程(必须遵守,否则无声):
|
||||
* const audio = new WebAudioEngine('audio/');
|
||||
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + "轻触开始"
|
||||
* // 此处 AudioContext 已解锁
|
||||
* game.start();
|
||||
*
|
||||
* 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后
|
||||
* 即可即时播放,**不需要** Android SoundPool 那种"音量 0 预播预热";
|
||||
* prewarm(ids) 的作用是提前解码(消除首次播放的网络+解码延迟)。
|
||||
*
|
||||
* **用户设置(engine/settings.js)自动生效**:
|
||||
* - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试
|
||||
* `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
|
||||
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
|
||||
* - settings.speed:voice 层 source.playbackRate(语速),从下一句开始生效。
|
||||
* - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。
|
||||
* 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过;
|
||||
* 调回非 0 时若此前有想播的 BGM 会自动补播)。
|
||||
*/
|
||||
|
||||
import { settings } from './settings.js';
|
||||
|
||||
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
|
||||
|
||||
export class WebAudioEngine {
|
||||
/**
|
||||
* @param {string} basePath 音频根目录,默认 'audio/'(相对游戏页面)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.bgmVolume=1] BGM 基础音量 0~1
|
||||
* @param {number} [opts.voiceVolume=1] 对白音量
|
||||
* @param {number} [opts.sfxVolume=1] 音效音量
|
||||
* @param {number} [opts.duckVolume=0.2] 对白播放时 BGM duck 到的比例
|
||||
* @param {number} [opts.fadeMs=800] BGM 交叉淡入时长 ms
|
||||
* @param {number} [opts.duckMs=300] duck / 恢复时长 ms
|
||||
*/
|
||||
constructor(basePath = 'audio/', opts = {}) {
|
||||
this.basePath = basePath.endsWith('/') ? basePath : basePath + '/';
|
||||
this.bgmVolume = opts.bgmVolume ?? 1;
|
||||
this.voiceVolume = opts.voiceVolume ?? 1;
|
||||
this.sfxVolume = opts.sfxVolume ?? 1;
|
||||
this.duckVolume = opts.duckVolume ?? 0.2;
|
||||
this.fadeMs = opts.fadeMs ?? 800;
|
||||
this.duckMs = opts.duckMs ?? 300;
|
||||
|
||||
/** @type {AudioContext|null} */
|
||||
this.ctx = null;
|
||||
this.unlocked = false;
|
||||
|
||||
this._master = null; // 总线
|
||||
this._bgmBus = null; // BGM 总线(duck 作用于此)
|
||||
this._bgmPrefBus = null; // BGM 偏好总线(settings.bgm 增益作用于此)
|
||||
this._voiceBus = null;
|
||||
this._sfxBus = null;
|
||||
|
||||
this._cache = new Map(); // key -> Promise<AudioBuffer|null>(voice 的 key 含性别)
|
||||
this._bgm = null; // { id, source, gain } 当前 BGM
|
||||
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
|
||||
this._voice = null; // { source, gain, stopped } 当前对白 token
|
||||
this._voiceQueue = []; // queue:true 时的等待队列
|
||||
|
||||
// 设置变更实时生效
|
||||
settings.subscribe((_, changed) => this._onSettingsChange(changed));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/恢复 AudioContext。必须在用户手势回调中调用(showStartOverlay 已处理)。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async unlock() {
|
||||
if (!this.ctx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) throw new Error('Web Audio API 不可用');
|
||||
this.ctx = new AC();
|
||||
this._master = this.ctx.createGain();
|
||||
this._master.connect(this.ctx.destination);
|
||||
this._bgmPrefBus = this.ctx.createGain();
|
||||
this._bgmPrefBus.gain.value = settings.get().bgm;
|
||||
this._bgmPrefBus.connect(this._master);
|
||||
this._bgmBus = this.ctx.createGain();
|
||||
this._bgmBus.connect(this._bgmPrefBus);
|
||||
this._voiceBus = this.ctx.createGain();
|
||||
this._voiceBus.connect(this._master);
|
||||
this._sfxBus = this.ctx.createGain();
|
||||
this._sfxBus.connect(this._master);
|
||||
}
|
||||
if (this.ctx.state !== 'running') await this.ctx.resume();
|
||||
this.unlocked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动遮罩层:全屏封面 + "轻触开始"。第一次点按时解锁音频并移除遮罩。
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景
|
||||
* @param {string} [opts.text='轻触开始']
|
||||
* @returns {Promise<void>} resolve 时音频已解锁,可安全 start()
|
||||
*/
|
||||
showStartOverlay(opts = {}) {
|
||||
const { cover, text = '轻触开始' } = opts;
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'start-overlay';
|
||||
if (cover) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'cover';
|
||||
img.src = cover;
|
||||
img.alt = '';
|
||||
overlay.appendChild(img);
|
||||
}
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'hint';
|
||||
hint.textContent = text;
|
||||
overlay.appendChild(hint);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
let done = false;
|
||||
const onTap = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
this.unlock().then(() => {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
// 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起
|
||||
overlay.addEventListener('touchend', onTap);
|
||||
overlay.addEventListener('click', onTap);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一播放入口。按 id 前缀分发到三层。
|
||||
* @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.volume] 本次播放音量 0~1(乘层音量)
|
||||
* @param {number} [opts.fadeMs] 仅 BGM:本次交叉淡入时长
|
||||
* @param {boolean} [opts.loop=true] 仅 BGM:是否循环
|
||||
* @param {() => void} [opts.onComplete] 仅 voice:自然播完回调(被打断不触发)
|
||||
* @param {boolean} [opts.queue=false] 仅 voice:true 时排队等待当前对白播完,
|
||||
* false(默认)顶掉当前对白(对齐 Android)
|
||||
*/
|
||||
play(id, opts = {}) {
|
||||
const slash = id.indexOf('/');
|
||||
const layer = slash > 0 ? id.slice(0, slash) : '';
|
||||
if (!this.unlocked) {
|
||||
console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id);
|
||||
return;
|
||||
}
|
||||
if (layer === 'bgm') this._playBgm(id, opts);
|
||||
else if (layer === 'voice') this._playVoice(id, opts);
|
||||
else if (layer === 'sfx') this._playSfx(id, opts);
|
||||
else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id);
|
||||
}
|
||||
|
||||
/** 预解码一批音频(消除首次播放延迟),id 同 play()。 */
|
||||
prewarm(ids) {
|
||||
for (const id of ids) this._load(id);
|
||||
}
|
||||
|
||||
/** 停止 BGM(淡出后释放)。 */
|
||||
stopBgm(fadeOutMs = this.fadeMs) {
|
||||
this._bgmWanted = null;
|
||||
const cur = this._bgm;
|
||||
if (!cur || !this.ctx) return;
|
||||
this._bgm = null;
|
||||
this._fadeGain(cur.gain, 0, fadeOutMs);
|
||||
setTimeout(() => { try { cur.source.stop(); } catch (_) { /* 已停止 */ } }, fadeOutMs + 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打断当前对白并清空等待队列。**不触发** onComplete(对齐 Android,
|
||||
* 游戏层依赖此实现"打断即作废整条语音链")。
|
||||
*/
|
||||
stopVoice() {
|
||||
this._voiceQueue.length = 0;
|
||||
const cur = this._voice;
|
||||
if (!cur) return;
|
||||
cur.stopped = true;
|
||||
this._voice = null;
|
||||
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
|
||||
this._unduckBgm();
|
||||
}
|
||||
|
||||
/** 停止 BGM + 对白(sfx 都很短,不主动停)。 */
|
||||
stopAll() {
|
||||
this.stopBgm(200);
|
||||
this.stopVoice();
|
||||
}
|
||||
|
||||
// ---------------- 内部实现 ----------------
|
||||
|
||||
/**
|
||||
* id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。
|
||||
* voice 层按 settings.voice 优先尝试 `voice/<gender>/` 子目录,
|
||||
* 找不到再回退平铺路径(过渡期兼容)。
|
||||
*/
|
||||
_candidates(id) {
|
||||
const name = id.slice(id.lastIndexOf('/') + 1);
|
||||
const hasExt = name.includes('.');
|
||||
if (id.startsWith('voice/')) {
|
||||
const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6);
|
||||
const flatBase = this.basePath + id;
|
||||
if (hasExt) return [genderBase, flatBase];
|
||||
return [
|
||||
...EXT_FALLBACKS.map((ext) => genderBase + ext),
|
||||
...EXT_FALLBACKS.map((ext) => flatBase + ext),
|
||||
];
|
||||
}
|
||||
const base = this.basePath + id;
|
||||
if (hasExt) return [base];
|
||||
return EXT_FALLBACKS.map((ext) => base + ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并解码,缓存 Promise(含失败结果 null,避免重复 404)。
|
||||
* 资产缺失/无法解码时 console.error 并返回 null,不抛异常。
|
||||
*/
|
||||
_load(id) {
|
||||
// voice 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果
|
||||
const key = id.startsWith('voice/') ? `${settings.get().voice}|${id}` : id;
|
||||
if (this._cache.has(key)) return this._cache.get(key);
|
||||
const p = (async () => {
|
||||
if (!this.ctx) return null;
|
||||
for (const url of this._candidates(id)) {
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) continue;
|
||||
const raw = await resp.arrayBuffer();
|
||||
return await this.ctx.decodeAudioData(raw);
|
||||
} catch (_) {
|
||||
// 解码失败(如 iOS Safari 遇到 .ogg)→ 尝试下一个扩展名
|
||||
}
|
||||
}
|
||||
console.error('[WebAudioEngine] 资产缺失或无法解码:', id, this._candidates(id));
|
||||
return null;
|
||||
})();
|
||||
this._cache.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
_fadeGain(gainNode, target, ms) {
|
||||
const t = this.ctx.currentTime;
|
||||
const g = gainNode.gain;
|
||||
g.cancelScheduledValues(t);
|
||||
g.setValueAtTime(g.value, t);
|
||||
g.linearRampToValueAtTime(target, t + ms / 1000);
|
||||
}
|
||||
|
||||
_duckBgm() {
|
||||
if (this._bgmBus) this._fadeGain(this._bgmBus, this.duckVolume, this.duckMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置变更实时生效(settings.subscribe 回调)。
|
||||
* - bgm 改值:进行中的 BGM 立即渐变到新增益(150ms);
|
||||
* 改到 0:立即淡出停止(保留"想播"意图);从 0 改回:补播最近想播的 BGM。
|
||||
* - voice/speed:无需处理——voice 播放在创建 source 时实时读取设置,
|
||||
* 自然从下一句生效。
|
||||
*/
|
||||
_onSettingsChange(changed) {
|
||||
if (changed.bgm === undefined) return;
|
||||
const v = changed.bgm;
|
||||
if (v === 0) {
|
||||
const wanted = this._bgmWanted;
|
||||
this.stopBgm(200); // stopBgm 会清 _bgmWanted,先存后恢复
|
||||
this._bgmWanted = wanted;
|
||||
} else {
|
||||
if (this.ctx && this._bgmPrefBus) this._fadeGain(this._bgmPrefBus, v, 150);
|
||||
if (!this._bgm && this._bgmWanted && this.unlocked) {
|
||||
const w = this._bgmWanted;
|
||||
this._playBgm(w.id, w.opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_unduckBgm() {
|
||||
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
|
||||
}
|
||||
|
||||
async _playBgm(id, { volume = 1, fadeMs = this.fadeMs, loop = true } = {}) {
|
||||
this._bgmWanted = { id, opts: { volume, fadeMs, loop } };
|
||||
if (settings.get().bgm === 0) return; // BGM 关闭:只记意图,不加载不播放
|
||||
if (this._bgm && this._bgm.id === id) return; // 同一首不重启
|
||||
const buf = await this._load(id);
|
||||
if (!buf || (this._bgm && this._bgm.id === id)) return;
|
||||
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buf;
|
||||
source.loop = loop;
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.value = 0;
|
||||
source.connect(gain);
|
||||
gain.connect(this._bgmBus);
|
||||
|
||||
const old = this._bgm;
|
||||
this._bgm = { id, source, gain };
|
||||
source.start();
|
||||
// 交叉淡入:新 0→目标,旧 →0 后释放(对齐 Android fadeVolume 时序)
|
||||
this._fadeGain(gain, this.bgmVolume * volume, fadeMs);
|
||||
if (old) {
|
||||
this._fadeGain(old.gain, 0, fadeMs);
|
||||
setTimeout(() => { try { old.source.stop(); } catch (_) { /* 已停止 */ } }, fadeMs + 60);
|
||||
}
|
||||
}
|
||||
|
||||
async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) {
|
||||
if (queue && this._voice) {
|
||||
this._voiceQueue.push({ id, volume, onComplete });
|
||||
return;
|
||||
}
|
||||
// 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义)
|
||||
if (this._voice) {
|
||||
const cur = this._voice;
|
||||
cur.stopped = true;
|
||||
this._voice = null;
|
||||
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
|
||||
}
|
||||
|
||||
const buf = await this._load(id);
|
||||
if (!buf) { this._unduckBgm(); return; }
|
||||
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buf;
|
||||
source.playbackRate.value = settings.get().speed; // 语速(下一句生效)
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.value = this.voiceVolume * volume;
|
||||
source.connect(gain);
|
||||
gain.connect(this._voiceBus);
|
||||
|
||||
const token = { source, gain, stopped: false };
|
||||
this._voice = token;
|
||||
this._duckBgm();
|
||||
|
||||
source.onended = () => {
|
||||
if (this._voice !== token || token.stopped) return; // 被打断:不回调
|
||||
this._voice = null;
|
||||
if (this._voiceQueue.length === 0) this._unduckBgm();
|
||||
if (onComplete) onComplete();
|
||||
const next = this._voiceQueue.shift();
|
||||
if (next) this._playVoice(next.id, next);
|
||||
};
|
||||
source.start();
|
||||
}
|
||||
|
||||
async _playSfx(id, { volume = 1 } = {}) {
|
||||
const buf = await this._load(id);
|
||||
if (!buf) return;
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buf;
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.value = this.sfxVolume * volume;
|
||||
source.connect(gain);
|
||||
gain.connect(this._sfxBus);
|
||||
source.start(); // 即发即弃,天然支持并发叠加
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* game.js — 游戏侧契约:Game 基类 + StateMachineGame 状态机基类。
|
||||
* 对齐 Android 的 Game 接口 / GameEngine<S>(见 docs/architecture.md)。
|
||||
*
|
||||
* 每个游戏的接线方式(见 web/template/index.html 完整示例):
|
||||
* const audio = new WebAudioEngine('audio/');
|
||||
* const game = new MyGame(audio, document.getElementById('game-root'));
|
||||
* await audio.showStartOverlay({ cover: 'cover.png' }); // 解锁音频
|
||||
* game.start();
|
||||
* attachGestures(document.getElementById('game-root'), (g) => game.onGesture(g));
|
||||
*/
|
||||
|
||||
/**
|
||||
* Game 基类(对齐 Android Game 接口)。
|
||||
* 直接继承它,覆写三个方法即可;适合状态复杂的游戏
|
||||
* (Android 端 storyline/reflex/escape 实际都是直接实现 Game 的)。
|
||||
*/
|
||||
export class Game {
|
||||
/**
|
||||
* @param {WebAudioEngine} audio 音频引擎(已解锁后才会调用 start)
|
||||
* @param {HTMLElement} root 全屏根元素(#game-root),可用于改封面/DOM 提示
|
||||
*/
|
||||
constructor(audio, root) {
|
||||
this.audio = audio;
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手势入口。g ∈ 'up'|'down'|'left'|'right'|'tap'|'doubletap'
|
||||
* 注意 'tap' 回调有约 300ms 延迟(双击判定窗口),即时反馈请用滑动/双击。
|
||||
* @param {string} g
|
||||
*/
|
||||
onGesture(g) { /* 子类实现 */ }
|
||||
|
||||
/** 音频解锁后由页面调用一次。游戏从这里开始(播 BGM、开场白等)。 */
|
||||
start() { /* 子类实现 */ }
|
||||
|
||||
/** 页面隐藏/退出时调用,应停止计时器与音频。可选实现。 */
|
||||
stop() {
|
||||
this.audio.stopAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态机基类(对齐 Android GameEngine<S>),线性剧情类游戏的推荐起点。
|
||||
*
|
||||
* 子类实现:
|
||||
* initialState() -> 初始状态
|
||||
* transition(state, g) -> 纯函数,返回新状态;返回原状态表示无转移
|
||||
* onEnterState(state, prev) -> 副作用(播音频等),prev 初始为 null
|
||||
*
|
||||
* onGesture 是 final 逻辑:算 transition,状态变了才更新并回调 onEnterState。
|
||||
*
|
||||
* 示例:
|
||||
* class MyGame extends StateMachineGame {
|
||||
* initialState() { return 'INTRO'; }
|
||||
* transition(s, g) {
|
||||
* if (s === 'INTRO' && g === 'tap') return 'PLAY';
|
||||
* return s;
|
||||
* }
|
||||
* onEnterState(s) {
|
||||
* if (s === 'INTRO') this.audio.play('voice/intro', { onComplete: () => ... });
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export class StateMachineGame extends Game {
|
||||
constructor(audio, root) {
|
||||
super(audio, root);
|
||||
this.state = null;
|
||||
}
|
||||
|
||||
/** @returns {*} 初始状态 */
|
||||
initialState() { throw new Error('implement initialState()'); }
|
||||
|
||||
/**
|
||||
* 纯函数:由 (当前状态, 手势) 算新状态。返回原状态表示无转移。
|
||||
* @param {*} state @param {string} g @returns {*}
|
||||
*/
|
||||
transition(state, g) { throw new Error('implement transition()'); }
|
||||
|
||||
/** 进入新状态的副作用(播音频)。prev 初始为 null。 */
|
||||
onEnterState(state, prev) { /* 子类实现 */ }
|
||||
|
||||
onGesture(g) {
|
||||
const next = this.transition(this.state, g);
|
||||
if (next !== this.state) {
|
||||
const prev = this.state;
|
||||
this.state = next;
|
||||
this.onEnterState(next, prev);
|
||||
}
|
||||
}
|
||||
|
||||
/** 强制切换状态(如计时器驱动、语音 onComplete 驱动)。 */
|
||||
setState(next) {
|
||||
if (next !== this.state) {
|
||||
const prev = this.state;
|
||||
this.state = next;
|
||||
this.onEnterState(next, prev);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = this.initialState();
|
||||
this.onEnterState(this.state, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。
|
||||
*
|
||||
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
|
||||
*
|
||||
* 判定规则(与 Android 版 GestureConfig 一致):
|
||||
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
|
||||
* 且平均速度 >= swipeMinVelocity(默认 400 px/s)。
|
||||
* 方向取位移较大的轴(|dx|>|dy| 判左右,否则判上下),斜滑归一到最近主轴。
|
||||
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
|
||||
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
|
||||
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
|
||||
*
|
||||
* 用法:
|
||||
* import { attachGestures } from '../engine/gesture.js';
|
||||
* const detach = attachGestures(element, (g) => game.onGesture(g));
|
||||
* // detach() 可解除绑定
|
||||
*
|
||||
* 基础震动反馈(haptics.js,不支持的平台自动 no-op):
|
||||
* 滑动识别成功→swipe,tap→confirm,doubletap→confirm。
|
||||
* 游戏逻辑层的语义反馈(error/success/warning 等)由游戏自行调用 vibrate()。
|
||||
*
|
||||
* 注意:绑定点按的元素的 touchstart/touchmove/touchend 会被 preventDefault
|
||||
* (阻止页面滚动、双指缩放与系统合成鼠标事件),元素需应用
|
||||
* style.css 里的全屏样式(touch-action: none)。
|
||||
*/
|
||||
|
||||
import { vibrate } from './haptics.js';
|
||||
|
||||
export const DEFAULT_GESTURE_CONFIG = {
|
||||
swipeMinDistance: 64, // px,累积位移阈值(≈ Android 64dp)
|
||||
swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长)
|
||||
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
|
||||
tapSlop: 24, // px,位移小于此值才算点按
|
||||
};
|
||||
|
||||
export function attachGestures(element, onGesture, config = {}) {
|
||||
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
|
||||
|
||||
// 单指/单指针跟踪状态
|
||||
let tracking = false;
|
||||
let startX = 0, startY = 0, startTime = 0;
|
||||
let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按)
|
||||
|
||||
// 点按状态
|
||||
let lastTapTime = 0;
|
||||
let pendingTapTimer = null;
|
||||
|
||||
// 触摸发生后短时间内忽略合成鼠标事件
|
||||
let lastTouchEndTime = 0;
|
||||
|
||||
function cancelPendingTap() {
|
||||
if (pendingTapTimer !== null) {
|
||||
clearTimeout(pendingTapTimer);
|
||||
pendingTapTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDown(x, y) {
|
||||
tracking = true;
|
||||
moved = false;
|
||||
startX = x;
|
||||
startY = y;
|
||||
startTime = performance.now();
|
||||
}
|
||||
|
||||
function handleMove(x, y) {
|
||||
if (!tracking) return;
|
||||
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) moved = true;
|
||||
}
|
||||
|
||||
function handleUp(x, y) {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
|
||||
const dx = x - startX;
|
||||
const dy = y - startY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const durationMs = Math.max(performance.now() - startTime, 1);
|
||||
const velocity = dist / (durationMs / 1000); // px/s
|
||||
|
||||
if (dist >= cfg.swipeMinDistance && velocity >= cfg.swipeMinVelocity) {
|
||||
// 主轴归一:位移较大的轴决定方向
|
||||
cancelPendingTap(); // 滑动作废等待中的单击
|
||||
lastTapTime = 0;
|
||||
let g;
|
||||
if (Math.abs(dx) > Math.abs(dy)) g = dx > 0 ? 'right' : 'left';
|
||||
else g = dy > 0 ? 'down' : 'up';
|
||||
vibrate('swipe'); // 滑动识别成功
|
||||
onGesture(g);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!moved && dist < cfg.tapSlop) {
|
||||
const now = performance.now();
|
||||
if (now - lastTapTime <= cfg.doubleTapWindow) {
|
||||
// 双击:取消尚未触发的单击
|
||||
cancelPendingTap();
|
||||
lastTapTime = 0;
|
||||
vibrate('confirm'); // 双击
|
||||
onGesture('doubletap');
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
cancelPendingTap();
|
||||
// 等双击窗口关闭后才回调单击(与 Android 一致的 ~300ms 延迟)
|
||||
pendingTapTimer = setTimeout(() => {
|
||||
pendingTapTimer = null;
|
||||
lastTapTime = 0;
|
||||
vibrate('confirm'); // 单击
|
||||
onGesture('tap');
|
||||
}, cfg.doubleTapWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
tracking = false;
|
||||
}
|
||||
|
||||
// ---- 触摸 ----
|
||||
// 全部 preventDefault:防滚动 / 防双击缩放 / 防合成鼠标事件重复触发。
|
||||
// 必须 passive:false 才能 preventDefault。
|
||||
function onTouchStart(e) {
|
||||
if (e.touches.length > 1) { handleCancel(); return; } // 忽略多指
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
handleDown(t.clientX, t.clientY);
|
||||
}
|
||||
function onTouchMove(e) {
|
||||
e.preventDefault();
|
||||
if (!tracking) return;
|
||||
const t = e.touches[0];
|
||||
handleMove(t.clientX, t.clientY);
|
||||
}
|
||||
function onTouchEnd(e) {
|
||||
e.preventDefault();
|
||||
lastTouchEndTime = performance.now();
|
||||
const t = e.changedTouches[0];
|
||||
handleUp(t.clientX, t.clientY);
|
||||
}
|
||||
function onTouchCancel() {
|
||||
lastTouchEndTime = performance.now();
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
// ---- 鼠标(桌面调试)----
|
||||
let mouseDown = false;
|
||||
function recentlyTouched() {
|
||||
return performance.now() - lastTouchEndTime < 700;
|
||||
}
|
||||
function onMouseDown(e) {
|
||||
if (recentlyTouched() || e.button !== 0) return;
|
||||
mouseDown = true;
|
||||
handleDown(e.clientX, e.clientY);
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!mouseDown) return;
|
||||
handleMove(e.clientX, e.clientY);
|
||||
}
|
||||
function onMouseUp(e) {
|
||||
if (!mouseDown || recentlyTouched()) return;
|
||||
mouseDown = false;
|
||||
handleUp(e.clientX, e.clientY);
|
||||
}
|
||||
function onContextMenu(e) {
|
||||
e.preventDefault(); // 防移动端长按弹菜单
|
||||
}
|
||||
|
||||
const opts = { passive: false };
|
||||
element.addEventListener('touchstart', onTouchStart, opts);
|
||||
element.addEventListener('touchmove', onTouchMove, opts);
|
||||
element.addEventListener('touchend', onTouchEnd, opts);
|
||||
element.addEventListener('touchcancel', onTouchCancel, opts);
|
||||
element.addEventListener('mousedown', onMouseDown);
|
||||
element.addEventListener('mousemove', onMouseMove);
|
||||
element.addEventListener('mouseup', onMouseUp);
|
||||
element.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
return function detach() {
|
||||
cancelPendingTap();
|
||||
element.removeEventListener('touchstart', onTouchStart, opts);
|
||||
element.removeEventListener('touchmove', onTouchMove, opts);
|
||||
element.removeEventListener('touchend', onTouchEnd, opts);
|
||||
element.removeEventListener('touchcancel', onTouchCancel, opts);
|
||||
element.removeEventListener('mousedown', onMouseDown);
|
||||
element.removeEventListener('mousemove', onMouseMove);
|
||||
element.removeEventListener('mouseup', onMouseUp);
|
||||
element.removeEventListener('contextmenu', onContextMenu);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* haptics.js — navigator.vibrate 震动反馈封装。
|
||||
*
|
||||
* 预定义震型:
|
||||
* | 名称 | 震型 (ms) | 建议用途 |
|
||||
* |-----------|----------------------|------------------------|
|
||||
* | confirm | [20] | 点按确认 |
|
||||
* | error | [60,40,60] | 操作错误 |
|
||||
* | success | [100] | 过关/成功 |
|
||||
* | warning | [30,50,30,50,30] | 警告/危险逼近 |
|
||||
* | swipe | [10] | 滑动识别成功(最轻) |
|
||||
* | heartbeat | [40,100,40] | 心跳/倒计时紧张感 |
|
||||
* | pickup | [15] | 拾取/获得物品 |
|
||||
* | hit | [80] | 受击/终结性失败 |
|
||||
* | beat | [8] | 节拍锚点(最轻) |
|
||||
* | danger | [25,40,25,40,25] | 危险逼近 |
|
||||
*
|
||||
* 特性检测:'vibrate' in navigator。不支持的平台(iOS Safari 等)
|
||||
* 所有调用静默 no-op,不抛异常。
|
||||
*
|
||||
* 用法:
|
||||
* import { vibrate } from '../engine/haptics.js';
|
||||
* vibrate('confirm'); // 预定义名称
|
||||
* vibrate([50, 30, 50]); // 自定义震型
|
||||
*
|
||||
* 注意:gesture.js 已内置基础反馈(滑动→swipe,tap/doubletap→confirm),
|
||||
* 游戏逻辑层只需在语义事件(错误/成功/警告等)处自行调用。
|
||||
*/
|
||||
|
||||
/** @type {Record<string, number[]>} */
|
||||
export const HAPTIC_PATTERNS = {
|
||||
confirm: [20],
|
||||
error: [60, 40, 60],
|
||||
success: [100],
|
||||
warning: [30, 50, 30, 50, 30],
|
||||
swipe: [10],
|
||||
heartbeat: [40, 100, 40],
|
||||
pickup: [15],
|
||||
hit: [80],
|
||||
beat: [8],
|
||||
danger: [25, 40, 25, 40, 25],
|
||||
};
|
||||
|
||||
/** 当前环境是否支持震动(iOS Safari 为 false)。 */
|
||||
export const hapticsSupported =
|
||||
typeof navigator !== 'undefined' && 'vibrate' in navigator;
|
||||
|
||||
/**
|
||||
* 触发震动。不支持时静默 no-op。
|
||||
* @param {keyof typeof HAPTIC_PATTERNS | number[]} pattern 预定义名称或毫秒数组
|
||||
*/
|
||||
export function vibrate(pattern) {
|
||||
if (!hapticsSupported) return;
|
||||
const p = typeof pattern === 'string' ? HAPTIC_PATTERNS[pattern] : pattern;
|
||||
if (!p) {
|
||||
console.warn('[haptics] 未知震型:', pattern);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
navigator.vibrate(p);
|
||||
} catch (_) {
|
||||
// 某些平台调用即抛(极少见)→ 静默
|
||||
}
|
||||
}
|
||||
|
||||
export default vibrate;
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* settings.js — 全局用户设置(纯音频游戏合集共享)。
|
||||
*
|
||||
* 三项设置:
|
||||
* | 键 | 取值 | 默认 | 作用 |
|
||||
* |--------|-----------------------|----------|----------------------------------------|
|
||||
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
|
||||
* | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) |
|
||||
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
|
||||
*
|
||||
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
|
||||
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
|
||||
*
|
||||
* 用法:
|
||||
* import { settings } from '../engine/settings.js';
|
||||
* settings.get(); // -> { voice, speed, bgm }(副本)
|
||||
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
|
||||
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
|
||||
*
|
||||
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置
|
||||
* (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'soundgame.settings';
|
||||
|
||||
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */
|
||||
|
||||
/** @type {SoundGameSettings} */
|
||||
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 };
|
||||
|
||||
const VALID = {
|
||||
voice: ['male', 'female'],
|
||||
speed: [1.0, 1.35, 1.7],
|
||||
bgm: [1.0, 0.3, 0],
|
||||
};
|
||||
|
||||
/** @type {SoundGameSettings} */
|
||||
let current = { ...DEFAULTS };
|
||||
|
||||
/** @type {Set<(s: SoundGameSettings, changed: Partial<SoundGameSettings>) => void>} */
|
||||
const listeners = new Set();
|
||||
|
||||
function isValid(key, value) {
|
||||
return VALID[key] && VALID[key].includes(value);
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取并合并默认值(非法值逐项丢弃,保证永不炸)。 */
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const obj = JSON.parse(raw);
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
if (isValid(key, obj[key])) current[key] = obj[key];
|
||||
}
|
||||
} catch (_) {
|
||||
// localStorage 不可用(隐私模式)或数据损坏 → 用默认值
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
||||
} catch (_) {
|
||||
// 写不进去就算了,内存值仍然生效
|
||||
}
|
||||
}
|
||||
|
||||
function notify(changed) {
|
||||
const snapshot = { ...current };
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(snapshot, changed);
|
||||
} catch (e) {
|
||||
console.error('[settings] 订阅者回调异常:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
// 跨标签页同步(大厅和游戏页同时开着时)
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
const before = { ...current };
|
||||
load();
|
||||
const changed = {};
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
if (before[key] !== current[key]) changed[key] = current[key];
|
||||
}
|
||||
if (Object.keys(changed).length > 0) notify(changed);
|
||||
});
|
||||
|
||||
export const settings = {
|
||||
/**
|
||||
* 读取当前设置(返回副本,改它不影响内部状态)。
|
||||
* @returns {SoundGameSettings}
|
||||
*/
|
||||
get() {
|
||||
return { ...current };
|
||||
},
|
||||
|
||||
/**
|
||||
* 部分更新设置。非法键/值被忽略(console.warn),合法项立即持久化
|
||||
* 并通知所有订阅者。
|
||||
* @param {Partial<SoundGameSettings>} patch
|
||||
* @returns {SoundGameSettings} 更新后的完整设置
|
||||
*/
|
||||
set(patch) {
|
||||
const changed = {};
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (!isValid(key, value)) {
|
||||
console.warn('[settings] 忽略非法设置项:', key, value);
|
||||
continue;
|
||||
}
|
||||
if (current[key] !== value) {
|
||||
current[key] = value;
|
||||
changed[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(changed).length > 0) {
|
||||
persist();
|
||||
notify(changed);
|
||||
}
|
||||
return { ...current };
|
||||
},
|
||||
|
||||
/**
|
||||
* 订阅设置变更。回调签名 (snapshot, changed)。
|
||||
* audio.js 靠它实现实时生效(BGM 立即调增益、voice 下一句生效)。
|
||||
* @param {(s: SoundGameSettings, changed: Partial<SoundGameSettings>) => void} fn
|
||||
* @returns {() => void} 退订函数
|
||||
*/
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
|
||||
export default settings;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* style.css — 共享引擎样式:全屏黑底 + 封面铺满 + 屏蔽系统手势干扰。
|
||||
* 游戏页引用方式:<link rel="stylesheet" href="../engine/style.css">
|
||||
* 同时必须在 <head> 里带 viewport meta(见各 index.html):
|
||||
* <meta name="viewport" content="width=device-width, initial-scale=1,
|
||||
* maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
*/
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
/* 防下拉刷新 / 橡皮筋回弹 */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* 游戏全屏根元素:手势全屏捕获层 */
|
||||
#game-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* 浏览器原生手势全部禁用,交给 engine/gesture.js */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none; /* iOS 长按弹菜单 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 封面图:居中铺满(裁切多余部分) */
|
||||
#game-root img.cover,
|
||||
#start-overlay img.cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 启动遮罩层:封面 + “轻触开始”(audio.showStartOverlay 创建) */
|
||||
#start-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: #000;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#start-overlay .hint {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 18%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font: 500 20px/1.5 system-ui, sans-serif;
|
||||
letter-spacing: 0.2em;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
||||
animation: start-overlay-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes start-overlay-pulse {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
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