diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index 01a10a2..09ed554 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -3,6 +3,7 @@ extends Node3D signal cast_completed signal outcome_completed(outcome: StringName) +signal presentation_interrupted enum VisualMode { NONE, @@ -18,10 +19,6 @@ enum LineMode { 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_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 @@ -30,19 +27,20 @@ enum LineMode { @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 @export_range(0.1, 5.0, 0.1) var cast_arc_height: float = 2.0 @export_range(0.0, 90.0, 1.0) var maximum_rod_cock_degrees: float = 50.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_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 +@export_category("Bobber Idle") +@export_range(0.0, 0.2, 0.005) var bobber_bob_amplitude: float = 0.035 +@export_range(0.1, 3.0, 0.05) var bobber_bob_cycles_per_second: float = 0.7 +@export_range(0.0, 12.0, 0.5) var bobber_tilt_degrees: float = 4.0 +@onready var _target_marker: Node3D = %CastTargetMarker +@onready var _valid_target_marker: MeshInstance3D = %ValidTargetMarker +@onready var _invalid_target_marker: Node3D = %InvalidTargetMarker @onready var _bobber: MeshInstance3D = %Bobber @onready var _line: MeshInstance3D = %FishingLine @@ -53,44 +51,27 @@ var _current_sag: float = 0.0 var _rod: Node3D var _rod_tip: Marker3D var _rod_neutral_rotation: Vector3 -var _target_goal: Vector3 -var _cast_position: Vector3 -var _pickup_position: Vector3 +var _cast_arrival_position: Vector3 +var _bobber_surface_position: Vector3 +var _bobber_idle_elapsed: float = 0.0 var _active_tween: Tween var _rod_tween: Tween var _bite_tween: Tween -var _default_water_surface_offset_y: float func _ready() -> void: - _default_water_surface_offset_y = water_surface_offset_y _line.mesh = _line_mesh cleanup() func _process(delta: float) -> void: - if _mode == VisualMode.AIMING: - var weight: float = 1.0 - exp(-target_movement_smoothing * delta) - _target_marker.global_position = _target_marker.global_position.lerp( - _target_goal, - weight - ) + if _mode == VisualMode.FISHING and _bobber.visible: + _bobber_idle_elapsed += delta + _apply_bobber_idle_motion() if _line_mode != LineMode.HIDDEN: _update_line(delta) -func get_water_surface_height() -> float: - return global_position.y + water_surface_offset_y - - -func set_water_surface_height(surface_height: float) -> void: - water_surface_offset_y = surface_height - global_position.y - - -func reset_water_surface_height() -> void: - water_surface_offset_y = _default_water_surface_offset_y - - func set_line_mode(mode: LineMode) -> void: _line_mode = mode _line.visible = mode != LineMode.HIDDEN @@ -103,6 +84,7 @@ func begin_aim( rod_tip: Marker3D, rod: Node3D, minimum_target: Vector3, + target_normal: Vector3, target_is_fishable: bool, ) -> void: cleanup() @@ -113,8 +95,7 @@ func begin_aim( _rod = rod _rod_tip = rod_tip _rod_neutral_rotation = _rod.rotation - _target_goal = minimum_target - _target_marker.global_position = minimum_target + _set_target_surface(minimum_target, target_normal) _target_marker.scale = Vector3.ONE _target_marker.visible = true _set_target_validity(target_is_fishable) @@ -131,19 +112,37 @@ func update_rod_charge(charge: float) -> void: func update_aim_target( target: Vector3, + target_normal: Vector3, charge: float, target_is_fishable: bool, ) -> void: if _mode != VisualMode.AIMING: return - _target_goal = target + # The gameplay resolver already advances the horizontal target smoothly. + # Snap each sample to its resolved surface so interpolation can never draw + # the marker through a vertical shoreline between two valid endpoints. + _set_target_surface(target, target_normal) 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: +func _set_target_surface(target: Vector3, surface_normal: Vector3) -> void: + _target_marker.global_position = target + var resolved_normal: Vector3 = surface_normal.normalized() + if resolved_normal.is_zero_approx(): + resolved_normal = Vector3.UP + _target_marker.global_basis = Basis( + Quaternion(Vector3.UP, resolved_normal) + ) + + +func begin_cast( + target: Vector3, + blocked_landing_position: Vector3 = Vector3.ZERO, + cast_is_blocked: bool = false, +) -> void: if _mode != VisualMode.AIMING or _rod_tip == null: return @@ -152,8 +151,9 @@ func begin_cast(target: Vector3) -> void: _target_marker.visible = false _bobber.visible = true _bobber.scale = Vector3.ONE - _cast_position = _with_water_height(target) - _pickup_position = _calculate_pickup_position(_cast_position) + _cast_arrival_position = ( + blocked_landing_position if cast_is_blocked else target + ) var cast_start: Vector3 = _rod_tip.global_position _bobber.global_position = cast_start @@ -162,11 +162,15 @@ func begin_cast(target: Vector3) -> void: _active_tween.set_trans(Tween.TRANS_SINE) _active_tween.set_ease(Tween.EASE_IN_OUT) _active_tween.tween_method( - _set_cast_sample.bind(cast_start, _cast_position), + _set_cast_sample.bind(cast_start, _cast_arrival_position), 0.0, 1.0, cast_travel_duration ) + if cast_is_blocked: + # Let the bobber visibly settle on the blocking surface before the + # normal invalid-cast return animation begins. + _active_tween.tween_interval(0.16) _active_tween.finished.connect(_on_cast_tween_finished) @@ -205,23 +209,15 @@ func show_withdrawal_position(world_position: Vector3) -> void: return _kill_active_tween() - _bobber.global_position = _with_water_height(world_position) + _bobber_surface_position = world_position + _apply_bobber_idle_motion() -func begin_reeling() -> void: +func begin_fight() -> void: if _mode != VisualMode.FISHING: return _kill_active_tween() - _cast_position = _with_water_height(_bobber.global_position) - - -func show_reel_position(world_position: Vector3, _input_held: bool) -> void: - if _mode != VisualMode.FISHING: - return - - _kill_active_tween() - _bobber.global_position = _with_water_height(world_position) func play_outcome(outcome: StringName) -> void: @@ -237,6 +233,12 @@ func play_outcome(outcome: StringName) -> void: _kill_rod_tween() _restore_rod_neutral() _mode = VisualMode.OUTCOME + _bobber.rotation = Vector3.ZERO + if outcome == &"withdrawal": + # Withdrawal is a visible return, not an instant cancellation. Keep the + # bobber present even if the preceding reel update hid or reset it. + _bobber.visible = true + _bobber.scale = Vector3.ONE _active_tween = create_tween() _active_tween.set_trans(Tween.TRANS_QUAD) _active_tween.set_ease(Tween.EASE_IN) @@ -247,12 +249,7 @@ func play_outcome(outcome: StringName) -> void: _target_marker.visible = false match outcome: &"catch": - var catch_target: Vector3 = _bobber.global_position - if _rod_tip != null: - catch_target = _rod_tip.global_position - _active_tween.set_parallel(true) - _active_tween.tween_property(_bobber, "global_position", catch_target, 0.35) - _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.35) + _queue_bobber_return(0.35, 0.45) &"escape": var start_x: float = _bobber.global_position.x _active_tween.tween_property( @@ -267,31 +264,9 @@ func play_outcome(outcome: StringName) -> void: start_x + 0.18, 0.08 ) - _active_tween.tween_property( - _bobber, - "global_position:y", - get_water_surface_height() - 0.4, - 0.2 - ) - &"invalid": - var return_target: Vector3 = _bobber.global_position - if _rod_tip != null: - return_target = _rod_tip.global_position - _active_tween.set_parallel(true) - _active_tween.tween_property(_bobber, "global_position", return_target, 0.28) - _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.28) - &"withdrawal": - var withdrawal_target: Vector3 = _bobber.global_position - if _rod_tip != null: - withdrawal_target = _rod_tip.global_position - _active_tween.set_parallel(true) - _active_tween.tween_property( - _bobber, - "global_position", - withdrawal_target, - 0.28 - ) - _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.28) + _queue_bobber_return(0.42, 0.65) + &"invalid", &"withdrawal": + _queue_bobber_return(0.42, 0.65) _: _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.18) @@ -308,10 +283,14 @@ 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 + _valid_target_marker.visible = true + _invalid_target_marker.visible = false _bobber.visible = false _bobber.scale = Vector3.ONE + _bobber.rotation = Vector3.ZERO + _cast_arrival_position = Vector3.ZERO + _bobber_surface_position = Vector3.ZERO + _bobber_idle_elapsed = 0.0 set_line_mode(LineMode.HIDDEN) @@ -330,45 +309,64 @@ func _set_cast_sample( _bobber.global_position = sample +func _queue_bobber_return(duration: float, arc_scale: float) -> void: + var return_start: Vector3 = _bobber.global_position + var return_target: Vector3 = return_start + if _rod_tip != null: + return_target = _rod_tip.global_position + _active_tween.tween_method( + _set_return_sample.bind(return_start, return_target, arc_scale), + 0.0, + 1.0, + duration, + ) + _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.1) + + +func _set_return_sample( + progress: float, + start: Vector3, + target: Vector3, + arc_scale: float, +) -> void: + var eased_progress: float = 1.0 - pow(1.0 - progress, 3.0) + var sample: Vector3 = start.lerp(target, eased_progress) + sample.y += sin(eased_progress * PI) * maxf( + cast_arc_height * arc_scale, + 0.75, + ) + _bobber.global_position = sample + + func _on_cast_tween_finished() -> void: if _mode != VisualMode.CASTING: return _active_tween = null - _bobber.global_position = _cast_position + _bobber.global_position = _cast_arrival_position + _bobber_surface_position = _cast_arrival_position + _bobber_idle_elapsed = 0.0 _mode = VisualMode.FISHING cast_completed.emit() -func _calculate_pickup_position(target: Vector3) -> Vector3: - var player_on_water: Vector3 = target - if _rod_tip != null: - player_on_water = _with_water_height(_rod_tip.global_position) - var outward: Vector3 = target - player_on_water - outward.y = 0.0 - if outward.is_zero_approx(): - outward = Vector3.FORWARD - else: - outward = outward.normalized() - var target_distance: float = player_on_water.distance_to(target) - var pickup_distance: float = minf( - pickup_distance_from_player, - target_distance * 0.8 +func _apply_bobber_idle_motion() -> void: + var phase: float = _bobber_idle_elapsed * bobber_bob_cycles_per_second * TAU + _bobber.global_position = ( + _bobber_surface_position + + Vector3.UP * sin(phase) * bobber_bob_amplitude ) - return player_on_water + outward * pickup_distance - - -func _with_water_height(world_position: Vector3) -> Vector3: - return Vector3( - world_position.x, - get_water_surface_height(), - world_position.z, + _bobber.rotation.z = ( + deg_to_rad(bobber_tilt_degrees) * sin(phase * 0.73) ) func _update_line(delta: float) -> void: if _rod_tip == null or not is_instance_valid(_rod_tip): + var was_active: bool = _mode != VisualMode.NONE cleanup() + if was_active: + presentation_interrupted.emit() return var rod_tip_position: Vector3 = _rod_tip.global_position @@ -447,12 +445,8 @@ func _restore_rod_neutral() -> void: 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 + _valid_target_marker.visible = target_is_fishable + _invalid_target_marker.visible = not target_is_fishable func _kill_active_tween() -> void: diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 1d93aba..09955ba 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -30,6 +30,12 @@ const NetworkSessionType = preload("res://network/network_session.gd") const NetworkFishingServiceType = preload( "res://network/network_fishing_service.gd" ) +const FishingSurfaceSampleType = preload( + "res://fishing/fishing_surface_sample.gd" +) +const FishingSurfaceResolverType = preload( + "res://fishing/fishing_surface_resolver.gd" +) signal status_changed(status: String) signal catch_display_changed( @@ -57,6 +63,7 @@ enum FishingState { WAITING_FOR_BITE, FIGHTING, SHOWING_CATCH, + RETURNING, COOLDOWN, } @@ -69,16 +76,27 @@ enum FishingState { @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(1.0, 200.0, 1.0) var preview_ray_start_height: float = 100.0 +@export_range(1.0, 400.0, 1.0) var preview_surface_ray_length: float = 240.0 @export_range(0.01, 1.0, 0.01) var preview_marker_vertical_offset: float = 0.06 +@export_range(0.0, 0.5, 0.01) var water_occlusion_tolerance: float = 0.04 +@export_range(0.01, 0.5, 0.01) var solid_bobber_clearance: float = 0.13 @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_range(0.0, 1.0, 0.01) var withdrawal_surface_clearance: float = 0.4 @export_category("Timing") -@export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0 +const BITE_QUICK_MIN_SECONDS: float = 10.0 +const BITE_QUICK_MAX_SECONDS: float = 30.0 +const BITE_TYPICAL_MAX_SECONDS: float = 90.0 +const BITE_LONG_MAX_SECONDS: float = 180.0 +const BITE_MAX_SECONDS: float = 240.0 +const BITE_QUICK_PROBABILITY: float = 0.15 +const BITE_TYPICAL_PROBABILITY: float = 0.55 +const BITE_LONG_PROBABILITY: float = 0.25 +const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 @export_category("Selection") @@ -117,16 +135,18 @@ 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 _network_primary_input_held: bool = false +var _network_input_resend_elapsed: float = 0.0 var _new_cast_press_armed: bool = true var _bobber_water_position: Vector3 -var _fight_start_position: Vector3 var _cooldown_status: String = "" -var _fishable_query_shape: CylinderShape3D = CylinderShape3D.new() var _fish_selector: FishSelectorType = FishSelectorType.new() +var _surface_resolver: FishingSurfaceResolverType = FishingSurfaceResolverType.new() +var _aim_surface_sample: FishingSurfaceSampleType = FishingSurfaceSampleType.new() +var _cast_path_is_clear: bool = false var _selected_water_region: FishableWaterRegionType var _selection_context: FishingContextType var _selected_fish: FishDataType @@ -137,14 +157,21 @@ var _showcase_restore_generation: int = 0 var _showcase_outcome_completed: bool = false var _network_auto_click_accumulator: float = 0.0 var _network_active_barrier_index: int = -1 +var _bite_rng: RandomNumberGenerator = RandomNumberGenerator.new() +var _pending_cleanup_message: String = "" func _ready() -> void: + _bite_rng.randomize() + _configure_surface_resolver() _catch_controller.encounter_updated.connect(_on_catch_encounter_updated) _catch_controller.caught.connect(_on_catch_completed) _catch_controller.escaped.connect(_on_catch_escaped) _presentation.cast_completed.connect(_on_cast_completed) _presentation.outcome_completed.connect(_on_outcome_completed) + _presentation.presentation_interrupted.connect( + _on_presentation_interrupted + ) func setup( @@ -387,6 +414,8 @@ func _process(delta: float) -> void: FishingState.WAITING_FOR_BITE: if _network_fishing == null: _update_waiting_for_bite(delta) + else: + _resend_network_input_state(delta) FishingState.FIGHTING: if _network_fishing == null: _catch_controller.set_effective_stats( @@ -396,6 +425,7 @@ func _process(delta: float) -> void: if not Input.is_action_pressed("fish_primary"): _catch_controller.set_reel_input(false) else: + _resend_network_input_state(delta) _update_network_auto_click(delta) FishingState.COOLDOWN: _state_time_remaining -= delta @@ -444,15 +474,10 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return _begin_aiming(_local_player) - FishingState.WAITING_FOR_BITE: - _withdrawal_input_held = true - if _network_fishing != null: - _network_fishing.submit_local_input(true, true) - _presentation.set_line_mode( - FishingPresentationType.LineMode.TAUT - ) FishingState.FIGHTING: if _network_fishing != null: + _network_primary_input_held = true + _network_input_resend_elapsed = 0.0 _network_fishing.submit_local_input(true, true) else: _catch_controller.handle_primary_pressed() @@ -464,6 +489,15 @@ func _unhandled_input(event: InputEvent) -> void: _put_away_catch() else: return + FishingState.WAITING_FOR_BITE: + _withdrawal_input_held = true + if _network_fishing != null: + _network_primary_input_held = true + _network_input_resend_elapsed = 0.0 + _network_fishing.submit_local_input(true, true) + _presentation.set_line_mode( + FishingPresentationType.LineMode.TAUT + ) _: return get_viewport().set_input_as_handled() @@ -473,11 +507,15 @@ func _unhandled_input(event: InputEvent) -> void: elif state == FishingState.WAITING_FOR_BITE: _withdrawal_input_held = false if _network_fishing != null: + _network_primary_input_held = false + _network_input_resend_elapsed = 0.0 _network_fishing.submit_local_input(false, false) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) get_viewport().set_input_as_handled() elif state == FishingState.FIGHTING: if _network_fishing != null: + _network_primary_input_held = false + _network_input_resend_elapsed = 0.0 _network_fishing.submit_local_input(false, false) else: _catch_controller.set_reel_input(false) @@ -488,21 +526,25 @@ func _begin_aiming(player: PlayerType) -> void: if state != FishingState.READY or _active_player != null: return - _presentation.reset_water_surface_height() _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("") - var target_is_fishable: bool = is_target_fishable(_cast_target) - var preview_position: Vector3 = _resolve_preview_surface_position(_cast_target) + _cast_path_is_clear = is_cast_path_clear(_cast_origin_position, _cast_target) + var target_is_fishable: bool = ( + _aim_surface_sample.is_fishable() and _cast_path_is_clear + ) + var preview_position: Vector3 = _aim_surface_sample.get_marker_position( + preview_marker_vertical_offset + ) _presentation.begin_aim( _active_player.get_fishing_rod_tip(), _active_player.get_fishing_rod(), preview_position, + _aim_surface_sample.normal, target_is_fishable ) @@ -575,6 +617,10 @@ func is_fighting() -> bool: return state == FishingState.FIGHTING +func is_returning() -> bool: + return state == FishingState.RETURNING + + func refresh_active_item_status() -> void: if state == FishingState.READY and has_active_fishing_rod(): status_changed.emit("") @@ -584,14 +630,24 @@ func _update_cast_charge(delta: float) -> void: if state != FishingState.AIMING_CAST: return + # Movement remains available during aiming. Re-sample the player transform + # so the cast origin and facing follow the player until release. + _cast_origin_position = _active_player.get_cast_origin_position() + _cast_direction = _capture_cast_direction(_active_player) _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) + _cast_path_is_clear = is_cast_path_clear(_cast_origin_position, _cast_target) + var target_is_fishable: bool = ( + _aim_surface_sample.is_fishable() and _cast_path_is_clear + ) + var preview_position: Vector3 = _aim_surface_sample.get_marker_position( + preview_marker_vertical_offset + ) _presentation.update_aim_target( preview_position, + _aim_surface_sample.normal, _cast_charge, target_is_fishable ) @@ -602,15 +658,39 @@ func _confirm_cast() -> void: if state != FishingState.AIMING_CAST: return + _cast_path_is_clear = is_cast_path_clear( + _cast_origin_position, + _cast_target, + ) + var cast_is_invalid: bool = ( + not _aim_surface_sample.is_fishable() or not _cast_path_is_clear + ) + var arrival_position: Vector3 = _cast_target + if not _cast_path_is_clear: + arrival_position = _resolve_cast_impact_position( + _cast_origin_position, + _cast_target, + ) + if _active_player != null: + # Movement is available while aiming, then locks once the cast is + # released so the active bobber/fishing event remains anchored. + _active_player.set_movement_enabled(false) state = FishingState.CASTING status_changed.emit("") - _presentation.begin_cast(_cast_target) + _presentation.begin_cast( + _cast_target, + arrival_position, + cast_is_invalid, + ) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) func _on_cast_completed() -> void: if state != FishingState.CASTING: return + if not _is_cast_target_valid(_cast_target): + _cleanup_attempt("", &"invalid") + return if _network_fishing != null: _network_fishing.request_local_cast( _cast_origin_position, @@ -622,10 +702,6 @@ func _on_cast_completed() -> void: return _selected_water_region = get_fishable_water_region(_cast_target) - _cast_landing_is_fishable = _selected_water_region != null - if not _cast_landing_is_fishable: - _cleanup_attempt("can't fish there.", &"invalid") - return _selection_context = _build_fishing_context(_selected_water_region) _fish_selector.undiscovered_weight_multiplier = undiscovered_weight_multiplier _fish_selector.rarity_weight_multipliers = [] @@ -648,7 +724,7 @@ func _on_cast_completed() -> void: return state = FishingState.WAITING_FOR_BITE - _state_time_remaining = wait_time * ( + _state_time_remaining = roll_bite_wait_time() * ( _item_effects.get_bite_time_multiplier() if _item_effects != null else 1.0 @@ -660,13 +736,40 @@ func _on_cast_completed() -> void: _bobber_water_position = _cast_target _withdrawal_endpoint = Vector3( _cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance, - _presentation.get_water_surface_height(), + _cast_target.y, _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 roll_bite_wait_time() -> float: + var bucket: float = _bite_rng.randf() + if bucket < BITE_QUICK_PROBABILITY: + return _bite_rng.randf_range( + BITE_QUICK_MIN_SECONDS, + BITE_QUICK_MAX_SECONDS, + ) + if bucket < BITE_QUICK_PROBABILITY + BITE_TYPICAL_PROBABILITY: + return _bite_rng.randf_range( + BITE_QUICK_MAX_SECONDS, + BITE_TYPICAL_MAX_SECONDS, + ) + if bucket < ( + BITE_QUICK_PROBABILITY + + BITE_TYPICAL_PROBABILITY + + BITE_LONG_PROBABILITY + ): + return _bite_rng.randf_range( + BITE_TYPICAL_MAX_SECONDS, + BITE_LONG_MAX_SECONDS, + ) + return _bite_rng.randf_range( + BITE_LONG_MAX_SECONDS, + BITE_MAX_SECONDS, + ) + + func _update_waiting_for_bite(delta: float) -> void: if ( _withdrawal_input_held @@ -713,16 +816,18 @@ func _update_withdrawal(delta: float) -> void: _withdrawal_endpoint, next_progress ) - var clamped_position: Vector3 = find_last_fishable_position( - _bobber_water_position, - desired_position + var withdrawal_surface: FishingSurfaceSampleType = ( + resolve_safe_withdrawal_surface( + _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): + if not withdrawal_surface.is_fishable(): _resolve_withdrawal_at_shore() return + _withdrawal_progress = next_progress + _bobber_water_position = withdrawal_surface.position + _presentation.show_withdrawal_position(_bobber_water_position) if is_equal_approx(_withdrawal_progress, 1.0): _cancel_from_withdrawal() @@ -740,20 +845,14 @@ func _get_withdrawal_completion_time(delta: float) -> float: _withdrawal_endpoint, next_progress ) - var clamped_position: Vector3 = find_last_fishable_position( - _bobber_water_position, - desired_position + var withdrawal_surface: FishingSurfaceSampleType = ( + resolve_safe_withdrawal_surface( + _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 not withdrawal_surface.is_fishable(): + return 0.0 if is_equal_approx(next_progress, 1.0): return delta * (1.0 - _withdrawal_progress) / progress_step return INF @@ -780,11 +879,10 @@ func _activate_bite() -> void: state = FishingState.FIGHTING _state_time_remaining = 0.0 _withdrawal_input_held = false - _fight_start_position = _bobber_water_position bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) - _presentation.begin_reeling() + _presentation.begin_fight() _catch_controller.start_encounter( _selected_fish.catch_profile, _get_effective_reel_speed(), @@ -845,19 +943,6 @@ func _on_catch_encounter_updated( if state != FishingState.FIGHTING or not visible: return - var desired_position: Vector3 = _fight_start_position.lerp( - _withdrawal_endpoint, - progress - ) - _bobber_water_position = find_last_fishable_position( - _fight_start_position, - desired_position - ) - _presentation.show_reel_position( - _bobber_water_position, - Input.is_action_pressed("fish_primary") - ) - func _on_catch_completed() -> void: if ( @@ -892,6 +977,11 @@ func _on_catch_escaped() -> void: func _on_outcome_completed(outcome: StringName) -> void: + if state == FishingState.RETURNING: + var cleanup_message: String = _pending_cleanup_message + _pending_cleanup_message = "" + _finalize_attempt_cleanup(cleanup_message) + return if ( outcome != &"catch" or state != FishingState.SHOWING_CATCH @@ -911,6 +1001,15 @@ func _on_outcome_completed(outcome: StringName) -> void: ) +func _on_presentation_interrupted() -> void: + if state in [FishingState.READY, FishingState.COOLDOWN]: + return + if _network_fishing != null and _network_fishing.has_local_attempt(): + _network_fishing.cancel_local_attempt("") + return + _finalize_attempt_cleanup("") + + func _put_away_catch() -> void: if ( state != FishingState.SHOWING_CATCH @@ -954,6 +1053,32 @@ func _cleanup_attempt( cooldown_message: String = "", visual_outcome: StringName = &"", ) -> void: + if not visual_outcome.is_empty(): + if state == FishingState.RETURNING: + return + _showcase_restore_generation += 1 + _pending_cleanup_message = cooldown_message + _showcase_ready = false + _showcase_outcome_completed = false + _put_away_press_armed = false + showcase_changed.emit("", "", 0.0, false) + _catch_controller.reset() + state = FishingState.RETURNING + status_changed.emit("") + if visual_outcome in [&"invalid", &"withdrawal", &"escape"]: + _presentation.set_line_mode( + FishingPresentationType.LineMode.TAUT + ) + else: + _presentation.set_line_mode( + FishingPresentationType.LineMode.HIDDEN + ) + _presentation.play_outcome(visual_outcome) + return + _finalize_attempt_cleanup(cooldown_message) + + +func _finalize_attempt_cleanup(cooldown_message: String) -> void: _showcase_restore_generation += 1 if _active_player != null: _active_player.end_catch_showcase() @@ -964,12 +1089,14 @@ func _cleanup_attempt( _cast_direction = Vector3.FORWARD _cast_origin_position = Vector3.ZERO _cast_target = Vector3.ZERO - _cast_landing_is_fishable = false + _aim_surface_sample = FishingSurfaceSampleType.new() + _cast_path_is_clear = false _withdrawal_endpoint = Vector3.ZERO _withdrawal_progress = 0.0 _withdrawal_input_held = false + _network_primary_input_held = false + _network_input_resend_elapsed = 0.0 _bobber_water_position = Vector3.ZERO - _fight_start_position = Vector3.ZERO _selected_water_region = null _selection_context = null _selected_fish = null @@ -979,19 +1106,8 @@ func _cleanup_attempt( _put_away_press_armed = false showcase_changed.emit("", "", 0.0, false) _catch_controller.reset() - if visual_outcome.is_empty(): - _presentation.cleanup() - _presentation.reset_water_surface_height() - 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.cleanup() + _pending_cleanup_message = "" if cooldown_message.is_empty(): state = FishingState.READY @@ -1006,7 +1122,6 @@ func _cleanup_attempt( func _return_to_ready() -> void: - _presentation.reset_water_surface_height() state = FishingState.READY _state_time_remaining = 0.0 _cooldown_status = "" @@ -1025,6 +1140,9 @@ func _cancel_from_withdrawal() -> void: if state != FishingState.WAITING_FOR_BITE: return _new_cast_press_armed = false + if _network_fishing != null and _network_fishing.has_local_attempt(): + _network_fishing.cancel_local_attempt("") + return _cleanup_attempt("", &"withdrawal") @@ -1046,61 +1164,102 @@ func _capture_cast_direction(player: PlayerType) -> Vector3: func _calculate_cast_target(distance: float) -> Vector3: - var target := Vector3( + var query_position := Vector3( _cast_origin_position.x + _cast_direction.x * distance, - _presentation.get_water_surface_height(), + _cast_origin_position.y, _cast_origin_position.z + _cast_direction.z * distance ) - var water_region: FishableWaterRegionType = get_fishable_water_region( - target + _aim_surface_sample = resolve_fishing_surface( + query_position, + _cast_origin_position.y, + ) + if _aim_surface_sample.has_surface: + return _aim_surface_sample.get_bobber_position(solid_bobber_clearance) + return query_position + + +func _configure_surface_resolver() -> void: + _surface_resolver.fishable_surface_mask = fishable_surface_mask + _surface_resolver.solid_surface_mask = ( + preview_surface_mask & ~fishable_surface_mask + ) + _surface_resolver.query_radius = fishable_query_radius + _surface_resolver.ray_start_height = preview_ray_start_height + _surface_resolver.ray_length = preview_surface_ray_length + _surface_resolver.water_occlusion_tolerance = water_occlusion_tolerance + + +func resolve_fishing_surface( + target: Vector3, + reference_y: float = NAN, +) -> FishingSurfaceSampleType: + if not is_inside_tree(): + var empty_sample := FishingSurfaceSampleType.new() + empty_sample.position = target + return empty_sample + var resolved_reference_y: float = reference_y + if is_nan(resolved_reference_y): + resolved_reference_y = ( + _active_player.global_position.y + if _active_player != null + else target.y + ) + return _surface_resolver.resolve_surface( + get_world_3d().direct_space_state, + target, + resolved_reference_y, ) - if water_region != null: - target.y = water_region.get_surface_height() - _presentation.set_water_surface_height(target.y) - else: - _presentation.reset_water_surface_height() - target.y = _presentation.get_water_surface_height() - return target func is_target_fishable(target: Vector3) -> bool: - return get_fishable_water_region(target) != null + return resolve_fishing_surface(target).is_fishable() + + +func _is_cast_target_valid(target: Vector3) -> bool: + return ( + is_target_fishable(target) + and is_cast_path_clear(_cast_origin_position, target) + ) + + +func is_cast_path_clear(origin: Vector3, target: Vector3) -> bool: + return _surface_resolver.find_first_cast_collision( + get_world_3d().direct_space_state, + origin, + target, + _presentation.cast_arc_height, + ).is_empty() + + +func _resolve_cast_impact_position(origin: Vector3, target: Vector3) -> Vector3: + var result: Dictionary = _surface_resolver.find_first_cast_collision( + get_world_3d().direct_space_state, + origin, + target, + _presentation.cast_arc_height, + ) + if result.is_empty(): + return target + var hit_position: Vector3 = result["position"] + var hit_normal: Vector3 = result.get("normal", Vector3.UP) + if hit_normal.is_zero_approx(): + hit_normal = Vector3.UP + return hit_position + hit_normal.normalized() * 0.12 func get_fishable_water_region( target: Vector3, ) -> FishableWaterRegionType: - _fishable_query_shape.radius = fishable_query_radius - _fishable_query_shape.height = preview_ray_length - var query := PhysicsShapeQueryParameters3D.new() - query.shape = _fishable_query_shape - query.transform = Transform3D( - Basis.IDENTITY, - Vector3(target.x, target.y, target.z) + var reference_y: float = ( + _active_player.global_position.y + if _active_player != null + else target.y ) - 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, - 32 + var surface: FishingSurfaceSampleType = resolve_fishing_surface( + target, + reference_y, ) - var selected_region: FishableWaterRegionType = null - for result: Dictionary in results: - var collider: Object = result.get("collider") - var region: FishableWaterRegionType = collider as FishableWaterRegionType - if region == null or region.fish_pool == null: - continue - if ( - selected_region == null - or region.selection_priority > selected_region.selection_priority - or ( - region.selection_priority == selected_region.selection_priority - and region.get_instance_id() < selected_region.get_instance_id() - ) - ): - selected_region = region - return selected_region + return surface.water_region if surface.is_fishable() else null func _build_fishing_context( @@ -1166,6 +1325,9 @@ func _on_network_cast_accepted( _cast_target = target _bobber_water_position = target state = FishingState.WAITING_FOR_BITE + _network_primary_input_held = false + _network_input_resend_elapsed = 0.0 + _presentation.show_withdrawal_position(_bobber_water_position) _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) status_changed.emit("waiting for a bite...") @@ -1173,7 +1335,17 @@ func _on_network_cast_accepted( func _on_network_cast_rejected(message: String) -> void: if state not in [FishingState.CASTING, FishingState.AIMING_CAST]: return - _cleanup_attempt(message, &"invalid") + var visible_message: String = message + if message.strip_edges().to_lower() in [ + "cannot fish here.", + "cannot fish here", + "can't fish here.", + "can't fish here", + "can't fish there.", + "can't fish there", + ]: + visible_message = "" + _cleanup_attempt(visible_message, &"invalid") func _on_network_bite_started(_attempt_id: String) -> void: @@ -1181,20 +1353,34 @@ func _on_network_bite_started(_attempt_id: String) -> void: return state = FishingState.FIGHTING _withdrawal_input_held = false + _network_primary_input_held = Input.is_action_pressed("fish_primary") + _network_input_resend_elapsed = 0.0 _network_auto_click_accumulator = 0.0 _network_active_barrier_index = -1 - _fight_start_position = _bobber_water_position bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) - _presentation.begin_reeling() + _presentation.begin_fight() _presentation.show_bite() _network_fishing.submit_local_input( - Input.is_action_pressed("fish_primary"), + _network_primary_input_held, false ) +func _resend_network_input_state(delta: float) -> void: + if _network_fishing == null or not _network_fishing.has_local_attempt(): + return + _network_input_resend_elapsed += delta + if _network_input_resend_elapsed < NETWORK_INPUT_RESEND_INTERVAL_SECONDS: + return + _network_input_resend_elapsed = fmod( + _network_input_resend_elapsed, + NETWORK_INPUT_RESEND_INTERVAL_SECONDS, + ) + _network_fishing.submit_local_input(_network_primary_input_held, false) + + func _on_network_fishing_snapshot(snapshot: Dictionary) -> void: if state not in [ FishingState.WAITING_FOR_BITE, @@ -1230,11 +1416,6 @@ func _on_network_fishing_snapshot(snapshot: Dictionary) -> void: ) if state == FishingState.WAITING_FOR_BITE: _presentation.show_withdrawal_position(_bobber_water_position) - else: - _presentation.show_reel_position( - _bobber_water_position, - Input.is_action_pressed("fish_primary") - ) func _update_network_auto_click(delta: float) -> void: @@ -1281,67 +1462,32 @@ func _on_network_attempt_ended( FishingState.FIGHTING, ]: return - _cleanup_attempt( - message, - &"escape" if outcome == &"escape" else &"cancel" + var visual_outcome: StringName = &"cancel" + if outcome == &"escape": + visual_outcome = &"escape" + elif outcome == &"withdrawal": + visual_outcome = &"withdrawal" + _cleanup_attempt(message, visual_outcome) + + +func resolve_safe_withdrawal_surface( + from: Vector3, + to: Vector3, + toward_player: Vector3 = Vector3.ZERO, +) -> FishingSurfaceSampleType: + var direction_to_player: Vector3 = toward_player + if direction_to_player.is_zero_approx(): + direction_to_player = to - from + var reference_y: float = ( + _active_player.global_position.y + if _active_player != null + else maxf(from.y, to.y) ) - - -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 + return _surface_resolver.resolve_withdrawal_surface( + get_world_3d().direct_space_state, + from, + to, + direction_to_player, + reference_y, + withdrawal_surface_clearance, ) diff --git a/fishing/fishing_spot.tscn b/fishing/fishing_spot.tscn index d9ba7ad..322f618 100644 --- a/fishing/fishing_spot.tscn +++ b/fishing/fishing_spot.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=11 format=3] +[gd_scene load_steps=12 format=3] [ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"] [ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"] @@ -36,7 +36,7 @@ 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) +size = Vector3(0.62, 0.04, 0.09) [node name="FishingSpot" type="Node3D"] script = ExtResource("1_spot") @@ -48,24 +48,24 @@ script = ExtResource("4_catch") [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"] +[node name="CastTargetMarker" type="Node3D" parent="FishingPresentation"] +unique_name_in_owner = true + +[node name="ValidTargetMarker" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"] unique_name_in_owner = true mesh = SubResource("CastTargetMesh") material_override = SubResource("CastTargetMaterial") -[node name="InvalidCrossA" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker"] +[node name="InvalidTargetMarker" type="Node3D" parent="FishingPresentation/CastTargetMarker"] unique_name_in_owner = true -position = Vector3(0, 0.075, 0) + +[node name="StrokeA" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker/InvalidTargetMarker"] 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) +[node name="StrokeB" type="MeshInstance3D" parent="FishingPresentation/CastTargetMarker/InvalidTargetMarker"] rotation = Vector3(0, -0.785398, 0) mesh = SubResource("InvalidCrossMesh") material_override = SubResource("InvalidTargetMaterial") diff --git a/fishing/fishing_surface_resolver.gd b/fishing/fishing_surface_resolver.gd new file mode 100644 index 0000000..08818ce --- /dev/null +++ b/fishing/fishing_surface_resolver.gd @@ -0,0 +1,230 @@ +class_name FishingSurfaceResolver +extends RefCounted + +const FishableWaterRegionType = preload( + "res://world/fishable_water_region.gd" +) +const FishingSurfaceSampleType = preload( + "res://fishing/fishing_surface_sample.gd" +) + +var fishable_surface_mask: int = 4 +var solid_surface_mask: int = 1 +var query_radius: float = 0.08 +var ray_start_height: float = 100.0 +var ray_length: float = 240.0 +var water_occlusion_tolerance: float = 0.04 +var arc_sample_spacing: float = 0.3 + +var _water_query_shape: CylinderShape3D = CylinderShape3D.new() + + +func resolve_surface( + space_state: PhysicsDirectSpaceState3D, + query_position: Vector3, + reference_y: float, +) -> FishingSurfaceSampleType: + var sample := FishingSurfaceSampleType.new() + var ray_top: float = maxf(query_position.y, reference_y) + ray_start_height + var ray_bottom: float = ray_top - ray_length + var water_region: FishableWaterRegionType = _find_highest_water_region( + space_state, + query_position, + ray_top, + ray_bottom, + ) + var solid_hit: Dictionary = _find_top_solid_surface( + space_state, + query_position, + ray_top, + ray_bottom, + ) + + if water_region != null: + var water_height: float = water_region.get_surface_height() + var solid_is_above_water: bool = false + if not solid_hit.is_empty(): + var solid_position: Vector3 = solid_hit["position"] + solid_is_above_water = ( + solid_position.y >= water_height - water_occlusion_tolerance + ) + if not solid_is_above_water: + sample.has_surface = true + sample.position = Vector3( + query_position.x, + water_height, + query_position.z, + ) + sample.normal = Vector3.UP + sample.water_region = water_region + sample.is_water_surface = true + return sample + + if not solid_hit.is_empty(): + sample.has_surface = true + sample.position = solid_hit["position"] + var hit_normal: Vector3 = solid_hit.get("normal", Vector3.UP) + sample.normal = ( + hit_normal.normalized() + if not hit_normal.is_zero_approx() + else Vector3.UP + ) + + return sample + + +func find_first_cast_collision( + space_state: PhysicsDirectSpaceState3D, + origin: Vector3, + target: Vector3, + arc_height: float, +) -> Dictionary: + if origin.is_equal_approx(target): + return {} + var segment_count: int = maxi( + 12, + ceili(origin.distance_to(target) / maxf(arc_sample_spacing, 0.05)), + ) + var previous_position: Vector3 = origin + for segment_index: int in range(1, segment_count + 1): + var progress: float = float(segment_index) / float(segment_count) + var next_position: Vector3 = origin.lerp(target, progress) + next_position.y += sin(progress * PI) * arc_height + var hit: Dictionary = _intersect_solid_segment( + space_state, + previous_position, + next_position, + ) + if not hit.is_empty(): + return hit + previous_position = next_position + return {} + + +func resolve_withdrawal_surface( + space_state: PhysicsDirectSpaceState3D, + current_position: Vector3, + desired_position: Vector3, + toward_player: Vector3, + reference_y: float, + shore_clearance: float, +) -> FishingSurfaceSampleType: + var desired_sample: FishingSurfaceSampleType = resolve_surface( + space_state, + desired_position, + reference_y, + ) + if not desired_sample.is_fishable(): + return _blocked_withdrawal_sample(current_position) + + var horizontal_direction: Vector3 = toward_player + horizontal_direction.y = 0.0 + if not horizontal_direction.is_zero_approx() and shore_clearance > 0.0: + horizontal_direction = horizontal_direction.normalized() + var lookahead_position: Vector3 = ( + desired_sample.position + horizontal_direction * shore_clearance + ) + var lookahead_sample: FishingSurfaceSampleType = resolve_surface( + space_state, + lookahead_position, + reference_y, + ) + if not lookahead_sample.is_fishable(): + return _blocked_withdrawal_sample(current_position) + + return desired_sample + + +func _blocked_withdrawal_sample( + current_position: Vector3, +) -> FishingSurfaceSampleType: + var sample := FishingSurfaceSampleType.new() + sample.position = current_position + return sample + + +func _find_highest_water_region( + space_state: PhysicsDirectSpaceState3D, + query_position: Vector3, + ray_top: float, + ray_bottom: float, +) -> FishableWaterRegionType: + _water_query_shape.radius = maxf(query_radius, 0.01) + _water_query_shape.height = maxf(ray_top - ray_bottom, 0.1) + var query := PhysicsShapeQueryParameters3D.new() + query.shape = _water_query_shape + query.transform = Transform3D( + Basis.IDENTITY, + Vector3( + query_position.x, + (ray_top + ray_bottom) * 0.5, + query_position.z, + ), + ) + query.collision_mask = fishable_surface_mask + query.collide_with_areas = true + query.collide_with_bodies = false + var results: Array[Dictionary] = space_state.intersect_shape(query, 64) + var selected_region: FishableWaterRegionType = null + for result: Dictionary in results: + var region: FishableWaterRegionType = ( + result.get("collider") as FishableWaterRegionType + ) + # A water surface remains the visible top surface even when it has no + # fish pool. Fishability is a separate policy checked by the sample. + if region == null: + continue + var surface_height: float = region.get_surface_height() + if surface_height < ray_bottom or surface_height > ray_top: + continue + if selected_region == null: + selected_region = region + continue + var selected_height: float = selected_region.get_surface_height() + if ( + surface_height > selected_height + 0.001 + or ( + is_equal_approx(surface_height, selected_height) + and ( + region.selection_priority > selected_region.selection_priority + or ( + region.selection_priority + == selected_region.selection_priority + and region.get_instance_id() + < selected_region.get_instance_id() + ) + ) + ) + ): + selected_region = region + return selected_region + + +func _find_top_solid_surface( + space_state: PhysicsDirectSpaceState3D, + query_position: Vector3, + ray_top: float, + ray_bottom: float, +) -> Dictionary: + return _intersect_solid_segment( + space_state, + Vector3(query_position.x, ray_top, query_position.z), + Vector3(query_position.x, ray_bottom, query_position.z), + ) + + +func _intersect_solid_segment( + space_state: PhysicsDirectSpaceState3D, + from: Vector3, + to: Vector3, +) -> Dictionary: + if from.is_equal_approx(to) or solid_surface_mask == 0: + return {} + var query := PhysicsRayQueryParameters3D.create( + from, + to, + solid_surface_mask, + ) + query.collide_with_areas = false + query.collide_with_bodies = true + return space_state.intersect_ray(query) diff --git a/fishing/fishing_surface_resolver.gd.uid b/fishing/fishing_surface_resolver.gd.uid new file mode 100644 index 0000000..606a2fa --- /dev/null +++ b/fishing/fishing_surface_resolver.gd.uid @@ -0,0 +1 @@ +uid://dcyvrak5pvlc diff --git a/fishing/fishing_surface_sample.gd b/fishing/fishing_surface_sample.gd new file mode 100644 index 0000000..4d2dd2c --- /dev/null +++ b/fishing/fishing_surface_sample.gd @@ -0,0 +1,28 @@ +class_name FishingSurfaceSample +extends RefCounted + +const FishableWaterRegionType = preload( + "res://world/fishable_water_region.gd" +) + +var has_surface: bool = false +var position: Vector3 = Vector3.ZERO +var normal: Vector3 = Vector3.UP +var water_region: FishableWaterRegionType +var is_water_surface: bool = false + + +func is_fishable() -> bool: + return is_water_surface and water_region != null and water_region.fish_pool != null + + +func get_marker_position(vertical_offset: float) -> Vector3: + if not has_surface: + return position + return position + normal.normalized() * vertical_offset + + +func get_bobber_position(solid_clearance: float) -> Vector3: + if not has_surface or is_water_surface: + return position + return position + normal.normalized() * solid_clearance diff --git a/fishing/fishing_surface_sample.gd.uid b/fishing/fishing_surface_sample.gd.uid new file mode 100644 index 0000000..8c00c2b --- /dev/null +++ b/fishing/fishing_surface_sample.gd.uid @@ -0,0 +1 @@ +uid://5n0ku0fxh7xt diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd index 3d20ee9..6399819 100644 --- a/fishing/remote_fishing_presentation.gd +++ b/fishing/remote_fishing_presentation.gd @@ -1,12 +1,20 @@ class_name RemoteFishingPresentation extends Node3D +signal return_completed + var _owner: Player var _bobber: MeshInstance3D var _line: MeshInstance3D var _line_mesh := ImmediateMesh.new() var _target: Vector3 +var _pending_target: Vector3 var _active: bool = false +var _cast_tween: Tween +var _return_tween: Tween +var _showcase_tween: Tween +var _return_showcase_catch: FishCatch +var _bobber_idle_elapsed: float = 0.0 func setup(owning_player: Player) -> void: @@ -31,21 +39,42 @@ func setup(owning_player: Player) -> void: cleanup() -func show_cast(target: Vector3) -> void: - if _owner == null or not target.is_finite(): +func show_cast(origin: Vector3, target: Vector3) -> void: + if ( + _owner == null + or not origin.is_finite() + or not target.is_finite() + ): return + _kill_cast_tween() _active = true - _target = target - _bobber.global_position = target + _target = origin + _pending_target = target + _bobber_idle_elapsed = 0.0 + _bobber.global_position = origin + _bobber.scale = Vector3.ONE _bobber.visible = true _line.visible = true _owner.set_active_item_is_rod(true) _redraw_line() + _cast_tween = create_tween() + _cast_tween.set_trans(Tween.TRANS_SINE) + _cast_tween.set_ease(Tween.EASE_IN_OUT) + _cast_tween.tween_method( + _set_cast_sample.bind(origin, target), + 0.0, + 1.0, + 0.65, + ) + _cast_tween.finished.connect(_on_cast_finished) func update_bobber(world_position: Vector3) -> void: if not _active or not world_position.is_finite(): return + _pending_target = world_position + if _cast_tween != null: + return _target = world_position _bobber.global_position = world_position _redraw_line() @@ -59,8 +88,43 @@ func show_bite() -> void: tween.tween_property(_bobber, "scale", Vector3.ONE, 0.12) +func play_return(showcase_catch: FishCatch = null) -> void: + if not _active or _bobber == null: + cleanup() + return_completed.emit() + return + _kill_cast_tween() + _kill_return_tween() + _return_showcase_catch = showcase_catch + var start: Vector3 = _bobber.global_position + var target: Vector3 = start + if _owner != null and is_instance_valid(_owner): + var tip: Marker3D = _owner.get_fishing_rod_tip() + if tip != null: + target = tip.global_position + _return_tween = create_tween() + _return_tween.set_trans(Tween.TRANS_QUAD) + _return_tween.set_ease(Tween.EASE_IN) + _return_tween.tween_method( + _set_return_sample.bind(start, target), + 0.0, + 1.0, + 0.42, + ) + _return_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.1) + _return_tween.finished.connect(_on_return_finished) + + func cleanup() -> void: + _kill_cast_tween() + _kill_return_tween() + _kill_showcase_tween() _active = false + _pending_target = Vector3.ZERO + _return_showcase_catch = null + _bobber_idle_elapsed = 0.0 + if _owner != null and is_instance_valid(_owner): + _owner.end_catch_showcase(Callable(), true) if _bobber != null: _bobber.visible = false _bobber.scale = Vector3.ONE @@ -69,12 +133,96 @@ func cleanup() -> void: _line_mesh.clear_surfaces() -func _process(_delta: float) -> void: +func _set_cast_sample( + progress: float, + start: Vector3, + target: Vector3, +) -> void: + _target = start.lerp(target, progress) + _target.y += sin(progress * PI) * 2.0 + _bobber.global_position = _target + + +func _on_cast_finished() -> void: + _cast_tween = null + _bobber_idle_elapsed = 0.0 + _apply_bobber_idle_motion() + _redraw_line() + + +func _apply_bobber_idle_motion() -> void: + var phase: float = _bobber_idle_elapsed * 0.7 * TAU + _target = _pending_target + Vector3.UP * sin(phase) * 0.035 + _bobber.global_position = _target + _bobber.rotation.z = deg_to_rad(4.0) * sin(phase * 0.73) + + +func _set_return_sample( + progress: float, + start: Vector3, + target: Vector3, +) -> void: + var eased_progress: float = 1.0 - pow(1.0 - progress, 3.0) + _target = start.lerp(target, eased_progress) + _target.y += sin(eased_progress * PI) * 1.3 + _bobber.global_position = _target + _bobber.rotation = Vector3.ZERO + + +func _on_return_finished() -> void: + _return_tween = null + _active = false + _bobber.visible = false + _line.visible = false + _line_mesh.clear_surfaces() + if ( + _return_showcase_catch != null + and _owner != null + and is_instance_valid(_owner) + ): + _owner.begin_remote_catch_showcase(_return_showcase_catch) + _return_showcase_catch = null + _showcase_tween = create_tween() + _showcase_tween.tween_interval(2.5) + _showcase_tween.finished.connect(_on_showcase_finished) + return + return_completed.emit() + + +func _on_showcase_finished() -> void: + _showcase_tween = null + if _owner != null and is_instance_valid(_owner): + _owner.end_catch_showcase() + return_completed.emit() + + +func _kill_cast_tween() -> void: + if _cast_tween != null and _cast_tween.is_valid(): + _cast_tween.kill() + _cast_tween = null + + +func _kill_return_tween() -> void: + if _return_tween != null and _return_tween.is_valid(): + _return_tween.kill() + _return_tween = null + + +func _kill_showcase_tween() -> void: + if _showcase_tween != null and _showcase_tween.is_valid(): + _showcase_tween.kill() + _showcase_tween = null + + +func _process(delta: float) -> void: if _owner != null and not _owner.is_remote_presentation_visible(): _bobber.visible = false _line.visible = false return if _active: + if _cast_tween == null and _return_tween == null: + _bobber_idle_elapsed += delta + _apply_bobber_idle_motion() _redraw_line() diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 38f33bc..9329aea 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -256,10 +256,21 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: ): _record_and_reject(peer_id, request_id, "Cannot fish here.") return - var region: FishableWaterRegion = ( - _fishing_spot.get_fishable_water_region(target) + var surface: FishingSurfaceSample = _fishing_spot.resolve_fishing_surface( + target, + authoritative_origin.y, ) - if region == null or region.fish_pool == null: + var region: FishableWaterRegion = surface.water_region + var authoritative_target: Vector3 = surface.position + if ( + not surface.is_fishable() + or region == null + or region.fish_pool == null + or not _fishing_spot.is_cast_path_clear( + authoritative_origin, + authoritative_target + ) + ): _record_and_reject(peer_id, request_id, "Cannot fish here.") return var effects: PlayerItemEffects = ( @@ -281,8 +292,8 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: attempt.session_id = _session.get_session_id() attempt.phase = NetworkFishingAttempt.Phase.WAITING_FOR_BITE attempt.origin = authoritative_origin - attempt.target = target - attempt.bobber_position = target + attempt.target = authoritative_target + attempt.bobber_position = authoritative_target attempt.fish_id = selected_fish.id attempt.reel_speed = float(data["reel_speed"]) * ( effects.get_reel_multiplier() if effects != null else 1.0 @@ -290,7 +301,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: attempt.barrier_damage = int(data["barrier_damage"]) + ( effects.get_barrier_bonus() if effects != null else 0 ) - attempt.bite_time_remaining = _fishing_spot.wait_time * float( + attempt.bite_time_remaining = _fishing_spot.roll_bite_wait_time() * float( effects.get_bite_time_multiplier() if effects != null else 1.0 ) attempt.controller = CatchController.new() @@ -320,7 +331,7 @@ func _update_waiting_attempt( flat_offset.length() - _fishing_spot.withdrawal_cancel_distance ) if withdrawable_distance <= 0.0: - _cancel_attempt(attempt.owner_peer_id, "") + _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal") return attempt.withdrawal_progress = minf( attempt.withdrawal_progress @@ -336,16 +347,20 @@ func _update_waiting_attempt( endpoint, attempt.withdrawal_progress ) - attempt.bobber_position = _fishing_spot.find_last_fishable_position( - attempt.bobber_position, - desired + var withdrawal_surface: FishingSurfaceSample = ( + _fishing_spot.resolve_safe_withdrawal_surface( + attempt.bobber_position, + desired, + endpoint - attempt.bobber_position, + ) ) + if not withdrawal_surface.is_fishable(): + _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal") + return + attempt.bobber_position = withdrawal_surface.position attempt.set_meta("snapshot", _make_waiting_snapshot(attempt)) - if ( - not attempt.bobber_position.is_equal_approx(desired) - or is_equal_approx(attempt.withdrawal_progress, 1.0) - ): - _cancel_attempt(attempt.owner_peer_id, "") + if is_equal_approx(attempt.withdrawal_progress, 1.0): + _cancel_attempt(attempt.owner_peer_id, "", &"withdrawal") func _make_waiting_snapshot( @@ -479,7 +494,6 @@ func _on_encounter_updated( var attempt: NetworkFishingAttempt = _attempts.get(peer_id) if attempt == null or attempt.phase != NetworkFishingAttempt.Phase.FIGHTING: return - attempt.bobber_position = attempt.target.lerp(attempt.origin, progress) attempt.set_meta("snapshot", { "attempt_id": attempt.attempt_id, "owner_peer_id": peer_id, @@ -606,7 +620,7 @@ func _on_attempt_caught(peer_id: int) -> void: return attempt.phase = NetworkFishingAttempt.Phase.PENDING_CAPACITY attempt.result_id = _new_id("result") - attempt.catch_payload = fish_catch.to_save_dict() + attempt.catch_payload = fish_catch.to_network_dict() attempt.capacity_nonce = _new_id("capacity") attempt.capacity_deadline = ( Time.get_ticks_msec() / 1000.0 + CAPACITY_RESPONSE_TIMEOUT @@ -708,7 +722,12 @@ func _finalize_catch(attempt: NetworkFishingAttempt) -> void: _apply_target_outcome(outcome) else: receive_target_outcome.rpc_id(attempt.owner_peer_id, outcome) - _broadcast_public_outcome(attempt, &"catch", "") + _broadcast_public_outcome( + attempt, + &"catch", + "", + attempt.catch_payload, + ) _dispose_attempt(attempt.owner_peer_id) @@ -800,12 +819,16 @@ func _on_attempt_escaped(peer_id: int) -> void: _dispose_attempt(peer_id) -func _cancel_attempt(peer_id: int, message: String) -> void: +func _cancel_attempt( + peer_id: int, + message: String, + outcome: StringName = &"cancelled", +) -> void: var attempt: NetworkFishingAttempt = _attempts.get(peer_id) if attempt == null: return attempt.phase = NetworkFishingAttempt.Phase.CANCELLED - _broadcast_public_outcome(attempt, &"cancelled", message) + _broadcast_public_outcome(attempt, outcome, message) _dispose_attempt(peer_id) @@ -813,6 +836,7 @@ func _broadcast_public_outcome( attempt: NetworkFishingAttempt, outcome: StringName, message: String, + catch_payload: Dictionary = {}, ) -> void: var data: Dictionary = { "attempt_id": attempt.attempt_id, @@ -820,6 +844,8 @@ func _broadcast_public_outcome( "outcome": str(outcome), "message": message.left(128), } + if outcome == &"catch" and not catch_payload.is_empty(): + data["catch"] = catch_payload.duplicate(true) _apply_public_outcome(data) receive_public_outcome.rpc(data) @@ -846,7 +872,11 @@ func _apply_public_outcome(data: Dictionary) -> void: str(data["message"]) ) else: - _cleanup_remote_presentation(peer_id) + var outcome := StringName(str(data["outcome"])) + if outcome in [&"catch", &"escape", &"withdrawal"]: + _return_remote_presentation(peer_id, outcome, data) + else: + _cleanup_remote_presentation(peer_id) func _make_cast_accepted(attempt: NetworkFishingAttempt) -> Dictionary: @@ -874,12 +904,16 @@ func _apply_cast_accepted(data: Dictionary) -> void: if ( typeof(data.get("attempt_id")) != TYPE_STRING or typeof(data.get("owner_peer_id")) != TYPE_INT + or typeof(data.get("origin")) != TYPE_ARRAY or typeof(data.get("target")) != TYPE_ARRAY ): return var peer_id: int = data["owner_peer_id"] + var origin: Vector3 = NetworkFishingProtocol.array_to_vector3( + data.get("origin", []) + ) var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"]) - if not target.is_finite(): + if not origin.is_finite() or not target.is_finite(): return if peer_id == _session.get_local_peer_id(): var attempt := NetworkFishingAttempt.new() @@ -896,7 +930,7 @@ func _apply_cast_accepted(data: Dictionary) -> void: else: var presentation := _get_remote_presentation(peer_id) if presentation != null: - presentation.show_cast(target) + presentation.show_cast(origin, target) @rpc("authority", "call_remote", "reliable", 0) @@ -1000,6 +1034,30 @@ func _cleanup_remote_presentation(peer_id: int) -> void: presentation.queue_free() +func _return_remote_presentation( + peer_id: int, + outcome: StringName, + data: Dictionary, +) -> void: + var presentation: RemoteFishingPresentation = _remote_presentations.get( + peer_id + ) + _remote_presentations.erase(peer_id) + if presentation == null or not is_instance_valid(presentation): + return + presentation.return_completed.connect( + presentation.queue_free, + CONNECT_ONE_SHOT, + ) + var showcase_catch: FishCatch = null + if outcome == &"catch" and typeof(data.get("catch")) == TYPE_DICTIONARY: + var catch_data: Dictionary = data["catch"] + var fish_id := StringName(str(catch_data.get("fish_id", ""))) + var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id) + showcase_catch = FishCatchType.from_network_dict(catch_data, fish) + presentation.play_return(showcase_catch) + + func _dispose_attempt(peer_id: int) -> void: var attempt: NetworkFishingAttempt = _attempts.get(peer_id) _attempts.erase(peer_id) @@ -1007,7 +1065,12 @@ func _dispose_attempt(peer_id: int) -> void: attempt.controller.reset() attempt.controller.queue_free() var avatar: Player = _spawn_service.get_avatar(peer_id) - if avatar != null: + var local_return_is_active: bool = ( + peer_id == _session.get_local_peer_id() + and _fishing_spot != null + and _fishing_spot.is_returning() + ) + if avatar != null and not local_return_is_active: avatar.set_movement_enabled(true) diff --git a/player/player.gd b/player/player.gd index 3367f74..96a74d5 100644 --- a/player/player.gd +++ b/player/player.gd @@ -713,6 +713,22 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void: _catch_display.visible = _catch_sprite.texture != null +func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void: + if local_control_enabled or fish_catch == null or not fish_catch.is_valid(): + return + end_catch_showcase(Callable(), true) + _showcase_rod_visibility = _fishing_rod.visible + _showcase_rod_state_stored = true + _fishing_rod.visible = false + _catch_sprite.texture = fish_catch.fish.display_texture + _catch_display.scale = ( + Vector3.ONE + * fish_catch.display_scale + * catch_presentation_base_scale + ) + _catch_display.visible = _catch_sprite.texture != null + + func end_catch_showcase( restored_callback: Callable = Callable(), immediate: bool = false, diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd new file mode 100644 index 0000000..2a98968 --- /dev/null +++ b/tests/fishing_authority_validation.gd @@ -0,0 +1,104 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + assert(bool(main.call("_prepare_private_host"))) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 8: + await process_frame + + var session := main.get_node("%NetworkSession") as NetworkSession + var service := main.get_node( + "%NetworkFishingService" + ) as NetworkFishingService + var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType + var player := main.get("_player") as Player + assert(session.is_host()) + assert(not session.is_open_host()) + assert(service != null and fishing_spot != null and player != null) + assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") + + player.global_position = Vector3(-0.5, 3.95, 2.1) + var visuals := player.get_node("Visuals") as Node3D + visuals.rotation.y = PI * 0.5 + for _frame: int in 4: + await physics_frame + assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) + + fishing_spot.call("_begin_aiming", player) + assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST) + assert(player.is_movement_enabled()) + fishing_spot.set("_cast_charge", 0.32) + fishing_spot.call("_update_cast_charge", 0.0) + var aimed_target: Vector3 = fishing_spot.get("_cast_target") + assert(aimed_target.x < -1.35) + assert(is_equal_approx(aimed_target.y, 2.51)) + assert(fishing_spot.is_target_fishable(aimed_target)) + fishing_spot.call("_confirm_cast") + assert(fishing_spot.state == FishingSpotType.FishingState.CASTING) + assert(not player.is_movement_enabled()) + + var wait_deadline: int = Time.get_ticks_msec() + 4000 + while ( + Time.get_ticks_msec() < wait_deadline + and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) + assert(service.has_local_attempt()) + var attempts: Dictionary = service.get("_attempts") + var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) + assert(attempt != null) + assert(is_equal_approx(attempt.target.y, 2.51)) + assert(attempt.bobber_position.is_equal_approx(attempt.target)) + + fishing_spot.set("_withdrawal_input_held", true) + fishing_spot.set("_network_primary_input_held", true) + service.submit_local_input(true, true) + var return_deadline: int = Time.get_ticks_msec() + 4000 + while ( + Time.get_ticks_msec() < return_deadline + and fishing_spot.state != FishingSpotType.FishingState.RETURNING + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING) + assert(not service.has_local_attempt()) + var bobber := fishing_spot.get_node( + "FishingPresentation/Bobber" + ) as MeshInstance3D + assert(bobber.visible) + + var ready_deadline: int = Time.get_ticks_msec() + 3000 + while ( + Time.get_ticks_msec() < ready_deadline + and fishing_spot.state != FishingSpotType.FishingState.READY + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.READY) + assert(player.is_movement_enabled()) + assert(not bobber.visible) + + print("Fishing authority validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() diff --git a/tests/fishing_authority_validation.gd.uid b/tests/fishing_authority_validation.gd.uid new file mode 100644 index 0000000..650669c --- /dev/null +++ b/tests/fishing_authority_validation.gd.uid @@ -0,0 +1 @@ +uid://cgqcyldad6dsj diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd new file mode 100644 index 0000000..fe9db15 --- /dev/null +++ b/tests/fishing_multiplayer_validation.gd @@ -0,0 +1,194 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Fishing multiplayer validation requires host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + assert(bool(main.call("_prepare_private_host"))) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.set_host_open(true)) + var service := main.get_node( + "%NetworkFishingService" + ) as NetworkFishingService + + var join_deadline: int = Time.get_ticks_msec() + 20000 + while ( + Time.get_ticks_msec() < join_deadline + and session.get_authenticated_peer_ids().size() < 2 + ): + await process_frame + assert(session.get_authenticated_peer_ids().size() == 2) + var remote_peer_id: int = 0 + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + remote_peer_id = peer_id + break + assert(remote_peer_id > 1) + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) + assert(remote_avatar != null) + remote_avatar.global_position = Vector3(-0.5, 3.95, 2.1) + var remote_visuals := remote_avatar.get_node("Visuals") as Node3D + remote_visuals.rotation.y = PI * 0.5 + session.publish_authoritative_teleport(remote_peer_id) + + var saw_remote_attempt: bool = false + var remote_presentation: RemoteFishingPresentation + var attempt_deadline: int = Time.get_ticks_msec() + 12000 + while Time.get_ticks_msec() < attempt_deadline: + await process_frame + var attempts: Dictionary = service.get("_attempts") + for peer_id: int in attempts: + if peer_id == session.get_local_peer_id(): + continue + var attempt: NetworkFishingAttempt = attempts[peer_id] + assert(is_equal_approx(attempt.target.y, 2.51)) + assert(attempt.bobber_position.y <= 2.51 + 0.001) + var presentations: Dictionary = service.get("_remote_presentations") + remote_presentation = presentations.get(peer_id) + assert(remote_presentation != null) + var remote_bobber := remote_presentation.get( + "_bobber" + ) as MeshInstance3D + assert(remote_bobber.visible) + saw_remote_attempt = true + if saw_remote_attempt: + break + assert(saw_remote_attempt) + var return_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < return_deadline + and service.has_peer_attempt(remote_peer_id) + ): + await process_frame + assert(not service.has_peer_attempt(remote_peer_id)) + assert(is_instance_valid(remote_presentation)) + assert(remote_presentation.get("_return_tween") != null) + + var completion_deadline: int = Time.get_ticks_msec() + 12000 + while ( + Time.get_ticks_msec() < completion_deadline + and session.get_authenticated_peer_ids().size() == 2 + ): + await process_frame + print("Fishing multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call("_on_title_join_game_requested", "127.0.0.1:7777") + var session := main.get_node("%NetworkSession") as NetworkSession + var joined: bool = false + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + joined = true + break + assert(joined) + + var service := main.get_node( + "%NetworkFishingService" + ) as NetworkFishingService + var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType + var player := main.get("_player") as Player + var placement_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < placement_deadline + and player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) + > 0.25 + ): + await physics_frame + assert(player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) <= 0.25) + assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) + + fishing_spot.call("_begin_aiming", player) + assert(player.is_movement_enabled()) + fishing_spot.set("_cast_charge", 0.32) + fishing_spot.call("_update_cast_charge", 0.0) + var target: Vector3 = fishing_spot.get("_cast_target") + assert(fishing_spot.is_target_fishable(target)) + assert(is_equal_approx(target.y, 2.51)) + fishing_spot.call("_confirm_cast") + + var accepted_deadline: int = Time.get_ticks_msec() + 6000 + while ( + Time.get_ticks_msec() < accepted_deadline + and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) + assert(service.has_local_attempt()) + fishing_spot.set("_withdrawal_input_held", true) + fishing_spot.set("_network_primary_input_held", true) + service.submit_local_input(true, true) + + var return_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < return_deadline + and fishing_spot.state != FishingSpotType.FishingState.RETURNING + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING) + var bobber := fishing_spot.get_node( + "FishingPresentation/Bobber" + ) as MeshInstance3D + assert(bobber.visible) + + var ready_deadline: int = Time.get_ticks_msec() + 3000 + while ( + Time.get_ticks_msec() < ready_deadline + and fishing_spot.state != FishingSpotType.FishingState.READY + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.READY) + assert(player.is_movement_enabled()) + assert(not bobber.visible) + print("Fishing multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main diff --git a/tests/fishing_multiplayer_validation.gd.uid b/tests/fishing_multiplayer_validation.gd.uid new file mode 100644 index 0000000..c8fcbd8 --- /dev/null +++ b/tests/fishing_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://chyq7wjhpko7d diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd new file mode 100644 index 0000000..3c0ad87 --- /dev/null +++ b/tests/fishing_surface_validation.gd @@ -0,0 +1,337 @@ +extends SceneTree + +const FishingSpotScene = preload("res://fishing/fishing_spot.tscn") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const FishingPresentationType = preload( + "res://fishing/fishing_presentation.gd" +) +const RemoteFishingPresentationType = preload( + "res://fishing/remote_fishing_presentation.gd" +) +const PlayerScene = preload("res://player/player.tscn") +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishingSurfaceResolverType = preload( + "res://fishing/fishing_surface_resolver.gd" +) +const FishingSurfaceSampleType = preload( + "res://fishing/fishing_surface_sample.gd" +) +const FishableWaterRegionType = preload( + "res://world/fishable_water_region.gd" +) +const FishPoolType = preload("res://fish/fish_pool.gd") +const WaterBodyScene = preload("res://world/water_body.tscn") +const StarterRegionScene = preload( + "res://world/regions/starter_island_region.tscn" +) +const PondPool: FishPoolType = preload( + "res://fish/pools/starter_pond_pool.tres" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + await _validate_resolver_layers() + await _validate_portable_water_body() + await _validate_starter_region_surfaces() + await _validate_presentation() + await _validate_remote_presentation() + await _validate_bite_wait_distribution() + print("Fishing surface validation: PASS") + quit() + + +func _validate_resolver_layers() -> void: + var world := Node3D.new() + root.add_child(world) + _add_solid_box(world, Vector3(40.0, 2.0, 12.0), Vector3(10.0, -1.0, 0.0)) + _add_water_region(world, Vector3(0.0, 2.0, 0.0), Vector2(8.0, 8.0), PondPool) + _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(2.0, 2.25, 0.0)) + _add_water_region(world, Vector3(10.0, 5.0, 0.0), Vector2(6.0, 6.0), PondPool) + _add_water_region(world, Vector3(20.0, 3.0, 0.0), Vector2(6.0, 6.0), null) + await physics_frame + await physics_frame + + var resolver := FishingSurfaceResolverType.new() + var space_state: PhysicsDirectSpaceState3D = world.get_world_3d().direct_space_state + var water: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3.ZERO, + 4.0, + ) + assert(water.is_fishable()) + assert(is_equal_approx(water.position.y, 2.0)) + + var covered_water: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3(2.0, 2.0, 0.0), + 4.0, + ) + assert(covered_water.has_surface) + assert(not covered_water.is_water_surface) + assert(is_equal_approx(covered_water.position.y, 2.75)) + + var elevated_water: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3(10.0, 0.0, 0.0), + 8.0, + ) + assert(elevated_water.is_fishable()) + assert(is_equal_approx(elevated_water.position.y, 5.0)) + + var unstocked_water: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3(20.0, 0.0, 0.0), + 6.0, + ) + assert(unstocked_water.has_surface) + assert(unstocked_water.is_water_surface) + assert(not unstocked_water.is_fishable()) + assert(is_equal_approx(unstocked_water.position.y, 3.0)) + + var safe_reel: FishingSurfaceSampleType = resolver.resolve_withdrawal_surface( + space_state, + Vector3(0.0, 2.0, 0.0), + Vector3(3.0, 2.0, 0.0), + Vector3.RIGHT, + 4.0, + 0.4, + ) + assert(safe_reel.is_fishable()) + assert(is_equal_approx(safe_reel.position.y, 2.0)) + var shoreline_reel: FishingSurfaceSampleType = ( + resolver.resolve_withdrawal_surface( + space_state, + Vector3(3.0, 2.0, 0.0), + Vector3(3.7, 2.0, 0.0), + Vector3.RIGHT, + 4.0, + 0.4, + ) + ) + assert(not shoreline_reel.is_fishable()) + assert(shoreline_reel.position.is_equal_approx(Vector3(3.0, 2.0, 0.0))) + + world.queue_free() + await process_frame + + +func _validate_portable_water_body() -> void: + var water_body := WaterBodyScene.instantiate() as Node3D + water_body.position = Vector3(3.0, 7.0, -2.0) + water_body.set("surface_size", Vector2(12.0, 8.0)) + water_body.set("fishing_depth", 6.0) + water_body.set("recovery_depth", 7.0) + water_body.set("fish_pool", PondPool) + root.add_child(water_body) + await process_frame + + var visual := water_body.get_node("VisualWater") as MeshInstance3D + var visual_mesh := visual.mesh as PlaneMesh + assert(visual_mesh.size.is_equal_approx(Vector2(12.0, 8.0))) + var fishing_region := water_body.get_node( + "FishingRegion" + ) as FishableWaterRegionType + var fishing_shape_node := water_body.get_node( + "FishingRegion/Shape" + ) as CollisionShape3D + var fishing_shape := fishing_shape_node.shape as BoxShape3D + assert(fishing_region.fish_pool == PondPool) + assert(is_equal_approx(fishing_region.get_surface_height(), 7.0)) + assert(is_equal_approx(fishing_shape_node.position.y, -3.0)) + assert(fishing_shape.size.is_equal_approx(Vector3(12.0, 6.0, 8.0))) + var recovery_region := water_body.get_node("RecoveryRegion") as Area3D + assert(is_equal_approx(recovery_region.position.y, -3.5)) + + water_body.queue_free() + await process_frame + + +func _validate_starter_region_surfaces() -> void: + var region := StarterRegionScene.instantiate() as Node3D + root.add_child(region) + var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType + root.add_child(fishing_spot) + await physics_frame + await physics_frame + + var pond: FishingSurfaceSampleType = fishing_spot.resolve_fishing_surface( + Vector3(-9.4, 2.51, 2.1), + 4.0, + ) + assert(pond.is_fishable()) + assert(is_equal_approx(pond.position.y, 2.51)) + var ocean: FishingSurfaceSampleType = fishing_spot.resolve_fishing_surface( + Vector3(50.0, -0.45, 0.0), + 4.0, + ) + assert(ocean.is_fishable()) + assert(is_equal_approx(ocean.position.y, -0.45)) + var covered_ocean: FishingSurfaceSampleType = ( + fishing_spot.resolve_fishing_surface( + Vector3(20.0, -0.45, 0.0), + 4.0, + ) + ) + assert(covered_ocean.has_surface) + assert(not covered_ocean.is_fishable()) + assert(covered_ocean.position.y > -0.45) + + fishing_spot.queue_free() + region.queue_free() + await process_frame + + +func _validate_presentation() -> void: + var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType + root.add_child(fishing_spot) + var presentation := fishing_spot.get_node( + "FishingPresentation" + ) as FishingPresentationType + var rod := Node3D.new() + var rod_tip := Marker3D.new() + rod.add_child(rod_tip) + root.add_child(rod) + rod_tip.position = Vector3(0.0, 1.0, 0.0) + await process_frame + + var slope_normal := Vector3(0.0, 1.0, 1.0).normalized() + presentation.begin_aim( + rod_tip, + rod, + Vector3(0.0, 0.1, -1.0), + slope_normal, + false, + ) + var marker := presentation.get_node("CastTargetMarker") as Node3D + var valid_marker := presentation.get_node( + "CastTargetMarker/ValidTargetMarker" + ) as MeshInstance3D + var invalid_marker := presentation.get_node( + "CastTargetMarker/InvalidTargetMarker" + ) as Node3D + assert(not valid_marker.visible) + assert(invalid_marker.visible) + assert(invalid_marker.get_child_count() == 2) + assert(marker.global_basis.y.normalized().dot(slope_normal) > 0.999) + + var landing := Vector3(0.0, 0.13, -4.0) + presentation.begin_cast(landing, landing, true) + await presentation.cast_completed + var bobber := presentation.get_node("Bobber") as MeshInstance3D + assert(bobber.visible) + assert(bobber.global_position.is_equal_approx(landing)) + presentation.play_outcome(&"invalid") + await process_frame + assert(bobber.visible) + await presentation.outcome_completed + assert(not bobber.visible) + + rod.queue_free() + fishing_spot.queue_free() + await process_frame + + +func _validate_bite_wait_distribution() -> void: + var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType + root.add_child(fishing_spot) + await process_frame + var quick_count: int = 0 + var typical_or_long_count: int = 0 + for _sample_index: int in 10000: + var wait_seconds: float = fishing_spot.roll_bite_wait_time() + assert(wait_seconds >= 10.0) + assert(wait_seconds <= 240.0) + if wait_seconds < 30.0: + quick_count += 1 + else: + typical_or_long_count += 1 + assert(quick_count > 0) + assert(typical_or_long_count > quick_count) + fishing_spot.queue_free() + await process_frame + + +func _validate_remote_presentation() -> void: + var player := PlayerScene.instantiate() as Player + root.add_child(player) + player.set_local_control(false) + var presentation := RemoteFishingPresentationType.new() + root.add_child(presentation) + presentation.setup(player) + await process_frame + var origin: Vector3 = player.get_fishing_rod_tip().global_position + var target: Vector3 = origin + Vector3(-4.0, -0.5, 0.0) + presentation.show_cast(origin, target) + var bobber := presentation.get("_bobber") as MeshInstance3D + assert(bobber.visible) + assert(presentation.get("_cast_tween") != null) + await create_timer(0.7).timeout + assert(bobber.global_position.distance_to(target) < 0.1) + var first_bob_y: float = bobber.global_position.y + await create_timer(0.2).timeout + assert(not is_equal_approx(bobber.global_position.y, first_bob_y)) + + var fish_catch := FishCatchType.new() + var fish: FishData = PondPool.candidates.front() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.catch_id = &"remote-presentation-test" + fish_catch.weight_lb = 1.0 + fish_catch.display_scale = 1.0 + fish_catch.sale_value = 1 + assert(fish_catch.is_valid()) + presentation.play_return(fish_catch) + await create_timer(0.6).timeout + var catch_display := player.get_node("%CatchDisplay") as Node3D + assert(catch_display.visible) + await presentation.return_completed + assert(not catch_display.visible) + + presentation.queue_free() + player.queue_free() + await process_frame + + +func _add_water_region( + parent: Node3D, + position: Vector3, + surface_size: Vector2, + fish_pool: FishPoolType, +) -> void: + var water_root := Node3D.new() + water_root.position = position + parent.add_child(water_root) + var region := FishableWaterRegionType.new() + region.collision_layer = 4 + region.collision_mask = 0 + region.monitoring = false + region.surface_height_mode = FishableWaterRegionType.SurfaceHeightMode.PARENT_GLOBAL_Y + region.fish_pool = fish_pool + water_root.add_child(region) + var shape_node := CollisionShape3D.new() + shape_node.position.y = -2.0 + var shape := BoxShape3D.new() + shape.size = Vector3(surface_size.x, 4.0, surface_size.y) + shape_node.shape = shape + region.add_child(shape_node) + + +func _add_solid_box( + parent: Node3D, + size: Vector3, + position: Vector3, +) -> void: + var body := StaticBody3D.new() + body.position = position + body.collision_layer = 1 + body.collision_mask = 0 + parent.add_child(body) + var shape_node := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = size + shape_node.shape = shape + body.add_child(shape_node) diff --git a/tests/fishing_surface_validation.gd.uid b/tests/fishing_surface_validation.gd.uid new file mode 100644 index 0000000..3954c8a --- /dev/null +++ b/tests/fishing_surface_validation.gd.uid @@ -0,0 +1 @@ +uid://bh8g62bagphbd diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn index 7b9613f..8345704 100644 --- a/world/regions/starter_island_region.tscn +++ b/world/regions/starter_island_region.tscn @@ -124,6 +124,9 @@ position = Vector3(-9.4, 2.51, 2.1) script = ExtResource("10_water_body") surface_size = Vector2(16.1, 12.3675) water_material = ExtResource("19_fresh_water") +fish_pool = ExtResource("12_pond_pool") +location_tags = Array[StringName]([&"starter_pond"]) +selection_priority = 1 [node name="VisualWater" type="MeshInstance3D" parent="WaterBodies/Pond" unique_id=1034265960] material_override = ExtResource("19_fresh_water") @@ -173,19 +176,19 @@ fish_pool = ExtResource("4_pool") surface_height_mode = 1 [node name="WestShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=357406062] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -45.5, 0, 0) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -45.5, -1, 0) shape = SubResource("OceanWestShape") [node name="EastShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=772224738] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 32, 0, 0) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 32, -1, 0) shape = SubResource("OceanEastShape") [node name="NorthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=646147646] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, 0, 31) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, -1, 31) shape = SubResource("OceanNorthShape") [node name="SouthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=894083854] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, 0, -31) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -13, -1, -31) shape = SubResource("OceanSouthShape") [node name="RecoveryRegions" type="Node3D" parent="WaterBodies/Ocean" unique_id=463706703] diff --git a/world/water_body.tscn b/world/water_body.tscn new file mode 100644 index 0000000..77eb8f1 --- /dev/null +++ b/world/water_body.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://world/water_body_authoring.gd" id="1_authoring"] +[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="2_fishing"] +[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="3_recovery"] +[ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="4_water"] + +[sub_resource type="PlaneMesh" id="WaterSurfaceMesh"] +resource_local_to_scene = true +size = Vector2(10, 10) + +[sub_resource type="BoxShape3D" id="FishingVolumeShape"] +resource_local_to_scene = true +size = Vector3(10, 4, 10) + +[sub_resource type="BoxShape3D" id="RecoveryVolumeShape"] +resource_local_to_scene = true +size = Vector3(10, 4.8, 10) + +[node name="WaterBody" type="Node3D"] +script = ExtResource("1_authoring") +water_material = ExtResource("4_water") + +[node name="VisualWater" type="MeshInstance3D" parent="."] +cast_shadow = 0 +material_override = ExtResource("4_water") +mesh = SubResource("WaterSurfaceMesh") + +[node name="FishingRegion" type="Area3D" parent="."] +collision_layer = 4 +collision_mask = 0 +monitoring = false +script = ExtResource("2_fishing") +surface_height_mode = 1 + +[node name="Shape" type="CollisionShape3D" parent="FishingRegion"] +position = Vector3(0, -2, 0) +shape = SubResource("FishingVolumeShape") + +[node name="RecoveryRegion" type="Area3D" parent="."] +position = Vector3(0, -2.4, 0) +collision_layer = 8 +collision_mask = 2 +script = ExtResource("3_recovery") +surface_height_mode = 1 + +[node name="Shape" type="CollisionShape3D" parent="RecoveryRegion"] +shape = SubResource("RecoveryVolumeShape") diff --git a/world/water_body_authoring.gd b/world/water_body_authoring.gd index 9faa701..70ead79 100644 --- a/world/water_body_authoring.gd +++ b/world/water_body_authoring.gd @@ -2,6 +2,8 @@ class_name WaterBodyAuthoring extends Node3D +const FishPoolType = preload("res://fish/fish_pool.gd") + @export_group("Surface") ## Visible water footprint in meters. @export_custom( @@ -19,31 +21,29 @@ var surface_size: Vector2 = Vector2(10.0, 10.0): _sync_owned_nodes() @export_group("Fishing Coverage") -## Extra fishable coverage beyond each edge of the visible surface. -@export_custom( - PROPERTY_HINT_RANGE, - "0.0,20.0,0.1,or_greater,suffix:m", -) -var fishing_padding: Vector2 = Vector2.ZERO: - set(value): - fishing_padding = Vector2(maxf(value.x, 0.0), maxf(value.y, 0.0)) - _sync_owned_nodes() @export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m") var fishing_depth: float = 4.0: set(value): fishing_depth = maxf(value, 0.1) _sync_owned_nodes() +@export var fish_pool: FishPoolType: + set(value): + fish_pool = value + _sync_owned_nodes() +@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER: + set(value): + water_type = value + _sync_owned_nodes() +@export var location_tags: Array[StringName] = []: + set(value): + location_tags = value + _sync_owned_nodes() +@export var selection_priority: int = 0: + set(value): + selection_priority = value + _sync_owned_nodes() @export_group("Recovery Coverage") -## Extra recovery coverage beyond each edge of the visible surface. -@export_custom( - PROPERTY_HINT_RANGE, - "0.0,20.0,0.1,or_greater,suffix:m", -) -var recovery_padding: Vector2 = Vector2.ZERO: - set(value): - recovery_padding = Vector2(maxf(value.x, 0.0), maxf(value.y, 0.0)) - _sync_owned_nodes() @export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m") var recovery_depth: float = 4.8: set(value): @@ -56,6 +56,8 @@ var visual_water_path: NodePath = ^"VisualWater" @export_node_path("CollisionShape3D") var fishing_shape_path: NodePath = ^"FishingRegion/Shape" @export_node_path("Area3D") +var fishing_region_path: NodePath = ^"FishingRegion" +@export_node_path("Area3D") var recovery_region_path: NodePath = ^"RecoveryRegion" @export_node_path("CollisionShape3D") var recovery_shape_path: NodePath = ^"RecoveryRegion/Shape" @@ -80,13 +82,28 @@ func _sync_owned_nodes() -> void: var fishing_shape_node := ( get_node_or_null(fishing_shape_path) as CollisionShape3D ) + var fishing_region := ( + get_node_or_null(fishing_region_path) as FishableWaterRegion + ) + if fishing_region != null: + fishing_region.fish_pool = fish_pool + fishing_region.water_type = water_type + fishing_region.location_tags = location_tags.duplicate() + fishing_region.selection_priority = selection_priority + fishing_region.surface_height_mode = ( + FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y + ) if fishing_shape_node != null: var fishing_shape := fishing_shape_node.shape as BoxShape3D if fishing_shape != null: + # The authored root is the one water-surface height. Keep the fishable + # volume entirely below that plane so visible water and interaction + # can be moved together without a second height to tune. + fishing_shape_node.position.y = -fishing_depth * 0.5 fishing_shape.size = Vector3( - surface_size.x + fishing_padding.x * 2.0, + surface_size.x, fishing_depth, - surface_size.y + fishing_padding.y * 2.0 + surface_size.y ) var recovery_region := ( @@ -101,9 +118,9 @@ func _sync_owned_nodes() -> void: var recovery_shape := recovery_shape_node.shape as BoxShape3D if recovery_shape != null: recovery_shape.size = Vector3( - surface_size.x + recovery_padding.x * 2.0, + surface_size.x, recovery_depth, - surface_size.y + recovery_padding.y * 2.0 + surface_size.y ) if Engine.is_editor_hint(): @@ -139,11 +156,9 @@ func _get_configuration_warnings() -> PackedStringArray: ) if recovery_shape == null or not recovery_shape.shape is BoxShape3D: warnings.append("RecoveryRegion must provide a BoxShape3D.") - var fishing_region := ( - fishing_shape.get_parent() as FishableWaterRegion - if fishing_shape != null - else null - ) + var fishing_region := get_node_or_null( + fishing_region_path + ) as FishableWaterRegion if fishing_region == null: warnings.append("FishingRegion must use FishableWaterRegion.") elif ( @@ -155,4 +170,6 @@ func _get_configuration_warnings() -> PackedStringArray: ) if water_material == null: warnings.append("Assign a water material.") + if fish_pool == null: + warnings.append("Assign a fish pool.") return warnings