Refine fishing validity, shoreline, and line routing

This commit is contained in:
Alexander Sellite 2026-07-25 13:04:23 -04:00
parent e3d86b1cb8
commit ad848f9431
6 changed files with 669 additions and 120 deletions

View file

@ -11,10 +11,33 @@ enum VisualMode {
OUTCOME, OUTCOME,
} }
enum LineMode {
HIDDEN,
TAUT,
SLACK,
}
@export_category("Water") @export_category("Water")
@export var water_surface_offset_y: float = -0.72 @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_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_category("Cast")
@export_range(0.1, 30.0, 0.1) var target_movement_smoothing: float = 14.0 @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 @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.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_forward_swing_duration: float = 0.12
@export_range(0.05, 1.0, 0.01) var rod_recovery_duration: float = 0.22 @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 _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 _bobber: MeshInstance3D = %Bobber
@onready var _line: MeshInstance3D = %FishingLine @onready var _line: MeshInstance3D = %FishingLine
var _mode: VisualMode = VisualMode.NONE var _mode: VisualMode = VisualMode.NONE
var _line_mode: LineMode = LineMode.HIDDEN
var _line_mesh: ImmediateMesh = ImmediateMesh.new() 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: Node3D
var _rod_tip: Marker3D var _rod_tip: Marker3D
var _rod_neutral_rotation: Vector3 var _rod_neutral_rotation: Vector3
@ -52,18 +84,30 @@ func _process(delta: float) -> void:
_target_goal, _target_goal,
weight weight
) )
if _line.visible: if _line_mode != LineMode.HIDDEN:
_update_line() _update_line(delta)
func get_water_surface_height() -> float: func get_water_surface_height() -> float:
return global_position.y + water_surface_offset_y 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( func begin_aim(
rod_tip: Marker3D, rod_tip: Marker3D,
rod: Node3D, rod: Node3D,
minimum_target: Vector3, minimum_target: Vector3,
target_is_fishable: bool,
) -> void: ) -> void:
cleanup() cleanup()
if rod_tip == null or rod == null: if rod_tip == null or rod == null:
@ -77,6 +121,7 @@ func begin_aim(
_target_marker.global_position = minimum_target _target_marker.global_position = minimum_target
_target_marker.scale = Vector3.ONE _target_marker.scale = Vector3.ONE
_target_marker.visible = true _target_marker.visible = true
_set_target_validity(target_is_fishable)
func update_rod_charge(charge: float) -> void: func update_rod_charge(charge: float) -> void:
@ -88,13 +133,18 @@ func update_rod_charge(charge: float) -> void:
_rod.rotation = cock_rotation _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: if _mode != VisualMode.AIMING:
return return
_target_goal = target _target_goal = target
var maximum_response: float = smoothstep(0.9, 1.0, clampf(charge, 0.0, 1.0)) 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) _target_marker.scale = Vector3.ONE * lerpf(1.0, 1.15, maximum_response)
_set_target_validity(target_is_fishable)
func begin_cast(target: Vector3) -> void: func begin_cast(target: Vector3) -> void:
@ -106,7 +156,6 @@ func begin_cast(target: Vector3) -> void:
_target_marker.visible = false _target_marker.visible = false
_bobber.visible = true _bobber.visible = true
_bobber.scale = Vector3.ONE _bobber.scale = Vector3.ONE
_line.visible = true
_cast_position = _with_water_height(target) _cast_position = _with_water_height(target)
_pickup_position = _calculate_pickup_position(_cast_position) _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) _bobber.global_position = _with_water_height(position)
func begin_reeling(pickup_position: Vector3) -> void: func begin_reeling() -> void:
if _mode != VisualMode.FISHING: if _mode != VisualMode.FISHING:
return return
_kill_active_tween() _kill_active_tween()
_cast_position = _with_water_height(_bobber.global_position) _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: if _mode != VisualMode.FISHING:
return return
_kill_active_tween() _kill_active_tween()
_bobber.scale = Vector3.ONE _bobber.scale = Vector3.ONE
_bobber.global_position = _cast_position.lerp( _bobber.global_position = _with_water_height(position)
_pickup_position,
clampf(progress, 0.0, 1.0)
)
_bobber.global_position.y = get_water_surface_height()
func play_outcome(outcome: StringName) -> void: func play_outcome(outcome: StringName) -> void:
@ -272,10 +316,11 @@ func cleanup() -> void:
_rod_tip = null _rod_tip = null
_target_marker.visible = false _target_marker.visible = false
_target_marker.scale = Vector3.ONE _target_marker.scale = Vector3.ONE
_invalid_cross_a.visible = false
_invalid_cross_b.visible = false
_bobber.visible = false _bobber.visible = false
_bobber.scale = Vector3.ONE _bobber.scale = Vector3.ONE
_line.visible = false set_line_mode(LineMode.HIDDEN)
_line_mesh.clear_surfaces()
func _set_cast_sample( 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) 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): if _rod_tip == null or not is_instance_valid(_rod_tip):
cleanup() cleanup()
return 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() _line_mesh.clear_surfaces()
if rendered_points.size() < 2:
return
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES) _line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
_line_mesh.surface_add_vertex(to_local(_rod_tip.global_position)) for point_index: int in range(1, rendered_points.size()):
_line_mesh.surface_add_vertex(to_local(_bobber.global_position)) _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() _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: func _begin_rod_release() -> void:
_kill_rod_tween() _kill_rod_tween()
if _rod == null: if _rod == null:
@ -362,6 +731,15 @@ func _restore_rod_neutral() -> void:
_rod.rotation = _rod_neutral_rotation _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: func _kill_active_tween() -> void:
if _active_tween != null and _active_tween.is_valid(): if _active_tween != null and _active_tween.is_valid():
_active_tween.kill() _active_tween.kill()

View file

@ -1,5 +1,5 @@
class_name FishingSpot class_name FishingSpot
extends Area3D extends Node3D
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
const FishingPresentationType = preload("res://fishing/fishing_presentation.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, 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_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_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_category("Withdrawal")
@export_range(0.1, 20.0, 0.1) var withdrawal_rate: float = 4.9 @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 @onready var _presentation: FishingPresentationType = %FishingPresentation
var state: FishingState = FishingState.READY var state: FishingState = FishingState.READY
var _nearby_player: PlayerType var _local_player: PlayerType
var _active_player: PlayerType var _active_player: PlayerType
var _state_time_remaining: float = 0.0 var _state_time_remaining: float = 0.0
var _cast_charge: 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_progress: float = 0.0
var _withdrawal_input_held: bool = false var _withdrawal_input_held: bool = false
var _new_cast_press_armed: bool = true 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_progress: float = 0.0
var _reel_input_held: bool = false var _reel_input_held: bool = false
var _cooldown_status: String = "" var _cooldown_status: String = ""
var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
func _ready() -> void: func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
_presentation.cast_completed.connect(_on_cast_completed) _presentation.cast_completed.connect(_on_cast_completed)
func setup(local_player: PlayerType) -> void:
_local_player = local_player
func _process(delta: float) -> void: func _process(delta: float) -> void:
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
_new_cast_press_armed = true _new_cast_press_armed = true
@ -75,17 +87,7 @@ func _process(delta: float) -> void:
if not Input.is_action_pressed("fish_primary"): if not Input.is_action_pressed("fish_primary"):
_confirm_cast() _confirm_cast()
FishingState.WAITING_FOR_BITE: FishingState.WAITING_FOR_BITE:
_state_time_remaining -= delta _update_waiting_for_bite(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)
FishingState.BITE_ACTIVE: FishingState.BITE_ACTIVE:
_state_time_remaining -= delta _state_time_remaining -= delta
if _state_time_remaining <= 0.0: if _state_time_remaining <= 0.0:
@ -116,7 +118,7 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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 return
if not event.is_action("fish_primary"): if not event.is_action("fish_primary"):
return return
@ -126,13 +128,19 @@ func _unhandled_input(event: InputEvent) -> void:
FishingState.READY: FishingState.READY:
if not _new_cast_press_armed: if not _new_cast_press_armed:
return return
_begin_aiming(_nearby_player) _begin_aiming(_local_player)
FishingState.WAITING_FOR_BITE: FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = true _withdrawal_input_held = true
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
FishingState.BITE_ACTIVE: FishingState.BITE_ACTIVE:
_confirm_hook() _confirm_hook()
FishingState.REELING: FishingState.REELING:
_reel_input_held = true _reel_input_held = true
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
_: _:
return return
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -141,6 +149,7 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif state == FishingState.WAITING_FOR_BITE: elif state == FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = false _withdrawal_input_held = false
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif state == FishingState.REELING: elif state == FishingState.REELING:
_reel_input_held = false _reel_input_held = false
@ -159,10 +168,13 @@ func _begin_aiming(player: PlayerType) -> void:
_cast_target = _calculate_cast_target(minimum_cast_distance) _cast_target = _calculate_cast_target(minimum_cast_distance)
state = FishingState.AIMING_CAST state = FishingState.AIMING_CAST
status_changed.emit("Hold left click to aim • Release to 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( _presentation.begin_aim(
_active_player.get_fishing_rod_tip(), _active_player.get_fishing_rod_tip(),
_active_player.get_fishing_rod(), _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 maximum_distance: float = maxf(minimum_cast_distance, maximum_cast_distance)
var distance: float = lerpf(minimum_cast_distance, maximum_distance, _cast_charge) var distance: float = lerpf(minimum_cast_distance, maximum_distance, _cast_charge)
_cast_target = _calculate_cast_target(distance) _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) _presentation.update_rod_charge(_cast_charge)
@ -182,16 +200,17 @@ func _confirm_cast() -> void:
if state != FishingState.AIMING_CAST: if state != FishingState.AIMING_CAST:
return return
_cast_landing_is_fishable = _is_landing_fishable(_cast_target)
state = FishingState.CASTING state = FishingState.CASTING
status_changed.emit("Casting...") status_changed.emit("Casting...")
_presentation.begin_cast(_cast_target) _presentation.begin_cast(_cast_target)
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
func _on_cast_completed() -> void: func _on_cast_completed() -> void:
if state != FishingState.CASTING: if state != FishingState.CASTING:
return return
_cast_landing_is_fishable = is_target_fishable(_cast_target)
if not _cast_landing_is_fishable: if not _cast_landing_is_fishable:
_cleanup_attempt("Can't fish there.", &"invalid") _cleanup_attempt("Can't fish there.", &"invalid")
return return
@ -200,39 +219,114 @@ func _on_cast_completed() -> void:
_state_time_remaining = wait_time _state_time_remaining = wait_time
_withdrawal_progress = 0.0 _withdrawal_progress = 0.0
_withdrawal_input_held = false _withdrawal_input_held = false
_bobber_water_position = _cast_target
_withdrawal_endpoint = Vector3( _withdrawal_endpoint = Vector3(
_cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance, _cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance,
_presentation.get_water_surface_height(), _presentation.get_water_surface_height(),
_cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance _cast_origin_position.z + _cast_direction.z * withdrawal_cancel_distance
) )
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
status_changed.emit("Waiting for a bite...") 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: func _update_withdrawal(delta: float) -> void:
if state != FishingState.WAITING_FOR_BITE: if state != FishingState.WAITING_FOR_BITE:
return return
var landing_offset: Vector3 = _cast_target - _cast_origin_position var withdrawable_distance: float = _get_withdrawable_distance()
landing_offset.y = 0.0
var landing_distance: float = landing_offset.length()
var withdrawable_distance: float = landing_distance - withdrawal_cancel_distance
if withdrawable_distance <= 0.0: if withdrawable_distance <= 0.0:
_cancel_from_withdrawal() _cancel_from_withdrawal()
return return
_withdrawal_progress = minf( var next_progress: float = minf(
_withdrawal_progress + withdrawal_rate * delta / withdrawable_distance, _withdrawal_progress + withdrawal_rate * delta / withdrawable_distance,
1.0 1.0
) )
var current_position: Vector3 = _cast_target.lerp( var desired_position: Vector3 = _cast_target.lerp(
_withdrawal_endpoint, _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): if is_equal_approx(_withdrawal_progress, 1.0):
_cancel_from_withdrawal() _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: func _activate_bite() -> void:
if state != FishingState.WAITING_FOR_BITE: if state != FishingState.WAITING_FOR_BITE:
return return
@ -241,6 +335,7 @@ func _activate_bite() -> void:
_state_time_remaining = bite_window_duration _state_time_remaining = bite_window_duration
_withdrawal_input_held = false _withdrawal_input_held = false
status_changed.emit("Bite! Left click to hook") status_changed.emit("Bite! Left click to hook")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.show_bite() _presentation.show_bite()
@ -252,10 +347,12 @@ func _confirm_hook() -> void:
_state_time_remaining = 0.0 _state_time_remaining = 0.0
_reel_progress = clampf(hooked_starting_progress, 0.0, 0.95) _reel_progress = clampf(hooked_starting_progress, 0.0, 0.95)
_reel_input_held = true _reel_input_held = true
_reel_start_position = _bobber_water_position
status_changed.emit("Hold left click to reel") status_changed.emit("Hold left click to reel")
reel_progress_changed.emit(_reel_progress, true) reel_progress_changed.emit(_reel_progress, true)
_presentation.begin_reeling(_withdrawal_endpoint) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.show_reel_progress(_reel_progress, _reel_input_held) _presentation.begin_reeling()
_update_reel_presentation()
func _update_reel_progress(delta: float) -> void: func _update_reel_progress(delta: float) -> void:
@ -267,13 +364,32 @@ func _update_reel_progress(delta: float) -> void:
else: else:
_reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0) _reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0)
reel_progress_changed.emit(_reel_progress, true) 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): if is_equal_approx(_reel_progress, 1.0):
_resolve_catch() _resolve_catch()
elif is_zero_approx(_reel_progress): elif is_zero_approx(_reel_progress):
_resolve_escape() _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: func _resolve_catch() -> void:
if state != FishingState.REELING or _active_player == null or catchable_fish == null: if state != FishingState.REELING or _active_player == null or catchable_fish == null:
_cancel_attempt() _cancel_attempt()
@ -315,20 +431,27 @@ func _cleanup_attempt(
_withdrawal_endpoint = Vector3.ZERO _withdrawal_endpoint = Vector3.ZERO
_withdrawal_progress = 0.0 _withdrawal_progress = 0.0
_withdrawal_input_held = false _withdrawal_input_held = false
_bobber_water_position = Vector3.ZERO
_reel_start_position = Vector3.ZERO
_reel_input_held = false _reel_input_held = false
_reel_progress = 0.0 _reel_progress = 0.0
reel_progress_changed.emit(0.0, false) reel_progress_changed.emit(0.0, false)
if visual_outcome.is_empty(): if visual_outcome.is_empty():
_presentation.cleanup() _presentation.cleanup()
else: 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) _presentation.play_outcome(visual_outcome)
if cooldown_message.is_empty(): if cooldown_message.is_empty():
state = FishingState.READY state = FishingState.READY
_cooldown_status = "" _cooldown_status = ""
if _nearby_player != null:
status_changed.emit("Left click to cast")
else:
status_changed.emit("") status_changed.emit("")
else: else:
state = FishingState.COOLDOWN state = FishingState.COOLDOWN
@ -341,9 +464,6 @@ func _return_to_ready() -> void:
state = FishingState.READY state = FishingState.READY
_state_time_remaining = 0.0 _state_time_remaining = 0.0
_cooldown_status = "" _cooldown_status = ""
if _nearby_player != null:
status_changed.emit("Left click to cast")
else:
status_changed.emit("") status_changed.emit("")
@ -358,6 +478,12 @@ func _cancel_from_withdrawal() -> void:
_cleanup_attempt("", &"withdrawal") _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: func _capture_cast_direction(player: PlayerType) -> Vector3:
var direction: Vector3 = player.get_facing_direction() var direction: Vector3 = player.get_facing_direction()
direction.y = 0.0 direction.y = 0.0
@ -377,36 +503,79 @@ func _calculate_cast_target(distance: float) -> Vector3:
) )
func _is_landing_fishable(target: Vector3) -> bool: func is_target_fishable(target: Vector3) -> bool:
var query := PhysicsPointQueryParameters3D.new() _fishable_query_shape.radius = fishable_query_radius
query.position = target 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.collision_mask = fishable_surface_mask
query.collide_with_areas = true query.collide_with_areas = true
query.collide_with_bodies = false 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, query,
1 1
) )
return not results.is_empty() return not results.is_empty()
func _on_body_entered(body: Node3D) -> void: func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3:
if body is not PlayerType or not body.is_local_control_enabled(): if from.is_equal_approx(to):
return return from
if _nearby_player != null:
return
_nearby_player = body var segment_length: float = from.distance_to(to)
if state == FishingState.READY: var sample_spacing: float = maxf(fishable_query_radius, 0.05)
status_changed.emit("Left click to cast") 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
func _on_body_exited(body: Node3D) -> void: var invalid_fraction: float = sample_fraction
if body != _nearby_player: for _iteration: int in range(10):
return var midpoint: float = (
last_valid_fraction + invalid_fraction
_nearby_player = null ) * 0.5
if body == _active_player: if is_target_fishable(from.lerp(to, midpoint)):
_cancel_attempt() last_valid_fraction = midpoint
else: else:
status_changed.emit("") 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
)

View file

@ -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="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="Resource" path="res://fish/bluegill.tres" id="2_bluegill"]
[ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"] [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"] [sub_resource type="SphereMesh" id="BobberMesh"]
radius = 0.12 radius = 0.12
height = 0.24 height = 0.24
@ -42,41 +23,50 @@ height = 0.035
[sub_resource type="StandardMaterial3D" id="CastTargetMaterial"] [sub_resource type="StandardMaterial3D" id="CastTargetMaterial"]
shading_mode = 0 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_enabled = true
emission = Color(0.08, 0.35, 0.22, 1) emission = Color(0.08, 0.8, 0.42, 1)
emission_energy_multiplier = 0.8 emission_energy_multiplier = 1.6
[node name="FishingSpot" type="Area3D"] [sub_resource type="StandardMaterial3D" id="InvalidTargetMaterial"]
collision_layer = 0 shading_mode = 0
collision_mask = 2 albedo_color = Color(1, 0.08, 0.22, 1)
monitoring = true emission_enabled = true
monitorable = false 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") script = ExtResource("1_spot")
catchable_fish = ExtResource("2_bluegill") 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="."] [node name="FishingPresentation" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("3_presentation") script = ExtResource("3_presentation")
valid_target_material = SubResource("CastTargetMaterial")
invalid_target_material = SubResource("InvalidTargetMaterial")
[node name="CastTargetMarker" type="MeshInstance3D" parent="FishingPresentation"] [node name="CastTargetMarker" type="MeshInstance3D" parent="FishingPresentation"]
unique_name_in_owner = true unique_name_in_owner = true
mesh = SubResource("CastTargetMesh") mesh = SubResource("CastTargetMesh")
material_override = SubResource("CastTargetMaterial") 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"] [node name="FishingLine" type="MeshInstance3D" parent="FishingPresentation"]
unique_name_in_owner = true unique_name_in_owner = true
material_override = SubResource("LineMaterial") material_override = SubResource("LineMaterial")

View file

@ -10,4 +10,5 @@ const PlayerType = preload("res://player/player.gd")
func _ready() -> void: func _ready() -> void:
_fishing_spot.setup(_player)
_game_ui.setup(_player.inventory, _fishing_spot) _game_ui.setup(_player.inventory, _fishing_spot)

View file

@ -46,7 +46,7 @@ theme_override_constants/margin_bottom = 8
[node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"] [node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false
text = "Left click to cast" text = ""
horizontal_alignment = 1 horizontal_alignment = 1
[node name="ReelProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer"] [node name="ReelProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer"]

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=11 format=3] [gd_scene load_steps=12 format=3]
[sub_resource type="BoxShape3D" id="GroundShape"] [sub_resource type="BoxShape3D" id="GroundShape"]
size = Vector3(30, 1, 30) size = Vector3(30, 1, 30)
@ -22,7 +22,10 @@ albedo_color = Color(0.42, 0.25, 0.12, 1)
size = Vector2(40, 25) size = Vector2(40, 25)
[sub_resource type="BoxShape3D" id="FishableWaterShape"] [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"] [sub_resource type="StandardMaterial3D" id="WaterMaterial"]
transparency = 1 transparency = 1
@ -77,3 +80,11 @@ monitoring = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="FishableWater"] [node name="CollisionShape3D" type="CollisionShape3D" parent="FishableWater"]
shape = SubResource("FishableWaterShape") 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")