完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+384 -11
View File
@@ -1,9 +1,19 @@
# Audio —— BGM 交叉淡入淡出 + UI 音效池 + 3D 定位音效。
# 对标客户端 AudioLibBGM`bgm/*.mp3`)、UI wav`**/sound/ui/`)、
# 3D wav`**/sound/<rel>`)。纯资产接线,无解码器移植(`.mss` 不用)。
# 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 = []
@@ -18,6 +28,20 @@ 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)。
@@ -34,6 +58,7 @@ func _ready() -> void:
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/
@@ -57,6 +82,48 @@ func setup(assets_root: String) -> void:
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 -----------------------------------------------------------
@@ -78,9 +145,17 @@ func _load_stream(path: String) -> AudioStream:
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"])
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)
@@ -95,6 +170,120 @@ func _find_sound(rel: String) -> String:
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:
@@ -141,6 +330,30 @@ func set_music_volume(v: float) -> void:
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
@@ -178,18 +391,178 @@ func play_ui(name: String) -> void:
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
return -1
var s := _load_stream(path)
if s == null:
return
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 = 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)
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)