diff --git a/default_bus_layout.tres b/default_bus_layout.tres new file mode 100644 index 0000000..64eb45e --- /dev/null +++ b/default_bus_layout.tres @@ -0,0 +1,16 @@ +[gd_resource type="AudioBusLayout" load_steps=0 format=3 uid="uid://bqgqs5wkp0vg4"] + +[resource] +bus/1/name = &"Music" +bus/1/solo = false +bus/1/mute = false +bus/1/bypass_fx = false +bus/1/volume_db = 0.0 +bus/1/send = &"Master" +bus/2/name = &"SFX" +bus/2/solo = false +bus/2/mute = false +bus/2/bypass_fx = false +bus/2/volume_db = 0.0 +bus/2/send = &"Master" + diff --git a/project.godot b/project.godot index 991e62f..078a2c7 100644 --- a/project.godot +++ b/project.godot @@ -20,6 +20,7 @@ config/icon="res://icon.svg" EventBus="*res://scripts/core/event_bus.gd" GameStateManager="*res://scripts/core/game_state_manager.gd" AnimationCache="*res://scripts/core/animation_cache.gd" +AudioManager="*res://scripts/audio/audio_manager.gd" UIManager="*res://scripts/ui/ui_manager.gd" InputManager="*res://scripts/core/input_manager.gd" GameSettings="*res://scripts/core/game_settings.gd" @@ -63,6 +64,10 @@ move_down={ ] } +[audio] + +buses/default_bus_layout="res://default_bus_layout.tres" + [rendering] 2d/snap/snap_2d_transforms_to_pixel=true diff --git a/scripts/audio/audio_manager.gd b/scripts/audio/audio_manager.gd new file mode 100644 index 0000000..e53276a --- /dev/null +++ b/scripts/audio/audio_manager.gd @@ -0,0 +1,118 @@ +extends Node + +## Central audio manager for all game sounds and music. +## +## Manages procedural sound generation, audio playback with polyphony, +## and spatial audio positioning. Provides consistent API for playing +## game sounds with automatic variation. + +var sound_generator: SoundGenerator +var sfx_players: Array[AudioStreamPlayer2D] = [] +const SFX_PLAYER_COUNT := 16 # Pool size for simultaneous sounds + +# Pre-generated sound cache (multiple variations per type) +var _jump_sounds: Array[AudioStreamWAV] = [] +var _death_sounds: Array[AudioStreamWAV] = [] +var _stomp_sounds: Array[AudioStreamWAV] = [] +var _spawn_sounds: Array[AudioStreamWAV] = [] +var _footstep_sounds: Array[AudioStreamWAV] = [] + +const VARIATIONS_PER_SOUND := 4 # Pre-generate 4 variations of each sound + +func _ready() -> void: + sound_generator = SoundGenerator.new() + add_child(sound_generator) + + # Pre-generate sound variations + _pregenerate_sounds() + + # Create pool of 2D audio players for spatial sound + for i in range(SFX_PLAYER_COUNT): + var player := AudioStreamPlayer2D.new() + player.bus = "SFX" + player.max_distance = 2000.0 + player.attenuation = 2.0 + add_child(player) + sfx_players.append(player) + +## Pre-generates sound variations to avoid runtime generation lag. +func _pregenerate_sounds() -> void: + for i in range(VARIATIONS_PER_SOUND): + _jump_sounds.append(sound_generator.generate_jump()) + _death_sounds.append(sound_generator.generate_death()) + _stomp_sounds.append(sound_generator.generate_stomp()) + _spawn_sounds.append(sound_generator.generate_spawn()) + _footstep_sounds.append(sound_generator.generate_footstep()) + +## Plays a jump sound at the given position. +func play_jump(global_position: Vector2 = Vector2.ZERO) -> void: + var stream := _get_random_sound(_jump_sounds) + _play_at_position(stream, global_position, 0.4, randf_range(0.95, 1.05)) + +## Plays a death sound at the given position. +func play_death(global_position: Vector2 = Vector2.ZERO) -> void: + var stream := _get_random_sound(_death_sounds) + _play_at_position(stream, global_position, 0.5, randf_range(0.9, 1.1)) + +## Plays a stomp/landing sound at the given position. +func play_stomp(global_position: Vector2 = Vector2.ZERO) -> void: + var stream := _get_random_sound(_stomp_sounds) + _play_at_position(stream, global_position, 0.6, randf_range(0.9, 1.1)) + +## Plays a footstep sound at the given position. +func play_footstep(global_position: Vector2 = Vector2.ZERO) -> void: + var stream := _get_random_sound(_footstep_sounds) + _play_at_position(stream, global_position, 0.25, randf_range(0.95, 1.05)) + +## Plays a spawn/respawn shimmer sound at the given position. +func play_spawn(global_position: Vector2 = Vector2.ZERO) -> void: + var stream := _get_random_sound(_spawn_sounds) + _play_at_position(stream, global_position, 0.35, randf_range(0.95, 1.05)) + +## Returns a random sound from a pre-generated array. +func _get_random_sound(sounds: Array[AudioStreamWAV]) -> AudioStreamWAV: + if sounds.is_empty(): + push_error("Sound array is empty!") + return null + return sounds[randi() % sounds.size()] + +## Internal: Plays a stream at a position with given volume and pitch variation. +func _play_at_position(stream: AudioStream, global_position: Vector2, volume_linear: float, pitch_scale: float = 1.0) -> void: + if stream == null: + return + + # Find available player from pool + var player: AudioStreamPlayer2D = null + for p in sfx_players: + if not p.playing: + player = p + break + + # If all players busy, steal the oldest one (first in array) + if player == null: + player = sfx_players[0] + + # Configure and play + player.stream = stream + player.global_position = global_position + player.volume_db = linear_to_db(volume_linear) + player.pitch_scale = pitch_scale + player.play() + +## Sets master volume (0.0 to 1.0). +func set_master_volume(volume: float) -> void: + var bus_idx := AudioServer.get_bus_index("Master") + AudioServer.set_bus_volume_db(bus_idx, linear_to_db(volume) if volume > 0 else -80.0) + +## Sets SFX volume (0.0 to 1.0). +func set_sfx_volume(volume: float) -> void: + var bus_idx := AudioServer.get_bus_index("SFX") + if bus_idx >= 0: + AudioServer.set_bus_volume_db(bus_idx, linear_to_db(volume) if volume > 0 else -80.0) + +## Sets music volume (0.0 to 1.0). +func set_music_volume(volume: float) -> void: + var bus_idx := AudioServer.get_bus_index("Music") + if bus_idx >= 0: + AudioServer.set_bus_volume_db(bus_idx, linear_to_db(volume) if volume > 0 else -80.0) + diff --git a/scripts/audio/audio_manager.gd.uid b/scripts/audio/audio_manager.gd.uid new file mode 100644 index 0000000..bfba30c --- /dev/null +++ b/scripts/audio/audio_manager.gd.uid @@ -0,0 +1 @@ +uid://boq1wnratva1n diff --git a/scripts/audio/sound_generator.gd b/scripts/audio/sound_generator.gd new file mode 100644 index 0000000..ba7a7cc --- /dev/null +++ b/scripts/audio/sound_generator.gd @@ -0,0 +1,233 @@ +extends Node +class_name SoundGenerator + +## Generates procedural sound effects with variation using AudioStreamGenerator. +## +## Creates retro-style game sounds dynamically with randomized parameters +## to ensure each playback sounds slightly different, avoiding repetition. + +const SAMPLE_RATE := 44100 + +## Generates a jump sound with rising pitch and variation. +func generate_jump() -> AudioStreamWAV: + var base_freq := randf_range(280.0, 350.0) + var end_freq := base_freq * randf_range(2.2, 2.8) + var duration := randf_range(0.07, 0.11) + + var samples := _generate_chirp(base_freq, end_freq, duration, "square") + return _create_wav_stream(samples) + +## Generates a death sound with descending pitch. +func generate_death() -> AudioStreamWAV: + var base_freq := randf_range(700.0, 900.0) + var end_freq := randf_range(80.0, 120.0) + var duration := randf_range(0.35, 0.50) + + # Mix sine wave with noise for texture + var sine_samples := _generate_chirp(base_freq, end_freq, duration, "sine") + var noise_samples := _generate_noise(duration, 0.15) + var mixed := _mix_samples(sine_samples, noise_samples, 0.8, 0.2) + + return _create_wav_stream(mixed) + +## Generates a stomp/landing impact sound. +func generate_stomp() -> AudioStreamWAV: + var base_freq := randf_range(80.0, 150.0) + var duration := randf_range(0.08, 0.12) + + # Short percussive burst with noise + var tone := _generate_tone(base_freq, duration, "triangle") + var noise := _generate_noise(duration * 0.5, 0.4) + var mixed := _mix_samples(tone, noise, 0.6, 0.4) + + # Apply sharp decay envelope + _apply_percussive_envelope(mixed) + + return _create_wav_stream(mixed) + +## Generates a footstep sound (short noise burst). +func generate_footstep() -> AudioStreamWAV: + var duration := randf_range(0.03, 0.05) + var samples := _generate_noise(duration, randf_range(0.15, 0.25)) + + # Quick attack and release + _apply_percussive_envelope(samples) + + return _create_wav_stream(samples) + +## Generates a spawn/respawn shimmer effect. +func generate_spawn() -> AudioStreamWAV: + var duration := 0.4 + var freqs := [400.0, 600.0, 800.0, 1000.0, 1200.0] + + var sample_count := int(SAMPLE_RATE * duration) + var samples := PackedByteArray() + samples.resize(sample_count * 2) + + for i in range(sample_count): + var t := float(i) / SAMPLE_RATE + var progress := t / duration + + # Layered arpeggiated tones + var value := 0.0 + for freq_idx in range(freqs.size()): + var freq: float = freqs[freq_idx] + var delay := freq_idx * 0.05 + if t > delay: + var amp := _envelope_adsr(progress, 0.05, 0.1, 0.7, 0.15) + value += sin((t - delay) * freq * TAU) * amp * 0.15 + + # Add vibrato + value *= 1.0 + sin(t * 8.0) * 0.1 + + _write_sample(samples, i, value) + + return _create_wav_stream(samples) + +## Generates a chirp (frequency sweep) sound. +func _generate_chirp(start_hz: float, end_hz: float, duration: float, waveform: String) -> PackedByteArray: + var sample_count := int(SAMPLE_RATE * duration) + var samples := PackedByteArray() + samples.resize(sample_count * 2) + + for i in range(sample_count): + var t := float(i) / SAMPLE_RATE + var progress := t / duration + var freq: float = lerp(start_hz, end_hz, progress) + var amplitude := _envelope_simple(progress) + + var value := _get_waveform(t, freq, waveform) * amplitude + _write_sample(samples, i, value) + + return samples + +## Generates a constant tone with envelope. +func _generate_tone(freq: float, duration: float, waveform: String) -> PackedByteArray: + var sample_count := int(SAMPLE_RATE * duration) + var samples := PackedByteArray() + samples.resize(sample_count * 2) + + for i in range(sample_count): + var t := float(i) / SAMPLE_RATE + var progress := t / duration + var amplitude := _envelope_simple(progress) + + var value := _get_waveform(t, freq, waveform) * amplitude + _write_sample(samples, i, value) + + return samples + +## Generates white noise. +func _generate_noise(duration: float, amplitude: float) -> PackedByteArray: + var sample_count := int(SAMPLE_RATE * duration) + var samples := PackedByteArray() + samples.resize(sample_count * 2) + + for i in range(sample_count): + var value := randf_range(-1.0, 1.0) * amplitude + _write_sample(samples, i, value) + + return samples + +## Returns waveform value at time t for given frequency. +func _get_waveform(t: float, freq: float, waveform: String) -> float: + var phase := fmod(t * freq, 1.0) + + match waveform: + "sine": + return sin(t * freq * TAU) + "square": + return 1.0 if phase < 0.5 else -1.0 + "triangle": + return abs(phase * 4.0 - 2.0) - 1.0 + "sawtooth": + return phase * 2.0 - 1.0 + _: + return sin(t * freq * TAU) + +## Simple envelope: quick attack, exponential decay. +func _envelope_simple(progress: float) -> float: + var attack_time := 0.08 + if progress < attack_time: + return progress / attack_time + else: + return exp(-(progress - attack_time) * 6.0) + +## ADSR envelope. +func _envelope_adsr(progress: float, attack: float, decay: float, sustain: float, release: float) -> float: + if progress < attack: + return progress / attack + elif progress < attack + decay: + var decay_progress := (progress - attack) / decay + return lerp(1.0, sustain, decay_progress) + elif progress < 1.0 - release: + return sustain + else: + var release_progress := (progress - (1.0 - release)) / release + return sustain * (1.0 - release_progress) + +## Applies sharp percussive envelope to samples (modifies in place). +func _apply_percussive_envelope(samples: PackedByteArray) -> void: + var sample_count := samples.size() / 2 + + for i in range(sample_count): + var progress := float(i) / float(sample_count) + var envelope := exp(-progress * 12.0) # Sharp decay + + var idx := i * 2 + var value := _read_sample(samples, i) + value *= envelope + _write_sample(samples, i, value) + +## Mixes two sample arrays with given weights. +func _mix_samples(samples_a: PackedByteArray, samples_b: PackedByteArray, weight_a: float, weight_b: float) -> PackedByteArray: + var count_a := samples_a.size() / 2 + var count_b := samples_b.size() / 2 + var max_count: int = max(count_a, count_b) + + var result := PackedByteArray() + result.resize(max_count * 2) + + for i in range(max_count): + var value_a := _read_sample(samples_a, i) if i < count_a else 0.0 + var value_b := _read_sample(samples_b, i) if i < count_b else 0.0 + var mixed := value_a * weight_a + value_b * weight_b + _write_sample(result, i, mixed) + + return result + +## Writes a float sample (-1.0 to 1.0) to byte array as 16-bit PCM. +func _write_sample(samples: PackedByteArray, index: int, value: float) -> void: + var clamped := clampf(value, -1.0, 1.0) + var sample_int := int(clamped * 32767.0) + var idx := index * 2 + + if idx + 1 < samples.size(): + samples[idx] = sample_int & 0xFF + samples[idx + 1] = (sample_int >> 8) & 0xFF + +## Reads a 16-bit PCM sample from byte array as float. +func _read_sample(samples: PackedByteArray, index: int) -> float: + var idx := index * 2 + if idx + 1 >= samples.size(): + return 0.0 + + var low := samples[idx] + var high := samples[idx + 1] + var sample_int := low | (high << 8) + + # Convert from unsigned to signed + if sample_int >= 32768: + sample_int -= 65536 + + return float(sample_int) / 32767.0 + +## Creates an AudioStreamWAV from sample data. +func _create_wav_stream(samples: PackedByteArray) -> AudioStreamWAV: + var stream := AudioStreamWAV.new() + stream.data = samples + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = SAMPLE_RATE + stream.stereo = false + return stream + diff --git a/scripts/audio/sound_generator.gd.uid b/scripts/audio/sound_generator.gd.uid new file mode 100644 index 0000000..b99e11b --- /dev/null +++ b/scripts/audio/sound_generator.gd.uid @@ -0,0 +1 @@ +uid://snonaac1g1o6 diff --git a/scripts/characters/character_controller.gd b/scripts/characters/character_controller.gd index 6f2d15c..7fbc9ef 100644 --- a/scripts/characters/character_controller.gd +++ b/scripts/characters/character_controller.gd @@ -101,6 +101,7 @@ func _check_character_collisions(previous_velocity_y: float) -> void: if hit_their_top: # Successful stomp - they die, we bounce other_character.despawn(self) + AudioManager.play_stomp(global_position) velocity.y = physics.jump_velocity * 0.5 elif hit_their_bottom: # Hit their bottom from below - we die and credit them diff --git a/scripts/characters/components/character_lifecycle.gd b/scripts/characters/components/character_lifecycle.gd index 644f142..7e9236e 100644 --- a/scripts/characters/components/character_lifecycle.gd +++ b/scripts/characters/components/character_lifecycle.gd @@ -58,6 +58,9 @@ func despawn(killer: CharacterController = null) -> void: respawn_timer = 0.0 character.velocity = Vector2.ZERO + # Play death sound + AudioManager.play_death(character.global_position) + if killer and killer != character: EventBus.character_killed.emit(killer, character) @@ -86,6 +89,9 @@ func respawn() -> void: character.global_position = spawn_position character.velocity = Vector2.ZERO + # Play spawn sound + AudioManager.play_spawn(character.global_position) + # Restore collision shape if shape_alive: shape_alive.disabled = false diff --git a/scripts/characters/components/character_physics.gd b/scripts/characters/components/character_physics.gd index ef09620..10109c6 100644 --- a/scripts/characters/components/character_physics.gd +++ b/scripts/characters/components/character_physics.gd @@ -144,6 +144,7 @@ func _apply_gravity(delta: float) -> void: func _perform_jump() -> void: character.velocity.y = jump_velocity + AudioManager.play_jump(character.global_position) func _wrap_after_motion() -> void: if not wrap_enabled: diff --git a/scripts/core/event_bus.gd b/scripts/core/event_bus.gd index c7cec5b..d4cf21e 100644 --- a/scripts/core/event_bus.gd +++ b/scripts/core/event_bus.gd @@ -13,7 +13,7 @@ signal game_resumed @warning_ignore("unused_signal") signal game_state_changed(from_state: String, to_state: String) -## Match events +## Match evyents @warning_ignore("unused_signal") signal match_started @warning_ignore("unused_signal")