From 9732f2799e0cd747c670fb1bd12f1c32f0ee3aeb Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 2 Sep 2026 15:31:58 +0900 Subject: [PATCH] =?UTF-8?q?40250=20classic:=20increment=2050=20(W4)=20?= =?UTF-8?q?=E2=80=94=20map=20BGM=20selection=20+=20background=20volume=20s?= =?UTF-8?q?ave/restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.1 map BGM selection (bgm_director.gd): - server bgm_changed(name, volume): non-empty name plays it as field music; empty name falls back to musicInfo.fieldMusic (METIN2THEMA = "M2BG.mp3") - volume < 0 (server unspecified, GC_MAIN_CHARACTER3_BGM) -> default 1/5*0.1 = 0.02; otherwise server volume; volume applied before play_bgm() fade-in (matches game.py:190 SetMusicVolume/SetSoundVolume then FadeInMusic) - connect M2Client.world_reset -> re-run selection on map change - new musicinfo.gd: METIN2THEMA/loginMusic/createMusic/selectMusic/fieldMusic constants + save/load_last_play_field_music -> user://BGM/lastplay.inf §9.2 background/foreground volume save-restore: - audio.gd: save_volume()/restore_volume() (backup fields echo reference m_fBackupMusicVolume / m_fBackupSoundVolume) + set_music_volume() - app_lifecycle.gd: _enter_background() uses save_volume() + mute instead of stop_bgm(); _exit_background() calls restore_volume(); _bgm_name never cleared §9.3 lifecycle ownership: DOC ONLY — design confirmed, deferred to Phase 2 / W0 (AppFlow sole lifecycle owner; M2Client drops NOTIFICATION_* keeps suspend/resume). game_scene.gd / app_flow.gd / m2_client.* left untouched per workflow constraints. Tests: bgm_test.gd (new) PASS; gamescene_test / netplay_test / p9_test green. Docs: CLIENT-GAP.md increment 50 bullet + W4 progress row; CLIENT-GAP-FIX.md §9.1 §9.2 状态/改动/测试/验收, §9.3 deferred note, C.5 batch record. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W --- docs/CLIENT-GAP-FIX.md | 82 ++++++++++++++++++- docs/CLIENT-GAP.md | 18 ++++- project/app_lifecycle.gd | 11 ++- project/audio.gd | 32 ++++++++ project/bgm_director.gd | 58 ++++++++++++-- project/bgm_director.gd.uid | 1 + project/bgm_test.gd | 153 ++++++++++++++++++++++++++++++++++++ project/bgm_test.gd.uid | 1 + project/musicinfo.gd | 47 +++++++++++ project/musicinfo.gd.uid | 1 + 10 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 project/bgm_director.gd.uid create mode 100644 project/bgm_test.gd create mode 100644 project/bgm_test.gd.uid create mode 100644 project/musicinfo.gd create mode 100644 project/musicinfo.gd.uid diff --git a/docs/CLIENT-GAP-FIX.md b/docs/CLIENT-GAP-FIX.md index cb57021c..b2d0b766 100644 --- a/docs/CLIENT-GAP-FIX.md +++ b/docs/CLIENT-GAP-FIX.md @@ -2638,6 +2638,28 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn (文件路径 `user://BGM/lastplay.inf`)。 4. 换图(§1.3)走同一条选曲逻辑。 +**状态**:✅ 完成(曲目解析 + 音量在 Godot 侧落地;C++ 侧 BGM 名/音量路由与保留由增量 49 完成)。 +真服验收待 40250 服务端。 + +**改动**(2026-09-02 / W4) + +- `project/bgm_director.gd`:`_on_bgm_changed` / 新增 `_on_world_reset` → `_select()`; + `_select()` 实现 `game.py:190` 三行逻辑——服务端 `current_track` 非空即播,为空回落 + `MusicInfo.fieldMusic`(`METIN2THEMA = "M2BG.mp3"`);音量 `current_volume >= 0` 用服务端值, + 否则 `DEFAULT_FIELD_MUSIC_VOLUME = 1.0/5.0*0.1 = 0.02`;先 `master_bgm = vol` + + `set_music_volume(vol)` 再 `play_bgm(track)`(淡入前置音量);`bind()` 连 + `M2Client.world_reset` 做换图重选。声音(SFX)音量参考取自系统设置,不随 BGM 包下发, + 故不改 `master_sfx`。 +- `project/musicinfo.gd`(新):常量 `METIN2THEMA / loginMusic / createMusic / selectMusic / + fieldMusic`;静态 `save_last_play_field_music(name)` / `load_last_play_field_music()` + 持久化到 `user://BGM/lastplay.inf`。 + +**测试**:`godot --headless --path project --script res://bgm_test.gd` → PASS(exit 0)。 +覆盖:服务端名+音量、名+音量未指定(默认 0.02)、名为空回落 `fieldMusic`、`world_reset` +重跑选曲、`lastplay.inf` 往返。`gamescene_test` / `netplay_test` / `p9_test` 回归全绿。无 C++ 改动。 + +**验收**:未验证(无真实 40250 服务端;地图 BGM 名 / 音量需服务端 `GC_MAIN_CHARACTER3/4` 数据)。 + --- ### 9.2 后台恢复后可能没有 BGM @@ -2657,10 +2679,42 @@ OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWn `m_fBackupMusicVolume / m_fBackupSoundVolume`。 4. 这样 `_bgm_name` 不再被清空,曲目继续播放,恢复后音量还原。 +**状态**:✅ 完成。 + +**改动**(2026-09-02 / W4) + +- `project/audio.gd`:新增 `_backup_music_volume` / `_backup_sound_volume`(对齐参考 + `m_fBackupMusicVolume / m_fBackupSoundVolume`)+ `_volume_saved` 守卫;`save_volume()` + 存 `master_bgm/master_sfx`,`restore_volume()` 还原并把音量重新贴到当前 BGM 声道; + 新增 `set_music_volume(v)`(立即把 `master_bgm` 应用到正在播放的声道,无淡入)。 +- `project/app_lifecycle.gd`:`_enter_background()` 把 `_bound_audio.stop_bgm()` 换成 + `save_volume()` + `set_music_volume(0.0)`(等价 `SaveVolume()` 后 `SetMusicVolume(0)`); + `_exit_background()` 加 `restore_volume()`。`_bgm_name` / `_bgm[]` 全程不动。 + +**测试**:`bgm_test.gd` 的 `_test_background_save_restore`——`_enter_background()` 后 +`master_bgm == 0`、备份为 `0.6/0.9`、`_bgm_name` 不变;`_exit_background()` 后 +`master_bgm/master_sfx` 还原为 `0.6/0.9`、`_bgm_name` 仍不变、备份标志清除。PASS(exit 0)。 + +**验收**:未验证(需真机 Android/iOS 前后台切换;本条无 1:1 桌面参考)。 + --- ### 9.3 生命周期所有权重复 +**状态**:设计已确认,落地推迟到 Phase 2(由 W0 执行)。 + +**当前**:`AppLifecycle` 由 `GameScene._build()` 自建(`game_scene.gd:181` `AppLifecycle.new()` ++ `add_child` + `bind(client, _audio)`);`M2Client` 自带 `_notification()` 处理 +`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 直接调 `suspend()/resume()` +(`extension/src/net/m2_client.cpp:2607`)。两处都在监听生命周期通知。 + +**目标设计(推迟到 Phase 2 / W0)**:`AppFlow` 作为唯一生命周期属主(等价 +`CPythonApplication`),由它创建并持有 `AppLifecycle`;`GameScene` 不再自建; +`M2Client` 删除 `NOTIFICATION_*` 处理,只保留被 `AppLifecycle` 调用的 +`suspend() / resume()`。暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进 +`docs/CLIENT-PORT.md` 的生命周期小节。本批次未改 `game_scene.gd` / `app_flow.gd` / +`m2_client.*`(跨工作流共享文件,属 W0 / Phase 2 范围)。 + **参考**:唯一协调者是 `CPythonApplication` (`REF/UserInterface/PythonApplication.cpp` + `PythonApplicationEvent.cpp`), `CPythonNetworkStream` 只是被它驱动的一个子系统。 @@ -2925,7 +2979,7 @@ var _pw := "123456789" | W1 协议 / 账号会话 | 🟡 开发中 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;`m_pending_actor` 暂存表成员就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑 | | W2 实体 / 世界 / 战斗 | 🟡 开发中 | 地图实体 / 掉落清理、地图重建首版、实体测试和场景测试;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags | | W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 | -| W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 | +| W4 渲染 / 资产 / 音频 | 🟡 开发中 | 增量 50:§9.1 地图 BGM 选曲 + 音量(`bgm_director.gd` / 新 `musicinfo.gd`)、§9.2 后台音量存恢复(`audio.gd` / `app_lifecycle.gd`)已完成,`bgm_test` + 3 回归全绿;§9.3 设计已确认、落地推迟 Phase 2 | §9.3 由 W0 在 Phase 2 落地;BGM / 前后台真机验收 | | W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | ### C.4 每批次回写模板 @@ -2971,3 +3025,29 @@ var _pw := "123456789" 下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、 W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并 ``` + +```text +批次:2026-09-02 / W4 / §9.1 §9.2 §9.3-doc +状态:✅ 完成(§9.1 §9.2 代码 + 回归);§9.3 设计已确认,落地推迟到 Phase 2 +参考:POC/assets/root/game.py:190/225(选曲 + 先置音量);POC/assets/root/musicinfo.py + (常量 + Save/LoadLastPlayFieldMusic);PythonNetworkStreamPhaseLoading.cpp:299 + (gs_fieldMusic_volume = 1/5*0.1);REF/MilesLib/SoundManager.h:31(SaveVolume/RestoreVolume) +改动: + project/bgm_director.gd —— _select() 补齐地图→曲目解析(服务端名优先 / 回落 + MusicInfo.fieldMusic / 默认音量 0.02 / 淡入前置音量);bind() 连 M2Client.world_reset + 换图重选 + project/musicinfo.gd(新)—— METIN2THEMA/loginMusic/createMusic/selectMusic/fieldMusic + 五常量 + save/load_last_play_field_music → user://BGM/lastplay.inf + project/audio.gd —— +save_volume()/restore_volume()(字段 _backup_music_volume/ + _backup_sound_volume 对齐 m_fBackupMusicVolume/m_fBackupSoundVolume)+ set_music_volume() + project/app_lifecycle.gd —— _enter_background 用 save_volume()+set_music_volume(0) 取代 + stop_bgm();_exit_background 加 restore_volume();_bgm_name 不清空 + project/bgm_test.gd(新)—— §9.1 选曲 4 例 + lastplay 往返 + §9.2 后台→前台存恢复 +测试:godot --headless --path project --script res://bgm_test.gd → PASS(exit 0); + gamescene_test / netplay_test / p9_test 回归全绿;无 build/ 目录,无 C++ 改动(ctest 不涉及) +验收:未验证(无真实 40250 服务端;前后台切换需真机 Android/iOS) +风险与依赖:§9.3 需 W0 在 Phase 2 把 AppLifecycle 属主收敛到 AppFlow、M2Client 去掉 + NOTIFICATION_* —— 本批次按约束未改 game_scene.gd/app_flow.gd/m2_client.*; + set_music_volume 只作用于当前 _bgm[_bgm_cur] 声道 +下一批次:Phase 2 由 W0 执行 §9.3 落地 + G1 集成与文档合并 +``` diff --git a/docs/CLIENT-GAP.md b/docs/CLIENT-GAP.md index a5f98f15..ce805ac7 100644 --- a/docs/CLIENT-GAP.md +++ b/docs/CLIENT-GAP.md @@ -59,7 +59,7 @@ | W1 协议 / 账号会话 | 🟡 | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN 已覆盖;`m_pending_actor` 暂存表成员已就位 | §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑;真实双连接、enter delay | | W2 实体 / 世界 / 战斗 | 🟡 | 地图实体 / 掉落清理、地图重建首版及离线回归已覆盖;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags | | W3 玩法 UI / 输入 | 🟡 | 多数窗口、背包 / 任务 / 社交、键位和输入已有首版;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位与表情包 | -| W4 渲染 / 资产 / 音频 | 🟡 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点已就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 | +| W4 渲染 / 资产 / 音频 | 🟡 | 增量 50:§9.1 地图 BGM 选曲(服务端名优先 / 回落 `musicInfo.fieldMusic` / 默认音量 0.02 / `world_reset` 重跑)在 `bgm_director.gd` 落地,新增 `musicinfo.gd`;§9.2 `Audio.save_volume/restore_volume` + `app_lifecycle.gd` 后台静音存恢复(不清 `_bgm_name`);§9.3 设计已确认、落地推迟到 Phase 2;`bgm_test` + 3 项回归全绿 | §9.3 由 W0 在 Phase 2 落地(`AppFlow` 单一属主);BGM 真服验收待 40250 服务端 | | W5 打包 / 发布 / 验收 | ⬜ | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 | ### 进度同步是交付条件 @@ -115,6 +115,22 @@ > `net_classic_session_test`、`gamescene_test`、`p9_test`、`p2b_test`、`netbridge_test`、 > `p8_test` 已通过;受限环境下 localhost TCP 目标测试仍自动 skip。真实 40250 换图 / 第二次 > 连接 E2E、地图 BGM 选择和 `MT_CLASSIC_ENTER_DELAY` 仍待完成。 +> - **2026-09-02 增量 50(Phase 1 W4:地图 BGM / 音频 / 生命周期)**:落地 +> [`CLIENT-GAP-FIX.md`](CLIENT-GAP-FIX.md) §9.1 §9.2,§9.3 仅确认设计。§9.1:`bgm_director.gd` +> 补齐「地图 → 曲目」解析——服务端 `bgm_changed(name, volume)` 名非空即播;名为空回落 +> `musicInfo.fieldMusic`(`METIN2THEMA = "M2BG.mp3"`);`volume < 0`(服务端未下发, +> `GC_MAIN_CHARACTER3_BGM`)用默认 `1/5*0.1 = 0.02`,否则用服务端值;应用音量在 +> `play_bgm()` 淡入之前(对标 `game.py:190` 先 `SetMusicVolume/SetSoundVolume` 再 +> `FadeInMusic`);连 `M2Client.world_reset` 在换图时重跑选曲。新增 `musicinfo.gd` +> (`METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic` 五常量 + +> `save/load_last_play_field_music` → `user://BGM/lastplay.inf`)。§9.2:`Audio` 新增 +> `save_volume() / restore_volume()`(字段名对齐参考 `m_fBackupMusicVolume / +> m_fBackupSoundVolume`)与 `set_music_volume()`;`app_lifecycle.gd` 进后台改为 +> `save_volume()` + 静音(不再 `stop_bgm()`),回前台 `restore_volume()`,`_bgm_name` +> 全程不清空,同一首继续播。§9.3:`AppLifecycle` 目前由 `GameScene` 自建、`M2Client` +> 自带 `NOTIFICATION_*`;目标设计(`AppFlow` 单一属主、`M2Client` 只留 `suspend/resume`) +> 已确认,落地推迟到 Phase 2 / W0。新增 `bgm_test.gd`;`bgm_test`、`gamescene_test`、 +> `netplay_test`、`p9_test` 全绿;无 C++ 改动。真服 BGM 验收待 40250 服务端。 > - **2026-09-02 增量 49(W0 接口冻结批次)**:为 Phase 1 的 4 个并行工作流一次性落地跨工作流 > 共享文件的骨架,冻结接口后再拆分。`EntityStore`:`Entity` 补 `empire / affect_flags / > owner_vid / state_flags` 字段;新增 `mut_spawn_full()`(§2.3 两包合并的单次落地入口)、 diff --git a/project/app_lifecycle.gd b/project/app_lifecycle.gd index 60224a2d..ab01b8cd 100644 --- a/project/app_lifecycle.gd +++ b/project/app_lifecycle.gd @@ -64,7 +64,13 @@ func _enter_background() -> void: if background_max_fps > 0: _saved_max_fps = Engine.max_fps Engine.max_fps = background_max_fps - if _bound_audio and _bound_audio.has_method("stop_bgm"): + # §9.2:不停 BGM,改为「保存音量 + 静音」(对标 CSoundManager::SaveVolume 后 + # SetMusicVolume(0))。_bgm_name 不清空,回前台恢复音量即可同一首继续播。 + if _bound_audio and _bound_audio.has_method("save_volume"): + _bound_audio.save_volume() + if _bound_audio.has_method("set_music_volume"): + _bound_audio.set_music_volume(0.0) + elif _bound_audio and _bound_audio.has_method("stop_bgm"): _bound_audio.stop_bgm() if _bound_client and _bound_client.has_method("suspend"): _bound_client.suspend() @@ -80,6 +86,9 @@ func _exit_background() -> void: get_tree().paused = false if background_max_fps > 0: Engine.max_fps = _saved_max_fps + # §9.2:还原后台前保存的音量。 + if _bound_audio and _bound_audio.has_method("restore_volume"): + _bound_audio.restore_volume() if _bound_client and _bound_client.has_method("resume"): _bound_client.resume() resumed.emit() diff --git a/project/audio.gd b/project/audio.gd index 2ee1a02d..716723a0 100644 --- a/project/audio.gd +++ b/project/audio.gd @@ -19,6 +19,12 @@ var master_bgm := 0.6 var master_sfx := 0.9 var _queued_bgm := "" +# §9.2 前后台音量存/恢复,字段名对齐参考 CSoundManager 的 +# m_fBackupMusicVolume / m_fBackupSoundVolume(REF/MilesLib/SoundManager.h:31)。 +var _backup_music_volume := -1.0 +var _backup_sound_volume := -1.0 +var _volume_saved := false + func _ready() -> void: if _queued_bgm != "": var n := _queued_bgm @@ -127,6 +133,32 @@ func _fade(pl: AudioStreamPlayer, to_db: float, dur: float, stop_after := false) if stop_after: tw.tween_callback(pl.stop) +# 立即把 master_bgm 应用到当前正在播放的 BGM 声道(无淡入)。 +# 对标 CSoundManager::SetMusicVolume:改的是「当前音乐实例」的音量。 +func set_music_volume(v: float) -> void: + master_bgm = clampf(v, 0.0, 1.0) + var pl: AudioStreamPlayer = _bgm[_bgm_cur] + if pl and pl.playing: + pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0 + +# §9.2:进入后台前保存音量(CSoundManager::SaveVolume)。 +func save_volume() -> void: + _backup_music_volume = master_bgm + _backup_sound_volume = master_sfx + _volume_saved = true + +# §9.2:回到前台后恢复音量(CSoundManager::RestoreVolume)。 +# 不碰 _bgm_name / _bgm[],同一首继续播,只把音量拉回来。 +func restore_volume() -> void: + if not _volume_saved: + return + master_bgm = _backup_music_volume + master_sfx = _backup_sound_volume + _volume_saved = false + var pl: AudioStreamPlayer = _bgm[_bgm_cur] + if pl and pl.playing: + pl.volume_db = linear_to_db(master_bgm) if master_bgm > 0.0 else -80.0 + # --- SFX ------------------------------------------------------------------- func play_ui(name: String) -> void: diff --git a/project/bgm_director.gd b/project/bgm_director.gd index 2eb30ca2..834c6504 100644 --- a/project/bgm_director.gd +++ b/project/bgm_director.gd @@ -1,8 +1,14 @@ # BgmDirector —— 把 M2Client.bgm_changed 信号翻译成 Audio 的 BGM 播放。 # # 存在的理由:§9.1 的地图 BGM 消费点,从 net_world.gd 里拆出来,避免 W2(可见性) -# 和 W4(音频)同时改 net_world.gd。真正的「地图 → 曲目」解析和音量应用由 W4 在 -# 这里补全(见 docs/CLIENT-GAP-FIX.md §9.1)。 +# 和 W4(音频)同时改 net_world.gd。地图 → 曲目解析和音量应用在这里(§9.1)。 +# +# 选曲逻辑对标 game.py:190(GameWindow.Open 内): +# snd.SetMusicVolume(...); snd.SetSoundVolume(...) # 先应用音量 +# name = net.GetFieldMusicFileName() +# if name: snd.FadeInMusic("BGM/" + name) +# elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic) +# 默认音量 gs_fieldMusic_volume = 1.0f/5.0f*0.1f = 0.02f(服务器未下发时)。 # # 用法: # var bd := preload("res://bgm_director.gd").new() @@ -10,6 +16,11 @@ # bd.bind(client, audio) extends Node +const MusicInfo = preload("res://musicinfo.gd") + +# gs_fieldMusic_volume 默认值(PythonNetworkStreamPhaseLoading.cpp:299)。 +const DEFAULT_FIELD_MUSIC_VOLUME := 1.0 / 5.0 * 0.1 # = 0.02 + var _client: Node var _audio: Node @@ -22,18 +33,49 @@ func bind(client: Node, audio: Node) -> void: _audio = audio if _client and _client.has_signal("bgm_changed"): _client.bgm_changed.connect(_on_bgm_changed) + # 换图(§1.3):world_reset 后重跑选曲逻辑。新图的 BGM 包到达前沿用当前曲目。 + if _client and _client.has_signal("world_reset"): + _client.world_reset.connect(_on_world_reset) func _on_bgm_changed(name: String, volume: float) -> void: + # M2Client 已在 EntityStore 侧做音量保留(BGM3 只带名、BGM4 带名+音量), + # 这里拿到的 volume 就是「有效音量」,<0 仅表示从未收到过 BGM4。 current_track = name - if volume >= 0.0: - current_volume = volume - if _audio and "master_bgm" in _audio: - _audio.master_bgm = clampf(volume, 0.0, 1.0) + current_volume = volume + _select() + +func _on_world_reset() -> void: + _select() + +# game.py:190 的三行选曲 + 先置音量。 +func _select() -> void: if _audio == null: return - if name == "": + + # 1) 选曲:服务端名优先,否则回落 musicInfo.fieldMusic(METIN2THEMA = "M2BG.mp3")。 + var track := current_track + if track == "": + track = String(MusicInfo.fieldMusic) + + # 2) 音量:服务端未指定(<0)用默认 0.02,否则用服务端值。 + var vol := current_volume if current_volume >= 0.0 else DEFAULT_FIELD_MUSIC_VOLUME + vol = clampf(vol, 0.0, 1.0) + + # 3) 先应用音量再淡入:Audio.play_bgm() 的淡入目标是 linear_to_db(master_bgm), + # 对标 game.py 先 SetMusicVolume/SetSoundVolume 再 FadeInMusic。 + # 声音(SFX)音量在参考里取自系统设置(systemSetting.GetSoundVolume), + # 不随 BGM 包下发,这里不改 master_sfx。 + if "master_bgm" in _audio: + _audio.master_bgm = vol + if _audio.has_method("set_music_volume"): + _audio.set_music_volume(vol) + + if track == "": if _audio.has_method("stop_bgm"): _audio.stop_bgm() return + if _audio.has_method("play_bgm"): - _audio.play_bgm(name) + _audio.play_bgm(track) + # musicInfo.SaveLastPlayFieldMusic 语义:记录本次战场曲目。 + MusicInfo.save_last_play_field_music(track) diff --git a/project/bgm_director.gd.uid b/project/bgm_director.gd.uid new file mode 100644 index 00000000..2af70df0 --- /dev/null +++ b/project/bgm_director.gd.uid @@ -0,0 +1 @@ +uid://yglonaagb03t diff --git a/project/bgm_test.gd b/project/bgm_test.gd new file mode 100644 index 00000000..5fc43499 --- /dev/null +++ b/project/bgm_test.gd @@ -0,0 +1,153 @@ +# bgm_test —— §9.1 地图 BGM 选曲 + §9.2 前后台音量存/恢复的 headless 自检。 +# godot --headless --path project --script bgm_test.gd +# exit 0 = pass。 +extends SceneTree + +const BgmDirector = preload("res://bgm_director.gd") +const Audio = preload("res://audio.gd") +const AppLifecycle = preload("res://app_lifecycle.gd") +const MusicInfo = preload("res://musicinfo.gd") + +const DEFAULT_VOL := 1.0 / 5.0 * 0.1 # 0.02 + +# 假 M2Client:只发 §9.1 关心的两个信号。 +class FakeClient extends Node: + signal bgm_changed(name: String, volume: float) + signal world_reset() + +# 假 Audio:记录 play_bgm / stop_bgm 调用与音量,够 director 断言用。 +class FakeAudio extends Node: + var master_bgm := 0.6 + var master_sfx := 0.9 + var _bgm_name := "" + var played: Array[String] = [] + var stopped := 0 + func play_bgm(name: String, _fade := 1.2) -> void: + if name == _bgm_name: + return + _bgm_name = name + played.append(name) + func stop_bgm(_fade := 1.0) -> void: + _bgm_name = "" + stopped += 1 + func set_music_volume(v: float) -> void: + master_bgm = clampf(v, 0.0, 1.0) + +var _fail := 0 +func _ck(c: bool, m: String) -> void: + if not c: + _fail += 1 + printerr("FAIL: " + m) + +func _init() -> void: + _test_select_server_volume() + _test_select_default_volume() + _test_select_empty_falls_back() + _test_world_reset_reruns() + _test_musicinfo_lastplay_roundtrip() + _test_background_save_restore() + if _fail == 0: + print("PASS: bgm_test (§9.1 map BGM select + §9.2 volume save/restore)") + quit(0) + else: + printerr("%d check(s) failed" % _fail) + quit(1) + +func _mk_director() -> Array: + var client := FakeClient.new() + var audio := FakeAudio.new() + var bd := BgmDirector.new() + get_root().add_child(client) + get_root().add_child(audio) + get_root().add_child(bd) + bd.bind(client, audio) + return [client, audio, bd] + +# §9.1:服务端给了名字 + 音量 -> 用服务端音量并播放该曲。 +func _test_select_server_volume() -> void: + var t := _mk_director() + var client: FakeClient = t[0] + var audio: FakeAudio = t[1] + client.bgm_changed.emit("pvp_bgm.mp3", 0.5) + _ck(audio.played == ["pvp_bgm.mp3"], "server name played, got %s" % [audio.played]) + _ck(is_equal_approx(audio.master_bgm, 0.5), "server volume applied, got %f" % audio.master_bgm) + _ck(audio.stopped == 0, "no stop_bgm on server-provided track") + t[0].free(); t[1].free(); t[2].free() + +# §9.1:服务端给了名字但音量 <0(GC_MAIN_CHARACTER3_BGM,未收 BGM4)-> 默认 0.02。 +func _test_select_default_volume() -> void: + var t := _mk_director() + var client: FakeClient = t[0] + var audio: FakeAudio = t[1] + client.bgm_changed.emit("field_a.mp3", -1.0) + _ck(audio.played == ["field_a.mp3"], "name played on unspecified volume, got %s" % [audio.played]) + _ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 applied, got %f" % audio.master_bgm) + t[0].free(); t[1].free(); t[2].free() + +# §9.1:服务端名为空 -> 回落 musicInfo.fieldMusic(METIN2THEMA = "M2BG.mp3")。 +func _test_select_empty_falls_back() -> void: + var t := _mk_director() + var client: FakeClient = t[0] + var audio: FakeAudio = t[1] + client.bgm_changed.emit("", -1.0) + _ck(audio.played == [String(MusicInfo.fieldMusic)], + "empty name falls back to fieldMusic, got %s" % [audio.played]) + _ck(String(MusicInfo.fieldMusic) == "M2BG.mp3", "METIN2THEMA constant is M2BG.mp3") + _ck(is_equal_approx(audio.master_bgm, DEFAULT_VOL), "default 0.02 on fallback, got %f" % audio.master_bgm) + t[0].free(); t[1].free(); t[2].free() + +# §9.1 / §1.3:换图(world_reset)重跑选曲逻辑。 +func _test_world_reset_reruns() -> void: + var t := _mk_director() + var client: FakeClient = t[0] + var audio: FakeAudio = t[1] + var bd: BgmDirector = t[2] + _ck(client.world_reset.is_connected(bd._on_world_reset), "director connected to world_reset") + client.bgm_changed.emit("map_a.mp3", 0.4) + # warp:world_reset 先到(旧曲目仍在 store 里),随后新图的 BGM 包。 + client.world_reset.emit() + _ck(audio.played == ["map_a.mp3"], "world_reset keeps current track, got %s" % [audio.played]) + client.bgm_changed.emit("map_b.mp3", 0.2) + _ck(audio.played == ["map_a.mp3", "map_b.mp3"], "new map BGM played, got %s" % [audio.played]) + _ck(is_equal_approx(audio.master_bgm, 0.2), "new map volume applied, got %f" % audio.master_bgm) + t[0].free(); t[1].free(); t[2].free() + +# §9.1:musicInfo.SaveLastPlayFieldMusic / LoadLastPlayFieldMusic 往返。 +func _test_musicinfo_lastplay_roundtrip() -> void: + MusicInfo.save_last_play_field_music("last_field.mp3") + _ck(MusicInfo.load_last_play_field_music() == "last_field.mp3", + "lastplay.inf roundtrip, got %s" % MusicInfo.load_last_play_field_music()) + +# §9.2:后台保存音量 + 静音,前台恢复;_bgm_name 不变,同一首继续播。 +func _test_background_save_restore() -> void: + var client := FakeClient.new() + var audio := Audio.new() + var life := AppLifecycle.new() + get_root().add_child(audio) + audio.setup("res://__no_assets__") # 无资源,play_bgm 只置名不出声 + get_root().add_child(life) + life.pause_tree_on_background = false # 别把测试树暂停了 + life.background_max_fps = 0 + life.bind(null, audio) + + audio.master_bgm = 0.6 + audio.master_sfx = 0.9 + # 模拟「某首战场曲目正在播放」(headless 无资源,不实际解码)。 + audio._bgm_name = "M2BG.mp3" + var name_before := audio._bgm_name + _ck(name_before == "M2BG.mp3", "bgm name set before background, got %s" % name_before) + + life._enter_background() + _ck(is_equal_approx(audio.master_bgm, 0.0), "music muted in background, got %f" % audio.master_bgm) + _ck(audio._volume_saved, "save_volume ran (backup taken)") + _ck(is_equal_approx(audio._backup_music_volume, 0.6), "backup music volume, got %f" % audio._backup_music_volume) + _ck(is_equal_approx(audio._backup_sound_volume, 0.9), "backup sound volume, got %f" % audio._backup_sound_volume) + _ck(audio._bgm_name == name_before, "_bgm_name unchanged in background, got %s" % audio._bgm_name) + + life._exit_background() + _ck(is_equal_approx(audio.master_bgm, 0.6), "music volume restored, got %f" % audio.master_bgm) + _ck(is_equal_approx(audio.master_sfx, 0.9), "sound volume restored, got %f" % audio.master_sfx) + _ck(audio._bgm_name == name_before, "_bgm_name still unchanged after restore, got %s" % audio._bgm_name) + _ck(not audio._volume_saved, "backup flag cleared after restore") + + client.free(); life.free(); audio.free() diff --git a/project/bgm_test.gd.uid b/project/bgm_test.gd.uid new file mode 100644 index 00000000..0ce1a5a8 --- /dev/null +++ b/project/bgm_test.gd.uid @@ -0,0 +1 @@ +uid://cxhy4wk3451c1 diff --git a/project/musicinfo.gd b/project/musicinfo.gd new file mode 100644 index 00000000..a45178e5 --- /dev/null +++ b/project/musicinfo.gd @@ -0,0 +1,47 @@ +# MusicInfo —— 对标 POC/assets/root/musicinfo.py:默认曲目常量 + 上次战场曲目持久化。 +# +# 参考(musicinfo.py): +# METIN2THEMA = "M2BG.mp3" +# loginMusic = "login_window.mp3" +# createMusic = "characterselect.mp3" +# selectMusic = "characterselect.mp3" +# fieldMusic = METIN2THEMA +# SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf" +# +# 用法: +# const MusicInfo = preload("res://musicinfo.gd") +# MusicInfo.fieldMusic # -> "M2BG.mp3" +# MusicInfo.save_last_play_field_music("x.mp3") +# MusicInfo.load_last_play_field_music() # -> "x.mp3" 或 "" +extends RefCounted + +const METIN2THEMA := "M2BG.mp3" +const loginMusic := "login_window.mp3" +const createMusic := "characterselect.mp3" +const selectMusic := "characterselect.mp3" # 选人页 +const fieldMusic := METIN2THEMA + +# 参考写死 "BGM/lastplay.inf"(相对客户端目录);Godot 侧落到用户可写目录。 +const LAST_PLAY_DIR := "user://BGM" +const LAST_PLAY_PATH := "user://BGM/lastplay.inf" + +# musicInfo.SaveLastPlayFieldMusic(name) +static func save_last_play_field_music(name: String) -> void: + if name == "": + return + DirAccess.make_dir_recursive_absolute(LAST_PLAY_DIR) + var f := FileAccess.open(LAST_PLAY_PATH, FileAccess.WRITE) + if f: + f.store_string(name) + f.close() + +# musicInfo.LoadLastPlayFieldMusic() -> 曲目名(无记录时空串) +static func load_last_play_field_music() -> String: + if not FileAccess.file_exists(LAST_PLAY_PATH): + return "" + var f := FileAccess.open(LAST_PLAY_PATH, FileAccess.READ) + if f == null: + return "" + var s := f.get_as_text() + f.close() + return s.strip_edges() diff --git a/project/musicinfo.gd.uid b/project/musicinfo.gd.uid new file mode 100644 index 00000000..e94fe58f --- /dev/null +++ b/project/musicinfo.gd.uid @@ -0,0 +1 @@ +uid://ektx487ombx1