初始提交: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:
@@ -0,0 +1,161 @@
|
||||
# SoundGame — 架构文档
|
||||
|
||||
本文档描述共享引擎(`app/src/main/`)的内部实现与设计决策。
|
||||
如何基于引擎开发新游戏见 `docs/engine-api.md`(面向使用的 API 手册);
|
||||
本文面向需要读懂/改动引擎本身的人。
|
||||
|
||||
## 模块划分
|
||||
|
||||
```
|
||||
app/src/main/java/com/soundgame/
|
||||
├── MainActivity.kt # 唯一 Activity:全屏封面 + 手势路由 + 生命周期
|
||||
└── engine/
|
||||
├── Gesture.kt # Gesture 枚举(6 种手势)
|
||||
├── GestureEngine.kt # Modifier.gestureDetector:手势识别
|
||||
├── AudioEngine.kt # AudioEngine 接口 + DefaultAudioEngine 实现
|
||||
└── Game.kt # Game 接口 + GameEngine<S> 状态机基类
|
||||
```
|
||||
|
||||
调用关系:
|
||||
|
||||
```
|
||||
MainActivity ──创建──> DefaultAudioEngine ──注入──> GameFactory.create(audio)
|
||||
│ │
|
||||
└─ gestureDetector { game.onGesture(it) } <──────────┘
|
||||
(每个 flavor 提供自己的 GameFactory/Game 实现)
|
||||
```
|
||||
|
||||
- **MainActivity**:只做四件事——沉浸式全屏 + 屏幕常亮、显示
|
||||
`res/drawable/cover.png`(带 TalkBack 描述)、把手势回调路由给
|
||||
`game.onGesture`、在 `onDestroy` 里 `game.onStop()` + `audioEngine.release()`。
|
||||
它通过硬编码约定 `com.soundgame.game.GameFactory.create(audio)` 拿到游戏实例,
|
||||
三个 flavor 各自在隔离的源码集里提供同名类,互不冲突。
|
||||
- **GestureEngine**:一个 Compose `Modifier` 扩展,把触摸事件翻译成
|
||||
`Gesture` 枚举回调。
|
||||
- **AudioEngine**:接口 + 唯一实现 `DefaultAudioEngine`,三层音频分发。
|
||||
- **Game / GameEngine**:游戏侧契约。`Game` 是三个方法的极简接口;
|
||||
`GameEngine<S>` 是其上的状态机抽象(见下文)。
|
||||
|
||||
## 音频三层架构(DefaultAudioEngine)
|
||||
|
||||
| 层 | id 前缀 | 播放器 | 特性 |
|
||||
|---|---|---|---|
|
||||
| BGM | `bgm/` | ExoPlayer(单实例轮换) | 循环播放,切换时交叉淡入淡出 |
|
||||
| 对白 | `voice/` | ExoPlayer(单实例轮换) | 播放期间 duck BGM 到 20%,播完恢复 |
|
||||
| 音效 | `sfx/` | SoundPool(maxStreams=8) | 低延迟,可叠加 |
|
||||
|
||||
`play(id)` 按前缀分发;id 可省略扩展名,依次尝试 `.ogg` → `.mp3` → `.wav`
|
||||
(`resolveAssetPath`);找不到资产打 `Log.e("AudioEngine", ...)` 并静默跳过,
|
||||
**不会崩溃**——拼写错误只能靠 `adb logcat -s AudioEngine` 发现。
|
||||
|
||||
### BGM 交叉淡入时序
|
||||
|
||||
`playBgm(id)` 被调用时:
|
||||
|
||||
```
|
||||
t=0 新 player 以 volume=0 启动(prepare+play),旧 player 仍在响
|
||||
t=0~fadeMs 协程逐帧推音量:新 player 0 → 目标音量;旧 player 当前音量 → 0
|
||||
t=fadeMs 旧 player release()
|
||||
```
|
||||
|
||||
默认 `fadeMs=800`,步进 30ms 一格(`fadeVolume`)。`stopBgm(fadeOutMs)` 同理淡出后
|
||||
release。切 BGM 不会停止对白层。
|
||||
|
||||
### 对白 ducking 时序
|
||||
|
||||
```
|
||||
playVoice(id) ──> 新 voice player 开播,同时 duckBgm():
|
||||
BGM 音量 300ms 内渐降到 baseVolume × 0.2
|
||||
播放自然结束 ────> onPlaybackStateChanged(ENDED):
|
||||
unduckBgm()(300ms 恢复)→ release player → 回调 onComplete
|
||||
```
|
||||
|
||||
- duck 比例为 `duckVolume = 0.2`(构造参数)。
|
||||
- `stopVoice()` 打断的对白**不触发** `onComplete`——游戏层(storyline 的
|
||||
`playSeq`、escape 的 `speak`、reflex 的 `say` 队列)都依赖这一点实现
|
||||
"打断即作废整条语音链"。
|
||||
- `onComplete` 是"台词说完再推进剧情"的核心驱动机制。
|
||||
|
||||
### SFX 层注意事项
|
||||
|
||||
- SoundPool `load()` 是异步的:首次播放某音效时若尚未加载完成,`play()` 返回 0,
|
||||
静默失败(见 `playSfx` 注释)。**因此每个游戏 onStart 都必须以音量 0 预播一遍
|
||||
全部短音效**(storyline/reflex/escape 均已实现 `prewarm`)。
|
||||
- 音效按 `assetPath.hashCode` 缓存 soundId,重复播放不重复加载。
|
||||
|
||||
## 手势识别(GestureEngine)
|
||||
|
||||
实现:两个叠加的 `pointerInput`——`detectTapGestures`(单击/双击)+
|
||||
`detectDragGestures`(滑动)。
|
||||
|
||||
滑动判定(`GestureConfig` 默认值):
|
||||
|
||||
| 参数 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `minSwipeDistance` | 64dp | 累积位移低于此值不识别为滑动(防误触) |
|
||||
| `minSwipeVelocityPxPerSec` | 400 px/s | 平均速度(位移/拖动时长)低于此值不触发 |
|
||||
|
||||
方向取位移较大的轴(`|dx| > |dy|` 判左右,否则判上下),斜滑归一到最近主轴。
|
||||
|
||||
**单击 300ms 延迟**:因为同时注册了 `onDoubleTap`,Compose 的
|
||||
`detectTapGestures` 必须等双击判定窗口(约 300ms)关闭后才能回调 `onTap`。
|
||||
这是全引擎级的行为,游戏设计必须绕开它:
|
||||
|
||||
- reflex:正面攻击要求单击格挡,因此其反应窗口强制 ≥ 1100ms
|
||||
(`FRONT_MIN_WINDOW_MS`),且"无正面攻击时单击不惩罚";
|
||||
- storyline:单击只用于"继续剧情"这种非即时操作;
|
||||
- escape:单击检查房间,同样非即时。
|
||||
|
||||
关键即时反馈尽量放在滑动或双击上。
|
||||
|
||||
## 状态机模式(GameEngine<S>)
|
||||
|
||||
```kotlin
|
||||
abstract class GameEngine<S>(protected val audio: AudioEngine) : Game
|
||||
```
|
||||
|
||||
- 游戏定义状态类型 `S`,实现 `transition(state, gesture): S`(纯函数,
|
||||
返回原状态表示无转移)和 `onEnterState(state, previous)`(副作用:播音频)。
|
||||
- `onGesture` 是 final 的:算 `transition`,状态变了才更新并回调 `onEnterState`。
|
||||
- `onStart` 以 `previous=null` 进一次 `initialState`。
|
||||
|
||||
实际采用情况:**三个游戏都没有用 `GameEngine`**——
|
||||
|
||||
- storyline:剧情树 + STORY/MENU 双模式 + 选择缓存(`pendingChoice`),
|
||||
状态不是简单枚举,直接实现 `Game`;
|
||||
- reflex:计时器驱动的反应玩法(Handler 调度出怪),非状态机;
|
||||
- escape:INTRO/PLAY/END 三 phase + 位置/物品等多维状态,直接实现 `Game`。
|
||||
|
||||
`GameEngine` 仍是新游戏(线性剧情类)的推荐起点。
|
||||
|
||||
## flavor 机制
|
||||
|
||||
`app/build.gradle.kts` 中 `flavorDimensions += "game"` + 三个 productFlavor
|
||||
(storyline / reflex / escape),各自独立 `applicationId`,可同机共存安装。
|
||||
|
||||
每个 flavor 源码集(`app/src/<flavor>/`)包含:
|
||||
|
||||
```
|
||||
java/com/soundgame/game/GameFactory.kt # 注册点(包名/签名固定)
|
||||
java/com/soundgame/game/<Xxx>Game.kt # 游戏实现
|
||||
res/drawable/cover.png # 封面(1080x1920,文件名固定)
|
||||
assets/audio/{bgm,voice,sfx}/ # 全部音频资产(ogg)
|
||||
gen/ # 资产生成脚本(可复现,不入 APK)
|
||||
```
|
||||
|
||||
`minSdk = 26`,`compileSdk = 36`,依赖 Compose BOM 2025.06.01 +
|
||||
media3-exoplayer 1.6.1。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **单击延迟 ~300ms**(双击判定窗口),引擎级,见上。
|
||||
- **SoundPool 首次播放丢失**:靠各游戏预热缓解,引擎层未做加载完成队列。
|
||||
- **引擎静默容错**:资产缺失只打日志不崩溃,拼写错误不易发现。
|
||||
- **手势参数全局共享**:`GestureConfig` 在 MainActivity 处对所有 flavor 生效,
|
||||
不能单游戏定制。
|
||||
- **无持久化**:reflex 最高分为进程内变量(`companion object var highScore`),
|
||||
杀进程即丢;三款游戏均无存档。
|
||||
- **单 voice 通道**:同时只有一条对白在播,新 `playVoice` 会顶掉旧的;
|
||||
需要"背景环境音 + 对白"时环境音只能走 sfx 层(escape 的做法)或并进 BGM。
|
||||
- **未真机听测**:反应窗口、响度平衡、立体声分离度等手感参数只验证到
|
||||
构建层面,需实机试听调整(见 README 待办)。
|
||||
@@ -0,0 +1,136 @@
|
||||
# SoundGame — 资产生产流水线
|
||||
|
||||
三类音频资产(对白 voice / 音效 sfx / 音乐 bgm)的完整生成与再生成流程。
|
||||
所有脚本都在 `app/src/<flavor>/gen/` 下,可随时复现全部资产。
|
||||
|
||||
通用约定:
|
||||
|
||||
- **GPU 任务必须串行**:`flock /home/ubuntu/SoundGame/.gpu.lock <命令>`
|
||||
(TTS 与 Stable Audio 3 共用一块 RTX 3090)。
|
||||
- 环境脚本:`source env.d/tts.sh`(HF_HOME + hf-mirror 镜像)、
|
||||
`source env.d/android.sh`(ffmpeg 在 PATH)、`source env.d/sfx.sh`(HF_TOKEN,
|
||||
含密钥不入库,模板见 `env.d/sfx.example.sh`)。
|
||||
- 中间产物 wav 输出到各 gen 目录或 `/tmp/slg/`(不入库),最终 ogg 进
|
||||
`app/src/<flavor>/assets/audio/{bgm,voice,sfx}/`。
|
||||
|
||||
## 1. TTS 对白(Qwen3-TTS CustomVoice)
|
||||
|
||||
引擎与用法详见 `env.d/tts.md`(CustomVoice 预置音色 + instruct 情感指令、
|
||||
VoiceDesign、预置音色表)。流水线层面:
|
||||
|
||||
```bash
|
||||
source env.d/tts.sh
|
||||
# 《声命线》:48 条,6 条一批;输出 /tmp/slg/wav/,可传文件名参数只重生指定条目
|
||||
flock .gpu.lock .venv-tts/bin/python app/src/storyline/gen/gen_voice.py [文件id...]
|
||||
bash app/src/storyline/gen/convert.sh # wav -> ogg 进 assets(含电台带通效果)
|
||||
|
||||
# 《声东击西》:33 条,逐条生成;输出 gen/wav_voice/
|
||||
cd app/src/reflex/gen
|
||||
flock /home/ubuntu/SoundGame/.gpu.lock /home/ubuntu/SoundGame/.venv-tts/bin/python gen_voice.py
|
||||
|
||||
# 《暗室》:41 条,8 条一批,统一峰值归一 0.89;输出 gen/wav/voice/
|
||||
flock .gpu.lock .venv-tts/bin/python app/src/escape/gen/gen_voice.py
|
||||
```
|
||||
|
||||
- 每条生成后自动校验:**时长 > 0.3s 且峰值 > 0.05**,不达标直接 assert 失败。
|
||||
- 音色分配见各游戏文档(docs/games/*.md):旁白 Uncle_Fu、林晚 Serena、
|
||||
猎狗 Dylan、小满/广播 Vivian;escape 全员 Uncle_Fu 靠 instruct 区分语气。
|
||||
- 已知不稳条目:reflex 的 `num_6`("六"),单独用
|
||||
`app/src/reflex/gen/fix_num6.py` 重试生成(最多 4 次取时长合格的一条)。
|
||||
- 改台词只需改 gen_voice.py 里的 `LINES` 表再重跑;文件名即游戏代码引用的 id。
|
||||
|
||||
## 2. 音效(Stable Audio 3 + 程序合成)
|
||||
|
||||
### 2a. Stable Audio 3 small-sfx(主力,AI 生成)
|
||||
|
||||
```bash
|
||||
source env.d/sfx.sh # HF_TOKEN(gated 模型,直连官方端点,不走 hf-mirror)
|
||||
cd tools/stable-audio-3
|
||||
uv run stable-audio --model small-sfx \
|
||||
-p "footsteps on gravel, slow walking" --duration 3 \
|
||||
-o /home/ubuntu/SoundGame/tools/samples/sfx_test.wav
|
||||
```
|
||||
|
||||
- 提示词必须英文;`--duration` ≤ 120s;`--steps` 默认 8;small 模型峰值显存
|
||||
约 2.4GB。批量:`-p "p1" "p2" --duration 3 5`。
|
||||
- 首次使用需完成 gated 许可 + token 流程,详见 `env.d/sfx.md` / `docs/setup.md`。
|
||||
- 生成后用 ffmpeg 转 ogg 进 assets(规格见下文"资产规格")。
|
||||
|
||||
### 2b. 程序合成(保底/已在线方案,numpy + soundfile,纯 CPU 无需 GPU 锁)
|
||||
|
||||
| 游戏 | 脚本 | 输出 |
|
||||
|---|---|---|
|
||||
| storyline | `app/src/storyline/gen/gen_sfx.py` | `/tmp/slg/wav/sfx/`(24kHz),`convert.sh` 转 ogg |
|
||||
| reflex | `app/src/reflex/gen/gen_sfx_bgm.py` | `gen/wav_sfx/`(44.1kHz),`convert_audio.py` 转 ogg |
|
||||
| escape | `app/src/escape/gen/gen_sfx.py` | `gen/wav/sfx/`(44.1kHz),手动 ffmpeg 转 ogg |
|
||||
|
||||
当前线上资产全部为程序合成版;Stable Audio 3 环境已就绪,可按
|
||||
README 待办逐步替换(替换时保持同名同目录,游戏代码零改动)。
|
||||
|
||||
## 3. BGM
|
||||
|
||||
### 3a. Mureka API(storyline / escape)
|
||||
|
||||
```bash
|
||||
source env.d/mureka.sh # MUREKA_API_KEY(含密钥不入库,模板 env.d/mureka.example.sh)
|
||||
python3 tools/mureka_gen.py \
|
||||
--prompt "dark ambient horror game background music, low drone, ..., seamless loop, instrumental" \
|
||||
--out tools/mureka_out/storyline # 提交→轮询→下载全部候选 flac
|
||||
python3 tools/mureka_gen.py --task-id <id> --out tools/mureka_out/xxx # 只轮询已有任务
|
||||
```
|
||||
|
||||
后处理(两条已上线 BGM 相同参数,记录见 `tools/mureka_log.md`):
|
||||
|
||||
```
|
||||
裁剪前 120s → 首尾 3s acrossfade(tri) 无缝循环化 → loudnorm I=-20/TP=-2/LRA=11
|
||||
→ aresample 44.1kHz 立体声 → libvorbis -q:a 3
|
||||
```
|
||||
|
||||
成品 117.00s,保持原文件名(`dark_drone.ogg` / `drone.ogg`)直接替换 assets。
|
||||
原程序合成版备份在 `tools/bgm_backup/`。
|
||||
|
||||
**成本参考**:`POST /v1/instrumental/generate` 每条任务 **$0.03**(一次出 2 个
|
||||
候选),项目预算上限 $0.50,已用 $0.06(2 条任务均一次成功)。每次调用必须
|
||||
登记到 `tools/mureka_log.md`。
|
||||
|
||||
### 3b. 程序合成(reflex,有意保留)
|
||||
|
||||
`app/src/reflex/gen/gen_sfx_bgm.py` 的 `make_pulse_bgm(bpm, bars=8)`:
|
||||
心跳底鼓 + 低音脉冲音序 + 镲噪声 + 不协和 pad,100/128/152 BPM 三档各 8 小节。
|
||||
玩法要求 BPM 随波次精确切换,AI 生成曲目 BPM 不精确会破坏机制,**不要用
|
||||
Mureka 替换 reflex 的 BGM**。
|
||||
|
||||
## 资产规格
|
||||
|
||||
| 项 | 标准 |
|
||||
|---|---|
|
||||
| 容器/编码 | ogg vorbis(`-c:a libvorbis`) |
|
||||
| 码率 | voice/sfx `-q:a 4`;bgm `-q:a 3`(控制体积) |
|
||||
| 采样率 | TTS 源 24kHz;程序合成 reflex/escape 44.1kHz;BGM 统一 aresample 44.1kHz |
|
||||
| BGM 响度 | loudnorm **I=-20 LUFS / TP=-2 / LRA=11** |
|
||||
| BGM 循环 | 首尾交叉淡化无缝循环(Mureka 版 3s acrossfade;合成版 2s/4s 等功率交叉) |
|
||||
| 时长校验 | 每条 > 0.3s(数字语音等短条目除外,fix_num6 放宽到 0.3~1.5s 区间判定) |
|
||||
| 峰值校验 | 生成后 assert 峰值 > 0.05(防静音/失败条目混入) |
|
||||
| 峰值归一 | storyline 合成 sfx 0.85;escape voice 0.89 / sfx 0.85(环境音 0.55-0.7);reflex voice 0.6 / sfx 0.7-0.95 |
|
||||
|
||||
## 目录约定
|
||||
|
||||
```
|
||||
app/src/<flavor>/
|
||||
assets/audio/{bgm,voice,sfx}/*.ogg # 最终资产(入库)
|
||||
gen/ # 生成脚本(入库)+ 中间 wav/字体(不入库)
|
||||
tools/
|
||||
mureka_gen.py / mureka_log.md # Mureka 调用脚本与台账(入库)
|
||||
mureka_out/ bgm_backup/ samples/ # 原始下载/备份/冒烟输出(不入库)
|
||||
stable-audio-3/ # SFX 仓库(代码入库,.venv 不入库)
|
||||
```
|
||||
|
||||
引擎按 id 前缀(`bgm/` `voice/` `sfx/`)分发到三个播放层,文件名即代码中的
|
||||
引用 id;新增资产保持"同名替换、新增先改代码引用"的原则。
|
||||
|
||||
## 封面图
|
||||
|
||||
- storyline:`gen/make_cover.py`(PIL,字体 /tmp/slg/NotoSansSC.ttf)
|
||||
- escape:`gen/gen_cover.py`(PIL,字体 gen/font.otf,不入库需自备)
|
||||
- reflex:封面无独立生成脚本(骨架渐变图 + 后期替换)
|
||||
- 统一输出 `app/src/<flavor>/res/drawable/cover.png`,1080x1920,文件名固定。
|
||||
@@ -0,0 +1,191 @@
|
||||
# SoundGame 引擎 API 文档
|
||||
|
||||
面向游戏开发 agent:本文档说明如何在共享引擎骨架上开发一款纯音频游戏。
|
||||
骨架已提供:手势识别(GestureEngine)、分层音频(AudioEngine)、状态机基类
|
||||
(GameEngine)、全屏封面 Activity(MainActivity)。**你只需要实现 `Game`
|
||||
接口并提供资产,不需要碰 Activity、Compose 或音频底层。**
|
||||
|
||||
## 构建与环境
|
||||
|
||||
```bash
|
||||
source /home/ubuntu/SoundGame/env.d/android.sh # 必须先执行(JDK17 / SDK / ffmpeg)
|
||||
cd /home/ubuntu/SoundGame
|
||||
|
||||
# 构建某一个 flavor(各自独立安装,applicationId 不同)
|
||||
./gradlew assembleStorylineDebug # com.soundgame.storyline
|
||||
./gradlew assembleReflexDebug # com.soundgame.reflex
|
||||
./gradlew assembleEscapeDebug # com.soundgame.escape
|
||||
|
||||
# 一次构建全部
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
APK 输出在 `app/build/outputs/apk/<flavor>/debug/`。
|
||||
首次构建会下载依赖,较慢属正常(daemon 有缓存,重跑会快很多)。
|
||||
|
||||
## 工程结构
|
||||
|
||||
```
|
||||
app/
|
||||
build.gradle.kts # flavor 定义、依赖
|
||||
src/
|
||||
main/ # 共享引擎,不要放游戏逻辑
|
||||
java/com/soundgame/
|
||||
MainActivity.kt # 全屏封面 + 手势路由(一般不用改)
|
||||
engine/
|
||||
Gesture.kt # Gesture 枚举
|
||||
GestureEngine.kt # Modifier.gestureDetector
|
||||
AudioEngine.kt # AudioEngine 接口 + DefaultAudioEngine
|
||||
Game.kt # Game 接口 + GameEngine<S> 状态机基类
|
||||
AndroidManifest.xml
|
||||
storyline/ # 你的游戏放这里(以 storyline 为例)
|
||||
java/com/soundgame/game/
|
||||
GameFactory.kt # 注册点:创建你的 Game 实现
|
||||
... # 你的其他游戏代码,随意组织
|
||||
res/drawable/cover.png # 封面图(1080x1920,必须叫 cover)
|
||||
assets/audio/ # 全部音频资产
|
||||
bgm/ voice/ sfx/
|
||||
```
|
||||
|
||||
## 快速上手:实现一款游戏
|
||||
|
||||
**第 1 步**:在 `app/src/<你的flavor>/java/com/soundgame/game/` 下写游戏类。
|
||||
推荐继承 `GameEngine<S>`(状态机模式):
|
||||
|
||||
```kotlin
|
||||
package com.soundgame.game
|
||||
|
||||
import com.soundgame.engine.AudioEngine
|
||||
import com.soundgame.engine.GameEngine
|
||||
import com.soundgame.engine.Gesture
|
||||
|
||||
sealed interface Scene {
|
||||
data object Intro : Scene
|
||||
data object Hallway : Scene
|
||||
data object Ending : Scene
|
||||
}
|
||||
|
||||
class MyGame(audio: AudioEngine) : GameEngine<Scene>(audio) {
|
||||
|
||||
override val initialState = Scene.Intro
|
||||
|
||||
// 状态转移:当前状态 + 手势 -> 新状态(返回原状态表示无转移)
|
||||
override fun transition(state: Scene, gesture: Gesture): Scene = when (state) {
|
||||
Scene.Intro -> when (gesture) {
|
||||
Gesture.TAP -> Scene.Hallway
|
||||
else -> state
|
||||
}
|
||||
Scene.Hallway -> when (gesture) {
|
||||
Gesture.SWIPE_LEFT, Gesture.SWIPE_RIGHT -> Scene.Ending
|
||||
else -> state
|
||||
}
|
||||
Scene.Ending -> state
|
||||
}
|
||||
|
||||
// 进入新状态时播放音频(onStart 时也会以 previous=null 调一次 initialState)
|
||||
override fun onEnterState(state: Scene, previous: Scene?) {
|
||||
when (state) {
|
||||
Scene.Intro -> {
|
||||
audio.playBgm("bgm/main_theme")
|
||||
audio.playVoice("voice/intro")
|
||||
}
|
||||
Scene.Hallway -> audio.playVoice("voice/hallway")
|
||||
Scene.Ending -> {
|
||||
audio.stopBgm()
|
||||
audio.playVoice("voice/ending")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果你的玩法不是状态机(如 reflex 的计时反应),直接实现 `Game` 接口:
|
||||
|
||||
```kotlin
|
||||
class MyGame(private val audio: AudioEngine) : Game {
|
||||
override fun onGesture(gesture: Gesture) { /* 任意逻辑 */ }
|
||||
override fun onStart() { /* 开场 */ }
|
||||
override fun onStop() { /* 收尾 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**第 2 步**:在 `GameFactory.kt`(已在骨架中,包名必须是 `com.soundgame.game`)
|
||||
里把 stub 换成你的实现:
|
||||
|
||||
```kotlin
|
||||
object GameFactory {
|
||||
fun create(audio: AudioEngine): Game = MyGame(audio)
|
||||
}
|
||||
```
|
||||
|
||||
MainActivity 只认 `com.soundgame.game.GameFactory.create(audio)`,不要改它的签名。
|
||||
|
||||
**第 3 步**:放资产、构建、验证。
|
||||
|
||||
## Gesture 枚举
|
||||
|
||||
`com.soundgame.engine.Gesture`,共 6 种:
|
||||
|
||||
| 值 | 含义 | 判定 |
|
||||
|---|---|---|
|
||||
| `SWIPE_UP` / `SWIPE_DOWN` / `SWIPE_LEFT` / `SWIPE_RIGHT` | 四向滑动 | 位移 ≥ 64dp 且平均速度 ≥ 400px/s,方向取位移较大的轴(防误触) |
|
||||
| `TAP` | 单击 | 标准 tap |
|
||||
| `DOUBLE_TAP` | 双击 | 标准 double tap |
|
||||
|
||||
注意:因为注册了双击,**单击回调会延迟约 300ms**(等双击判定窗口)才触发。
|
||||
设计玩法时把关键即时反馈尽量放在滑动或双击上,或接受该延迟。
|
||||
阈值可在 `GestureConfig` 调整(骨架用默认值,如需改动在 `MainActivity` 的
|
||||
`gestureDetector(config = ...)` 处传参——这影响所有 flavor,改动前先沟通)。
|
||||
|
||||
## AudioEngine 接口
|
||||
|
||||
```kotlin
|
||||
interface AudioEngine {
|
||||
fun play(id: String) // 按 id 前缀分发
|
||||
fun playBgm(id: String, loop: Boolean = true, fadeMs: Long = 800)
|
||||
fun stopBgm(fadeOutMs: Long = 800)
|
||||
fun playVoice(id: String, duckBgm: Boolean = true, onComplete: (() -> Unit)? = null)
|
||||
fun stopVoice()
|
||||
fun playSfx(id: String, volume: Float = 1f)
|
||||
fun setBgmVolume(volume: Float)
|
||||
fun release() // Activity 已调用,你不用管
|
||||
}
|
||||
```
|
||||
|
||||
- **三层分离**:BGM 用 ExoPlayer(切换时自动交叉淡入淡出);对白用另一个
|
||||
ExoPlayer,播放期间自动把 BGM 压到 20%(ducking),播完恢复;短音效用
|
||||
SoundPool,低延迟可叠加。
|
||||
- **`id` 约定**:相对 `assets/audio/` 的路径,如 `"sfx/door_open"`。
|
||||
前缀 `bgm/`、`voice/`、`sfx/` 决定走哪一层;`play(id)` 就靠前缀分发。
|
||||
**扩展名可省略**,引擎依次尝试 `.ogg` → `.mp3` → `.wav`。
|
||||
- `playVoice` 的 `onComplete` 在对白自然播完时回调(被 `stopVoice` 打断不回调),
|
||||
适合做"台词说完再开选项"的剧情驱动。
|
||||
- 找不到资产时引擎打 `Log.e("AudioEngine", ...)` 并静默跳过,不会崩溃——
|
||||
但请用 logcat 检查拼写:`adb logcat -s AudioEngine`。
|
||||
|
||||
## 资产放哪
|
||||
|
||||
```
|
||||
app/src/<flavor>/assets/audio/
|
||||
bgm/ 背景音乐(循环长音频,建议 .ogg)
|
||||
voice/ 对白/旁白(建议 .ogg 或 .mp3)
|
||||
sfx/ 短音效(建议 .wav 或短 .ogg,SoundPool 低延迟)
|
||||
```
|
||||
|
||||
封面图:`app/src/<flavor>/res/drawable/cover.png`(1080x1920 竖图,骨架已生成
|
||||
占位渐变图,换成正式图时保持文件名 `cover.png` 不变,MainActivity 按名引用)。
|
||||
|
||||
生成/处理音频用 ffmpeg(已配好):如
|
||||
`ffmpeg -i in.wav -c:a libvorbis -q:a 5 app/src/storyline/assets/audio/bgm/theme.ogg`。
|
||||
|
||||
## 规则与注意事项
|
||||
|
||||
- **不要修改** `app/src/main/` 下的引擎代码和 `MainActivity`,除非你 flavor 的
|
||||
需求所有游戏都需要——那是共享代码。确需扩展引擎能力时,加新方法而不要改
|
||||
已有签名。
|
||||
- 每个 flavor 的包结构固定为 `com.soundgame.game.GameFactory` + 任意其他类,
|
||||
三个 flavor 的同名类互不冲突(源码集隔离)。
|
||||
- 全屏沉浸式、屏幕常亮、竖屏锁定已在 MainActivity/Manifest 处理,不用自己弄。
|
||||
- 封面图已带 TalkBack 无障碍描述(contentDescription),换图不影响。
|
||||
- 音频游戏注意节奏:手势回调在主线程,音频 API 均非阻塞,可以直接在
|
||||
`onGesture` 里调用。
|
||||
@@ -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 批量与响度 | 生成效率、语音响度统一 |
|
||||
@@ -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` | 响度与体积 |
|
||||
@@ -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 淡入淡出时长 |
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
# SoundGame — 从零环境搭建手册
|
||||
|
||||
本文档描述在一台全新的 WSL2(Ubuntu)机器上,如何以**纯用户态(无 sudo)**方式
|
||||
复现本项目的全部开发环境:Android 构建链、Qwen3-TTS 对白生成、Stable Audio 3
|
||||
音效生成、ffmpeg 音频处理。
|
||||
|
||||
前置条件:
|
||||
|
||||
- WSL2 + Ubuntu,能访问外网(部分站点需走镜像,见文末速查表)
|
||||
- NVIDIA GPU(本项目用 RTX 3090 24GB)+ Windows 侧已装 NVIDIA 驱动
|
||||
(WSL2 透传 CUDA,无需在 WSL 内装驱动,只需 `nvidia-smi` 能跑通)
|
||||
- 项目放在 `/home/ubuntu/SoundGame`,下文所有路径以此为准
|
||||
|
||||
## 为什么不用 apt
|
||||
|
||||
- 本机**没有 sudo 权限**,`apt install` 直接不可用;
|
||||
- 即使可用,Ubuntu 仓库的 JDK / Android SDK / ffmpeg 版本也偏旧,
|
||||
且会污染系统目录,不利于把整个环境收进项目目录随仓库一起搬走;
|
||||
- 所有工具因此都装在 `/home/ubuntu/SoundGame/tools/` 或用户目录下,
|
||||
通过 `env.d/*.sh` 注入 PATH 使用,不开 shell 不污染环境。
|
||||
|
||||
## 1. uv(Python 包/环境管理)
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh # 装到 ~/.local/bin
|
||||
~/.local/bin/uv --version # 实测 0.12.7 (x86_64-unknown-linux-gnu)
|
||||
```
|
||||
|
||||
uv 自带 `python` 管理和虚拟环境管理,后续两个 venv 都由它创建。
|
||||
注意 uv 不在默认 PATH,`env.d/sfx.sh` 里有 `export PATH="$HOME/.local/bin:$PATH"`。
|
||||
|
||||
## 2. JDK 17(用户态)
|
||||
|
||||
```bash
|
||||
# 来源:华为云镜像的 Oracle OpenJDK 17.0.2(Adoptium 官方源在本机过慢)
|
||||
# https://repo.huaweicloud.com/openjdk/17.0.2/openjdk-17.0.2_linux-x64_bin.tar.gz
|
||||
cd /home/ubuntu/SoundGame/tools
|
||||
curl -LO https://repo.huaweicloud.com/openjdk/17.0.2/openjdk-17.0.2_linux-x64_bin.tar.gz
|
||||
tar xzf openjdk-17.0.2_linux-x64_bin.tar.gz
|
||||
mv jdk-17.0.2 jdk17
|
||||
tools/jdk17/bin/java -version # openjdk 17.0.2
|
||||
```
|
||||
|
||||
## 3. Android SDK(cmdline-tools,无 Android Studio)
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/SoundGame/tools
|
||||
# 来源:Google 官方 dl.google.com(commandlinetools-linux-15859902_latest.zip)
|
||||
curl -LO https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip
|
||||
unzip commandlinetools-linux-15859902_latest.zip -d android-sdk-tmp
|
||||
# 官方要求目录结构必须是 cmdline-tools/latest/,否则 sdkmanager 报路径错误
|
||||
mkdir -p android-sdk/cmdline-tools
|
||||
mv android-sdk-tmp/cmdline-tools android-sdk/cmdline-tools/latest
|
||||
rm -rf android-sdk-tmp commandlinetools-linux-15859902_latest.zip
|
||||
|
||||
source /home/ubuntu/SoundGame/env.d/android.sh # 注入 JAVA_HOME/ANDROID_HOME/PATH
|
||||
yes | sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||||
```
|
||||
|
||||
安装结果(与 `env.d/android.md` 一致):platform-tools 37.0.1、android-36、
|
||||
build-tools 36.0.0。
|
||||
|
||||
## 4. ffmpeg / ffprobe(静态构建)
|
||||
|
||||
```bash
|
||||
# 来源:BtbN/FFmpeg-Builds 的 linux-x86_64 静态版(crosstool-NG 构建,零依赖)
|
||||
# https://github.com/BtbN/FFmpeg-Builds/releases (ffmpeg-master-latest-linux64-gpl.tar.xz)
|
||||
mkdir -p /home/ubuntu/SoundGame/tools/bin
|
||||
# 解压后把 ffmpeg / ffprobe 两个二进制放进 tools/bin/ 即可
|
||||
/home/ubuntu/SoundGame/tools/bin/ffmpeg -version
|
||||
# 实测版本 N-126303-g9f35e220ff-20260827,含 libvorbis/libmp3lame/libopus
|
||||
```
|
||||
|
||||
必须用含 `libvorbis` 的构建(资产出片格式是 ogg vorbis),GPL 版静态包自带。
|
||||
|
||||
## 5. Gradle
|
||||
|
||||
不需要单独安装:项目用 Gradle Wrapper,首次 `./gradlew` 自动下载。
|
||||
本机 dl.google.com / services.gradle.org 均较慢,wrapper 已指向腾讯镜像:
|
||||
|
||||
```
|
||||
# gradle/wrapper/gradle-wrapper.properties
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.13-bin.zip
|
||||
```
|
||||
|
||||
首次构建:
|
||||
|
||||
```bash
|
||||
source /home/ubuntu/SoundGame/env.d/android.sh
|
||||
cd /home/ubuntu/SoundGame
|
||||
./gradlew assembleDebug # 首次下载依赖较慢属正常,daemon 有缓存
|
||||
```
|
||||
|
||||
## 6. Qwen3-TTS 对白环境(.venv-tts)
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/SoundGame
|
||||
~/.local/bin/uv venv .venv-tts --python 3.12 # 实测 Python 3.12.14
|
||||
# PyPI 走阿里云镜像,torch 需 cu128 轮子
|
||||
~/.local/bin/uv pip install --python .venv-tts/bin/python \
|
||||
--index-url https://mirrors.aliyun.com/pypi/simple/ \
|
||||
qwen-tts soundfile transformers
|
||||
~/.local/bin/uv pip install --python .venv-tts/bin/python \
|
||||
torch torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
```
|
||||
|
||||
实测版本:`torch 2.11.0+cu128`、`torchaudio 2.11.0+cu128`、`qwen-tts 0.1.1`、
|
||||
`transformers 4.57.3`。
|
||||
|
||||
模型下载(共 3 个仓库,缓存在 `models/hf`,由 `HF_HOME` 指定):
|
||||
|
||||
```bash
|
||||
source /home/ubuntu/SoundGame/env.d/tts.sh # HF_HOME + HF_ENDPOINT=https://hf-mirror.com
|
||||
.venv-tts/bin/python -c "
|
||||
from huggingface_hub import snapshot_download
|
||||
for repo in ['Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice',
|
||||
'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign',
|
||||
'Qwen/Qwen3-TTS-Tokenizer-12Hz']:
|
||||
snapshot_download(repo); print('ok', repo)
|
||||
"
|
||||
```
|
||||
|
||||
**镜像注意事项**:本机连不通 huggingface.co,公开模型必须走
|
||||
`HF_ENDPOINT=https://hf-mirror.com`(tts.sh 已设好)。Qwen 三个仓库均为公开
|
||||
仓库,经镜像下载无障碍;gated 模型则不能走镜像,见下节。
|
||||
|
||||
冒烟测试:
|
||||
|
||||
```bash
|
||||
source env.d/tts.sh
|
||||
.venv-tts/bin/python tools/tts_smoke_test.py
|
||||
# 输出 tools/samples/tts_test.wav(约 2s,24kHz)
|
||||
```
|
||||
|
||||
详细用法(CustomVoice / VoiceDesign / 预置音色表)见 `env.d/tts.md`。
|
||||
|
||||
## 7. Stable Audio 3 音效环境(gated 模型)
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/SoundGame/tools
|
||||
git clone <stable-audio-3 仓库> stable-audio-3 # 项目内已有副本
|
||||
cd stable-audio-3
|
||||
cp /home/ubuntu/SoundGame/env.d/sfx.example.sh /home/ubuntu/SoundGame/env.d/sfx.sh
|
||||
# 编辑 sfx.sh 填入真实 HF_TOKEN(见下)
|
||||
source /home/ubuntu/SoundGame/env.d/sfx.sh
|
||||
uv sync --extra ui # Python 3.10 venv,PyPI 走阿里云镜像(sfx.sh 已设)
|
||||
```
|
||||
|
||||
实测:Python 3.10.6,`torch 2.7.1+cu126`,CUDA 验证通过。
|
||||
|
||||
### gated 模型 token 获取流程
|
||||
|
||||
`stabilityai/stable-audio-3-small-sfx` 是 **gated(auto 批准)** 仓库:
|
||||
|
||||
1. 注册/登录 HuggingFace 账号;
|
||||
2. 打开 https://huggingface.co/stabilityai/stable-audio-3-small-sfx
|
||||
接受 **Stable Audio Community License**(auto 模式立即通过,无需人工审核);
|
||||
3. 在 https://huggingface.co/settings/tokens 创建 **read** 权限 token;
|
||||
4. 填入 `env.d/sfx.sh` 的 `HF_TOKEN`(该文件已被 .gitignore 忽略,不会入库)。
|
||||
|
||||
### 坑:hf-mirror 对 gated 模型的 308 重定向
|
||||
|
||||
**gated 模型不能走 hf-mirror**:镜像会把带 token 的请求 308 重定向回
|
||||
huggingface.co,本机直连官方又不可达时会直接失败。因此 `env.d/sfx.sh` 里
|
||||
**故意不设 `HF_ENDPOINT`**(直连官方端点,当前实测 huggingface.co 对该域名
|
||||
可达)。如果以后直连失效且只下公开模型,可临时取消脚本里的镜像注释。
|
||||
|
||||
Qwen3-TTS(公开)走镜像、Stable Audio 3(gated)走直连——这就是
|
||||
`tts.sh` 与 `sfx.sh` 的 `HF_ENDPOINT` 配置相反的原因。两个环境不要同时 source。
|
||||
|
||||
## 8. Mureka BGM API(可选,需付费 key)
|
||||
|
||||
```bash
|
||||
cp env.d/mureka.example.sh env.d/mureka.sh # 填入 https://platform.mureka.ai 的 key
|
||||
source env.d/mureka.sh
|
||||
python3 tools/mureka_gen.py --prompt "..." --out tools/mureka_out/xxx
|
||||
```
|
||||
|
||||
`env.d/mureka.sh` 含真实 API key,已被 .gitignore 忽略。计费与调用记录见
|
||||
`tools/mureka_log.md`,完整流程见 `docs/asset-pipeline.md`。
|
||||
|
||||
## 网络镜像速查表
|
||||
|
||||
| 用途 | 镜像 | 配置位置 |
|
||||
|---|---|---|
|
||||
| HuggingFace 公开模型 | `https://hf-mirror.com` | `HF_ENDPOINT`(tts.sh / gen 脚本内) |
|
||||
| HuggingFace gated 模型 | **不可用**,直连官方 | sfx.sh 故意不设 HF_ENDPOINT |
|
||||
| PyPI | `https://mirrors.aliyun.com/pypi/simple/` | `UV_DEFAULT_INDEX` / `uv pip --index-url` |
|
||||
| Gradle 发行包 | `https://mirrors.cloud.tencent.com/gradle/` | `gradle/wrapper/gradle-wrapper.properties` |
|
||||
| JDK | `https://repo.huaweicloud.com/openjdk/` | 手动下载 |
|
||||
|
||||
## 常见故障排查
|
||||
|
||||
**`sdkmanager` 提示路径/找不到**
|
||||
cmdline-tools 必须位于 `android-sdk/cmdline-tools/latest/`,多一层少一层都会报错。
|
||||
|
||||
**Gradle 下载卡住**
|
||||
确认 wrapper 用的是腾讯镜像(见上);依赖仓库(google/mavenCentral)本机直连可用,
|
||||
如不行可在 `settings.gradle.kts` 临时加阿里云 maven 镜像。
|
||||
|
||||
**`qwen-tts` 打印 sox 警告**
|
||||
系统无 `sox` 二进制(无 sudo 装不了),警告可忽略,不影响生成;
|
||||
只有用到 sox 音频处理链路时才需要它。
|
||||
|
||||
**HF 下载 403 / 401**
|
||||
模型是 gated 的(Stable Audio 3):先接受许可再带 token;且 gated 模型经
|
||||
hf-mirror 会被 308 回官网,见第 7 节。
|
||||
|
||||
**HF 下载卡住不动**
|
||||
确认 `HF_ENDPOINT=https://hf-mirror.com` 已生效(`source env.d/tts.sh`),
|
||||
并关掉 hf_transfer(`HF_HUB_ENABLE_HF_TRANSFER=0`,tts.sh 已设)。
|
||||
|
||||
**TTS / SFX 同时抢 GPU**
|
||||
所有 GPU 任务必须用 `flock /home/ubuntu/SoundGame/.gpu.lock` 串行
|
||||
(各 gen_voice.py 头部注释都有标准命令)。Stable Audio 3 small 峰值显存约
|
||||
2.4GB,Qwen3-TTS 1.7B bf16 也远低于 24GB,理论上可共存,但仍建议串行省心。
|
||||
|
||||
**WSL2 里 `adb devices` 看不到手机**
|
||||
WSL2 不能直接访问 USB。用无线调试(`adb pair` + `adb connect`)或让 adb server
|
||||
跑在 Windows 侧,详见 `env.d/android.md`。
|
||||
|
||||
**真机上第一个音效没声音**
|
||||
SoundPool 加载是异步的,首个音效会被吞。三个游戏都已在 `onStart` 里用
|
||||
音量 0 预播预热(搜索源码里的 `prewarm`),新增音效记得加进预热清单。
|
||||
Reference in New Issue
Block a user