straywild/world/water_recovery_controller.gd
Voyager 4575830857 Improve nearby water recovery and camera prompts
Recover players on generated worlds to the nearest safe grass or sand surface. Use the active gameplay camera when positioning shop and storage prompts.
2026-08-29 17:43:11 -04:00

227 lines
6.9 KiB
GDScript

class_name WaterRecoveryController
extends Node
signal recovery_starting
signal recovery_finished
signal local_respawn_completed(entry_position: Vector3)
enum RecoveryState {
IDLE,
BOBBING,
FADING_OUT,
FADING_IN,
}
@export_range(-1.0, 1.0, 0.01) var water_center_offset: float = 0.0
@export_range(0.0, 0.5, 0.005) var bob_amplitude: float = 0.08
@export_range(0.1, 10.0, 0.1) var bob_speed: float = 2.4
@export_range(0.0, 5.0, 0.05) var pre_fade_bob_duration: float = 1.0
@export_range(0.0, 1.0, 0.01) var respawn_height_offset: float = 0.08
var state: RecoveryState = RecoveryState.IDLE
var _player: Player
var _fishing_spot: FishingSpot
var _game_ui: GameUI
var _screen_fade: ScreenFade
var _water_triggers: Array[PlayerWaterTrigger] = []
var _safe_points: Array[SafeRespawnPoint] = []
var _recovery_position_resolver := Callable()
var _initial_spawn_transform: Transform3D
var _entry_position: Vector3
var _bob_base_position: Vector3
var _recovery_root_basis: Basis
var _bob_elapsed: float = 0.0
var _prior_movement_enabled: bool = true
var _prior_camera_input_enabled: bool = true
var _generation: int = 0
var _recovery_enabled: bool = false
func setup(
player: Player,
fishing_spot: FishingSpot,
game_ui: GameUI,
screen_fade: ScreenFade,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
_player = player
_fishing_spot = fishing_spot
_game_ui = game_ui
_screen_fade = screen_fade
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
_initial_spawn_transform = player.global_transform
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
and not water_trigger.recovery_requested.is_connected(
_on_recovery_requested
)
):
water_trigger.recovery_requested.connect(
_on_recovery_requested
)
if not _screen_fade.transition_completed.is_connected(
_on_fade_transition_completed
):
_screen_fade.transition_completed.connect(
_on_fade_transition_completed
)
func update_world_context(
initial_spawn_transform: Transform3D,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
and is_instance_valid(water_trigger)
and water_trigger.recovery_requested.is_connected(
_on_recovery_requested
)
):
water_trigger.recovery_requested.disconnect(
_on_recovery_requested
)
_initial_spawn_transform = initial_spawn_transform
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
and not water_trigger.recovery_requested.is_connected(
_on_recovery_requested
)
):
water_trigger.recovery_requested.connect(
_on_recovery_requested
)
func _process(delta: float) -> void:
if state != RecoveryState.BOBBING or _player == null:
return
_bob_elapsed += delta
var bob_offset: float = sin(_bob_elapsed * bob_speed * TAU) * bob_amplitude
_player.global_position = _bob_base_position + Vector3.UP * bob_offset
if _bob_elapsed >= pre_fade_bob_duration:
state = RecoveryState.FADING_OUT
_screen_fade.fade_to_black(_generation)
func set_recovery_enabled(enabled: bool) -> void:
_recovery_enabled = enabled
func is_recovery_active() -> bool:
return state != RecoveryState.IDLE
func _exit_tree() -> void:
_generation += 1
if _screen_fade != null and is_instance_valid(_screen_fade):
_screen_fade.reset_immediately()
if _player != null and is_instance_valid(_player):
_player.restore_gameplay_orientation_after_recovery()
_player.set_water_recovery_active(false)
_player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_input_enabled)
state = RecoveryState.IDLE
func _on_recovery_requested(
triggered_player: Player,
surface_height: float,
) -> void:
if (
not _recovery_enabled
or
state != RecoveryState.IDLE
or triggered_player != _player
or not is_instance_valid(triggered_player)
):
return
_generation += 1
_entry_position = _player.global_position
_recovery_root_basis = _player.global_basis
_fishing_spot.begin_water_recovery()
_player.prepare_for_water_recovery()
# Snapshot the normalized gameplay state before recovery_starting closes
# menus for the handoff. Those closures deliberately disable movement and
# camera input, which are recovery-owned locks rather than states to restore.
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_input_enabled = _player.is_camera_input_enabled()
recovery_starting.emit()
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_player.set_water_recovery_active(true)
_bob_base_position = _player.global_position
_bob_base_position.y = (
surface_height
+ water_center_offset
- _player.get_body_center_height()
)
_player.global_position = _bob_base_position
_bob_elapsed = 0.0
state = RecoveryState.BOBBING
func _on_fade_transition_completed(
generation: int,
faded_to_black: bool,
) -> void:
if generation != _generation:
return
if faded_to_black and state == RecoveryState.FADING_OUT:
_respawn_player()
state = RecoveryState.FADING_IN
_screen_fade.fade_from_black(_generation)
elif not faded_to_black and state == RecoveryState.FADING_IN:
_finish_recovery()
func _respawn_player() -> void:
var target_position := _resolve_recovery_position()
target_position.y += respawn_height_offset
var respawn_transform: Transform3D = _player.global_transform
respawn_transform.basis = _recovery_root_basis
respawn_transform.origin = target_position
_player.global_transform = respawn_transform
_player.restore_gameplay_orientation_after_recovery()
_player.velocity = Vector3.ZERO
local_respawn_completed.emit(_entry_position)
func _resolve_recovery_position() -> Vector3:
if _recovery_position_resolver.is_valid():
var resolved: Variant = _recovery_position_resolver.call(_entry_position)
if resolved is Vector3 and (resolved as Vector3).is_finite():
return resolved as Vector3
var target_position := _initial_spawn_transform.origin
var nearest_distance := INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(_entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func _finish_recovery() -> void:
_player.restore_gameplay_orientation_after_recovery()
_player.set_water_recovery_active(false)
_player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_input_enabled)
_fishing_spot.end_water_recovery()
state = RecoveryState.IDLE
_bob_elapsed = 0.0
recovery_finished.emit()