netfishing/fishing/fishing_spot.gd

608 lines
17 KiB
GDScript

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