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

7.5 KiB
Raw Permalink Blame History

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 待办)。