# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。 # 对标客户端 AudioLib:BGM(`bgm/*.mp3`)、UI wav(`**/sound/ui/`)、 # 3D wav(`**/sound/`)。`.mss` 只保存按帧触发的声音文件名, # 在 Godot 侧解析为 60 FPS 的声音实例,再由同一个 Miles 音频入口播放。 # 用 preload("res://audio.gd").new() 造一个,add_child,再 setup()。 extends Node ## MilesLib constants (40250/ClientVS22/source/MilesLib/SoundManager.cpp). ## The reference client keeps the listener at (0, 0, 0) and submits world ## coordinates relative to it, divided by the sound scale. const SOUND_SCALE := 200.0 const AMBIENCE_SOUND_SCALE := 1000.0 const FADE_SPEED := 0.016 const CHARACTER_SOUND_MIN_INTERVAL := 0.3 const CHARACTER_SOUND_LIMIT_DISTANCE_SQ := 5000.0 * 5000.0 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 := "" var sound_scale := SOUND_SCALE var ambience_sound_scale := AMBIENCE_SOUND_SCALE var _listener_world := Vector3.ZERO var _listener_forward := Vector3(0, 0, -1) var _listener_up := Vector3.UP var _spatial_root: Node3D var _listener: AudioListener3D var _next_3d_id := 1 var _active_3d := {} # int -> AudioStreamPlayer3D var _character_history := {} # sound path -> last time (seconds) var _mss_cache := {} # mss path + header -> sound instances var _ambience_states := {} # source key -> {id, inside, next_time} const SOUND_FPS := 60.0 # §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 _sound_roots.clear() # bgm/ 与 assets/ 同级(/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) _ensure_spatial_audio() func _ensure_spatial_audio() -> void: if _spatial_root and is_instance_valid(_spatial_root): return _spatial_root = Node3D.new() _spatial_root.name = "MilesSpatialAudio" add_child(_spatial_root) _listener = AudioListener3D.new() _listener.name = "MilesListener" _spatial_root.add_child(_listener) _listener.current = true func set_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void: # SetPosition / SetDirection + Update in SoundManager. The Godot listener # stays at the origin; active instances are re-based just like Miles3D. _listener_world = world_pos _listener_forward = forward.normalized() if forward.length_squared() > 0.000001 else Vector3(0, 0, -1) _listener_up = up.normalized() if up.length_squared() > 0.000001 else Vector3.UP if _listener: _listener.position = Vector3.ZERO _listener.look_at(_listener.position + _listener_forward, _listener_up) for id in _active_3d: var pl: AudioStreamPlayer3D = _active_3d[id] if is_instance_valid(pl): var scale: float = float(pl.get_meta("miles_scale", sound_scale)) pl.position = (pl.get_meta("miles_world", _listener_world) - _listener_world) * 100.0 / maxf(scale, 0.001) func update_listener(world_pos: Vector3, forward := Vector3(0, 0, -1), up := Vector3.UP) -> void: set_listener(world_pos, forward, up) func set_sound_scale(value: float) -> void: sound_scale = maxf(value, 0.001) func set_ambience_sound_scale(value: float) -> void: ambience_sound_scale = maxf(value, 0.001) func get_sound_scale() -> float: return sound_scale func get_ambience_sound_scale() -> float: return ambience_sound_scale # --- 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 rels := PackedStringArray([rel]) # ClientVS22's .mss files store paths with the virtual `sound/` prefix, # while each mounted sound root already points at `/sound`. if rel.to_lower().begins_with("sound/"): rels.append(rel.substr(6)) var cands := PackedStringArray() for candidate: String in rels: cands.append(candidate) if candidate.get_extension().is_empty(): cands.append(candidate + ".wav") cands.append(candidate + ".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 "" # --- MilesLib .mss sound scripts ----------------------------------------- # ClientVS22/ MilesLib/Type.cpp::LoadSoundInformationPiece. A sound script # contains `SoundDataCount N` followed by exactly N `SoundData%02d time # "path"` records. The native loader replaces the output vector on every # call and rejects a missing or malformed record; preserve that contract here. func load_sound_information_piece(file_path: String, path_header: String = "") -> Array: var cache_key := file_path + "\n" + path_header if _mss_cache.has(cache_key): return _mss_cache[cache_key].duplicate(true) var empty: Array = [] var f := FileAccess.open(file_path, FileAccess.READ) if f == null: _mss_cache[cache_key] = empty return empty var count := -1 var records := {} while not f.eof_reached(): var tokens := _mss_tokens(f.get_line()) if tokens.is_empty(): continue var key := String(tokens[0]).to_lower() if key == "sounddatacount": if tokens.size() != 2: _mss_cache[cache_key] = empty return empty count = int(tokens[1]) continue if not key.begins_with("sounddata") or key.length() <= 9: continue var index_text := key.substr(9) if not index_text.is_valid_int() or tokens.size() != 3: continue var index := int(index_text) if index < 0: continue records[index] = {"time": float(tokens[1]), "sound": path_header + String(tokens[2])} if count < 0 or count > 4096: _mss_cache[cache_key] = empty return empty var out: Array = [] for i in count: if not records.has(i): _mss_cache[cache_key] = empty return empty out.append(records[i]) _mss_cache[cache_key] = out.duplicate(true) return out # Exact equivalent of NSound::DataToInstance: `DWORD(time / (1/60))`. func data_to_sound_instances(sound_data: Array) -> Array: var out: Array = [] for row in sound_data: if not row is Dictionary: continue var time := maxf(0.0, float(row.get("time", 0.0))) out.append({"frame": int(time * SOUND_FPS), "sound": String(row.get("sound", "")), "time": time}) return out func load_mss_instances(file_path: String, path_header: String = "") -> Array: return data_to_sound_instances(load_sound_information_piece(file_path, path_header)) # Resolve the virtual `sound/.mss` generated by RaceMotionData from a # motion .msa path. Sound roots are patch-aware and searched in their setup # order, just like `_find_sound`. func load_mss_for_motion(motion_path: String) -> Array: var p := motion_path.replace("\\", "/") var lower := p.to_lower() var marker := "/ymir work/" var at := lower.find(marker) if at < 0: return [] var tail := p.substr(at + marker.length()).get_basename() + ".mss" for root in _sound_roots: var candidate := root.path_join(tail) if FileAccess.file_exists(candidate): return load_mss_instances(candidate) return [] # Equivalent of CSoundManager::UpdateSoundInstance(fx, fy, fz, frame, ...). # The caller owns the animation frame cursor and calls this once per newly # reached frame, so looping animations naturally replay frame-zero records. func update_sound_instances_3d(fx: float, fy: float, fz: float, frame: int, instances: Array, check_frequency := false) -> int: var played := 0 for row in instances: if not row is Dictionary or int(row.get("frame", -1)) != frame: continue var id := play_character_sound_3d(fx, fy, fz, String(row.get("sound", "")), check_frequency) if id >= 0: played += 1 return played func _mss_tokens(line: String) -> Array: var out: Array = [] var word := "" var quoted := false for i in line.length(): var c := line.substr(i, 1) if c == "\"": quoted = not quoted continue if not quoted and (c == " " or c == "\t" or c == "\r"): if word != "": out.append(word) word = "" continue word += c if word != "": out.append(word) return out # --- 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 static func ratio_to_apply_volume(ratio: float) -> float: # CSoundManager::__ConvertRatioVolumeToApplyVolume. ratio = clampf(ratio, 0.0, 1.0) return ratio if ratio < 0.1 else pow(10.0, -1.0 + ratio) static func grade_to_apply_volume(grade: int) -> float: return ratio_to_apply_volume(float(grade) / 5.0) func set_sound_volume(v: float) -> void: master_sfx = clampf(v, 0.0, 1.0) _apply_3d_volume() func set_sound_volume_ratio(ratio: float) -> void: set_sound_volume(ratio_to_apply_volume(ratio)) func set_sound_volume_grade(grade: int) -> void: set_sound_volume(grade_to_apply_volume(grade)) func set_music_volume_ratio(ratio: float) -> void: set_music_volume(ratio_to_apply_volume(ratio)) func set_music_volume_grade(grade: int) -> void: set_music_volume(ratio_to_apply_volume(float(grade) / 5.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: play_sound_3d(world_pos.x, world_pos.y, world_pos.z, rel, 1) func play_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int: return _play_3d(rel, Vector3(fx, fy, fz), sound_scale, play_count) func play_ambience_sound_3d(fx: float, fy: float, fz: float, rel: String, play_count := 1) -> int: return _play_3d(rel, Vector3(fx, fy, fz), ambience_sound_scale, play_count) func update_ambience_sources(sources: Array) -> void: # CArea::UpdateAroundAmbience: source positions/ranges are in centimetres # on the reference side. The world adapter exposes metres, so convert the # listener distance back to centimetres before applying the strict range. var now := Time.get_ticks_msec() / 1000.0 var seen := {} for i in sources.size(): var source: Dictionary = sources[i] if sources[i] is Dictionary else {} var key := String(source.get("key", i)) seen[key] = true var sounds: Array = source.get("sounds", []) if sounds.is_empty(): continue var pos: Vector3 = source.get("position", Vector3.ZERO) var range_cm := maxf(0.0, float(source.get("range_cm", 0.0))) var inside := (pos - _listener_world).length() * 100.0 < range_cm var state: Dictionary = _ambience_states.get(key, {"id": -1, "inside": false, "next_time": 0.0}) var sound := String(sounds[0]) var play_type := String(source.get("play_type", "LOOP")).to_upper() var id := int(state.get("id", -1)) if play_type == "LOOP": if inside: if id < 0 or not _active_3d.has(id): id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 0) if id >= 0: var percentage := clampf(float(source.get("max_volume_area_percentage", 0.0)), 0.0, 1.0) var full_volume_radius := range_cm * percentage var distance_cm := (pos - _listener_world).length() * 100.0 var volume := 1.0 if full_volume_radius <= 0.0 or distance_cm <= full_volume_radius else \ 1.0 - ((distance_cm - full_volume_radius) / maxf(range_cm - full_volume_radius, 0.001)) set_sound_volume_3d(id, clampf(volume, 0.0, 1.0)) else: if id >= 0: stop_sound_3d(id) id = -1 elif play_type == "ONCE": if inside and not bool(state.get("inside", false)): id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1) elif not inside: # ClientVS22 drops the bookkeeping id on exit; the one-shot itself # is allowed to finish playing. id = -1 elif play_type == "STEP": if inside and now >= float(state.get("next_time", 0.0)): id = play_ambience_sound_3d(pos.x, pos.y, pos.z, sound, 1) var interval := maxf(0.0, float(source.get("play_interval", 0.0))) var variation := maxf(0.0, float(source.get("play_interval_variation", 0.0))) state["next_time"] = now + interval + randf_range(0.0, variation) elif not inside: id = -1 state["id"] = id state["inside"] = inside _ambience_states[key] = state for key in _ambience_states.keys().duplicate(): if seen.has(key): continue var old: Dictionary = _ambience_states[key] var old_id := int(old.get("id", -1)) if old_id >= 0: stop_sound_3d(old_id) _ambience_states.erase(key) func can_play_character_sound(rel: String, world_pos: Vector3, now := -1.0) -> bool: # PlayCharacterSound3D's optional bCheckFrequency path. Coordinates in the # game scene are metres; convert to centimetres before applying the exact # 5000*5000 ClientVS22 threshold. var delta_cm := (world_pos - _listener_world) * 100.0 if delta_cm.x * delta_cm.x + delta_cm.y * delta_cm.y > CHARACTER_SOUND_LIMIT_DISTANCE_SQ: return false if now < 0.0: now = Time.get_ticks_msec() / 1000.0 var key := rel.replace("\\", "/") if _character_history.has(key) and now - float(_character_history[key]) < CHARACTER_SOUND_MIN_INTERVAL: return false _character_history[key] = now return true func play_character_sound_3d(fx: float, fy: float, fz: float, rel: String, check_frequency := false) -> int: var p := Vector3(fx, fy, fz) if check_frequency and not can_play_character_sound(rel, p): return -1 return _play_3d(rel, p, sound_scale, 1) func _play_3d(rel: String, world_pos: Vector3, scale: float, play_count: int) -> int: if master_sfx <= 0.0: return -1 var path := _find_sound(rel) if path.is_empty(): return -1 var s := _load_stream(path) if s == null: return -1 _ensure_spatial_audio() var pl := AudioStreamPlayer3D.new() pl.stream = s pl.unit_size = 6.0 pl.max_distance = 60.0 pl.volume_db = _volume_db(master_sfx) _spatial_root.add_child(pl) pl.set_meta("miles_world", world_pos) pl.set_meta("miles_scale", scale) pl.position = (world_pos - _listener_world) * 100.0 / maxf(scale, 0.001) var id := _next_3d_id _next_3d_id += 1 _active_3d[id] = pl pl.set_meta("miles_remaining", maxi(play_count, 1)) pl.set_meta("miles_loop", play_count == 0) pl.finished.connect(func(): if not is_instance_valid(pl): return if bool(pl.get_meta("miles_loop", false)): pl.play() return var remaining := int(pl.get_meta("miles_remaining", 1)) - 1 if remaining > 0: pl.set_meta("miles_remaining", remaining) pl.play() else: _active_3d.erase(id) pl.queue_free()) if pl.is_inside_tree(): pl.play() else: pl.call_deferred("play") return id func _volume_db(value: float) -> float: return linear_to_db(maxf(value, 0.0001)) if value > 0.0 else -80.0 func _apply_3d_volume() -> void: for id in _active_3d: var pl: AudioStreamPlayer3D = _active_3d[id] if is_instance_valid(pl): pl.volume_db = _volume_db(master_sfx) func set_sound_volume_3d(id: int, value: float) -> void: var pl: AudioStreamPlayer3D = _active_3d.get(id, null) if pl and is_instance_valid(pl): pl.volume_db = _volume_db(clampf(value, 0.0, 1.0)) func stop_sound_3d(id: int) -> void: var pl: AudioStreamPlayer3D = _active_3d.get(id, null) _active_3d.erase(id) if pl and is_instance_valid(pl): pl.stop() pl.queue_free() func stop_all_sound_3d() -> void: for id in _active_3d.keys().duplicate(): stop_sound_3d(int(id)) _character_history.clear() _ambience_states.clear() func fade_limit_out_music(name: String, limit_volume: float, speed := FADE_SPEED) -> void: if name != _bgm_name: return var pl: AudioStreamPlayer = _bgm[_bgm_cur] if pl == null or not pl.playing: return var limit_db := _volume_db(ratio_to_apply_volume(limit_volume)) var duration := maxf(absf(pl.volume_db - limit_db) / maxf(speed * 60.0, 0.001), 0.01) _fade(pl, limit_db, duration) func fade_out_all_music(speed := 0.01) -> void: var duration := maxf(1.0 / maxf(speed * 60.0, 0.001), 0.01) _fade(_bgm[0], -80.0, duration, true) _fade(_bgm[1], -80.0, duration, true)