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")