264 lines
9.2 KiB
GDScript
264 lines
9.2 KiB
GDScript
extends Node
|
|
|
|
## Placeholder SFX layer (AnchorV1.0 §20.4). Until real sound assets land,
|
|
## every required cue is a small synthesized tone generated at startup, routed
|
|
## through the SFX bus so §20.5 sfx_volume applies. Pure fact subscriber: it
|
|
## listens to the EventBus and never drives gameplay.
|
|
|
|
const SAMPLE_RATE := 22050
|
|
const VOICE_COUNT := 10
|
|
|
|
var _streams: Dictionary = {}
|
|
var _voices: Array[AudioStreamPlayer] = []
|
|
var _voice_cursor := 0
|
|
var _last_charge_level := 0
|
|
var _last_player_health := -1
|
|
|
|
|
|
func _ready() -> void:
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
_build_streams()
|
|
_build_voices()
|
|
_connect_bus()
|
|
|
|
|
|
func play_sfx(sfx_name: StringName) -> void:
|
|
var stream := _streams.get(sfx_name, null) as AudioStream
|
|
if stream == null or _voices.is_empty():
|
|
return
|
|
var voice := _voices[_voice_cursor % _voices.size()]
|
|
_voice_cursor += 1
|
|
voice.stream = stream
|
|
voice.play()
|
|
|
|
|
|
func has_sfx(sfx_name: StringName) -> bool:
|
|
return _streams.has(sfx_name)
|
|
|
|
|
|
func sfx_names() -> Array:
|
|
return _streams.keys()
|
|
|
|
|
|
func _build_voices() -> void:
|
|
for index: int in range(VOICE_COUNT):
|
|
var voice := AudioStreamPlayer.new()
|
|
voice.name = "Voice%d" % index
|
|
voice.bus = "SFX" if AudioServer.get_bus_index("SFX") != -1 else "Master"
|
|
add_child(voice)
|
|
_voices.append(voice)
|
|
|
|
|
|
func _connect_bus() -> void:
|
|
var bus := _event_bus_or_null()
|
|
if bus == null:
|
|
return
|
|
_connect_once(bus, "judgement_made", _on_judgement_made)
|
|
_connect_once(bus, "time_anchor_scheduled", _on_time_anchor_scheduled)
|
|
_connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved)
|
|
_connect_once(bus, "time_phase_changed", _on_time_phase_changed)
|
|
_connect_once(bus, "hit_confirmed", _on_hit_confirmed)
|
|
_connect_once(bus, "skill_executed", _on_skill_executed)
|
|
_connect_once(bus, "player_charge_changed", _on_player_charge_changed)
|
|
_connect_once(bus, "player_health_changed", _on_player_health_changed)
|
|
_connect_once(bus, "flow_state_changed", _on_flow_state_changed)
|
|
|
|
|
|
func _build_streams() -> void:
|
|
# Judgement feedback (§20.4: Perfect / Good / Bad / Miss + key feedback).
|
|
_streams[&"perfect"] = _tone([[1318.5, 1318.5, 0.05], [1760.0, 1760.0, 0.09]], 0.35)
|
|
_streams[&"good"] = _tone([[880.0, 880.0, 0.09]], 0.3)
|
|
_streams[&"bad"] = _tone([[392.0, 340.0, 0.11]], 0.3)
|
|
_streams[&"miss"] = _noise(0.14, 0.22, 900.0)
|
|
_streams[&"key_press"] = _tone([[520.0, 500.0, 0.03]], 0.15)
|
|
# Time anchor cues.
|
|
_streams[&"anchor_appear"] = _tone([[988.0, 988.0, 0.05], [1244.5, 1244.5, 0.05]], 0.22)
|
|
_streams[&"anchor_success"] = _tone([[784.0, 784.0, 0.06], [988.0, 988.0, 0.06], [1174.7, 1174.7, 0.1]], 0.32)
|
|
_streams[&"anchor_fail"] = _tone([[660.0, 330.0, 0.22]], 0.34)
|
|
# Phase switches (both directions distinct).
|
|
_streams[&"phase_to_future"] = _tone([[440.0, 1320.0, 0.3]], 0.3)
|
|
_streams[&"phase_to_past"] = _tone([[1320.0, 440.0, 0.3]], 0.3)
|
|
# Combat.
|
|
_streams[&"hit"] = _noise(0.09, 0.4, 2400.0)
|
|
_streams[&"skill_cast"] = _tone([[600.0, 900.0, 0.12]], 0.28)
|
|
_streams[&"swing"] = _noise(0.05, 0.18, 3200.0)
|
|
_streams[&"charge_start"] = _tone([[300.0, 420.0, 0.16]], 0.2)
|
|
_streams[&"charge_level"] = _tone([[700.0, 1050.0, 0.09]], 0.26)
|
|
_streams[&"charge_release"] = _tone([[1050.0, 500.0, 0.18]], 0.3)
|
|
_streams[&"guard_success"] = _tone([[1567.98, 1244.5, 0.07]], 0.3)
|
|
_streams[&"guard_stun"] = _tone([[350.0, 250.0, 0.12]], 0.26)
|
|
_streams[&"hurt"] = _noise(0.12, 0.34, 1200.0)
|
|
_streams[&"death"] = _tone([[520.0, 130.0, 0.6]], 0.34)
|
|
_streams[&"ui_click"] = _tone([[900.0, 860.0, 0.035]], 0.2)
|
|
# Run-outcome jingles (Victory / Defeat splash). Discrete note steps so
|
|
# defeat stays distinct from the &"death" 520→130Hz glide moments earlier.
|
|
_streams[&"victory"] = _note_sequence([[523.25, 0.2], [659.25, 0.2], [784.0, 0.2], [1046.5, 0.6]], 0.34)
|
|
_streams[&"defeat"] = _note_sequence([[392.0, 0.24], [311.13, 0.24], [261.63, 0.24], [196.0, 0.58]], 0.32)
|
|
|
|
|
|
## segments: Array of [freq_from, freq_to, seconds]; simple sine with a short
|
|
## exponential decay envelope.
|
|
func _tone(segments: Array, volume: float) -> AudioStreamWAV:
|
|
var total_seconds := 0.0
|
|
for segment: Array in segments:
|
|
total_seconds += float(segment[2])
|
|
var frame_count := maxi(1, int(total_seconds * SAMPLE_RATE))
|
|
var data := PackedByteArray()
|
|
data.resize(frame_count * 2)
|
|
var written := 0
|
|
var phase := 0.0
|
|
for segment: Array in segments:
|
|
var seg_frames := int(float(segment[2]) * SAMPLE_RATE)
|
|
for index: int in range(seg_frames):
|
|
if written >= frame_count:
|
|
break
|
|
var t := float(index) / float(maxi(1, seg_frames))
|
|
var freq := lerpf(float(segment[0]), float(segment[1]), t)
|
|
phase += TAU * freq / float(SAMPLE_RATE)
|
|
var global_t := float(written) / float(frame_count)
|
|
var envelope := minf(1.0, global_t * 24.0) * pow(1.0 - global_t, 1.4)
|
|
var sample := sin(phase) * envelope * volume
|
|
_write_sample(data, written, sample)
|
|
written += 1
|
|
return _make_wav(data, frame_count)
|
|
|
|
|
|
## notes: Array of [freq_hz, seconds]; each note gets its own attack/decay
|
|
## envelope — unlike _tone, whose single whole-stream decay would bury the
|
|
## closing notes of a second-long jingle.
|
|
func _note_sequence(notes: Array, volume: float) -> AudioStreamWAV:
|
|
var frame_count := 0
|
|
for note: Array in notes:
|
|
frame_count += maxi(1, int(float(note[1]) * SAMPLE_RATE))
|
|
var data := PackedByteArray()
|
|
data.resize(frame_count * 2)
|
|
var written := 0
|
|
for note: Array in notes:
|
|
var note_frames := maxi(1, int(float(note[1]) * SAMPLE_RATE))
|
|
var phase := 0.0
|
|
for index: int in range(note_frames):
|
|
var t := float(index) / float(note_frames)
|
|
phase += TAU * float(note[0]) / float(SAMPLE_RATE)
|
|
var envelope := minf(1.0, t * 24.0) * pow(1.0 - t, 1.6)
|
|
_write_sample(data, written, sin(phase) * envelope * volume)
|
|
written += 1
|
|
return _make_wav(data, frame_count)
|
|
|
|
|
|
func _noise(seconds: float, volume: float, cutoff_hint_hz: float) -> AudioStreamWAV:
|
|
var frame_count := maxi(1, int(seconds * SAMPLE_RATE))
|
|
var data := PackedByteArray()
|
|
data.resize(frame_count * 2)
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = int(cutoff_hint_hz)
|
|
var previous := 0.0
|
|
var smoothing := clampf(1.0 - cutoff_hint_hz / 6000.0, 0.0, 0.96)
|
|
for index: int in range(frame_count):
|
|
var t := float(index) / float(frame_count)
|
|
var envelope := minf(1.0, t * 30.0) * pow(1.0 - t, 1.8)
|
|
var raw := rng.randf_range(-1.0, 1.0)
|
|
previous = previous * smoothing + raw * (1.0 - smoothing)
|
|
_write_sample(data, index, previous * envelope * volume)
|
|
return _make_wav(data, frame_count)
|
|
|
|
|
|
func _write_sample(data: PackedByteArray, frame_index: int, sample: float) -> void:
|
|
var value := int(clampf(sample, -1.0, 1.0) * 32767.0)
|
|
data.encode_s16(frame_index * 2, value)
|
|
|
|
|
|
func _make_wav(data: PackedByteArray, _frame_count: int) -> AudioStreamWAV:
|
|
var stream := AudioStreamWAV.new()
|
|
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
|
stream.mix_rate = SAMPLE_RATE
|
|
stream.stereo = false
|
|
stream.data = data
|
|
return stream
|
|
|
|
|
|
## ------------------------------------------------------------- subscribers
|
|
|
|
|
|
func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void:
|
|
match quality:
|
|
&"perfect":
|
|
play_sfx(&"perfect")
|
|
&"good":
|
|
play_sfx(&"good")
|
|
&"bad":
|
|
play_sfx(&"bad")
|
|
&"miss":
|
|
play_sfx(&"miss")
|
|
|
|
|
|
func _on_time_anchor_scheduled(_anchor: Dictionary) -> void:
|
|
play_sfx(&"anchor_appear")
|
|
|
|
|
|
func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void:
|
|
play_sfx(&"anchor_success" if held else &"anchor_fail")
|
|
|
|
|
|
func _on_time_phase_changed(_previous: StringName, current: StringName, reason: StringName) -> void:
|
|
if reason == &"chart_init":
|
|
return
|
|
play_sfx(&"phase_to_future" if current == &"future" else &"phase_to_past")
|
|
|
|
|
|
func _on_hit_confirmed(result: Dictionary) -> void:
|
|
var defense = result.get("defense", {})
|
|
if defense is Dictionary and StringName(str((defense as Dictionary).get("defense_state", &"Vulnerable"))) == &"Parrying":
|
|
play_sfx(&"guard_success")
|
|
return
|
|
if int(result.get("damage", 0)) > 0:
|
|
play_sfx(&"hit")
|
|
|
|
|
|
func _on_skill_executed(skill: Resource, _judgement: StringName) -> void:
|
|
if skill == null:
|
|
return
|
|
play_sfx(&"skill_cast" if float(skill.get("base_cost")) > 0.0 else &"swing")
|
|
|
|
|
|
func _on_player_charge_changed(current: float, _maximum: float, _ready: bool, active: bool) -> void:
|
|
if not active:
|
|
_last_charge_level = 0
|
|
return
|
|
var level := 1 + int(floor(current + 0.0001))
|
|
if _last_charge_level == 0:
|
|
play_sfx(&"charge_start")
|
|
elif level > _last_charge_level:
|
|
play_sfx(&"charge_level")
|
|
_last_charge_level = level
|
|
|
|
|
|
func _on_player_health_changed(current: int, _maximum: int) -> void:
|
|
if _last_player_health < 0:
|
|
_last_player_health = current
|
|
return
|
|
if current <= 0 and _last_player_health > 0:
|
|
play_sfx(&"death")
|
|
elif current < _last_player_health:
|
|
play_sfx(&"hurt")
|
|
_last_player_health = current
|
|
|
|
|
|
func _on_flow_state_changed(_previous: StringName, current: StringName) -> void:
|
|
if current == &"Victory":
|
|
play_sfx(&"victory")
|
|
elif current == &"Defeat":
|
|
play_sfx(&"defeat")
|
|
|
|
|
|
func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void:
|
|
if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback):
|
|
bus.connect(signal_name, callback)
|
|
|
|
|
|
func _event_bus_or_null() -> Node:
|
|
if not is_inside_tree():
|
|
return null
|
|
return get_tree().root.get_node_or_null("EventBus")
|