§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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
196 lines
5.9 KiB
GDScript
196 lines
5.9 KiB
GDScript
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
|
||
# 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、
|
||
# 3D wav(`**/sound/<rel>`)。纯资产接线,无解码器移植(`.mss` 不用)。
|
||
# 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。
|
||
extends Node
|
||
|
||
var _assets: String
|
||
var _bgm_root: String
|
||
var _sound_roots: PackedStringArray = []
|
||
var _cache := {} # 绝对路径 -> AudioStream
|
||
|
||
var _bgm := [null, null] # 两个 AudioStreamPlayer 交叉淡
|
||
var _bgm_cur := 0
|
||
var _bgm_name := ""
|
||
var _ui_pool: Array[AudioStreamPlayer] = []
|
||
var _ui_next := 0
|
||
|
||
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
|
||
_queued_bgm = ""
|
||
_bgm_name = ""
|
||
play_bgm(n)
|
||
|
||
func setup(assets_root: String) -> void:
|
||
_assets = assets_root
|
||
# bgm/ 与 assets/ 同级(<repo>/bgm,见 AssetRoot)
|
||
_bgm_root = _assets.get_base_dir().path_join("bgm")
|
||
# 已知声音根 + 各 patch 的 sound/
|
||
for r in ["Sound/sound", "sound2/sound", "sound_m/sound"]:
|
||
var p := _assets.path_join(r)
|
||
if DirAccess.dir_exists_absolute(p):
|
||
_sound_roots.append(p)
|
||
var da := DirAccess.open(_assets)
|
||
if da:
|
||
for sub in da.get_directories():
|
||
var p := _assets.path_join(sub).path_join("sound")
|
||
if DirAccess.dir_exists_absolute(p):
|
||
_sound_roots.append(p)
|
||
|
||
for i in 2:
|
||
var pl := AudioStreamPlayer.new()
|
||
pl.bus = "Master"
|
||
add_child(pl)
|
||
_bgm[i] = pl
|
||
for i in 8:
|
||
var pl := AudioStreamPlayer.new()
|
||
add_child(pl)
|
||
_ui_pool.append(pl)
|
||
|
||
# --- resolution -----------------------------------------------------------
|
||
|
||
func _load_stream(path: String) -> AudioStream:
|
||
if _cache.has(path):
|
||
return _cache[path]
|
||
var s: AudioStream = null
|
||
var ext := path.get_extension().to_lower()
|
||
if ext == "mp3":
|
||
var m := AudioStreamMP3.new()
|
||
m.data = FileAccess.get_file_as_bytes(path)
|
||
s = m if m.data.size() > 0 else null
|
||
elif ext == "ogg":
|
||
s = AudioStreamOggVorbis.load_from_file(path)
|
||
elif ext == "wav":
|
||
s = AudioStreamWAV.load_from_file(path)
|
||
_cache[path] = s
|
||
return s
|
||
|
||
func _find_sound(rel: String) -> String:
|
||
rel = rel.replace("\\", "/")
|
||
var cands := PackedStringArray([rel])
|
||
if rel.get_extension().is_empty():
|
||
cands = PackedStringArray([rel + ".wav", rel + ".ogg"])
|
||
for root in _sound_roots:
|
||
for c in cands:
|
||
var p := root.path_join(c)
|
||
if FileAccess.file_exists(p):
|
||
return p
|
||
# also try under sound/ui/ and sound/common/ for bare names
|
||
if not rel.contains("/"):
|
||
for d in ["ui", "common"]:
|
||
for c in cands:
|
||
var p := root.path_join(d).path_join(c)
|
||
if FileAccess.file_exists(p):
|
||
return p
|
||
return ""
|
||
|
||
# --- BGM ---------------------------------------------------------------
|
||
|
||
func play_bgm(name: String, fade := 1.2) -> void:
|
||
if name == _bgm_name:
|
||
return
|
||
if not is_node_ready() or not is_inside_tree():
|
||
_queued_bgm = name # flushed in _ready()
|
||
return
|
||
_bgm_name = name
|
||
var path := name
|
||
if path.get_extension().is_empty():
|
||
path = _bgm_root.path_join(name + ".mp3")
|
||
elif not path.is_absolute_path():
|
||
path = _bgm_root.path_join(name)
|
||
var stream := _load_stream(path)
|
||
if stream == null:
|
||
push_warning("[audio] bgm not found: " + path)
|
||
return
|
||
if stream is AudioStreamMP3:
|
||
stream.loop = true
|
||
var nxt := 1 - _bgm_cur
|
||
_bgm[nxt].stream = stream
|
||
_bgm[nxt].volume_db = -40.0
|
||
_bgm[nxt].play()
|
||
_fade(_bgm[nxt], linear_to_db(master_bgm), fade)
|
||
_fade(_bgm[_bgm_cur], -40.0, fade, true)
|
||
_bgm_cur = nxt
|
||
|
||
func stop_bgm(fade := 1.0) -> void:
|
||
_bgm_name = ""
|
||
_fade(_bgm[_bgm_cur], -40.0, fade, true)
|
||
|
||
func _fade(pl: AudioStreamPlayer, to_db: float, dur: float, stop_after := false) -> void:
|
||
var tw := create_tween()
|
||
tw.tween_property(pl, "volume_db", to_db, dur)
|
||
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:
|
||
var path := _find_sound(name if name.contains("/") else "ui/" + name)
|
||
if path.is_empty():
|
||
path = _find_sound(name)
|
||
if path.is_empty():
|
||
push_warning("[audio] ui sfx not found: " + name)
|
||
return
|
||
var s := _load_stream(path)
|
||
if s == null:
|
||
return
|
||
var pl := _ui_pool[_ui_next]
|
||
_ui_next = (_ui_next + 1) % _ui_pool.size()
|
||
pl.stream = s
|
||
pl.volume_db = linear_to_db(master_sfx)
|
||
pl.play()
|
||
|
||
func play_at(rel: String, world_pos: Vector3, parent: Node = null) -> void:
|
||
var path := _find_sound(rel)
|
||
if path.is_empty():
|
||
return
|
||
var s := _load_stream(path)
|
||
if s == null:
|
||
return
|
||
var pl := AudioStreamPlayer3D.new()
|
||
pl.stream = s
|
||
pl.unit_size = 6.0
|
||
pl.max_distance = 60.0
|
||
pl.volume_db = linear_to_db(master_sfx)
|
||
(parent if parent else self).add_child(pl)
|
||
pl.global_position = world_pos
|
||
pl.play()
|
||
pl.finished.connect(pl.queue_free)
|