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