359 lines
11 KiB
GDScript
359 lines
11 KiB
GDScript
# audio_driver.gd —— 桥接 40250 Python/MilesLib 音频调用至 Godot 引擎。
|
||
# 每帧通过 Metin2PythonHost.audio_commands() 拉取原生端产生的音频播放/音量指令并执行。
|
||
class_name AudioDriver
|
||
extends Node
|
||
|
||
const AssetRoot = preload("res://asset_root.gd")
|
||
|
||
var _cache := {} # 绝对路径 -> AudioStream
|
||
var _bgm_root: String
|
||
var _sound_roots: PackedStringArray = []
|
||
|
||
var _bgm: Array[AudioStreamPlayer] = [] # 40250 CSoundManagerStream has three music slots.
|
||
var _bgm_names: Array[String] = []
|
||
var _bgm_volumes: Array[float] = []
|
||
var _bgm_fades := {} # slot -> {target, speed, mode}
|
||
|
||
var _ui_pool: Array[AudioStreamPlayer] = []
|
||
var _ui_next := 0
|
||
|
||
var master_sound := 1.0
|
||
var master_music := 1.0
|
||
|
||
# 3D 循环音效实例:id -> AudioStreamPlayer3D;所有 3D 播放器用于 StopAll。
|
||
var _active_3d := {}
|
||
var _all_3d: Array[AudioStreamPlayer3D] = []
|
||
|
||
func _ready() -> void:
|
||
_init_roots()
|
||
_init_players()
|
||
|
||
func _init_roots() -> void:
|
||
var client := AssetRoot.client_path()
|
||
var assets := AssetRoot.path()
|
||
|
||
# BGM 根目录:优先 Client/BGM,次选 assets/BGM
|
||
if DirAccess.dir_exists_absolute(client.path_join("BGM")):
|
||
_bgm_root = client.path_join("BGM")
|
||
elif DirAccess.dir_exists_absolute(assets.path_join("BGM")):
|
||
_bgm_root = assets.path_join("BGM")
|
||
else:
|
||
_bgm_root = client.path_join("BGM")
|
||
|
||
# 音效查找根目录
|
||
var candidate_roots := [
|
||
client.path_join("sound"),
|
||
client.path_join("Sound/sound"),
|
||
assets.path_join("Sound/sound"),
|
||
assets.path_join("sound2/sound"),
|
||
assets.path_join("sound_m/sound"),
|
||
assets.path_join("metin2_patch_w20_sound/sound"),
|
||
assets.path_join("Sound"),
|
||
assets,
|
||
client,
|
||
]
|
||
for r in candidate_roots:
|
||
if DirAccess.dir_exists_absolute(r) and not _sound_roots.has(r):
|
||
_sound_roots.append(r)
|
||
|
||
func _init_players() -> void:
|
||
for i in 3:
|
||
var pl := AudioStreamPlayer.new()
|
||
pl.bus = "Master"
|
||
pl.name = "BgmPlayer%d" % i
|
||
add_child(pl)
|
||
_bgm.append(pl)
|
||
_bgm_names.append("")
|
||
_bgm_volumes.append(0.0)
|
||
|
||
# 16 个 2D 音效池播放器
|
||
for i in 16:
|
||
var pl := AudioStreamPlayer.new()
|
||
pl.bus = "Master"
|
||
pl.name = "UiPlayer%d" % i
|
||
add_child(pl)
|
||
_ui_pool.append(pl)
|
||
|
||
func _process(_delta: float) -> void:
|
||
if not ClassDB.class_exists("Metin2PythonHost") or not Metin2PythonHost.is_running():
|
||
return
|
||
|
||
var commands: Array = Metin2PythonHost.audio_commands()
|
||
for cmd in commands:
|
||
var type: String = cmd.get("type", "")
|
||
match type:
|
||
"play_sound_2d":
|
||
play_sound_2d(cmd.get("filename", ""), cmd.get("volume", 1.0))
|
||
"play_sound_3d":
|
||
play_sound_3d(cmd.get("filename", ""), Vector3(cmd.get("x", 0.0) / 100.0, cmd.get("z", 0.0) / 100.0, -cmd.get("y", 0.0) / 100.0), cmd.get("volume", 1.0), cmd.get("id", 0), cmd.get("play_count", 1))
|
||
"stop_sound_3d":
|
||
stop_sound_3d(cmd.get("id", 0))
|
||
"stop_all_sound_3d":
|
||
stop_all_sound_3d()
|
||
"set_sound_volume_3d":
|
||
set_sound_volume_3d(cmd.get("id", 0), cmd.get("volume", 1.0))
|
||
"play_music":
|
||
play_music(cmd.get("filename", ""), cmd.get("volume", 1.0))
|
||
"fade_in_music":
|
||
fade_in_music(cmd.get("filename", ""), cmd.get("volume", 1.0), cmd.get("speed", 0.016))
|
||
"fade_out_music":
|
||
fade_out_music(cmd.get("filename", ""), cmd.get("speed", 0.016))
|
||
"fade_limit_out_music":
|
||
fade_limit_out_music(cmd.get("filename", ""), cmd.get("volume", 0.0), cmd.get("speed", 0.016))
|
||
"fade_out_all_music":
|
||
fade_out_all_music()
|
||
"set_sound_volume":
|
||
set_sound_volume(cmd.get("volume", 1.0))
|
||
"set_music_volume":
|
||
set_music_volume(cmd.get("volume", 1.0))
|
||
_update_music_fades()
|
||
|
||
# --- 音效路径检索与加载 ---------------------------------------------------
|
||
|
||
func _find_sound(rel: String) -> String:
|
||
if rel.is_empty():
|
||
return ""
|
||
rel = rel.replace("\\", "/").strip_edges()
|
||
if rel.begins_with("d:/ymir work/") or rel.begins_with("D:/ymir work/"):
|
||
rel = rel.substr(13)
|
||
elif rel.begins_with("d:/") or rel.begins_with("D:/"):
|
||
rel = rel.substr(3)
|
||
|
||
# 补全扩展名候选
|
||
var cands := PackedStringArray([rel])
|
||
if rel.get_extension().is_empty():
|
||
cands = PackedStringArray([rel + ".wav", rel + ".mp3", rel + ".ogg"])
|
||
|
||
# 40250's CEterPackManager is the authority for sounds, as it is for models and UI images.
|
||
if ClassDB.class_exists("Metin2Pack") and Metin2Pack.is_ready():
|
||
for c in cands:
|
||
if Metin2Pack.exists(c):
|
||
return "pack://" + c
|
||
if not c.begins_with("sound/") and Metin2Pack.exists("sound/" + c):
|
||
return "pack://sound/" + c
|
||
|
||
if FileAccess.file_exists(rel):
|
||
return rel
|
||
|
||
# 开发期散文件兜底。
|
||
for root in _sound_roots:
|
||
for c in cands:
|
||
var p := root.path_join(c)
|
||
if FileAccess.file_exists(p):
|
||
return p
|
||
# 若路径带 sound/,剥掉后再试一次(防止 root 自身已带 sound/)
|
||
if c.begins_with("sound/"):
|
||
var p_sub := root.path_join(c.substr(6))
|
||
if FileAccess.file_exists(p_sub):
|
||
return p_sub
|
||
|
||
return ""
|
||
|
||
func _load_stream(path: String) -> AudioStream:
|
||
if _cache.has(path):
|
||
return _cache[path]
|
||
var s: AudioStream = null
|
||
var ext := path.get_extension().to_lower()
|
||
var bytes: PackedByteArray
|
||
if path.begins_with("pack://"):
|
||
bytes = Metin2Pack.get_bytes(path.substr(7))
|
||
else:
|
||
bytes = FileAccess.get_file_as_bytes(path)
|
||
if bytes.is_empty():
|
||
return null
|
||
if ext == "mp3":
|
||
var m := AudioStreamMP3.new()
|
||
m.data = bytes
|
||
s = m if m.data.size() > 0 else null
|
||
elif ext == "ogg":
|
||
s = AudioStreamOggVorbis.load_from_buffer(bytes)
|
||
elif ext == "wav":
|
||
s = AudioStreamWAV.load_from_buffer(bytes)
|
||
_cache[path] = s
|
||
return s
|
||
|
||
# --- 2D 音效播放 -----------------------------------------------------------
|
||
|
||
func play_sound_2d(rel: String, volume_scale := 1.0) -> void:
|
||
var path := _find_sound(rel)
|
||
if path.is_empty():
|
||
return
|
||
var stream := _load_stream(path)
|
||
if stream == null:
|
||
return
|
||
if _ui_pool.is_empty():
|
||
return
|
||
var pl := _ui_pool[_ui_next]
|
||
_ui_next = (_ui_next + 1) % _ui_pool.size()
|
||
pl.stream = stream
|
||
# SoundManager already applied the current master volume to this command.
|
||
var vol: float = clampf(volume_scale, 0.0, 1.0)
|
||
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
|
||
pl.play()
|
||
|
||
# --- 3D 空间音效播放 -------------------------------------------------------
|
||
|
||
func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0, play_count := 1) -> void:
|
||
var path := _find_sound(rel)
|
||
if path.is_empty():
|
||
return
|
||
var stream := _load_stream(path)
|
||
if stream == null:
|
||
return
|
||
var pl := AudioStreamPlayer3D.new()
|
||
pl.stream = stream
|
||
pl.unit_size = 8.0
|
||
pl.max_distance = 80.0
|
||
var vol: float = clampf(volume_scale, 0.0, 1.0)
|
||
pl.volume_db = linear_to_db(vol) if vol > 0.001 else -80.0
|
||
add_child(pl)
|
||
_all_3d.append(pl)
|
||
pl.global_position = pos
|
||
pl.play()
|
||
if sound_id > 0:
|
||
_active_3d[sound_id] = pl
|
||
var state := {"remaining": play_count}
|
||
pl.finished.connect(func():
|
||
if state.remaining == 0 or state.remaining > 1:
|
||
if state.remaining > 1:
|
||
state.remaining -= 1
|
||
pl.play()
|
||
return
|
||
_all_3d.erase(pl)
|
||
if sound_id > 0 and _active_3d.get(sound_id) == pl:
|
||
_active_3d.erase(sound_id)
|
||
pl.queue_free())
|
||
|
||
func set_sound_volume_3d(sound_id: int, volume: float) -> void:
|
||
var pl: AudioStreamPlayer3D = _active_3d.get(sound_id)
|
||
if pl != null and is_instance_valid(pl):
|
||
var level := clampf(volume, 0.0, 1.0)
|
||
pl.volume_db = linear_to_db(level) if level > 0.001 else -80.0
|
||
|
||
func stop_sound_3d(sound_id: int) -> void:
|
||
if _active_3d.has(sound_id):
|
||
var pl: AudioStreamPlayer3D = _active_3d[sound_id]
|
||
if is_instance_valid(pl):
|
||
pl.stop()
|
||
_all_3d.erase(pl)
|
||
pl.queue_free()
|
||
_active_3d.erase(sound_id)
|
||
|
||
func stop_all_sound_3d() -> void:
|
||
for pl in _all_3d:
|
||
if is_instance_valid(pl):
|
||
pl.stop()
|
||
pl.queue_free()
|
||
_all_3d.clear()
|
||
_active_3d.clear()
|
||
|
||
# --- BGM 背景音乐播放 -----------------------------------------------------
|
||
|
||
func _music_stream(name: String) -> AudioStream:
|
||
var path := name.replace("\\", "/")
|
||
if not path.is_absolute_path():
|
||
var direct := _bgm_root.path_join(name)
|
||
if FileAccess.file_exists(direct):
|
||
path = direct
|
||
elif FileAccess.file_exists(direct + ".mp3"):
|
||
path = direct + ".mp3"
|
||
else:
|
||
path = _find_sound(name)
|
||
return _load_stream(path)
|
||
|
||
func _music_slot(name: String) -> int:
|
||
var normalized := name.replace("\\", "/").to_lower()
|
||
for i in _bgm_names.size():
|
||
if _bgm_names[i].replace("\\", "/").to_lower() == normalized and _bgm[i].playing:
|
||
return i
|
||
return -1
|
||
|
||
func _set_music_volume(slot: int, volume: float) -> void:
|
||
_bgm_volumes[slot] = clampf(volume, 0.0, 1.0)
|
||
_bgm[slot].volume_db = linear_to_db(_bgm_volumes[slot]) if _bgm_volumes[slot] > 0.001 else -80.0
|
||
|
||
func _start_music(slot: int, name: String, volume: float) -> bool:
|
||
var stream := _music_stream(name)
|
||
if stream == null:
|
||
return false
|
||
if stream is AudioStreamMP3:
|
||
stream.loop = true
|
||
_bgm[slot].stop()
|
||
_bgm[slot].stream = stream
|
||
_bgm_names[slot] = name
|
||
_set_music_volume(slot, volume)
|
||
_bgm[slot].play()
|
||
return true
|
||
|
||
func play_music(name: String, volume_scale := 1.0) -> void:
|
||
if name.is_empty():
|
||
return
|
||
# 40250 PlayMusic(name) always replaces stream slot 0.
|
||
_bgm_fades.erase(0)
|
||
_start_music(0, name, volume_scale)
|
||
|
||
func fade_in_music(name: String, target_volume := 1.0, speed := 0.016) -> void:
|
||
if name.is_empty():
|
||
return
|
||
var slot := _music_slot(name)
|
||
if slot < 0:
|
||
fade_out_all_music()
|
||
for i in _bgm.size():
|
||
if not _bgm[i].playing:
|
||
slot = i
|
||
break
|
||
if slot < 0 or not _start_music(slot, name, 0.0):
|
||
return
|
||
_bgm_fades[slot] = {"target": clampf(target_volume, 0.0, 1.0), "speed": speed, "mode": "in"}
|
||
|
||
func fade_out_music(name: String, speed := 0.016) -> void:
|
||
var slot := _music_slot(name)
|
||
if slot >= 0:
|
||
_bgm_fades[slot] = {"target": 0.0, "speed": speed, "mode": "out"}
|
||
|
||
func fade_limit_out_music(name: String, limit: float, speed := 0.016) -> void:
|
||
var slot := _music_slot(name)
|
||
if slot >= 0:
|
||
_bgm_fades[slot] = {"target": clampf(limit, 0.0, 1.0), "speed": speed, "mode": "limit"}
|
||
|
||
func fade_out_all_music() -> void:
|
||
for i in _bgm.size():
|
||
if _bgm[i].playing:
|
||
_bgm_fades[i] = {"target": 0.0, "speed": 0.01, "mode": "out"}
|
||
|
||
func _update_music_fades() -> void:
|
||
# 40250 advances fVolume by fVolumeSpeed once per SoundManager::Update frame.
|
||
for slot in _bgm_fades.keys():
|
||
if not _bgm[slot].playing:
|
||
_bgm_fades.erase(slot)
|
||
_bgm_names[slot] = ""
|
||
continue
|
||
var fade: Dictionary = _bgm_fades[slot]
|
||
var target: float = fade.target
|
||
var step: float = fade.speed
|
||
var volume: float = _bgm_volumes[slot]
|
||
match fade.mode:
|
||
"in":
|
||
volume = minf(target, volume + step)
|
||
"limit":
|
||
volume = maxf(target, volume - step)
|
||
"out":
|
||
volume = maxf(0.0, volume - step)
|
||
_set_music_volume(slot, volume)
|
||
if (fade.mode == "in" and volume >= target) or (fade.mode != "in" and volume <= target):
|
||
if fade.mode == "out":
|
||
_bgm[slot].stop()
|
||
_bgm_names[slot] = ""
|
||
_bgm_fades.erase(slot)
|
||
|
||
# --- 音量控制 -------------------------------------------------------------
|
||
|
||
func set_sound_volume(v: float) -> void:
|
||
master_sound = clampf(v, 0.0, 1.0)
|
||
|
||
func set_music_volume(v: float) -> void:
|
||
master_music = clampf(v, 0.0, 1.0)
|
||
for i in _bgm.size():
|
||
if _bgm[i].playing and not (_bgm_fades.has(i) and _bgm_fades[i].mode == "out"):
|
||
_set_music_volume(i, master_music)
|