netfishing/fishing/fishing_spot.gd

1519 lines
43 KiB
GDScript

class_name FishingSpot
extends Node3D
const FishDataType = preload("res://fish/fish_data.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishSelectorType = preload("res://fish/fish_selector.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const CatchControllerType = preload("res://fishing/catch_controller.gd")
const FishingContextType = preload("res://fishing/fishing_context.gd")
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
)
const PlayerItemEffectsType = preload(
"res://progression/player_item_effects.gd"
)
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const PlayerType = preload("res://player/player.gd")
const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd"
)
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"
)
const ArtShopStockType = preload("res://economy/art_shop_stock.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,
)
signal showcase_changed(
fish_name: String,
rarity_name: String,
weight_lb: float,
visible: bool,
)
signal bite_activated
signal ready_for_equipment_refresh
signal fish_showcase_toggle_requested
signal art_ui_toggle_requested
enum FishingState {
READY,
AIMING_CAST,
CASTING,
WAITING_FOR_BITE,
FIGHTING,
SHOWING_CATCH,
RETURNING,
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, 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")
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")
@export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5
@export var use_deterministic_selection_seed: bool = false
@export var deterministic_selection_seed: int = 24680
@export_category("Context Testing")
@export var context_is_night: bool = false
@export var context_is_raining: bool = false
@export var context_event_tags: Array[StringName] = []
@export var context_bait_tags: Array[StringName] = []
@onready var _catch_controller: CatchControllerType = %CatchController
@onready var _presentation: FishingPresentationType = %FishingPresentation
var state: FishingState = FishingState.READY
var _external_input_blocked: bool = false
var _gameplay_input_enabled: bool = false
var _local_menu_input_owners: Dictionary[StringName, bool] = {}
var _local_player: PlayerType
var _local_inventory: FishInventoryType
var _local_collection_log: CollectionLogType
var _local_bag: PlayerBagType
var _local_hotbar: PlayerHotbarType
var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType
var _item_effects: PlayerItemEffectsType
var _network_item_use: NetworkItemUseService
var _cooler_capacity: PlayerCoolerCapacityType
var _network_session: NetworkSessionType
var _network_fishing: NetworkFishingServiceType
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 _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 _cooldown_status: String = ""
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
var _pending_catch: FishCatchType
var _showcase_ready: bool = false
var _put_away_press_armed: bool = false
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(
local_player: PlayerType,
local_inventory: FishInventoryType,
local_collection_log: CollectionLogType,
local_bag: PlayerBagType,
local_hotbar: PlayerHotbarType,
item_catalog: ItemCatalogType,
fishing_upgrades: PlayerFishingUpgradesType,
item_effects: PlayerItemEffectsType,
network_item_use: NetworkItemUseService,
cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType = null,
network_fishing: NetworkFishingServiceType = null,
) -> void:
_local_player = local_player
_local_inventory = local_inventory
_local_collection_log = local_collection_log
_local_bag = local_bag
_local_hotbar = local_hotbar
_item_catalog = item_catalog
_fishing_upgrades = fishing_upgrades
_item_effects = item_effects
_network_item_use = network_item_use
if _network_item_use != null:
_network_item_use.local_item_use_finished.connect(
_on_network_item_use_finished
)
_cooler_capacity = cooler_capacity
_network_session = network_session
_network_fishing = network_fishing
if _network_fishing != null:
_network_fishing.local_cast_accepted.connect(
_on_network_cast_accepted
)
_network_fishing.local_cast_rejected.connect(
_on_network_cast_rejected
)
_network_fishing.local_bite_started.connect(
_on_network_bite_started
)
_network_fishing.local_snapshot_received.connect(
_on_network_fishing_snapshot
)
_network_fishing.local_catch_received.connect(
_on_network_catch_received
)
_network_fishing.local_attempt_ended.connect(
_on_network_attempt_ended
)
if not _local_inventory.catches_changed.is_connected(
_on_cooler_availability_changed
):
_local_inventory.catches_changed.connect(
_on_cooler_availability_changed
)
if not _cooler_capacity.capacity_changed.is_connected(
_on_cooler_capacity_changed
):
_cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed)
func can_open_player_menu() -> bool:
return (
_gameplay_input_enabled
and not _external_input_blocked
and _local_menu_input_owners.is_empty()
and state in [
FishingState.READY,
FishingState.WAITING_FOR_BITE,
]
)
func can_open_system_menu() -> bool:
return (
_gameplay_input_enabled
and not _external_input_blocked
and _local_menu_input_owners.is_empty()
and state in [
FishingState.READY,
FishingState.WAITING_FOR_BITE,
]
)
func can_open_fishing_shop() -> bool:
return (
_can_use_shop_gameplay()
and
_gameplay_input_enabled
and not _external_input_blocked
and _local_menu_input_owners.is_empty()
and state == FishingState.READY
)
func can_use_surface_drawing() -> bool:
return (
_gameplay_input_enabled
and not _external_input_blocked
and state == FishingState.READY
and _active_player == null
)
func is_ready_for_shop_transaction() -> bool:
return (
_can_use_shop_gameplay()
and
_gameplay_input_enabled
and not _external_input_blocked
and state == FishingState.READY
)
func can_change_hotbar_selection() -> bool:
return (
_gameplay_input_enabled
and not _external_input_blocked
and _local_menu_input_owners.is_empty()
and state == FishingState.READY
)
func handle_escape_action() -> bool:
if not _gameplay_input_enabled or _external_input_blocked:
return false
if state == FishingState.SHOWING_CATCH:
_put_away_catch()
return true
if (
_active_player != null
and state in [
FishingState.AIMING_CAST,
FishingState.CASTING,
FishingState.FIGHTING,
]
):
_cancel_attempt()
return true
return false
func set_local_menu_input_suppressed(
owner: StringName,
suppressed: bool,
) -> void:
if owner.is_empty():
return
if suppressed:
_local_menu_input_owners[owner] = true
else:
_local_menu_input_owners.erase(owner)
if suppressed and state == FishingState.WAITING_FOR_BITE:
_withdrawal_input_held = false
_presentation.set_line_mode(
FishingPresentationType.LineMode.SLACK
)
func set_gameplay_input_enabled(enabled: bool) -> void:
_gameplay_input_enabled = enabled
if not enabled:
_local_menu_input_owners.clear()
func configure_accessibility_auto_click(
enabled: bool,
interval: float,
) -> void:
_catch_controller.configure_accessibility(enabled, interval)
func begin_water_recovery() -> void:
_external_input_blocked = true
if state in [
FishingState.AIMING_CAST,
FishingState.CASTING,
FishingState.WAITING_FOR_BITE,
FishingState.FIGHTING,
]:
_cancel_attempt()
elif state == FishingState.SHOWING_CATCH:
_secure_showcase_catch_for_recovery()
func end_water_recovery() -> void:
_external_input_blocked = false
func _secure_showcase_catch_for_recovery() -> void:
if (
_pending_catch != null
and _local_inventory != null
and _local_collection_log != null
):
_local_inventory.add_catch(_pending_catch)
_local_collection_log.mark_discovered(_pending_catch.fish_id)
_pending_catch = null
_showcase_ready = false
_put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false)
if _active_player != null:
_active_player.end_catch_showcase(Callable(), true)
_cleanup_attempt()
func _exit_tree() -> void:
_showcase_restore_generation += 1
if (
state == FishingState.SHOWING_CATCH
and _pending_catch != null
and _local_inventory != null
and is_instance_valid(_local_inventory)
and _local_collection_log != null
and is_instance_valid(_local_collection_log)
):
_local_inventory.add_catch(_pending_catch)
_local_collection_log.mark_discovered(_pending_catch.fish_id)
_pending_catch = null
if _active_player != null and is_instance_valid(_active_player):
_active_player.end_catch_showcase(Callable(), true)
_active_player.set_movement_enabled(true)
_active_player = null
_selected_water_region = null
_selection_context = null
_selected_fish = null
_showcase_ready = false
_put_away_press_armed = false
func _process(delta: float) -> void:
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
_new_cast_press_armed = true
if (
state == FishingState.SHOWING_CATCH
and _showcase_ready
and not Input.is_action_pressed("fish_primary")
):
_put_away_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:
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(
_get_effective_reel_speed(),
_get_effective_barrier_damage()
)
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
if _state_time_remaining <= 0.0:
_return_to_ready()
func _unhandled_input(event: InputEvent) -> void:
if (
not _gameplay_input_enabled
or _external_input_blocked
or not _local_menu_input_owners.is_empty()
):
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
if (
_local_hotbar != null
and not _local_hotbar.get_selected_fish_catch_id().is_empty()
):
fish_showcase_toggle_requested.emit()
get_viewport().set_input_as_handled()
return
var active_item: ItemDataType = _get_active_item()
if (
active_item != null
and active_item.item_id == ArtShopStockType.ART_KIT_ITEM_ID
):
art_ui_toggle_requested.emit()
get_viewport().set_input_as_handled()
return
if (
active_item != null
and active_item.category == ItemDataType.Category.CONSUMABLE
):
if _network_item_use != null:
_network_item_use.request_use(active_item.item_id)
elif _item_effects.use_consumable(active_item, _local_bag):
status_changed.emit(_item_effects.get_feedback(
active_item.item_id
))
get_viewport().set_input_as_handled()
return
if not has_active_fishing_rod():
get_viewport().set_input_as_handled()
return
if (
_is_cooler_full()
):
status_changed.emit(
"cooler full. sell fish before casting again."
)
get_viewport().set_input_as_handled()
return
_begin_aiming(_local_player)
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()
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
FishingState.SHOWING_CATCH:
if _showcase_ready and _put_away_press_armed:
_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()
elif state == FishingState.AIMING_CAST:
_confirm_cast()
get_viewport().set_input_as_handled()
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)
get_viewport().set_input_as_handled()
func _begin_aiming(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null:
return
_active_player = player
_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("")
_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
)
func _can_use_shared_gameplay() -> bool:
return _network_session == null or _network_session.can_use_host_gameplay()
func _can_use_shop_gameplay() -> bool:
return (
_network_session == null
or _network_session.is_host()
or (
_network_session.is_joined_client()
and _network_session.supports_server_capability(&"shop_v1")
)
)
func has_active_fishing_rod() -> bool:
if (
_local_bag == null
or _local_hotbar == null
or _item_catalog == null
):
return false
var item: ItemDataType = _get_active_item()
return (
item != null
and item.is_valid()
and item.category == ItemDataType.Category.ROD
and item.usable
and item.equippable
)
func _get_active_item() -> ItemDataType:
if (
_local_bag == null
or _local_hotbar == null
or _item_catalog == null
):
return null
var item_id: StringName = _local_hotbar.get_selected_item_id()
if item_id.is_empty() or not _local_bag.owns_item(item_id):
return null
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
return item if item != null and item.is_valid() else null
func _is_cooler_full() -> bool:
return (
_cooler_capacity != null
and _local_inventory != null
and _local_inventory.get_all_catches().size()
>= _cooler_capacity.get_capacity()
)
func _on_cooler_availability_changed() -> void:
if state == FishingState.READY and not _is_cooler_full():
refresh_active_item_status()
func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
_on_cooler_availability_changed()
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("")
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)
_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
)
_presentation.update_rod_charge(_cast_charge)
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,
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,
_cast_target,
_cast_charge,
_build_network_evidence()
)
status_changed.emit("")
return
_selected_water_region = get_fishable_water_region(_cast_target)
_selection_context = _build_fishing_context(_selected_water_region)
_fish_selector.undiscovered_weight_multiplier = undiscovered_weight_multiplier
_fish_selector.rarity_weight_multipliers = []
for rarity: int in range(FishDataType.Rarity.size()):
_fish_selector.rarity_weight_multipliers.append(
_item_effects.get_rarity_weight_multiplier(rarity)
if _item_effects != null
else 1.0
)
_fish_selector.use_deterministic_test_seed = use_deterministic_selection_seed
_fish_selector.deterministic_test_seed = deterministic_selection_seed
_fish_selector.begin_roll()
_selected_fish = _fish_selector.select_fish(
_selected_water_region.fish_pool,
_selection_context,
_local_collection_log
)
if _selected_fish == null:
_cleanup_attempt("nothing is biting here.", &"invalid")
return
state = FishingState.WAITING_FOR_BITE
_state_time_remaining = roll_bite_wait_time() * (
_item_effects.get_bite_time_multiplier()
if _item_effects != null
else 1.0
)
_withdrawal_progress = 0.0
_withdrawal_input_held = false
_network_auto_click_accumulator = 0.0
_network_active_barrier_index = -1
_bobber_water_position = _cast_target
_withdrawal_endpoint = Vector3(
_cast_origin_position.x + _cast_direction.x * withdrawal_cancel_distance,
_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
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 withdrawal_surface: FishingSurfaceSampleType = (
resolve_safe_withdrawal_surface(
_bobber_water_position,
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()
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 withdrawal_surface: FishingSurfaceSampleType = (
resolve_safe_withdrawal_surface(
_bobber_water_position,
desired_position,
)
)
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
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
if (
_active_player == null
or _selected_fish == null
or _selected_fish.catch_profile == null
):
_cancel_attempt()
return
state = FishingState.FIGHTING
_state_time_remaining = 0.0
_withdrawal_input_held = false
bite_activated.emit()
status_changed.emit("fish on!")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_fight()
_catch_controller.start_encounter(
_selected_fish.catch_profile,
_get_effective_reel_speed(),
_get_effective_barrier_damage()
)
_catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary")
)
_presentation.show_bite()
func _get_effective_reel_speed() -> float:
if _active_player == null:
return 0.0
return _active_player.reel_speed * (
_fishing_upgrades.get_reel_speed_multiplier()
if _fishing_upgrades != null
else 1.0
) * (
_item_effects.get_reel_multiplier()
if _item_effects != null
else 1.0
)
func _get_effective_barrier_damage() -> int:
if _active_player == null:
return 1
return (
_fishing_upgrades.get_barrier_damage()
if _fishing_upgrades != null
else _active_player.click_power
) + (
_item_effects.get_barrier_bonus()
if _item_effects != null
else 0
)
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
func _on_catch_completed() -> void:
if (
state != FishingState.FIGHTING
or _active_player == null
or _selected_fish == null
):
_cancel_attempt()
return
_pending_catch = _fish_selector.create_catch(_selected_fish)
if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt()
return
state = FishingState.SHOWING_CATCH
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
_catch_controller.reset()
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.play_outcome(&"catch")
func _on_catch_escaped() -> void:
if state != FishingState.FIGHTING:
return
var fish_name: String = "the fish"
if _selected_fish != null and not _selected_fish.display_name.is_empty():
fish_name = "the %s" % _selected_fish.display_name
_cleanup_attempt("%s got away!" % fish_name, &"escape")
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
or _active_player == null
or _pending_catch == null
or _showcase_outcome_completed
):
return
_showcase_outcome_completed = true
_showcase_ready = true
_active_player.begin_catch_showcase(_pending_catch)
showcase_changed.emit(
_pending_catch.fish.display_name,
_pending_catch.fish.get_rarity_name(),
_pending_catch.weight_lb,
true
)
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
or _active_player == null
or _local_inventory == null
or _local_collection_log == null
or _pending_catch == null
):
return
_local_inventory.add_catch(_pending_catch)
_local_collection_log.mark_discovered(_pending_catch.fish_id)
_pending_catch = null
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false)
_showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation
_active_player.end_catch_showcase(
_finish_showcase_put_away.bind(
restore_generation,
""
)
)
func _finish_showcase_put_away(
restore_generation: int,
cooldown_message: String,
) -> void:
if (
restore_generation != _showcase_restore_generation
or state != FishingState.SHOWING_CATCH
or _active_player == null
):
return
_cleanup_attempt(cooldown_message)
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()
_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
_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
_selected_water_region = null
_selection_context = null
_selected_fish = null
_pending_catch = null
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false)
_catch_controller.reset()
_presentation.cleanup()
_pending_cleanup_message = ""
if cooldown_message.is_empty():
state = FishingState.READY
_cooldown_status = ""
status_changed.emit("")
ready_for_equipment_refresh.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("")
ready_for_equipment_refresh.emit()
func _cancel_attempt() -> void:
if _network_fishing != null and _network_fishing.has_local_attempt():
_network_fishing.cancel_local_attempt("")
return
_cleanup_attempt("", &"cancel")
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")
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:
var query_position := Vector3(
_cast_origin_position.x + _cast_direction.x * distance,
_cast_origin_position.y,
_cast_origin_position.z + _cast_direction.z * distance
)
_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,
)
func is_target_fishable(target: Vector3) -> bool:
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:
var reference_y: float = (
_active_player.global_position.y
if _active_player != null
else target.y
)
var surface: FishingSurfaceSampleType = resolve_fishing_surface(
target,
reference_y,
)
return surface.water_region if surface.is_fishable() else null
func _build_fishing_context(
region: FishableWaterRegionType,
) -> FishingContextType:
var context := FishingContextType.new()
context.location_tags = region.location_tags.duplicate()
context.water_type = region.water_type
context.active_event_tags = context_event_tags.duplicate()
context.active_bait_tags = context_bait_tags.duplicate()
context.is_night = context_is_night
context.is_raining = context_is_raining
return context
func build_network_context(
region: FishableWaterRegionType,
) -> FishingContextType:
return _build_fishing_context(region)
func _build_network_evidence() -> Dictionary:
var rarity_multipliers: Array[float] = []
for rarity: int in range(FishDataType.Rarity.size()):
rarity_multipliers.append(1.0)
var discovered_ids: Array[String] = []
if _local_collection_log != null:
for fish_id: StringName in _local_collection_log.get_discovered_ids():
discovered_ids.append(str(fish_id))
var item: ItemDataType = _get_active_item()
return {
"rod_id": str(item.item_id) if item != null else "",
"reel_speed": _active_player.reel_speed * (
_fishing_upgrades.get_reel_speed_multiplier()
if _fishing_upgrades != null else 1.0
),
"barrier_damage": (
_fishing_upgrades.get_barrier_damage()
if _fishing_upgrades != null else _active_player.click_power
),
"bite_multiplier": 1.0,
"rarity_multipliers": rarity_multipliers,
"discovered_fish_ids": discovered_ids,
"capacity_available": not _is_cooler_full(),
}
func _on_network_item_use_finished(
accepted: bool,
message: String,
) -> void:
status_changed.emit(message)
if accepted:
refresh_active_item_status()
func _on_network_cast_accepted(
_attempt_id: String,
target: Vector3,
) -> void:
if state != FishingState.CASTING:
return
_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...")
func _on_network_cast_rejected(message: String) -> void:
if state not in [FishingState.CASTING, FishingState.AIMING_CAST]:
return
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:
if state != FishingState.WAITING_FOR_BITE:
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
bite_activated.emit()
status_changed.emit("fish on!")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_fight()
_presentation.show_bite()
_network_fishing.submit_local_input(
_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,
FishingState.FIGHTING,
]:
return
var positions := PackedFloat32Array(snapshot.get("barrier_positions", []))
var health := PackedInt32Array(snapshot.get("barrier_health", []))
var maximum_health := PackedInt32Array(
snapshot.get("barrier_max_health", [])
)
var progress: float = float(snapshot.get("progress", 0.0))
var chase_progress: float = float(snapshot.get("chase_progress", 0.0))
if state == FishingState.FIGHTING:
_network_active_barrier_index = int(
snapshot.get("active_barrier_index", -1)
)
catch_display_changed.emit(
progress,
chase_progress,
positions,
health,
maximum_health,
int(snapshot.get("active_barrier_index", -1)),
bool(snapshot.get("visible", false))
)
var bobber_data: Array = snapshot.get("bobber_position", [])
if bobber_data.size() == 3:
_bobber_water_position = Vector3(
float(bobber_data[0]),
float(bobber_data[1]),
float(bobber_data[2])
)
if state == FishingState.WAITING_FOR_BITE:
_presentation.show_withdrawal_position(_bobber_water_position)
func _update_network_auto_click(delta: float) -> void:
if (
not _catch_controller.auto_click_enabled
or not Input.is_action_pressed("fish_primary")
or _network_active_barrier_index < 0
):
_network_auto_click_accumulator = 0.0
return
_network_auto_click_accumulator += delta
var interval: float = maxf(
_catch_controller.auto_click_interval,
0.05
)
while _network_auto_click_accumulator >= interval:
_network_auto_click_accumulator -= interval
_network_fishing.submit_local_input(true, true)
func _on_network_catch_received(fish_catch: FishCatchType) -> void:
if state != FishingState.FIGHTING or fish_catch == null:
return
_pending_catch = fish_catch
state = FishingState.SHOWING_CATCH
_showcase_ready = false
_showcase_outcome_completed = false
_put_away_press_armed = false
catch_display_changed.emit(
0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
PackedInt32Array(), -1, false
)
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.play_outcome(&"catch")
func _on_network_attempt_ended(
outcome: StringName,
message: String,
) -> void:
if state not in [
FishingState.CASTING,
FishingState.WAITING_FOR_BITE,
FishingState.FIGHTING,
]:
return
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)
)
return _surface_resolver.resolve_withdrawal_surface(
get_world_3d().direct_space_state,
from,
to,
direction_to_player,
reference_y,
withdrawal_surface_clearance,
)