412 lines
12 KiB
GDScript
412 lines
12 KiB
GDScript
class_name FishingSpot
|
|
extends Area3D
|
|
|
|
const FishDataType = preload("res://fish/fish_data.gd")
|
|
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
|
|
const PlayerType = preload("res://player/player.gd")
|
|
|
|
signal status_changed(status: String)
|
|
signal reel_progress_changed(progress: float, visible: bool)
|
|
|
|
enum FishingState {
|
|
READY,
|
|
AIMING_CAST,
|
|
CASTING,
|
|
WAITING_FOR_BITE,
|
|
BITE_ACTIVE,
|
|
REELING,
|
|
COOLDOWN,
|
|
}
|
|
|
|
@export_category("Cast")
|
|
@export_range(0.1, 30.0, 0.05) var minimum_cast_distance: float = 0.5
|
|
@export_range(0.1, 30.0, 0.1) var maximum_cast_distance: float = 14.0
|
|
@export_range(0.1, 10.0, 0.1) var cast_charge_duration: float = 2.75
|
|
@export_flags_3d_physics var fishable_surface_mask: int = 4
|
|
|
|
@export_category("Withdrawal")
|
|
@export_range(0.1, 20.0, 0.1) var withdrawal_rate: float = 4.9
|
|
@export_range(0.1, 10.0, 0.1) var withdrawal_cancel_distance: float = 1.0
|
|
|
|
@export_category("Timing")
|
|
@export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0
|
|
@export_range(0.1, 10.0, 0.1) var bite_window_duration: float = 1.5
|
|
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
|
|
@export_range(0.01, 5.0, 0.01) var reel_gain_rate: float = 0.67
|
|
@export_range(0.01, 5.0, 0.01) var reel_decay_rate: float = 0.2
|
|
@export_range(0.0, 0.95, 0.05) var hooked_starting_progress: float = 0.25
|
|
|
|
@export_category("Catch")
|
|
@export var catchable_fish: FishDataType
|
|
|
|
@onready var _presentation: FishingPresentationType = %FishingPresentation
|
|
|
|
var state: FishingState = FishingState.READY
|
|
var _nearby_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 _reel_progress: float = 0.0
|
|
var _reel_input_held: bool = false
|
|
var _cooldown_status: String = ""
|
|
|
|
|
|
func _ready() -> void:
|
|
body_entered.connect(_on_body_entered)
|
|
body_exited.connect(_on_body_exited)
|
|
_presentation.cast_completed.connect(_on_cast_completed)
|
|
|
|
|
|
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:
|
|
_state_time_remaining -= delta
|
|
if _state_time_remaining <= 0.0:
|
|
_activate_bite()
|
|
else:
|
|
if (
|
|
_withdrawal_input_held
|
|
and not Input.is_action_pressed("fish_primary")
|
|
):
|
|
_withdrawal_input_held = false
|
|
if _withdrawal_input_held:
|
|
_update_withdrawal(delta)
|
|
FishingState.BITE_ACTIVE:
|
|
_state_time_remaining -= delta
|
|
if _state_time_remaining <= 0.0:
|
|
_miss_bite()
|
|
FishingState.REELING:
|
|
if _reel_input_held and not Input.is_action_pressed("fish_primary"):
|
|
_reel_input_held = false
|
|
_update_reel_progress(delta)
|
|
FishingState.COOLDOWN:
|
|
_state_time_remaining -= delta
|
|
if _state_time_remaining <= 0.0:
|
|
_return_to_ready()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if (
|
|
event.is_action_pressed("ui_cancel")
|
|
and _active_player != null
|
|
and state in [
|
|
FishingState.AIMING_CAST,
|
|
FishingState.CASTING,
|
|
FishingState.WAITING_FOR_BITE,
|
|
FishingState.BITE_ACTIVE,
|
|
FishingState.REELING,
|
|
]
|
|
):
|
|
_cancel_attempt()
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
|
|
if _nearby_player == null or not _nearby_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(_nearby_player)
|
|
FishingState.WAITING_FOR_BITE:
|
|
_withdrawal_input_held = true
|
|
FishingState.BITE_ACTIVE:
|
|
_confirm_hook()
|
|
FishingState.REELING:
|
|
_reel_input_held = true
|
|
_:
|
|
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
|
|
get_viewport().set_input_as_handled()
|
|
elif state == FishingState.REELING:
|
|
_reel_input_held = false
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _begin_aiming(player: PlayerType) -> void:
|
|
if state != FishingState.READY or _active_player != null:
|
|
return
|
|
|
|
_active_player = player
|
|
_active_player.set_movement_enabled(false)
|
|
_cast_direction = _capture_cast_direction(_active_player)
|
|
_cast_origin_position = _active_player.get_cast_origin_position()
|
|
_cast_charge = 0.0
|
|
_cast_target = _calculate_cast_target(minimum_cast_distance)
|
|
state = FishingState.AIMING_CAST
|
|
status_changed.emit("Hold left click to aim • Release to cast")
|
|
_presentation.begin_aim(
|
|
_active_player.get_fishing_rod_tip(),
|
|
_active_player.get_fishing_rod(),
|
|
_cast_target
|
|
)
|
|
|
|
|
|
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)
|
|
_presentation.update_aim_target(_cast_target, _cast_charge)
|
|
_presentation.update_rod_charge(_cast_charge)
|
|
|
|
|
|
func _confirm_cast() -> void:
|
|
if state != FishingState.AIMING_CAST:
|
|
return
|
|
|
|
_cast_landing_is_fishable = _is_landing_fishable(_cast_target)
|
|
state = FishingState.CASTING
|
|
status_changed.emit("Casting...")
|
|
_presentation.begin_cast(_cast_target)
|
|
|
|
|
|
func _on_cast_completed() -> void:
|
|
if state != FishingState.CASTING:
|
|
return
|
|
|
|
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
|
|
_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
|
|
)
|
|
status_changed.emit("Waiting for a bite...")
|
|
|
|
|
|
func _update_withdrawal(delta: float) -> void:
|
|
if state != FishingState.WAITING_FOR_BITE:
|
|
return
|
|
|
|
var landing_offset: Vector3 = _cast_target - _cast_origin_position
|
|
landing_offset.y = 0.0
|
|
var landing_distance: float = landing_offset.length()
|
|
var withdrawable_distance: float = landing_distance - withdrawal_cancel_distance
|
|
if withdrawable_distance <= 0.0:
|
|
_cancel_from_withdrawal()
|
|
return
|
|
|
|
_withdrawal_progress = minf(
|
|
_withdrawal_progress + withdrawal_rate * delta / withdrawable_distance,
|
|
1.0
|
|
)
|
|
var current_position: Vector3 = _cast_target.lerp(
|
|
_withdrawal_endpoint,
|
|
_withdrawal_progress
|
|
)
|
|
_presentation.show_withdrawal_position(current_position)
|
|
if is_equal_approx(_withdrawal_progress, 1.0):
|
|
_cancel_from_withdrawal()
|
|
|
|
|
|
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.show_bite()
|
|
|
|
|
|
func _confirm_hook() -> void:
|
|
if state != FishingState.BITE_ACTIVE or _active_player == null:
|
|
return
|
|
|
|
state = FishingState.REELING
|
|
_state_time_remaining = 0.0
|
|
_reel_progress = clampf(hooked_starting_progress, 0.0, 0.95)
|
|
_reel_input_held = true
|
|
status_changed.emit("Hold left click to reel")
|
|
reel_progress_changed.emit(_reel_progress, true)
|
|
_presentation.begin_reeling(_withdrawal_endpoint)
|
|
_presentation.show_reel_progress(_reel_progress, _reel_input_held)
|
|
|
|
|
|
func _update_reel_progress(delta: float) -> void:
|
|
if state != FishingState.REELING:
|
|
return
|
|
|
|
if _reel_input_held:
|
|
_reel_progress = minf(_reel_progress + reel_gain_rate * delta, 1.0)
|
|
else:
|
|
_reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0)
|
|
reel_progress_changed.emit(_reel_progress, true)
|
|
_presentation.show_reel_progress(_reel_progress, _reel_input_held)
|
|
if is_equal_approx(_reel_progress, 1.0):
|
|
_resolve_catch()
|
|
elif is_zero_approx(_reel_progress):
|
|
_resolve_escape()
|
|
|
|
|
|
func _resolve_catch() -> void:
|
|
if state != FishingState.REELING or _active_player == null or catchable_fish == null:
|
|
_cancel_attempt()
|
|
return
|
|
|
|
_active_player.inventory.add_fish(catchable_fish)
|
|
_cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch")
|
|
|
|
|
|
func _resolve_escape() -> void:
|
|
if state != FishingState.REELING:
|
|
return
|
|
|
|
var fish_name: String = "The fish"
|
|
if catchable_fish != null and not catchable_fish.display_name.is_empty():
|
|
fish_name = "The %s" % catchable_fish.display_name
|
|
_cleanup_attempt("%s got away!" % fish_name, &"escape")
|
|
|
|
|
|
func _miss_bite() -> void:
|
|
if state != FishingState.BITE_ACTIVE:
|
|
return
|
|
_cleanup_attempt("The fish got away.", &"miss")
|
|
|
|
|
|
func _cleanup_attempt(
|
|
cooldown_message: String = "",
|
|
visual_outcome: StringName = &"",
|
|
) -> void:
|
|
if _active_player != null:
|
|
_active_player.set_movement_enabled(true)
|
|
_active_player = null
|
|
_state_time_remaining = 0.0
|
|
_cast_charge = 0.0
|
|
_cast_direction = Vector3.FORWARD
|
|
_cast_origin_position = Vector3.ZERO
|
|
_cast_target = Vector3.ZERO
|
|
_cast_landing_is_fishable = false
|
|
_withdrawal_endpoint = Vector3.ZERO
|
|
_withdrawal_progress = 0.0
|
|
_withdrawal_input_held = false
|
|
_reel_input_held = false
|
|
_reel_progress = 0.0
|
|
reel_progress_changed.emit(0.0, false)
|
|
if visual_outcome.is_empty():
|
|
_presentation.cleanup()
|
|
else:
|
|
_presentation.play_outcome(visual_outcome)
|
|
|
|
if cooldown_message.is_empty():
|
|
state = FishingState.READY
|
|
_cooldown_status = ""
|
|
if _nearby_player != null:
|
|
status_changed.emit("Left click to cast")
|
|
else:
|
|
status_changed.emit("")
|
|
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 = ""
|
|
if _nearby_player != null:
|
|
status_changed.emit("Left click to cast")
|
|
else:
|
|
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 _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_landing_fishable(target: Vector3) -> bool:
|
|
var query := PhysicsPointQueryParameters3D.new()
|
|
query.position = target
|
|
query.collision_mask = fishable_surface_mask
|
|
query.collide_with_areas = true
|
|
query.collide_with_bodies = false
|
|
var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_point(
|
|
query,
|
|
1
|
|
)
|
|
return not results.is_empty()
|
|
|
|
|
|
func _on_body_entered(body: Node3D) -> void:
|
|
if body is not PlayerType or not body.is_local_control_enabled():
|
|
return
|
|
if _nearby_player != null:
|
|
return
|
|
|
|
_nearby_player = body
|
|
if state == FishingState.READY:
|
|
status_changed.emit("Left click to cast")
|
|
|
|
|
|
func _on_body_exited(body: Node3D) -> void:
|
|
if body != _nearby_player:
|
|
return
|
|
|
|
_nearby_player = null
|
|
if body == _active_player:
|
|
_cancel_attempt()
|
|
else:
|
|
status_changed.emit("")
|