diff --git a/audio/ambience/waves.wav b/audio/ambience/waves.wav new file mode 100644 index 0000000..15f258e Binary files /dev/null and b/audio/ambience/waves.wav differ diff --git a/audio/ambience/waves.wav.import b/audio/ambience/waves.wav.import new file mode 100644 index 0000000..5ff6ba8 --- /dev/null +++ b/audio/ambience/waves.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dck3tf8qkadea" +path="res://.godot/imported/waves.wav-90ffb886ce064ba838eebb25ec72a17e.sample" + +[deps] + +source_file="res://audio/ambience/waves.wav" +dest_files=["res://.godot/imported/waves.wav-90ffb886ce064ba838eebb25ec72a17e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=1 +edit/loop_begin=0 +edit/loop_end=1044956 +compress/mode=0 diff --git a/audio/sfx/fishing/bobber.wav b/audio/sfx/fishing/bobber.wav new file mode 100644 index 0000000..8c9d916 Binary files /dev/null and b/audio/sfx/fishing/bobber.wav differ diff --git a/audio/sfx/fishing/bobber.wav.import b/audio/sfx/fishing/bobber.wav.import new file mode 100644 index 0000000..d5fc82d --- /dev/null +++ b/audio/sfx/fishing/bobber.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://sxydt4wnmrj3" +path="res://.godot/imported/bobber.wav-b435bb251a112445f1ddebcacc74804a.sample" + +[deps] + +source_file="res://audio/sfx/fishing/bobber.wav" +dest_files=["res://.godot/imported/bobber.wav-b435bb251a112445f1ddebcacc74804a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/audio/sfx/fishing/fighting.wav b/audio/sfx/fishing/fighting.wav new file mode 100644 index 0000000..f647069 Binary files /dev/null and b/audio/sfx/fishing/fighting.wav differ diff --git a/audio/sfx/fishing/fighting.wav.import b/audio/sfx/fishing/fighting.wav.import new file mode 100644 index 0000000..ec59561 --- /dev/null +++ b/audio/sfx/fishing/fighting.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://df6yg85cldetw" +path="res://.godot/imported/fighting.wav-1427843e18acf5134cc66d4a813033b6.sample" + +[deps] + +source_file="res://audio/sfx/fishing/fighting.wav" +dest_files=["res://.godot/imported/fighting.wav-1427843e18acf5134cc66d4a813033b6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=1 +edit/loop_begin=0 +edit/loop_end=124487 +compress/mode=0 diff --git a/audio/sfx/fishing/reeling.wav b/audio/sfx/fishing/reeling.wav new file mode 100644 index 0000000..6be5d03 Binary files /dev/null and b/audio/sfx/fishing/reeling.wav differ diff --git a/audio/sfx/fishing/reeling.wav.import b/audio/sfx/fishing/reeling.wav.import new file mode 100644 index 0000000..e5e6749 --- /dev/null +++ b/audio/sfx/fishing/reeling.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://v4yjxtslbrd7" +path="res://.godot/imported/reeling.wav-97a9415ae2d9e7ca4c976bb15b10e6fd.sample" + +[deps] + +source_file="res://audio/sfx/fishing/reeling.wav" +dest_files=["res://.godot/imported/reeling.wav-97a9415ae2d9e7ca4c976bb15b10e6fd.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=1 +edit/loop_begin=0 +edit/loop_end=114628 +compress/mode=0 diff --git a/default_bus_layout.tres b/default_bus_layout.tres index ca38684..665b0e9 100644 --- a/default_bus_layout.tres +++ b/default_bus_layout.tres @@ -13,3 +13,9 @@ 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/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index a12427b..284b6de 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -122,6 +122,9 @@ const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @onready var _catch_controller: CatchControllerType = %CatchController @onready var _presentation: FishingPresentationType = %FishingPresentation +@onready var _bobber_audio: AudioStreamPlayer = %BobberAudio +@onready var _fight_audio: AudioStreamPlayer = %FightAudio +@onready var _reeling_audio: AudioStreamPlayer = %ReelingAudio var state: FishingState = FishingState.READY var _external_input_blocked: bool = false @@ -177,6 +180,12 @@ var _pending_cleanup_message: String = "" func _ready() -> void: _bite_rng.randomize() _configure_surface_resolver() + var fight_stream := _fight_audio.stream as AudioStreamWAV + if fight_stream != null: + _configure_audio_loop(fight_stream) + var reeling_stream := _reeling_audio.stream as AudioStreamWAV + if reeling_stream != null: + _configure_audio_loop(reeling_stream) _catch_controller.encounter_updated.connect(_on_catch_encounter_updated) _catch_controller.caught.connect(_on_catch_completed) _catch_controller.escaped.connect(_on_catch_escaped) @@ -223,6 +232,15 @@ func setup( _network_fishing = network_fishing _world_time = world_time _world_weather = world_weather + if ( + _network_session != null + and not _network_session.state_changed.is_connected( + _on_network_session_state_changed + ) + ): + _network_session.state_changed.connect( + _on_network_session_state_changed + ) if _network_fishing != null: _network_fishing.local_cast_accepted.connect( _on_network_cast_accepted @@ -348,6 +366,7 @@ func set_local_menu_input_suppressed( _local_menu_input_owners.erase(owner) if suppressed and state == FishingState.WAITING_FOR_BITE: _withdrawal_input_held = false + _stop_reeling_audio() _presentation.set_line_mode( FishingPresentationType.LineMode.SLACK ) @@ -357,6 +376,7 @@ func set_gameplay_input_enabled(enabled: bool) -> void: _gameplay_input_enabled = enabled if not enabled: _local_menu_input_owners.clear() + _stop_reeling_audio() func configure_accessibility_auto_click( @@ -400,6 +420,8 @@ func _secure_showcase_catch_for_recovery() -> void: func _exit_tree() -> void: + _stop_fight_audio() + _stop_reeling_audio() _showcase_restore_generation += 1 if ( state == FishingState.SHOWING_CATCH @@ -531,6 +553,7 @@ func _unhandled_input(event: InputEvent) -> void: return FishingState.WAITING_FOR_BITE: _withdrawal_input_held = true + _start_reeling_audio() if _network_fishing != null: _network_primary_input_held = true _network_input_resend_elapsed = 0.0 @@ -546,6 +569,7 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() elif state == FishingState.WAITING_FOR_BITE: _withdrawal_input_held = false + _stop_reeling_audio() if _network_fishing != null: _network_primary_input_held = false _network_input_resend_elapsed = 0.0 @@ -731,6 +755,7 @@ func _on_cast_completed() -> void: if not _is_cast_target_valid(_cast_target): _cleanup_attempt("", &"invalid") return + _play_bobber_audio() if _network_fishing != null: _network_fishing.request_local_cast( _cast_origin_position, @@ -816,6 +841,7 @@ func _update_waiting_for_bite(delta: float) -> void: and not Input.is_action_pressed("fish_primary") ): _withdrawal_input_held = false + _stop_reeling_audio() _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) if not _withdrawal_input_held: @@ -908,6 +934,7 @@ func _activate_bite() -> void: if state != FishingState.WAITING_FOR_BITE: return + _stop_reeling_audio() if ( _active_player == null or _selected_fish == null @@ -923,6 +950,7 @@ func _activate_bite() -> void: if _pending_catch == null or not _pending_catch.is_valid(): _cancel_attempt() return + _start_fight_audio() bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) @@ -1001,6 +1029,7 @@ func _on_catch_completed() -> void: if _pending_catch == null or not _pending_catch.is_valid(): _cancel_attempt() return + _stop_fight_audio() state = FishingState.SHOWING_CATCH _showcase_ready = false _showcase_outcome_completed = false @@ -1014,6 +1043,7 @@ func _on_catch_escaped() -> void: if state != FishingState.FIGHTING: return + _stop_fight_audio() var fish_name: String = "the fish" if _selected_fish != null and not _selected_fish.display_name.is_empty(): fish_name = "the %s" % _selected_fish.display_name @@ -1120,6 +1150,8 @@ func _cleanup_attempt( cooldown_message: String = "", visual_outcome: StringName = &"", ) -> void: + _stop_fight_audio() + _stop_reeling_audio() if not visual_outcome.is_empty(): if state == FishingState.RETURNING: return @@ -1422,12 +1454,14 @@ func _on_network_cast_rejected(message: String) -> void: func _on_network_bite_started(_attempt_id: String) -> void: if state != FishingState.WAITING_FOR_BITE: return + _stop_reeling_audio() state = FishingState.FIGHTING _withdrawal_input_held = false _network_primary_input_held = Input.is_action_pressed("fish_primary") _network_input_resend_elapsed = 0.0 _network_auto_click_accumulator = 0.0 _network_active_barrier_index = -1 + _start_fight_audio() bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) @@ -1510,6 +1544,7 @@ func _update_network_auto_click(delta: float) -> void: func _on_network_catch_received(fish_catch: FishCatchType) -> void: if state != FishingState.FIGHTING or fish_catch == null: return + _stop_fight_audio() _pending_catch = fish_catch state = FishingState.SHOWING_CATCH _showcase_ready = false @@ -1541,6 +1576,57 @@ func _on_network_attempt_ended( _cleanup_attempt(message, visual_outcome) +func _on_network_session_state_changed(state_value: NetworkSession.State) -> void: + if state_value in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _stop_fight_audio() + _stop_reeling_audio() + + +func _start_fight_audio() -> void: + if _fight_audio == null or _fight_audio.stream == null: + return + if not _fight_audio.playing: + _fight_audio.play() + + +func _play_bobber_audio() -> void: + if _bobber_audio == null or _bobber_audio.stream == null: + return + _bobber_audio.play() + + +func _stop_fight_audio() -> void: + if _fight_audio != null and _fight_audio.playing: + _fight_audio.stop() + + +func _configure_audio_loop(stream: AudioStreamWAV) -> void: + stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + if stream.loop_end <= stream.loop_begin: + stream.loop_begin = 0 + stream.loop_end = maxi( + 1, + roundi(stream.get_length() * float(stream.mix_rate)), + ) + + +func _start_reeling_audio() -> void: + if _reeling_audio == null or _reeling_audio.stream == null: + return + if not _reeling_audio.playing: + _reeling_audio.play() + + +func _stop_reeling_audio() -> void: + if _reeling_audio != null and _reeling_audio.playing: + _reeling_audio.stop() + + func resolve_safe_withdrawal_surface( from: Vector3, to: Vector3, diff --git a/fishing/fishing_spot.tscn b/fishing/fishing_spot.tscn index 322f618..09cf1ae 100644 --- a/fishing/fishing_spot.tscn +++ b/fishing/fishing_spot.tscn @@ -1,6 +1,9 @@ -[gd_scene load_steps=12 format=3] +[gd_scene load_steps=15 format=3] [ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"] +[ext_resource type="AudioStream" path="res://audio/sfx/fishing/fighting.wav" id="2_fighting_audio"] +[ext_resource type="AudioStream" path="res://audio/sfx/fishing/reeling.wav" id="2_reeling_audio"] +[ext_resource type="AudioStream" path="res://audio/sfx/fishing/bobber.wav" id="2_bobber_audio"] [ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"] [ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"] @@ -41,6 +44,24 @@ size = Vector3(0.62, 0.04, 0.09) [node name="FishingSpot" type="Node3D"] script = ExtResource("1_spot") +[node name="BobberAudio" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true +stream = ExtResource("2_bobber_audio") +volume_db = -14.0 +bus = &"SFX" + +[node name="FightAudio" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true +stream = ExtResource("2_fighting_audio") +volume_db = -10.0 +bus = &"SFX" + +[node name="ReelingAudio" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true +stream = ExtResource("2_reeling_audio") +volume_db = -10.0 +bus = &"SFX" + [node name="CatchController" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("4_catch") diff --git a/main/main.gd b/main/main.gd index 8b14388..059d0b1 100644 --- a/main/main.gd +++ b/main/main.gd @@ -201,9 +201,15 @@ var _application_initialized := false var _pending_existing_root_path := "" var _local_recovery_attempt_id: String = "" +@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience + func _ready() -> void: DisplayServer.window_set_title("NETfishing") + _shoreline_ambience.configure( + _player, + _test_world.get_saltwater_shoreline_mesh(), + ) var menu_pattern_material := ( _player_menu_backdrop.material as ShaderMaterial ) @@ -1026,6 +1032,7 @@ func _reset_pixelation() -> void: func _set_gameplay_active(active: bool) -> void: _gameplay_started = active + _shoreline_ambience.set_active(active) _title_background.visible = not active _world_pixelation.set_gameplay_active(active) _ui_pixelation.set_gameplay_active(active) diff --git a/main/main.tscn b/main/main.tscn index 03a38ab..8f66df1 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -53,6 +53,8 @@ [ext_resource type="Script" path="res://network/network_world_weather_service.gd" id="51_network_world_weather"] [ext_resource type="Script" path="res://jobs/player_job_service.gd" id="52_player_jobs"] [ext_resource type="Script" path="res://network/network_job_service.gd" id="53_network_jobs"] +[ext_resource type="Script" path="res://world/shoreline_ambience.gd" id="54_shoreline_ambience"] +[ext_resource type="AudioStream" path="res://audio/ambience/waves.wav" id="55_waves"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -277,6 +279,15 @@ stream = ExtResource("13_title_music") volume_db = -80.0 bus = &"Music" +[node name="ShorelineAmbience" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("54_shoreline_ambience") + +[node name="WavesAudio" type="AudioStreamPlayer" parent="ShorelineAmbience"] +unique_name_in_owner = true +stream = ExtResource("55_waves") +bus = &"SFX" + [node name="WorldPixelationPostprocess" parent="." unique_id=344378000 instance=ExtResource("16_world_pixelation")] unique_name_in_owner = true diff --git a/tests/fishing_audio_validation.gd b/tests/fishing_audio_validation.gd new file mode 100644 index 0000000..02a59eb --- /dev/null +++ b/tests/fishing_audio_validation.gd @@ -0,0 +1,54 @@ +extends SceneTree + +const FishingSpotScene := preload("res://fishing/fishing_spot.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var fishing_spot := FishingSpotScene.instantiate() as FishingSpot + root.add_child(fishing_spot) + await process_frame + + var fight_audio := fishing_spot.get_node("%FightAudio") as AudioStreamPlayer + var bobber_audio := fishing_spot.get_node( + "%BobberAudio" + ) as AudioStreamPlayer + assert(bobber_audio != null) + assert(bobber_audio.stream != null) + assert(bobber_audio.bus == &"SFX") + assert(is_equal_approx(bobber_audio.volume_db, -14.0)) + var bobber_stream := bobber_audio.stream as AudioStreamWAV + assert(bobber_stream != null) + assert(bobber_stream.loop_mode == AudioStreamWAV.LOOP_DISABLED) + assert(fight_audio != null) + assert(fight_audio.stream != null) + assert(fight_audio.bus == &"SFX") + assert(is_equal_approx(fight_audio.volume_db, -10.0)) + var fight_stream := fight_audio.stream as AudioStreamWAV + assert(fight_stream != null) + assert(fight_stream.loop_mode == AudioStreamWAV.LOOP_FORWARD) + assert(fight_stream.loop_end > fight_stream.loop_begin) + var reeling_audio := fishing_spot.get_node( + "%ReelingAudio" + ) as AudioStreamPlayer + assert(reeling_audio != null) + assert(reeling_audio.stream != null) + assert(reeling_audio.bus == &"SFX") + assert(is_equal_approx(reeling_audio.volume_db, -10.0)) + var reeling_stream := reeling_audio.stream as AudioStreamWAV + assert(reeling_stream != null) + assert(reeling_stream.loop_mode == AudioStreamWAV.LOOP_FORWARD) + assert(reeling_stream.loop_end > reeling_stream.loop_begin) + + assert(fishing_spot.has_method("_start_fight_audio")) + assert(fishing_spot.has_method("_play_bobber_audio")) + assert(fishing_spot.has_method("_stop_fight_audio")) + assert(fishing_spot.has_method("_start_reeling_audio")) + assert(fishing_spot.has_method("_stop_reeling_audio")) + fishing_spot.free() + await process_frame + print("Fishing audio validation: PASS") + quit() diff --git a/tests/fishing_audio_validation.gd.uid b/tests/fishing_audio_validation.gd.uid new file mode 100644 index 0000000..e9cd6d7 --- /dev/null +++ b/tests/fishing_audio_validation.gd.uid @@ -0,0 +1 @@ +uid://bby8mv7bsjd3w diff --git a/tests/shoreline_ambience_validation.gd b/tests/shoreline_ambience_validation.gd new file mode 100644 index 0000000..505452a --- /dev/null +++ b/tests/shoreline_ambience_validation.gd @@ -0,0 +1,48 @@ +extends SceneTree + +const ShorelineAmbienceType := preload("res://world/shoreline_ambience.gd") +const WavesStream := preload("res://audio/ambience/waves.wav") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var waves := WavesStream as AudioStreamWAV + assert(waves != null) + + var segments: Array[PackedVector2Array] = [ + PackedVector2Array([Vector2.ZERO, Vector2(10.0, 0.0)]), + ] + assert(is_equal_approx( + ShorelineAmbienceType.distance_to_segments(Vector2(5.0, 3.0), segments), + 3.0, + )) + assert(is_equal_approx( + ShorelineAmbienceType.distance_to_segments(Vector2(-4.0, 0.0), segments), + 4.0, + )) + + var controller := ShorelineAmbienceType.new() as ShorelineAmbience + var runtime_audio := AudioStreamPlayer.new() + runtime_audio.name = "WavesAudio" + runtime_audio.unique_name_in_owner = true + runtime_audio.stream = WavesStream + runtime_audio.bus = &"SFX" + controller.add_child(runtime_audio) + runtime_audio.owner = controller + root.add_child(controller) + await process_frame + assert(runtime_audio.bus == &"SFX") + assert(controller.near_distance < controller.far_distance) + assert(controller.near_volume_db > controller.far_volume_db) + var runtime_waves := runtime_audio.stream as AudioStreamWAV + assert(runtime_waves.loop_mode == AudioStreamWAV.LOOP_FORWARD) + assert(runtime_waves.loop_begin == 0) + assert(runtime_waves.loop_end == 1044956) + controller.free() + await process_frame + + print("Shoreline ambience validation: PASS") + quit() diff --git a/tests/shoreline_ambience_validation.gd.uid b/tests/shoreline_ambience_validation.gd.uid new file mode 100644 index 0000000..4b7987b --- /dev/null +++ b/tests/shoreline_ambience_validation.gd.uid @@ -0,0 +1 @@ +uid://bwvhgpbkb00l1 diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index 399c937..c03243f 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -58,6 +58,10 @@ func get_pelican_landmark() -> Node3D: return get_node_or_null(pelican_landmark_path) as Node3D +func get_saltwater_shoreline_mesh() -> MeshInstance3D: + return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D + + func has_terrain_collision() -> bool: var collision_shape: CollisionShape3D = ( get_node_or_null(terrain_collision_shape_path) as CollisionShape3D diff --git a/world/shoreline_ambience.gd b/world/shoreline_ambience.gd new file mode 100644 index 0000000..903904a --- /dev/null +++ b/world/shoreline_ambience.gd @@ -0,0 +1,156 @@ +class_name ShorelineAmbience +extends Node + +@export_range(0.0, 20.0, 0.1) var near_distance: float = 2.0 +@export_range(1.0, 100.0, 0.5) var far_distance: float = 24.0 +@export_range(-40.0, 12.0, 0.5) var near_volume_db: float = 5.0 +@export_range(-80.0, 0.0, 0.5) var far_volume_db: float = -18.0 +@export_range(0.05, 1.0, 0.05) var distance_update_interval: float = 0.2 +@export_range(0.1, 20.0, 0.1) var volume_smoothing_speed: float = 4.0 + +@onready var _waves_audio: AudioStreamPlayer = %WavesAudio + +var _listener: Node3D +var _shoreline_segments: Array[PackedVector2Array] = [] +var _distance_update_remaining: float = 0.0 +var _target_volume_db: float = -80.0 +var _is_active := false + + +func _ready() -> void: + _configure_audio_loop(_waves_audio.stream) + _waves_audio.volume_db = -80.0 + set_process(false) + + +func configure(listener: Node3D, shoreline_mesh: MeshInstance3D) -> void: + _listener = listener + _shoreline_segments = _extract_shoreline_segments(shoreline_mesh) + _distance_update_remaining = 0.0 + if _shoreline_segments.is_empty(): + push_warning("Saltwater shoreline ambience has no coastline geometry.") + + +func set_active(active: bool) -> void: + _is_active = active + set_process(active) + if active: + _distance_update_remaining = 0.0 + _update_target_volume() + _waves_audio.volume_db = _target_volume_db + if not _waves_audio.playing and _waves_audio.stream != null: + _waves_audio.play() + else: + _waves_audio.stop() + _waves_audio.volume_db = -80.0 + + +func _process(delta: float) -> void: + if not _is_active: + return + _distance_update_remaining -= delta + if _distance_update_remaining <= 0.0: + _distance_update_remaining = distance_update_interval + _update_target_volume() + _waves_audio.volume_db = lerpf( + _waves_audio.volume_db, + _target_volume_db, + 1.0 - exp(-volume_smoothing_speed * delta), + ) + + +func _update_target_volume() -> void: + if _listener == null or _shoreline_segments.is_empty(): + _target_volume_db = -80.0 + return + var listener_position := Vector2( + _listener.global_position.x, + _listener.global_position.z, + ) + var distance := distance_to_segments(listener_position, _shoreline_segments) + var blend := clampf( + inverse_lerp(near_distance, maxf(near_distance + 0.01, far_distance), distance), + 0.0, + 1.0, + ) + _target_volume_db = lerpf(near_volume_db, far_volume_db, smoothstep(0.0, 1.0, blend)) + + +func _extract_shoreline_segments( + shoreline_mesh: MeshInstance3D, +) -> Array[PackedVector2Array]: + var segments: Array[PackedVector2Array] = [] + var seen_segments: Dictionary = {} + if shoreline_mesh == null or shoreline_mesh.mesh == null: + return segments + for surface_index in shoreline_mesh.mesh.get_surface_count(): + var arrays := shoreline_mesh.mesh.surface_get_arrays(surface_index) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + if indices.is_empty(): + continue + for index_offset in range(0, indices.size() - 2, 3): + var triangle := PackedVector2Array() + for corner in 3: + var vertex := shoreline_mesh.to_global( + vertices[indices[index_offset + corner]] + ) + triangle.append(Vector2(vertex.x, vertex.z)) + _append_unique_segment(segments, seen_segments, triangle[0], triangle[1]) + _append_unique_segment(segments, seen_segments, triangle[1], triangle[2]) + _append_unique_segment(segments, seen_segments, triangle[2], triangle[0]) + return segments + + +func _append_unique_segment( + segments: Array[PackedVector2Array], + seen_segments: Dictionary, + start: Vector2, + end: Vector2, +) -> void: + if start.distance_squared_to(end) <= 0.000001: + return + # The baked ribbon repeats shared triangle edges. Quantized canonical keys + # keep only distinct segments without an O(n²) startup pass. + var first := start + var second := end + if first.x > second.x or (is_equal_approx(first.x, second.x) and first.y > second.y): + first = end + second = start + var key := "%d,%d:%d,%d" % [ + roundi(first.x * 10000.0), + roundi(first.y * 10000.0), + roundi(second.x * 10000.0), + roundi(second.y * 10000.0), + ] + if seen_segments.has(key): + return + seen_segments[key] = true + segments.append(PackedVector2Array([start, end])) + + +static func distance_to_segments( + point: Vector2, + segments: Array[PackedVector2Array], +) -> float: + var closest_squared := INF + for segment in segments: + if segment.size() < 2: + continue + var closest := Geometry2D.get_closest_point_to_segment( + point, + segment[0], + segment[1], + ) + closest_squared = minf(closest_squared, point.distance_squared_to(closest)) + return sqrt(closest_squared) if closest_squared < INF else INF + + +func _configure_audio_loop(stream: AudioStream) -> void: + var wav := stream as AudioStreamWAV + if wav == null: + return + wav.loop_mode = AudioStreamWAV.LOOP_FORWARD + if wav.loop_end <= wav.loop_begin: + wav.loop_begin = 0 + wav.loop_end = int(round(wav.get_length() * wav.mix_rate)) diff --git a/world/shoreline_ambience.gd.uid b/world/shoreline_ambience.gd.uid new file mode 100644 index 0000000..3188ec7 --- /dev/null +++ b/world/shoreline_ambience.gd.uid @@ -0,0 +1 @@ +uid://b0c3o2vy76fg3 diff --git a/world/test_world.gd b/world/test_world.gd index 189c119..5d6660a 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -61,6 +61,10 @@ func get_pelican_convenience_landmark() -> Node3D: return _starter_island.get_pelican_landmark() +func get_saltwater_shoreline_mesh() -> MeshInstance3D: + return _starter_island.get_saltwater_shoreline_mesh() + + func _get_regions() -> Array[WorldRegion]: var regions: Array[WorldRegion] = [] for child: Node in _regions_root.get_children():