class_name FishingSpot extends Node3D const FishDataType = preload("res://fish/fish_data.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") const PlayerType = preload("res://player/player.gd") signal status_changed(status: String) signal reel_progress_changed(progress: float, visible: bool) enum FishingState { READY, AIMING_CAST, CASTING, WAITING_FOR_BITE, BITE_ACTIVE, REELING, COOLDOWN, } @export_category("Cast") @export_range(0.1, 30.0, 0.05) var minimum_cast_distance: float = 0.5 @export_range(0.1, 30.0, 0.1) var maximum_cast_distance: float = 14.0 @export_range(0.1, 10.0, 0.1) var cast_charge_duration: float = 2.75 @export_flags_3d_physics var fishable_surface_mask: int = 4 @export_range(0.01, 1.0, 0.01) var fishable_query_radius: float = 0.08 @export_category("Target Preview") @export_flags_3d_physics var preview_surface_mask: int = 5 @export_range(1.0, 100.0, 1.0) var preview_ray_start_height: float = 30.0 @export_range(1.0, 200.0, 1.0) var preview_ray_length: float = 60.0 @export_range(0.01, 1.0, 0.01) var preview_marker_vertical_offset: float = 0.06 @export_category("Withdrawal") @export_range(0.1, 20.0, 0.1) var withdrawal_rate: float = 4.9 @export_range(0.1, 10.0, 0.1) var withdrawal_cancel_distance: float = 1.0 @export_category("Timing") @export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0 @export_range(0.1, 10.0, 0.1) var bite_window_duration: float = 1.5 @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 @export_range(0.01, 5.0, 0.01) var reel_gain_rate: float = 0.67 @export_range(0.01, 5.0, 0.01) var reel_decay_rate: float = 0.2 @export_range(0.0, 0.95, 0.05) var hooked_starting_progress: float = 0.25 @export_category("Catch") @export var catchable_fish: FishDataType @onready var _presentation: FishingPresentationType = %FishingPresentation var state: FishingState = FishingState.READY var _local_player: PlayerType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 var _cast_direction: Vector3 = Vector3.FORWARD var _cast_origin_position: Vector3 var _cast_target: Vector3 var _cast_landing_is_fishable: bool = false var _withdrawal_endpoint: Vector3 var _withdrawal_progress: float = 0.0 var _withdrawal_input_held: bool = false var _new_cast_press_armed: bool = true var _bobber_water_position: Vector3 var _reel_start_position: Vector3 var _reel_progress: float = 0.0 var _reel_input_held: bool = false var _cooldown_status: String = "" var _fishable_query_shape: SphereShape3D = SphereShape3D.new() func _ready() -> void: _presentation.cast_completed.connect(_on_cast_completed) func setup(local_player: PlayerType) -> void: _local_player = local_player func _process(delta: float) -> void: if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): _new_cast_press_armed = true match state: FishingState.AIMING_CAST: _update_cast_charge(delta) if not Input.is_action_pressed("fish_primary"): _confirm_cast() FishingState.WAITING_FOR_BITE: _update_waiting_for_bite(delta) FishingState.BITE_ACTIVE: _state_time_remaining -= delta if _state_time_remaining <= 0.0: _miss_bite() FishingState.REELING: if _reel_input_held and not Input.is_action_pressed("fish_primary"): _reel_input_held = false _update_reel_progress(delta) FishingState.COOLDOWN: _state_time_remaining -= delta if _state_time_remaining <= 0.0: _return_to_ready() func _unhandled_input(event: InputEvent) -> void: if ( event.is_action_pressed("ui_cancel") and _active_player != null and state in [ FishingState.AIMING_CAST, FishingState.CASTING, FishingState.WAITING_FOR_BITE, FishingState.BITE_ACTIVE, FishingState.REELING, ] ): _cancel_attempt() get_viewport().set_input_as_handled() return if _local_player == null or not _local_player.is_local_control_enabled(): return if not event.is_action("fish_primary"): return if event.is_pressed(): match state: FishingState.READY: if not _new_cast_press_armed: return _begin_aiming(_local_player) FishingState.WAITING_FOR_BITE: _withdrawal_input_held = true _presentation.set_line_mode( FishingPresentationType.LineMode.TAUT ) FishingState.BITE_ACTIVE: _confirm_hook() FishingState.REELING: _reel_input_held = true _presentation.set_line_mode( FishingPresentationType.LineMode.TAUT ) _: return get_viewport().set_input_as_handled() elif state == FishingState.AIMING_CAST: _confirm_cast() get_viewport().set_input_as_handled() elif state == FishingState.WAITING_FOR_BITE: _withdrawal_input_held = false _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) get_viewport().set_input_as_handled() elif state == FishingState.REELING: _reel_input_held = false get_viewport().set_input_as_handled() func _begin_aiming(player: PlayerType) -> void: if state != FishingState.READY or _active_player != null: return _active_player = player _active_player.set_movement_enabled(false) _cast_direction = _capture_cast_direction(_active_player) _cast_origin_position = _active_player.get_cast_origin_position() _cast_charge = 0.0 _cast_target = _calculate_cast_target(minimum_cast_distance) state = FishingState.AIMING_CAST status_changed.emit("Hold left click to aim • Release to cast") var target_is_fishable: bool = is_target_fishable(_cast_target) var preview_position: Vector3 = _resolve_preview_surface_position(_cast_target) _presentation.begin_aim( _active_player.get_fishing_rod_tip(), _active_player.get_fishing_rod(), preview_position, target_is_fishable ) func _update_cast_charge(delta: float) -> void: if state != FishingState.AIMING_CAST: return _cast_charge = minf(_cast_charge + delta / cast_charge_duration, 1.0) var maximum_distance: float = maxf(minimum_cast_distance, maximum_cast_distance) var distance: float = lerpf(minimum_cast_distance, maximum_distance, _cast_charge) _cast_target = _calculate_cast_target(distance) var target_is_fishable: bool = is_target_fishable(_cast_target) var preview_position: Vector3 = _resolve_preview_surface_position(_cast_target) _presentation.update_aim_target( preview_position, _cast_charge, target_is_fishable ) _presentation.update_rod_charge(_cast_charge) func _confirm_cast() -> void: if state != FishingState.AIMING_CAST: return state = FishingState.CASTING status_changed.emit("Casting...") _presentation.begin_cast(_cast_target) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) func _on_cast_completed() -> void: if state != FishingState.CASTING: return _cast_landing_is_fishable = is_target_fishable(_cast_target) if not _cast_landing_is_fishable: _cleanup_attempt("Can't fish there.", &"invalid") return state = FishingState.WAITING_FOR_BITE _state_time_remaining = wait_time _withdrawal_progress = 0.0 _withdrawal_input_held = false _bobber_water_position = _cast_target _withdrawal_endpoint = Vector3( _cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance, _presentation.get_water_surface_height(), _cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance ) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) status_changed.emit("Waiting for a bite...") func _update_waiting_for_bite(delta: float) -> void: if ( _withdrawal_input_held and not Input.is_action_pressed("fish_primary") ): _withdrawal_input_held = false _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) if not _withdrawal_input_held: _state_time_remaining -= delta if _state_time_remaining <= 0.0: _activate_bite() return var completion_time: float = _get_withdrawal_completion_time(delta) if _state_time_remaining <= delta and _state_time_remaining <= completion_time: _update_withdrawal(maxf(_state_time_remaining, 0.0)) if state == FishingState.WAITING_FOR_BITE: _activate_bite() return _update_withdrawal(delta) if state != FishingState.WAITING_FOR_BITE: return _state_time_remaining -= delta if _state_time_remaining <= 0.0: _activate_bite() func _update_withdrawal(delta: float) -> void: if state != FishingState.WAITING_FOR_BITE: return var withdrawable_distance: float = _get_withdrawable_distance() if withdrawable_distance <= 0.0: _cancel_from_withdrawal() return var next_progress: float = minf( _withdrawal_progress + withdrawal_rate * delta / withdrawable_distance, 1.0 ) var desired_position: Vector3 = _cast_target.lerp( _withdrawal_endpoint, next_progress ) var clamped_position: Vector3 = find_last_fishable_position( _bobber_water_position, desired_position ) _withdrawal_progress = next_progress _bobber_water_position = clamped_position _presentation.show_withdrawal_position(_bobber_water_position) if not clamped_position.is_equal_approx(desired_position): _resolve_withdrawal_at_shore() return if is_equal_approx(_withdrawal_progress, 1.0): _cancel_from_withdrawal() func _get_withdrawal_completion_time(delta: float) -> float: var withdrawable_distance: float = _get_withdrawable_distance() if withdrawable_distance <= 0.0: return 0.0 var progress_step: float = withdrawal_rate * delta / withdrawable_distance if progress_step <= 0.0: return INF var next_progress: float = minf(_withdrawal_progress + progress_step, 1.0) var desired_position: Vector3 = _cast_target.lerp( _withdrawal_endpoint, next_progress ) var clamped_position: Vector3 = find_last_fishable_position( _bobber_water_position, desired_position ) if not clamped_position.is_equal_approx(desired_position): var segment_length: float = _bobber_water_position.distance_to( desired_position ) if segment_length <= 0.0: return 0.0 return delta * ( _bobber_water_position.distance_to(clamped_position) / segment_length ) if is_equal_approx(next_progress, 1.0): return delta * (1.0 - _withdrawal_progress) / progress_step return INF func _get_withdrawable_distance() -> float: var landing_offset: Vector3 = _cast_target - _cast_origin_position landing_offset.y = 0.0 return landing_offset.length() - withdrawal_cancel_distance func _activate_bite() -> void: if state != FishingState.WAITING_FOR_BITE: return state = FishingState.BITE_ACTIVE _state_time_remaining = bite_window_duration _withdrawal_input_held = false status_changed.emit("Bite! Left click to hook") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.show_bite() func _confirm_hook() -> void: if state != FishingState.BITE_ACTIVE or _active_player == null: return state = FishingState.REELING _state_time_remaining = 0.0 _reel_progress = clampf(hooked_starting_progress, 0.0, 0.95) _reel_input_held = true _reel_start_position = _bobber_water_position status_changed.emit("Hold left click to reel") reel_progress_changed.emit(_reel_progress, true) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.begin_reeling() _update_reel_presentation() func _update_reel_progress(delta: float) -> void: if state != FishingState.REELING: return if _reel_input_held: _reel_progress = minf(_reel_progress + reel_gain_rate * delta, 1.0) else: _reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0) reel_progress_changed.emit(_reel_progress, true) _update_reel_presentation() if is_equal_approx(_reel_progress, 1.0): _resolve_catch() elif is_zero_approx(_reel_progress): _resolve_escape() func _update_reel_presentation() -> void: var desired_position: Vector3 = _reel_start_position.lerp( _withdrawal_endpoint, _reel_progress ) _bobber_water_position = _get_clamped_reel_position(desired_position) _presentation.show_reel_position( _bobber_water_position, _reel_input_held ) func _get_clamped_reel_position(desired_position: Vector3) -> Vector3: return find_last_fishable_position( _reel_start_position, desired_position ) func _resolve_catch() -> void: if state != FishingState.REELING or _active_player == null or catchable_fish == null: _cancel_attempt() return _active_player.inventory.add_fish(catchable_fish) _cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch") func _resolve_escape() -> void: if state != FishingState.REELING: return var fish_name: String = "The fish" if catchable_fish != null and not catchable_fish.display_name.is_empty(): fish_name = "The %s" % catchable_fish.display_name _cleanup_attempt("%s got away!" % fish_name, &"escape") func _miss_bite() -> void: if state != FishingState.BITE_ACTIVE: return _cleanup_attempt("The fish got away.", &"miss") func _cleanup_attempt( cooldown_message: String = "", visual_outcome: StringName = &"", ) -> void: if _active_player != null: _active_player.set_movement_enabled(true) _active_player = null _state_time_remaining = 0.0 _cast_charge = 0.0 _cast_direction = Vector3.FORWARD _cast_origin_position = Vector3.ZERO _cast_target = Vector3.ZERO _cast_landing_is_fishable = false _withdrawal_endpoint = Vector3.ZERO _withdrawal_progress = 0.0 _withdrawal_input_held = false _bobber_water_position = Vector3.ZERO _reel_start_position = Vector3.ZERO _reel_input_held = false _reel_progress = 0.0 reel_progress_changed.emit(0.0, false) if visual_outcome.is_empty(): _presentation.cleanup() else: if visual_outcome in [&"catch", &"invalid", &"withdrawal"]: _presentation.set_line_mode( FishingPresentationType.LineMode.TAUT ) else: _presentation.set_line_mode( FishingPresentationType.LineMode.HIDDEN ) _presentation.play_outcome(visual_outcome) if cooldown_message.is_empty(): state = FishingState.READY _cooldown_status = "" status_changed.emit("") else: state = FishingState.COOLDOWN _state_time_remaining = cooldown_duration _cooldown_status = cooldown_message status_changed.emit(_cooldown_status) func _return_to_ready() -> void: state = FishingState.READY _state_time_remaining = 0.0 _cooldown_status = "" status_changed.emit("") func _cancel_attempt() -> void: _cleanup_attempt("Fishing cancelled.", &"cancel") func _cancel_from_withdrawal() -> void: if state != FishingState.WAITING_FOR_BITE: return _new_cast_press_armed = false _cleanup_attempt("", &"withdrawal") func _resolve_withdrawal_at_shore() -> void: if state != FishingState.WAITING_FOR_BITE: return _cancel_from_withdrawal() func _capture_cast_direction(player: PlayerType) -> Vector3: var direction: Vector3 = player.get_facing_direction() direction.y = 0.0 if direction.is_zero_approx(): direction = -player.global_basis.z direction.y = 0.0 if direction.is_zero_approx(): return Vector3.FORWARD return direction.normalized() func _calculate_cast_target(distance: float) -> Vector3: return Vector3( _cast_origin_position.x + _cast_direction.x * distance, _presentation.get_water_surface_height(), _cast_origin_position.z + _cast_direction.z * distance ) func is_target_fishable(target: Vector3) -> bool: _fishable_query_shape.radius = fishable_query_radius var query := PhysicsShapeQueryParameters3D.new() query.shape = _fishable_query_shape query.transform = Transform3D( Basis.IDENTITY, Vector3(target.x, _presentation.get_water_surface_height(), target.z) ) query.collision_mask = fishable_surface_mask query.collide_with_areas = true query.collide_with_bodies = false var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape( query, 1 ) return not results.is_empty() func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3: if from.is_equal_approx(to): return from var segment_length: float = from.distance_to(to) var sample_spacing: float = maxf(fishable_query_radius, 0.05) var sample_count: int = maxi(1, ceili(segment_length / sample_spacing)) var last_valid_fraction: float = 0.0 for sample_index: int in range(1, sample_count + 1): var sample_fraction: float = float(sample_index) / float(sample_count) var sample_position: Vector3 = from.lerp(to, sample_fraction) if is_target_fishable(sample_position): last_valid_fraction = sample_fraction continue var invalid_fraction: float = sample_fraction for _iteration: int in range(10): var midpoint: float = ( last_valid_fraction + invalid_fraction ) * 0.5 if is_target_fishable(from.lerp(to, midpoint)): last_valid_fraction = midpoint else: invalid_fraction = midpoint return from.lerp(to, last_valid_fraction) return to func _resolve_preview_surface_position(target: Vector3) -> Vector3: var ray_start := Vector3( target.x, target.y + preview_ray_start_height, target.z ) var ray_end := Vector3( target.x, ray_start.y - preview_ray_length, target.z ) var query := PhysicsRayQueryParameters3D.create( ray_start, ray_end, preview_surface_mask ) query.collide_with_areas = true query.collide_with_bodies = true var result: Dictionary = get_world_3d().direct_space_state.intersect_ray(query) if result.is_empty(): return target var hit_position: Vector3 = result["position"] return Vector3( target.x, hit_position.y + preview_marker_vertical_offset, target.z )