初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线

- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
This commit is contained in:
SoundGame Dev
2026-08-29 13:00:34 +08:00
commit f0f0a9b50e
446 changed files with 2627836 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
# 《暗室》(escape) — 密室逃脱解谜 设计文档
- 包名:`com.soundgame.escape`
- 代码:`app/src/escape/java/com/soundgame/game/EscapeGame.kt`
- 生成脚本:`app/src/escape/gen/`(`gen_voice.py` / `gen_sfx.py` / `gen_cover.py`)
在 3x3 全黑房间网格中靠环境音辨位探索,按"钥匙 → 铁柜 → 保险丝 → 电箱 →
大门"的解谜链逃出地下设施。逃脱后播报用时,可单击重开竞速。
## 手势映射
| 手势 | 含义 |
|---|---|
| 上/下/左/右滑 | 在相邻房间间移动;撞墙播闷响(首次撞墙追加一条语音说明) |
| 单击 | 检查当前房间 / 拾取物品 |
| 双击 | 使用物品(钥匙开铁柜 / 保险丝修电箱 / 通电后开大门);无可用目标时兜底播报背包清单 + 当前目标提示 |
**设计说明**(代码注释原文):四向滑动全部用于移动——3x3 网格必须占用四个
方向;原需求中"上滑=背包 / 下滑=方位提示"与四向移动冲突,故背包与提示合并到
"双击且无可用目标"的兜底反馈中。
## 房间地图与谜题链
```
x: 0(西) 1 2(东)
y=0(北) 电箱房·嗡鸣 风道·风声 空房·滴水
y=1 空房·风声 起点·滴水 铁柜房·嗡鸣
y=2(南) 储物间·滴水 空房·嗡鸣 大门·风声
```
环境音线索已烘焙立体声方位感:滴水偏左、风声双声道 LFO 错相、嗡鸣右偏带
电流嘶声(见 gen_sfx.py)。进房间时播放一次性线索音(`playAmbient`)。
谜题链(严格线性):
```
储物间(0,2) 单击拾取【钥匙】
│
v
铁柜房(2,1) 双击用钥匙开柜 → 得【保险丝】(钥匙消耗) sfx: unlock + door_creak
│
v
电箱房(0,0) 双击装保险丝 → 恢复供电(保险丝消耗) sfx: spark + power_on
│
v
大门(2,2) 双击开门 → 逃脱 sfx: gate_open + win_chime
BGM 2.5s 淡出,播报用时
```
每步的"检查"语音会随进度变化(如大门:未通电 `inspect_gate` → 已通电
`inspect_gate_ready`),兜底提示链 `hint_key → hint_cabinet → hint_box →
hint_gate` 始终指向下一个目标。
## 流程阶段
`Phase.INTRO`(旁白 → 教学,单击可逐段跳过,`introStep` 去重防止 onComplete
与跳过同时推进)→ `Phase.PLAY` → `Phase.ENDING`(单击重开,跳过教学直接
回 PLAY,`voice/replay` 过渡)。
用时播报用 0-9 + "十/分/秒" 数字语音拼接(`numberVoices`,支持 0..99),
格式:`time_prefix + [N分] + N秒 + again`。
## 角色音色分配(gen_voice.py)
全部 41 条语音统一使用 **Uncle_Fu**(低沉醇厚中年男声,契合黑暗密室氛围),
仅靠 instruct 区分三种语气:
| instruct | 用途 |
|---|---|
| NARRATE 低沉平稳、神秘紧张 | 旁白、房间描述、检查反馈 |
| HINT 清晰耐心、语气平和 | 教学、提示、背包播报、数字/单位 |
| RELIEF 如释重负、明亮激动 | 逃脱成功、用时播报前缀 |
## 资产清单
| 层 | 数量 | 内容 |
|---|---|---|
| voice | 41 | 开场/教学 3 + 检查与使用反馈 11 + 背包 4 + 提示 4 + 空房 3 + 用时播报 15(数字 0-9 + 十/分/秒 + 前缀/再来)+ replay 1(含 1 条未使用的 use_fail,见缺陷) |
| sfx | 13 | 交互 10(step/wall/pickup/ui_deny/unlock/door_creak/spark/power_on/gate_open/win_chime)+ 环境音 3(amb_drip/amb_wind/amb_hum,立体声) |
| bgm | 1 | drone.ogg(Mureka 生成,117s 无缝循环;原程序合成版备份在 tools/bgm_backup/) |
## 已知缺陷 / 遗留线索
- **`voice/use_fail.ogg` 是死资产**:gen_voice.py 生成了"这里没有用得上东西的
地方",但 `useItem()` 的兜底分支实际播的是 `ui_deny` 音效 + 背包 + 提示,
从未引用 use_fail。重新生成时可考虑删除该条,或把兜底改为先播 use_fail。
- 环境线索音只在进房间时播一次,停着不动就没有了;玩家原地迷路时只能靠
双击兜底提示,没有"重听环境音"入口(单击被"检查"占用)。
- 撞墙语音只播一次(`wallVoicePlayed`),此后只有闷响——有意减少重复,
但也意味着错过第一次就再听不到文字说明。
- 网格只有 3x3 且谜题严格线性,重玩价值主要来自竞速(用时播报)。
## 调参入口
| 位置 | 参数 | 控制什么 |
|---|---|---|
| `EscapeGame` 坐标判断 | 房间功能坐标(硬编码在 `playAmbient/inspect/useItem`) | 地图布局与谜题位置 |
| `useItem()` / `hintVoice()` | 解谜链条件与提示链 | 谜题结构 |
| `escape()` | `stopBgm(2500)` | 结局 BGM 淡出时长 |
| `gen_sfx.py` 各函数 | 合成参数(采样率 44.1kHz,环境音峰值 0.55-0.7) | 环境音方位感、音效质感 |
| `gen_sfx.py` `bgm_drone()` | 程序合成 BGM(50s 首尾 4s 交叉淡化) | 已被 Mureka 版替换,仅留作备份复现 |
| `gen_voice.py` `CHUNK = 8`、峰值归一 0.89 | TTS 批量与响度 | 生成效率、语音响度统一 |
+109
View File
@@ -0,0 +1,109 @@
# 《声东击西》(reflex) — 听声辨位反应 设计文档
- 包名:`com.soundgame.reflex`
- 代码:`app/src/reflex/java/com/soundgame/game/GameFactory.kt`
(`ReflexGame` 与 `GameFactory` 同文件,与其他两款游戏的拆文件风格不同)
- 生成脚本:`app/src/reflex/gen/`(`gen_voice.py` / `gen_sfx_bgm.py` /
`convert_audio.py` / `fix_num6.py`)
敌人从四个方向来袭,玩家纯靠听声音方位做出对应手势。教学演练 → 12 波战斗
(节奏递增、BGM 三档变速)→ 结算播报得分。共 5 点血,血尽或通关进结算。
## 方位声音设计(gen_sfx_bgm.py)
| 方位 | 提示音 | 声学设计 | 应对手势 |
|---|---|---|---|
| 左 | cue_left | 0.45s 噪声扫频 whoosh,声像硬打左声道 | 左滑 |
| 右 | cue_right | 同上,硬打右声道 | 右滑 |
| 上 | cue_up | 高音上行琶音 E6-G6-B6(1319/1568/1976 Hz) | 上滑 |
| 下 | cue_down | 低音下行轰击 110→55Hz + 闷响 | 下滑 |
| 正面 | warn_front | 急促三响警报(880/660 交替,约 1.0s) | 单击格挡 |
正面警报特意做到 ~1s 且反应窗口强制 ≥ 1100ms(`FRONT_MIN_WINDOW_MS`),
用来覆盖单击因双击判定窗口产生的 ~300ms 回调延迟(见 docs/architecture.md)。
## 手势映射
| 手势 | INTRO | PRACTICE | BATTLE | ENDED |
|---|---|---|---|---|
| 四向滑动 | 忽略 | 对应方向则过,错则重播提示 | 击杀对应方向敌人;划错断连击不扣血 | 忽略 |
| 单击 | 忽略 | 正面演练格挡 | 格挡正面攻击;无正面攻击不惩罚 | 重开(跳过教学) |
| 双击 | 忽略 | 忽略 | 音爆冲击清屏(12s 冷却) | 忽略 |
教学旁白(INTRO)期间完全不响应手势,防连点。
## 波次表(waveCfg,共 12 波)
| 波次 w | 出怪数 4+w | 出怪间隔 ms | 反应窗口 ms | 正面概率 | BGM 档 |
|---|---|---|---|---|---|
| 1 | 5 | 2000 | 1700 | 7.3% | calm (100 BPM) |
| 2 | 6 | 1873 | 1614 | 8.6% | calm |
| 3 | 7 | 1746 | 1528 | 9.9% | calm |
| 4 | 8 | 1619 | 1442 | 11.2% | **tense (128 BPM)** + 语音播报 |
| 5 | 9 | 1492 | 1356 | 12.5% | tense |
| 6 | 10 | 1365 | 1270 | 13.8% | tense |
| 7 | 11 | 1238 | 1184 | 15.1% | tense + 语音播报 |
| 8 | 12 | 1111 | 1098 | 16.4% | tense |
| 9 | 13 | 984 | 1012 | 17.7% | **frantic (152 BPM)** |
| 10 | 14 | 857 | 926 | 19.0% | frantic + 语音播报 |
| 11 | 15 | 730 | 840 | 20.3% | frantic |
| 12 | 16 | 600(触底) | 750(触底) | 21.6% | frantic + 总攻语音 |
- 出怪间隔公式:`(2000 - (w-1)*127).coerceAtLeast(600)`
- 反应窗口公式:`(1700 - (w-1)*86).coerceAtLeast(750)`;正面攻击取
`max(窗口, 1100)`
- 正面概率公式:`0.06 + w*0.013`
- 每波首怪在波开始后 1400ms 出现;波间间隔 1800ms
## 计分与状态
- 击杀:`100 + 10 × 当前连击`;技能击杀固定 50/个;连击 5/10/15(每5) 有音效,
连击 10 追加战友语音夸奖。
- 漏怪:扣 1 血、断连击;HP=2 / HP=1 时各播报一次警告语音。
- 双击技能:清空全部在场敌人,冷却 12s,冷却完成有语音提醒,冷却中双击
播 `skill_denied`。
- 结算:逐位播报得分(num_0..num_9 数字语音拼接),破纪录播 `new_record`,
否则播报历史最高。**最高分为进程内变量,杀进程即丢。**
- 时间调度全部走主线程 `Handler`(手势回调也在主线程),无需加锁;
`onStop` 清空全部回调。
## 角色音色分配(gen_voice.py)
| 角色 | speaker | instruct | 负责内容 |
|---|---|---|---|
| 教官/旁白 | Uncle_Fu(低沉醇厚中年男声) | 战场教官语气(教学一字一句、战前激昂、重伤焦急等逐条指定) | 教学 tut_01-07、战报 wave_04/07/10/12、血量警告、胜负播报、报分、数字 0-9 |
| 战友 | Vivian(明亮略带锋芒年轻女声) | 兴奋赞叹/轻快提醒/惊喜欢呼 | combo_praise、skill_ready、new_record、restart_hint、replay |
## 资产清单
| 层 | 数量 | 内容 |
|---|---|---|
| voice | 33 | 教学 7 + 战报 4 + 血量 2 + 激励 3 + 胜负/报分 6 + 重开 1 + 数字 10 |
| sfx | 18 | 方位 cue 5(cue_left/right/up/down + warn_front)+ 反馈 5(hit/block/hurt/skill/skill_denied)+ 连击 3 + wave_horn + ui_start + warmup + victory_fanfare + gameover_sting |
| bgm | 3 | bgm_calm / bgm_tense / bgm_frantic(程序合成脉冲节奏,100/128/152 BPM 各 8 小节,无缝循环) |
BGM 保留程序合成是有意为之:玩法要求三档 BPM 随波次精确切换,AI 生成曲目
BPM 不精确会破坏节奏机制(见 README 待办与 mureka_log.md)。
## 已知缺陷 / 遗留线索
- **`fix_num6.py`**:数字"六"的 TTS 生成不稳(单次生成时长经常超界),
单独写了个重试脚本(最多 4 次,取 0.3s~1.5s 的一条)。重新批量生成
`gen_voice.py` 时 num_6 可能再次翻车,需重跑 fix 脚本。
- **`warmup.wav` 是假资产**:0.1s 近静音文件(+1e-4 绕过峰值校验),
只为 SoundPool 预热存在。
- **最高分不持久化**:`highScore` 是 companion object 的 var,进程内有效。
- 划错方向只断连击不扣血、无正面攻击时单击不惩罚——为掩盖单击延迟的
有意宽容设计,严格模式下可视为难度漏洞。
- 结算逐位报分在长分数时较拖沓(每位一条独立语音文件)。
## 调参入口
| 位置 | 参数 | 控制什么 |
|---|---|---|
| `ReflexGame` companion | `MAX_HP=5` / `TOTAL_WAVES=12` / `SKILL_COOLDOWN_MS=12000` / `FRONT_MIN_WINDOW_MS=1100` | 血量/波数/技能冷却/正面窗口下限 |
| `waveCfg()` | attacks/interval/reaction/frontProb 四个公式 | 难度曲线核心 |
| `startWave()` | BGM 切换阈值(w≥4 / w≥9)、语音播报波次 | 节奏与叙事节拍 |
| `kill()` | 100/50 基础分、连击加成 10×combo | 计分 |
| `gen_sfx_bgm.py` | cue 音频率/声像、`make_pulse_bgm(bpm, bars)` | 方位可辨度、BGM 速度与密度 |
| `convert_audio.py` | voice 峰值归一 0.6、`-q:a 4/4/3` | 响度与体积 |
+114
View File
@@ -0,0 +1,114 @@
# 《声命线》(storyline) — 末世互动广播剧 设计文档
- 包名:`com.soundgame.storyline`
- 代码:`app/src/storyline/java/com/soundgame/game/StorylineGame.kt`
- 生成脚本:`app/src/storyline/gen/`(`gen_voice.py` / `gen_sfx.py` / `convert.sh` / `make_cover.py`)
大停电后第 30 天,前急救员林晚靠一台旧收音机寻找妹妹林晨,对抗抢掠者"猎狗"。
3 幕、8 个抉择点、4 种结局,单局 5-7 分钟。
## 手势映射
| 手势 | 剧情模式 (STORY) | 菜单模式 (MENU) |
|---|---|---|
| 单击 | 继续剧情(对白播完才生效)/ 结局后重开 | 返回故事 |
| 双击 | 重听当前节点全部台词 | 重播菜单语音 |
| 左滑/右滑 | 选择节点做抉择(提示音播完才生效,播放中会缓存 `pendingChoice`) | 左滑=重新开始;右滑=返回 |
| 上滑 | 帮助 + 当前幕进度,随后自动重播当前节点 | 听帮助,随后回菜单 |
| 下滑 | 呼出菜单 | — |
实现要点:
- 游戏是 `Mode.STORY / Mode.MENU` 双模式 + 剧情节点表驱动,未用 `GameEngine` 基类。
- `playSeq` 用 `seqToken` 做打断作废:任何重播/菜单/重开都会 `seqToken++`,
旧语音链的 `onComplete` 回调因此失效,不会错序推进。
- 对白播放中单击**不**跳节点(防误触);选择提示音播放中的滑动会被缓存,
播完立即生效(`choiceArmed` / `pendingChoice`)。
- `onStart` 里以音量 0 预播 `sfx/ui_confirm` 预热 SoundPool。
## 剧情树
节点 id 即资产文件名前缀(`voice/n00_intro.ogg` 等)。`◆` 为选择节点,
`【标记】`为选择写入的剧情标记,`※` 为结局。
```
n00 开场+教学 ──> n01 地铁站醒来 ──> n02 ◆p1 检查收音机 / 摸黑找出口
├─ 左: n03 收音机(林晨录音) ─┐
└─ 右: n04 出口(红漆警告) ──┤
v
n06 口哨包围 <── n05 避难所广播(坐标37.5)
│
n07 ◆p2 钻通风管 / 尸体堆装死
├─ 左: n08 通风管 ──────────┐
└─ 右: n09 装死(听到陷阱情报) ┤ 【knowTrap 死标记,见缺陷】
v
n10 ◆p3 酸雨夜,哭声:查看 / 不理会
├─ 左: n11 ◆p4 小满:带上他 / 留下食物
│ ├─ 左: n12 带上小满 【companion=true】 ─┐
│ └─ 右: n13 独自上路 ──────────────────┤
└─ 右: n14 无视哭声 ──────────────────────────┤
v
n15 ◆p5 断桥:涵洞 / 钢索
├─ 左: v_culvert → n16 / n16b(带小满版) ──┐
└─ 右: v_cable → n17 / n17b(带小满版) ──┤
(v_* 虚拟节点由 resolve() 按 companion 解析)
v
n18 ◆p6 猎狗索坐标:交出 / 周旋
├─ 左: n19 交出坐标 【betrayed=true】 ──> n20 ◆p7
│ ├─ 左: n21 追上去警告 ──> ※e2 代价
│ └─ 右: 自己逃命 ────────> ※e3 孤声(坏结局)
└─ 右: n22 扬沙脱身(中弹) ──> n23 ◆p8 广播频道
├─ 左: n24 公开频道 ──> ※e4 静默
└─ 右: n25 加密频段 ──> ※e1 黎明(好结局)
(companion 时追加
e1_child 彩蛋台词)
```
三幕划分(`actOf()`,用于上滑报进度):n00–n09 第一幕(地铁站与街面),
n10–n17b 第二幕(雨夜与断桥),n18 起及所有结局 第三幕(广播塔)。
## 角色音色分配(gen_voice.py)
| 角色 | speaker | instruct 变体 |
|---|---|---|
| 旁白 | Uncle_Fu(低沉醇厚中年男声) | NARR 冷静苍凉 / NARR_WARM 转温暖 / NARR_COLD 冷漠空洞 / NARR_EPIC 悲壮 |
| 林晚(主角) | Serena(温暖柔和年轻女声) | LIN_CALM 虚弱强撑 / LIN_SOFT 温柔坚定 / LIN_CRY 哭腔 / LIN_RUN 奔跑喘息 / LIN_SHOUT 嘶哑呼喊 / LIN_LOW 压低克制 |
| 猎狗(反派) | Dylan(北京话年轻男声) | DOG_PLAY 阴冷戏谑 / DOG_COLD 残忍带杀气 |
| 小满 / 林晨 / 广播 | Vivian(明亮年轻女声) | KID 稚嫩童声 / CHEN 急切压低(电台感) / BCAST 机械无感情 |
无线电失真不在 TTS 侧做:`convert.sh` 转码时对 `n03_msg` / `n05_bcast`
两条加 ffmpeg 带通(highpass 400Hz + lowpass 3200Hz + 压缩器)模拟电台。
## 资产清单
| 层 | 数量 | 内容 |
|---|---|---|
| voice | 48 | 系统语音 5(help/status1-3/menu)+ 选择提示 8(p1-p8)+ 剧情 30 + 结局 5 |
| sfx | 12 | ui_confirm, ui_menu, heartbeat, radio_static, whistle, footsteps, rubble, water, gunshot, struggle, wind, signal_beep(全部程序合成) |
| bgm | 1 | dark_drone.ogg(Mureka 生成,117s 无缝循环;原程序合成版备份在 tools/bgm_backup/) |
## 已知缺陷 / 遗留线索
- **`knowTrap` 是死标记**:`choose()` 里 `when (nodeId)` 的 `"n09"` 分支永远不会
命中——`choose` 只在选择节点被调用,而 n09 不是选择节点(它是 n07 抉择的
目标节点)。也就是说"装死听到北区陷阱情报"这个情报没有任何后续剧情消费它。
修复方向:把判定移到 `playNode("n09")` 处,或在 n10/n18 处消费该标记。
- **`betrayed` 只写不读**:n18 交出坐标后标记置位,但后续节点没有任何分支
读取它(e2/e3 的分化完全由 n20 的抉择决定)。可用于让旁白在结局前追加
一句"赎罪"差分。
- 菜单模式下双击重播菜单语音,但如果菜单语音正在播则无响应(`voicePlaying`
守卫),手感上像"双击失灵",属有意为之。
- 结局后任意单击直接重开,没有确认;误触会丢失当前结局画面(音频游戏
影响较小)。
## 调参入口
| 位置 | 参数 | 控制什么 |
|---|---|---|
| `StorylineGame.nodes` | 整张剧情表 | 节点增减、分支结构、台词文件映射 |
| `gen_voice.py` `LINES` | 台词/instruct | 文案与语气 |
| `gen_voice.py` `CHUNK = 6` | TTS 批量大小 | 显存/吞吐 |
| `gen_sfx.py` 各函数 | 合成参数 | 音效质感(采样率 24kHz,峰值归一 0.85) |
| `gen_sfx.py` `bgm_drone()` | 程序合成 BGM | 已被 Mureka 版替换,仅留作备份复现 |
| `convert.sh` | `-q:a 4`(voice/sfx)/ `-q:a 3`(bgm) | ogg 码率/体积 |
| 引擎层 | `playBgm` fadeMs=800 | BGM 淡入淡出时长 |