Files
SoundGame Dev f0f0a9b50e 初始提交: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 模板
2026-08-29 13:00:34 +08:00

162 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 待办)。