Files

569 lines
20 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
# 对标客户端 AudioLibBGM`bgm/*.mp3`)、UI wav`**/sound/ui/`)、
# 3D wav`**/sound/<rel>`)。`.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_fBackupSoundVolumeREF/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/ 同级(<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)
_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 `<patch>/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/<tail>.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)