- 共享引擎(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 模板
162 lines
7.5 KiB
Markdown
162 lines
7.5 KiB
Markdown
# 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 待办)。
|