569 lines
20 KiB
GDScript
569 lines
20 KiB
GDScript
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
|
||
# 对标客户端 AudioLib:BGM(`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_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/ 同级(<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)
|