diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index 9a84269..44c3a1a 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -11,10 +11,33 @@ enum VisualMode { OUTCOME, } +enum LineMode { + HIDDEN, + TAUT, + SLACK, +} + @export_category("Water") @export var water_surface_offset_y: float = -0.72 @export_range(0.1, 10.0, 0.1) var pickup_distance_from_player: float = 5.5 +@export_category("Fishing Line") +@export_flags_3d_physics var line_obstacle_mask: int = 1 +@export_range(1.0, 30.0, 0.5) var routing_probe_height: float = 8.0 +@export_range(0.1, 2.0, 0.05) var obstruction_sample_spacing: float = 0.4 +@export_range(0.01, 1.0, 0.01) var terrain_clearance: float = 0.1 +@export_range(1, 3, 1) var maximum_obstruction_ranges: int = 1 +@export_range(2, 12, 1) var maximum_contact_anchors_per_range: int = 5 +@export_range(0.0, 3.0, 0.05) var route_lead_in_distance: float = 0.8 +@export_range(0.0, 3.0, 0.05) var route_lead_out_distance: float = 0.8 +@export_range(0.05, 2.0, 0.05) var fallback_arch_height: float = 0.35 +@export_range(0.0, 0.5, 0.01) var slack_amount: float = 0.08 +@export_range(0.0, 2.0, 0.01) var minimum_slack: float = 0.08 +@export_range(0.0, 3.0, 0.01) var maximum_slack: float = 0.65 +@export_range(2, 20, 1) var clear_span_interpolation_count: int = 8 +@export_range(1, 8, 1) var contact_span_interpolation_count: int = 2 +@export_range(0.1, 30.0, 0.1) var line_transition_speed: float = 7.0 + @export_category("Cast") @export_range(0.1, 30.0, 0.1) var target_movement_smoothing: float = 14.0 @export_range(0.1, 3.0, 0.05) var cast_travel_duration: float = 0.65 @@ -23,13 +46,22 @@ enum VisualMode { @export_range(0.0, 60.0, 1.0) var rod_forward_swing_degrees: float = 22.0 @export_range(0.05, 1.0, 0.01) var rod_forward_swing_duration: float = 0.12 @export_range(0.05, 1.0, 0.01) var rod_recovery_duration: float = 0.22 +@export var valid_target_material: Material +@export var invalid_target_material: Material @onready var _target_marker: MeshInstance3D = %CastTargetMarker +@onready var _invalid_cross_a: MeshInstance3D = %InvalidCrossA +@onready var _invalid_cross_b: MeshInstance3D = %InvalidCrossB @onready var _bobber: MeshInstance3D = %Bobber @onready var _line: MeshInstance3D = %FishingLine var _mode: VisualMode = VisualMode.NONE +var _line_mode: LineMode = LineMode.HIDDEN var _line_mesh: ImmediateMesh = ImmediateMesh.new() +var _route_points: PackedVector3Array = PackedVector3Array() +var _contact_start_anchor_index: int = -1 +var _contact_end_anchor_index: int = -1 +var _current_sag: float = 0.0 var _rod: Node3D var _rod_tip: Marker3D var _rod_neutral_rotation: Vector3 @@ -52,18 +84,30 @@ func _process(delta: float) -> void: _target_goal, weight ) - if _line.visible: - _update_line() + if _line_mode != LineMode.HIDDEN: + _update_line(delta) func get_water_surface_height() -> float: return global_position.y + water_surface_offset_y +func set_line_mode(mode: LineMode) -> void: + _line_mode = mode + _line.visible = mode != LineMode.HIDDEN + if mode == LineMode.HIDDEN: + _current_sag = 0.0 + _route_points.clear() + _contact_start_anchor_index = -1 + _contact_end_anchor_index = -1 + _line_mesh.clear_surfaces() + + func begin_aim( rod_tip: Marker3D, rod: Node3D, minimum_target: Vector3, + target_is_fishable: bool, ) -> void: cleanup() if rod_tip == null or rod == null: @@ -77,6 +121,7 @@ func begin_aim( _target_marker.global_position = minimum_target _target_marker.scale = Vector3.ONE _target_marker.visible = true + _set_target_validity(target_is_fishable) func update_rod_charge(charge: float) -> void: @@ -88,13 +133,18 @@ func update_rod_charge(charge: float) -> void: _rod.rotation = cock_rotation -func update_aim_target(target: Vector3, charge: float) -> void: +func update_aim_target( + target: Vector3, + charge: float, + target_is_fishable: bool, +) -> void: if _mode != VisualMode.AIMING: return _target_goal = target var maximum_response: float = smoothstep(0.9, 1.0, clampf(charge, 0.0, 1.0)) _target_marker.scale = Vector3.ONE * lerpf(1.0, 1.15, maximum_response) + _set_target_validity(target_is_fishable) func begin_cast(target: Vector3) -> void: @@ -106,7 +156,6 @@ func begin_cast(target: Vector3) -> void: _target_marker.visible = false _bobber.visible = true _bobber.scale = Vector3.ONE - _line.visible = true _cast_position = _with_water_height(target) _pickup_position = _calculate_pickup_position(_cast_position) @@ -162,26 +211,21 @@ func show_withdrawal_position(position: Vector3) -> void: _bobber.global_position = _with_water_height(position) -func begin_reeling(pickup_position: Vector3) -> void: +func begin_reeling() -> void: if _mode != VisualMode.FISHING: return _kill_active_tween() _cast_position = _with_water_height(_bobber.global_position) - _pickup_position = _with_water_height(pickup_position) -func show_reel_progress(progress: float, _input_held: bool) -> void: +func show_reel_position(position: Vector3, _input_held: bool) -> void: if _mode != VisualMode.FISHING: return _kill_active_tween() _bobber.scale = Vector3.ONE - _bobber.global_position = _cast_position.lerp( - _pickup_position, - clampf(progress, 0.0, 1.0) - ) - _bobber.global_position.y = get_water_surface_height() + _bobber.global_position = _with_water_height(position) func play_outcome(outcome: StringName) -> void: @@ -272,10 +316,11 @@ func cleanup() -> void: _rod_tip = null _target_marker.visible = false _target_marker.scale = Vector3.ONE + _invalid_cross_a.visible = false + _invalid_cross_b.visible = false _bobber.visible = false _bobber.scale = Vector3.ONE - _line.visible = false - _line_mesh.clear_surfaces() + set_line_mode(LineMode.HIDDEN) func _set_cast_sample( @@ -320,18 +365,342 @@ func _with_water_height(position: Vector3) -> Vector3: return Vector3(position.x, get_water_surface_height(), position.z) -func _update_line() -> void: +func _update_line(delta: float) -> void: if _rod_tip == null or not is_instance_valid(_rod_tip): cleanup() return + var rod_tip_position: Vector3 = _rod_tip.global_position + var bobber_position: Vector3 = _bobber.global_position + var target_sag: float = 0.0 + if _line_mode == LineMode.SLACK: + var line_length: float = rod_tip_position.distance_to(bobber_position) + target_sag = clampf( + line_length * slack_amount, + minimum_slack, + maximum_slack + ) + var sag_weight: float = 1.0 - exp(-line_transition_speed * delta) + _current_sag = lerpf(_current_sag, target_sag, sag_weight) + _route_points = _build_line_route(rod_tip_position, bobber_position) + var rendered_points: PackedVector3Array = _build_rendered_line_points( + _route_points, + _current_sag + ) + _line_mesh.clear_surfaces() + if rendered_points.size() < 2: + return _line_mesh.surface_begin(Mesh.PRIMITIVE_LINES) - _line_mesh.surface_add_vertex(to_local(_rod_tip.global_position)) - _line_mesh.surface_add_vertex(to_local(_bobber.global_position)) + for point_index: int in range(1, rendered_points.size()): + _line_mesh.surface_add_vertex(to_local(rendered_points[point_index - 1])) + _line_mesh.surface_add_vertex(to_local(rendered_points[point_index])) _line_mesh.surface_end() +func _build_line_route(start: Vector3, end: Vector3) -> PackedVector3Array: + _contact_start_anchor_index = -1 + _contact_end_anchor_index = -1 + if _raycast_obstacle(start, end).is_empty(): + return PackedVector3Array([start, end]) + + var horizontal_delta := Vector2(end.x - start.x, end.z - start.z) + var horizontal_length: float = horizontal_delta.length() + if horizontal_length <= 0.001: + return _build_fallback_arch(start, end) + + var sample_count: int = clampi( + ceili(horizontal_length / obstruction_sample_spacing), + 2, + 64 + ) + var obstructed_samples := PackedByteArray() + for sample_index: int in range(sample_count + 1): + var progress: float = float(sample_index) / float(sample_count) + var base_point: Vector3 = start.lerp(end, progress) + var surface_hit: Dictionary = _probe_surface(base_point) + var is_obstructed: bool = false + if not surface_hit.is_empty(): + var surface_position: Vector3 = surface_hit["position"] + var required_height: float = surface_position.y + terrain_clearance + if required_height > base_point.y: + is_obstructed = true + obstructed_samples.append(1 if is_obstructed else 0) + + var obstruction_ranges: Array[Vector2i] = _find_obstruction_ranges( + obstructed_samples + ) + if obstruction_ranges.is_empty(): + return _build_fallback_arch(start, end) + if obstruction_ranges.size() > maximum_obstruction_ranges: + return _build_fallback_arch(start, end) + + var first_sample: int = obstruction_ranges[0].x + var last_sample: int = obstruction_ranges[obstruction_ranges.size() - 1].y + var first_progress: float = float(first_sample) / float(sample_count) + var last_progress: float = float(last_sample) / float(sample_count) + var lead_in_progress: float = maxf( + 0.0, + first_progress - route_lead_in_distance / horizontal_length + ) + var lead_out_progress: float = minf( + 1.0, + last_progress + route_lead_out_distance / horizontal_length + ) + + var route := PackedVector3Array([start]) + _append_unique_route_point(route, start.lerp(end, lead_in_progress)) + _contact_start_anchor_index = route.size() - 1 + _append_contact_anchors( + route, + start, + end, + first_sample, + last_sample, + sample_count + ) + _append_unique_route_point(route, start.lerp(end, lead_out_progress)) + _contact_end_anchor_index = route.size() - 1 + _append_unique_route_point(route, end) + route = _remove_near_duplicate_route_points(route) + _update_contact_indices_after_reduction(route) + return _validate_reduced_route(route, start, end) + + +func _build_rendered_line_points( + route: PackedVector3Array, + sag: float, +) -> PackedVector3Array: + var points := PackedVector3Array() + if route.is_empty(): + return points + if ( + _contact_start_anchor_index < 0 + or _contact_end_anchor_index < _contact_start_anchor_index + or _contact_end_anchor_index >= route.size() + ): + _append_clear_span(points, route[0], route[route.size() - 1], sag) + return points + + _append_clear_span( + points, + route[0], + route[_contact_start_anchor_index], + sag + ) + for anchor_index: int in range( + _contact_start_anchor_index, + _contact_end_anchor_index + ): + _append_contact_span( + points, + route[anchor_index], + route[anchor_index + 1] + ) + _append_clear_span( + points, + route[_contact_end_anchor_index], + route[route.size() - 1], + sag + ) + return points + + +func _raycast_obstacle(from: Vector3, to: Vector3) -> Dictionary: + if from.is_equal_approx(to): + return {} + var query := PhysicsRayQueryParameters3D.create( + from, + to, + line_obstacle_mask + ) + query.collide_with_areas = false + query.collide_with_bodies = true + return get_world_3d().direct_space_state.intersect_ray(query) + + +func _find_obstruction_ranges( + obstructed_samples: PackedByteArray, +) -> Array[Vector2i]: + var ranges: Array[Vector2i] = [] + var range_start: int = -1 + for sample_index: int in range(obstructed_samples.size()): + var is_obstructed: bool = obstructed_samples[sample_index] != 0 + if is_obstructed and range_start < 0: + range_start = sample_index + elif not is_obstructed and range_start >= 0: + ranges.append(Vector2i(range_start, sample_index - 1)) + range_start = -1 + if range_start >= 0: + ranges.append(Vector2i(range_start, obstructed_samples.size() - 1)) + return ranges + + +func _append_contact_anchors( + route: PackedVector3Array, + start: Vector3, + end: Vector3, + first_sample: int, + last_sample: int, + sample_count: int, +) -> void: + var available_samples: int = last_sample - first_sample + 1 + var anchor_count: int = mini( + available_samples, + maximum_contact_anchors_per_range + ) + for anchor_offset: int in range(anchor_count): + var weight: float = 0.0 + if anchor_count > 1: + weight = float(anchor_offset) / float(anchor_count - 1) + var sample_index: int = roundi( + lerpf(float(first_sample), float(last_sample), weight) + ) + var progress: float = float(sample_index) / float(sample_count) + var contact: Vector3 = _raise_point_above_obstacle( + start.lerp(end, progress) + ) + _append_unique_route_point(route, contact) + + +func _append_unique_route_point( + route: PackedVector3Array, + point: Vector3, +) -> void: + if route.is_empty() or point.distance_to(route[route.size() - 1]) > 0.03: + route.append(point) + + +func _remove_near_duplicate_route_points( + route: PackedVector3Array, +) -> PackedVector3Array: + var cleaned := PackedVector3Array() + for point: Vector3 in route: + _append_unique_route_point(cleaned, point) + return cleaned + + +func _update_contact_indices_after_reduction(route: PackedVector3Array) -> void: + if route.size() <= 2: + _contact_start_anchor_index = -1 + _contact_end_anchor_index = -1 + return + _contact_start_anchor_index = 1 + _contact_end_anchor_index = route.size() - 2 + + +func _validate_reduced_route( + route: PackedVector3Array, + start: Vector3, + end: Vector3, +) -> PackedVector3Array: + var correction_index: int = -1 + var correction_hit: Dictionary + for segment_index: int in range(route.size() - 1): + var hit: Dictionary = _raycast_obstacle( + route[segment_index], + route[segment_index + 1] + ) + if not hit.is_empty(): + correction_index = segment_index + 1 + correction_hit = hit + break + if correction_index < 0: + return route + + var hit_position: Vector3 = correction_hit["position"] + var correction: Vector3 = _raise_point_above_obstacle(hit_position) + route.insert(correction_index, correction) + _contact_start_anchor_index = mini( + _contact_start_anchor_index, + correction_index + ) + _contact_end_anchor_index = maxi( + _contact_end_anchor_index + 1, + correction_index + ) + for segment_index: int in range(route.size() - 1): + if not _raycast_obstacle( + route[segment_index], + route[segment_index + 1] + ).is_empty(): + return _build_fallback_arch(start, end) + return route + + +func _append_clear_span( + points: PackedVector3Array, + start: Vector3, + end: Vector3, + sag: float, +) -> void: + if points.is_empty(): + points.append(start) + elif not points[points.size() - 1].is_equal_approx(start): + points.append(start) + var subdivisions: int = 1 if sag <= 0.001 else clear_span_interpolation_count + for step: int in range(1, subdivisions + 1): + var progress: float = float(step) / float(subdivisions) + var point: Vector3 = start.lerp(end, progress) + if step < subdivisions and sag > 0.0: + point.y -= 4.0 * progress * (1.0 - progress) * sag + point.y = maxf( + point.y, + get_water_surface_height() + terrain_clearance * 0.25 + ) + point = _raise_point_above_obstacle(point) + points.append(point) + + +func _append_contact_span( + points: PackedVector3Array, + start: Vector3, + end: Vector3, +) -> void: + if points.is_empty(): + points.append(start) + elif not points[points.size() - 1].is_equal_approx(start): + points.append(start) + for step: int in range(1, contact_span_interpolation_count + 1): + var progress: float = ( + float(step) / float(contact_span_interpolation_count) + ) + var point: Vector3 = start.lerp(end, progress) + if step < contact_span_interpolation_count: + point = _raise_point_above_obstacle(point) + points.append(point) + + +func _build_fallback_arch( + start: Vector3, + end: Vector3, +) -> PackedVector3Array: + var route := PackedVector3Array([start]) + for progress: float in [0.25, 0.5, 0.75]: + var point: Vector3 = start.lerp(end, progress) + point.y += sin(progress * PI) * fallback_arch_height + route.append(_raise_point_above_obstacle(point)) + route.append(end) + _contact_start_anchor_index = 1 + _contact_end_anchor_index = route.size() - 2 + return route + + +func _probe_surface(point: Vector3) -> Dictionary: + var probe_start: Vector3 = point + Vector3.UP * routing_probe_height + var probe_end: Vector3 = point - Vector3.UP * routing_probe_height + return _raycast_obstacle(probe_start, probe_end) + + +func _raise_point_above_obstacle(point: Vector3) -> Vector3: + var hit: Dictionary = _probe_surface(point) + if hit.is_empty(): + return point + var surface_position: Vector3 = hit["position"] + point.y = maxf(point.y, surface_position.y + terrain_clearance) + return point + + func _begin_rod_release() -> void: _kill_rod_tween() if _rod == null: @@ -362,6 +731,15 @@ func _restore_rod_neutral() -> void: _rod.rotation = _rod_neutral_rotation +func _set_target_validity(target_is_fishable: bool) -> void: + if target_is_fishable: + _target_marker.material_override = valid_target_material + else: + _target_marker.material_override = invalid_target_material + _invalid_cross_a.visible = not target_is_fishable + _invalid_cross_b.visible = not target_is_fishable + + func _kill_active_tween() -> void: if _active_tween != null and _active_tween.is_valid(): _active_tween.kill() diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 99a43bc..e040a15 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -1,5 +1,5 @@ class_name FishingSpot -extends Area3D +extends Node3D const FishDataType = preload("res://fish/fish_data.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") @@ -23,6 +23,13 @@ enum FishingState { @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 @@ -42,7 +49,7 @@ enum FishingState { @onready var _presentation: FishingPresentationType = %FishingPresentation var state: FishingState = FishingState.READY -var _nearby_player: PlayerType +var _local_player: PlayerType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -54,17 +61,22 @@ 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: - body_entered.connect(_on_body_entered) - body_exited.connect(_on_body_exited) _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 @@ -75,17 +87,7 @@ func _process(delta: float) -> void: if not Input.is_action_pressed("fish_primary"): _confirm_cast() FishingState.WAITING_FOR_BITE: - _state_time_remaining -= delta - if _state_time_remaining <= 0.0: - _activate_bite() - else: - if ( - _withdrawal_input_held - and not Input.is_action_pressed("fish_primary") - ): - _withdrawal_input_held = false - if _withdrawal_input_held: - _update_withdrawal(delta) + _update_waiting_for_bite(delta) FishingState.BITE_ACTIVE: _state_time_remaining -= delta if _state_time_remaining <= 0.0: @@ -116,7 +118,7 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return - if _nearby_player == null or not _nearby_player.is_local_control_enabled(): + if _local_player == null or not _local_player.is_local_control_enabled(): return if not event.is_action("fish_primary"): return @@ -126,13 +128,19 @@ func _unhandled_input(event: InputEvent) -> void: FishingState.READY: if not _new_cast_press_armed: return - _begin_aiming(_nearby_player) + _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() @@ -141,6 +149,7 @@ func _unhandled_input(event: InputEvent) -> void: 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 @@ -159,10 +168,13 @@ func _begin_aiming(player: PlayerType) -> void: _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(), - _cast_target + preview_position, + target_is_fishable ) @@ -174,7 +186,13 @@ func _update_cast_charge(delta: float) -> void: 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) - _presentation.update_aim_target(_cast_target, _cast_charge) + 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) @@ -182,16 +200,17 @@ func _confirm_cast() -> void: if state != FishingState.AIMING_CAST: return - _cast_landing_is_fishable = _is_landing_fishable(_cast_target) 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 @@ -200,39 +219,114 @@ func _on_cast_completed() -> void: _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 landing_offset: Vector3 = _cast_target - _cast_origin_position - landing_offset.y = 0.0 - var landing_distance: float = landing_offset.length() - var withdrawable_distance: float = landing_distance - withdrawal_cancel_distance + var withdrawable_distance: float = _get_withdrawable_distance() if withdrawable_distance <= 0.0: _cancel_from_withdrawal() return - _withdrawal_progress = minf( + var next_progress: float = minf( _withdrawal_progress + withdrawal_rate * delta / withdrawable_distance, 1.0 ) - var current_position: Vector3 = _cast_target.lerp( + var desired_position: Vector3 = _cast_target.lerp( _withdrawal_endpoint, - _withdrawal_progress + next_progress ) - _presentation.show_withdrawal_position(current_position) + 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 @@ -241,6 +335,7 @@ func _activate_bite() -> void: _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() @@ -252,10 +347,12 @@ func _confirm_hook() -> void: _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.begin_reeling(_withdrawal_endpoint) - _presentation.show_reel_progress(_reel_progress, _reel_input_held) + _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) + _presentation.begin_reeling() + _update_reel_presentation() func _update_reel_progress(delta: float) -> void: @@ -267,13 +364,32 @@ func _update_reel_progress(delta: float) -> void: else: _reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0) reel_progress_changed.emit(_reel_progress, true) - _presentation.show_reel_progress(_reel_progress, _reel_input_held) + _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() @@ -315,21 +431,28 @@ func _cleanup_attempt( _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 = "" - if _nearby_player != null: - status_changed.emit("Left click to cast") - else: - status_changed.emit("") + status_changed.emit("") else: state = FishingState.COOLDOWN _state_time_remaining = cooldown_duration @@ -341,10 +464,7 @@ func _return_to_ready() -> void: state = FishingState.READY _state_time_remaining = 0.0 _cooldown_status = "" - if _nearby_player != null: - status_changed.emit("Left click to cast") - else: - status_changed.emit("") + status_changed.emit("") func _cancel_attempt() -> void: @@ -358,6 +478,12 @@ func _cancel_from_withdrawal() -> void: _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 @@ -377,36 +503,79 @@ func _calculate_cast_target(distance: float) -> Vector3: ) -func _is_landing_fishable(target: Vector3) -> bool: - var query := PhysicsPointQueryParameters3D.new() - query.position = target +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_point( + var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape( query, 1 ) return not results.is_empty() -func _on_body_entered(body: Node3D) -> void: - if body is not PlayerType or not body.is_local_control_enabled(): - return - if _nearby_player != null: - return +func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3: + if from.is_equal_approx(to): + return from - _nearby_player = body - if state == FishingState.READY: - status_changed.emit("Left click to cast") + 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 _on_body_exited(body: Node3D) -> void: - if body != _nearby_player: - return +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 - _nearby_player = null - if body == _active_player: - _cancel_attempt() - else: - status_changed.emit("") + var hit_position: Vector3 = result["position"] + return Vector3( + target.x, + hit_position.y + preview_marker_vertical_offset, + target.z + ) diff --git a/fishing/fishing_spot.tscn b/fishing/fishing_spot.tscn index 5e19d62..fbed769 100644 --- a/fishing/fishing_spot.tscn +++ b/fishing/fishing_spot.tscn @@ -1,28 +1,9 @@ -[gd_scene load_steps=13 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"] [ext_resource type="Resource" path="res://fish/bluegill.tres" id="2_bluegill"] [ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"] -[sub_resource type="SphereShape3D" id="RangeShape"] -radius = 3.5 - -[sub_resource type="CylinderMesh" id="MarkerPostMesh"] -top_radius = 0.12 -bottom_radius = 0.12 -height = 2.0 - -[sub_resource type="TorusMesh" id="RangeMarkerMesh"] -inner_radius = 3.35 -outer_radius = 3.5 -rings = 32 - -[sub_resource type="StandardMaterial3D" id="MarkerMaterial"] -albedo_color = Color(0.95, 0.82, 0.18, 1) -emission_enabled = true -emission = Color(0.3, 0.2, 0.01, 1) -emission_energy_multiplier = 0.7 - [sub_resource type="SphereMesh" id="BobberMesh"] radius = 0.12 height = 0.24 @@ -42,41 +23,50 @@ height = 0.035 [sub_resource type="StandardMaterial3D" id="CastTargetMaterial"] shading_mode = 0 -albedo_color = Color(0.3, 0.95, 0.72, 0.9) +albedo_color = Color(0.18, 1, 0.72, 1) emission_enabled = true -emission = Color(0.08, 0.35, 0.22, 1) -emission_energy_multiplier = 0.8 +emission = Color(0.08, 0.8, 0.42, 1) +emission_energy_multiplier = 1.6 -[node name="FishingSpot" type="Area3D"] -collision_layer = 0 -collision_mask = 2 -monitoring = true -monitorable = false +[sub_resource type="StandardMaterial3D" id="InvalidTargetMaterial"] +shading_mode = 0 +albedo_color = Color(1, 0.08, 0.22, 1) +emission_enabled = true +emission = Color(1, 0.01, 0.08, 1) +emission_energy_multiplier = 2.2 + +[sub_resource type="BoxMesh" id="InvalidCrossMesh"] +size = Vector3(0.72, 0.06, 0.12) + +[node name="FishingSpot" type="Node3D"] script = ExtResource("1_spot") catchable_fish = ExtResource("2_bluegill") -[node name="RangeCollision" type="CollisionShape3D" parent="."] -shape = SubResource("RangeShape") - -[node name="MarkerPost" type="MeshInstance3D" parent="."] -position = Vector3(0, 1, 0) -mesh = SubResource("MarkerPostMesh") -material_override = SubResource("MarkerMaterial") - -[node name="RangeMarker" type="MeshInstance3D" parent="."] -position = Vector3(0, 0.03, 0) -mesh = SubResource("RangeMarkerMesh") -material_override = SubResource("MarkerMaterial") - [node name="FishingPresentation" type="Node3D" parent="."] unique_name_in_owner = true script = ExtResource("3_presentation") +valid_target_material = SubResource("CastTargetMaterial") +invalid_target_material = SubResource("InvalidTargetMaterial") [node name="CastTargetMarker" type="MeshInstance3D" parent="FishingPresentation"] unique_name_in_owner = true mesh = SubResource("CastTargetMesh") material_override = SubResource("CastTargetMaterial") +[node name="InvalidCrossA" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"] +unique_name_in_owner = true +position = Vector3(0, 0.075, 0) +rotation = Vector3(0, 0.785398, 0) +mesh = SubResource("InvalidCrossMesh") +material_override = SubResource("InvalidTargetMaterial") + +[node name="InvalidCrossB" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"] +unique_name_in_owner = true +position = Vector3(0, 0.075, 0) +rotation = Vector3(0, -0.785398, 0) +mesh = SubResource("InvalidCrossMesh") +material_override = SubResource("InvalidTargetMaterial") + [node name="FishingLine" type="MeshInstance3D" parent="FishingPresentation"] unique_name_in_owner = true material_override = SubResource("LineMaterial") diff --git a/main/main.gd b/main/main.gd index 0944cd7..9b5b11b 100644 --- a/main/main.gd +++ b/main/main.gd @@ -10,4 +10,5 @@ const PlayerType = preload("res://player/player.gd") func _ready() -> void: + _fishing_spot.setup(_player) _game_ui.setup(_player.inventory, _fishing_spot) diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index da0d97c..53ced13 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -46,7 +46,7 @@ theme_override_constants/margin_bottom = 8 [node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"] unique_name_in_owner = true visible = false -text = "Left click to cast" +text = "" horizontal_alignment = 1 [node name="ReelProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer"] diff --git a/world/test_world.tscn b/world/test_world.tscn index 6d8893c..57259fb 100644 --- a/world/test_world.tscn +++ b/world/test_world.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=12 format=3] [sub_resource type="BoxShape3D" id="GroundShape"] size = Vector3(30, 1, 30) @@ -22,7 +22,10 @@ albedo_color = Color(0.42, 0.25, 0.12, 1) size = Vector2(40, 25) [sub_resource type="BoxShape3D" id="FishableWaterShape"] -size = Vector3(40, 0.5, 19.5) +size = Vector3(40, 0.16, 19.5) + +[sub_resource type="BoxShape3D" id="FishableShoreShape"] +size = Vector3(17.5, 0.16, 5.5) [sub_resource type="StandardMaterial3D" id="WaterMaterial"] transparency = 1 @@ -77,3 +80,11 @@ monitoring = false [node name="CollisionShape3D" type="CollisionShape3D" parent="FishableWater"] shape = SubResource("FishableWaterShape") + +[node name="ShoreLeft" type="CollisionShape3D" parent="FishableWater"] +position = Vector3(-11.25, 0, 12.5) +shape = SubResource("FishableShoreShape") + +[node name="ShoreRight" type="CollisionShape3D" parent="FishableWater"] +position = Vector3(11.25, 0, 12.5) +shape = SubResource("FishableShoreShape")