# 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 状态机基类 ``` 调用关系: ``` 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` 是其上的状态机抽象(见下文)。 ## 音频三层架构(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) ```kotlin abstract class GameEngine(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//`)包含: ``` java/com/soundgame/game/GameFactory.kt # 注册点(包名/签名固定) java/com/soundgame/game/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 待办)。