@tool class_name PlayerWaterTrigger extends Area3D signal recovery_requested(player: Player, surface_height: float) enum SurfaceHeightMode { EXPLICIT, PARENT_GLOBAL_Y, } enum EntryHeightReference { BODY_CENTER, PLAYER_ORIGIN, } @export_group("Surface") @export var surface_height_mode: SurfaceHeightMode = SurfaceHeightMode.EXPLICIT: set(value): surface_height_mode = value if Engine.is_editor_hint(): notify_property_list_changed() ## Legacy explicit height used when Surface Height Mode is Explicit. @export var surface_height: float = 0.2 @export_group("Recovery Trigger") ## Point on the player used to measure entry depth below the water surface. @export var entry_height_reference: EntryHeightReference = ( EntryHeightReference.BODY_CENTER ) ## Recovery begins when the selected reference reaches this depth. @export_range(0.0, 2.0, 0.05) var entry_depth_threshold: float = 0.35 ## The player must remain below the threshold for this long before recovery. ## A short freshwater confirmation keeps jumps across stepping stones from ## being mistaken for a completed fall into the water. @export_range(0.0, 1.0, 0.05) var entry_confirmation_seconds: float = 0.0 var _tracked_players: Array[Player] = [] var _triggered_players: Dictionary[StringName, bool] = {} var _submerged_seconds: Dictionary[StringName, float] = {} func _ready() -> void: if Engine.is_editor_hint(): set_physics_process(false) return body_entered.connect(_on_body_entered) body_exited.connect(_on_body_exited) # Generated worlds can contain many independent water volumes. Keep an # empty trigger completely asleep instead of polling an empty array on every # physics tick. set_physics_process(false) func _physics_process(delta: float) -> void: var active_surface_height: float = get_surface_height() for player: Player in _tracked_players.duplicate(): if not is_instance_valid(player): _tracked_players.erase(player) continue var player_key: StringName = StringName(str(player.get_instance_id())) if _triggered_players.has(player_key): continue var entry_height := player.get_body_center_position().y if entry_height_reference == EntryHeightReference.PLAYER_ORIGIN: entry_height = player.global_position.y if entry_height > active_surface_height - entry_depth_threshold: _submerged_seconds[player_key] = 0.0 continue var submerged_seconds: float = ( _submerged_seconds.get(player_key, 0.0) + maxf(delta, 0.0) ) _submerged_seconds[player_key] = submerged_seconds if submerged_seconds < entry_confirmation_seconds: continue _triggered_players[player_key] = true recovery_requested.emit(player, active_surface_height) if _tracked_players.is_empty(): set_physics_process(false) func get_surface_height() -> float: if surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y: var surface_owner := get_parent() as Node3D if surface_owner != null: return surface_owner.global_position.y return surface_height func _validate_property(property: Dictionary) -> void: if ( property.name == "surface_height" and surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y ): property.usage = int(property.usage) & ~PROPERTY_USAGE_EDITOR func _on_body_entered(body: Node3D) -> void: var player: Player = body as Player if player == null or player in _tracked_players: return _tracked_players.append(player) _submerged_seconds[StringName(str(player.get_instance_id()))] = 0.0 set_physics_process(true) func _on_body_exited(body: Node3D) -> void: var player: Player = body as Player if player == null: return _tracked_players.erase(player) var player_key := StringName(str(player.get_instance_id())) _triggered_players.erase(player_key) _submerged_seconds.erase(player_key) if _tracked_players.is_empty(): set_physics_process(false)