Add barrier-based catch and chase mechanic

This commit is contained in:
Alexander Sellite 2026-07-25 14:02:02 -04:00
parent ad848f9431
commit ae2f724fe2
12 changed files with 631 additions and 68 deletions

314
fishing/catch_controller.gd Normal file
View file

@ -0,0 +1,314 @@
class_name CatchController
extends Node
const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd"
)
signal encounter_updated(
progress: float,
chase_progress: float,
barrier_positions: PackedFloat32Array,
barrier_health: PackedInt32Array,
barrier_max_health: PackedInt32Array,
active_barrier_index: int,
visible: bool,
)
signal caught
signal escaped
enum CatchState {
IDLE,
REELING,
BLOCKED_BY_BARRIER,
CAUGHT,
ESCAPED,
}
class Barrier:
extends RefCounted
var position: float
var health: int
var maximum_health: int
var defeated: bool = false
func _init(
barrier_position: float,
barrier_health: int,
) -> void:
position = barrier_position
health = barrier_health
maximum_health = barrier_health
@export_category("Accessibility")
@export var auto_click_enabled: bool = false
@export_range(0.05, 2.0, 0.01) var auto_click_interval: float = 0.20
@export_category("Testing")
@export var use_deterministic_test_seed: bool = false
@export var deterministic_test_seed: int = 12345
var state: CatchState = CatchState.IDLE
var progress: float = 0.0
var chase_progress: float = 0.0
var encounter_seed: int = 0
var _barriers: Array[Barrier] = []
var _active_barrier_index: int = -1
var _reel_input_held: bool = false
var _reel_speed: float = 0.0
var _click_power: int = 1
var _chase_start_delay: float = 0.0
var _chase_delay_remaining: float = 0.0
var _chase_speed: float = 0.0
var _failure_epsilon: float = 0.0001
var _auto_click_accumulator: float = 0.0
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _process(delta: float) -> void:
match state:
CatchState.REELING:
_update_free_reeling(delta)
CatchState.BLOCKED_BY_BARRIER:
_update_auto_click(delta)
_:
return
if state in [CatchState.CAUGHT, CatchState.ESCAPED, CatchState.IDLE]:
return
if progress >= 1.0:
_resolve_catch()
return
_update_chase(delta)
if _is_chase_failure():
_resolve_escape()
return
_emit_encounter_update()
func start_encounter(
profile: CatchDifficultyProfileType,
reel_speed: float,
click_power: int,
) -> void:
reset()
if profile == null:
_resolve_escape()
return
_reel_speed = maxf(reel_speed, 0.0)
_click_power = maxi(click_power, 1)
_chase_start_delay = maxf(profile.chase_start_delay, 0.0)
_chase_delay_remaining = _chase_start_delay
chase_progress = -maxf(profile.chase_start_offset, 0.01)
_chase_speed = maxf(profile.chase_speed, 0.0)
_seed_encounter_rng()
_generate_barriers(profile)
progress = 0.0
state = CatchState.REELING
_emit_encounter_update()
func set_reel_input(held: bool) -> void:
_reel_input_held = held
if not held:
_auto_click_accumulator = 0.0
func handle_primary_pressed() -> void:
if state not in [CatchState.REELING, CatchState.BLOCKED_BY_BARRIER]:
return
_reel_input_held = true
if state == CatchState.BLOCKED_BY_BARRIER and not auto_click_enabled:
_damage_active_barrier()
func reset() -> void:
state = CatchState.IDLE
progress = 0.0
chase_progress = 0.0
encounter_seed = 0
_barriers.clear()
_active_barrier_index = -1
_reel_input_held = false
_reel_speed = 0.0
_click_power = 1
_chase_start_delay = 0.0
_chase_delay_remaining = 0.0
_chase_speed = 0.0
_auto_click_accumulator = 0.0
emit_signal(
"encounter_updated",
0.0,
0.0,
PackedFloat32Array(),
PackedInt32Array(),
PackedInt32Array(),
-1,
false
)
func get_barrier_count() -> int:
return _barriers.size()
func _update_free_reeling(delta: float) -> void:
if not _reel_input_held:
return
var intended_progress: float = minf(
progress + _reel_speed * delta,
1.0
)
var next_barrier: Barrier = _get_next_undefeated_barrier()
if next_barrier != null and intended_progress >= next_barrier.position:
progress = next_barrier.position
_active_barrier_index = _barriers.find(next_barrier)
state = CatchState.BLOCKED_BY_BARRIER
_auto_click_accumulator = 0.0
else:
progress = intended_progress
func _update_chase(delta: float) -> void:
var active_delta: float = delta
if _chase_delay_remaining > 0.0:
var protected_time: float = minf(_chase_delay_remaining, active_delta)
_chase_delay_remaining -= protected_time
active_delta -= protected_time
if active_delta <= 0.0:
return
chase_progress = minf(
chase_progress + _chase_speed * active_delta,
1.0
)
func _is_chase_failure() -> bool:
if _chase_delay_remaining > 0.0:
return false
return chase_progress + _failure_epsilon >= progress
func _update_auto_click(delta: float) -> void:
if not auto_click_enabled or not _reel_input_held:
return
_auto_click_accumulator += delta
var interval: float = maxf(auto_click_interval, 0.05)
while (
_auto_click_accumulator >= interval
and state == CatchState.BLOCKED_BY_BARRIER
and _reel_input_held
):
_auto_click_accumulator -= interval
_damage_active_barrier()
func _damage_active_barrier() -> void:
if (
state != CatchState.BLOCKED_BY_BARRIER
or _active_barrier_index < 0
or _active_barrier_index >= _barriers.size()
):
return
var barrier: Barrier = _barriers[_active_barrier_index]
if barrier.defeated:
return
barrier.health = maxi(barrier.health - _click_power, 0)
if barrier.health == 0:
barrier.defeated = true
_active_barrier_index = -1
_auto_click_accumulator = 0.0
state = CatchState.REELING
_emit_encounter_update()
func _get_next_undefeated_barrier() -> Barrier:
for barrier: Barrier in _barriers:
if not barrier.defeated and barrier.position >= progress:
return barrier
return null
func _seed_encounter_rng() -> void:
if use_deterministic_test_seed:
encounter_seed = deterministic_test_seed
_rng.seed = deterministic_test_seed
else:
_rng.randomize()
encounter_seed = _rng.seed
func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
var count_range: Vector2i = profile.get_barrier_count_range()
var health_range: Vector2i = profile.get_barrier_health_range()
var interval: Vector2 = profile.get_generation_interval()
var spacing: float = maxf(profile.minimum_barrier_spacing, 0.01)
var available_distance: float = maxf(interval.y - interval.x, 0.0)
var maximum_fitting_count: int = 0
if available_distance > 0.0:
maximum_fitting_count = int(floor(available_distance / spacing)) + 1
var requested_count: int = _rng.randi_range(count_range.x, count_range.y)
var barrier_count: int = mini(requested_count, maximum_fitting_count)
if barrier_count <= 0:
return
var random_offsets := PackedFloat32Array()
for _barrier_index: int in range(barrier_count):
random_offsets.append(_rng.randf())
random_offsets.sort()
var required_spacing: float = spacing * float(barrier_count - 1)
var random_slack: float = maxf(available_distance - required_spacing, 0.0)
for barrier_index: int in range(barrier_count):
var position: float = (
interval.x
+ spacing * float(barrier_index)
+ random_offsets[barrier_index] * random_slack
)
var health: int = _rng.randi_range(health_range.x, health_range.y)
_barriers.append(Barrier.new(position, health))
func _resolve_catch() -> void:
if state in [CatchState.CAUGHT, CatchState.ESCAPED, CatchState.IDLE]:
return
state = CatchState.CAUGHT
_reel_input_held = false
_auto_click_accumulator = 0.0
_emit_encounter_update()
caught.emit()
func _resolve_escape() -> void:
if state in [CatchState.CAUGHT, CatchState.ESCAPED]:
return
state = CatchState.ESCAPED
_reel_input_held = false
_auto_click_accumulator = 0.0
_emit_encounter_update()
escaped.emit()
func _emit_encounter_update() -> void:
var positions := PackedFloat32Array()
var health := PackedInt32Array()
var maximum_health := PackedInt32Array()
for barrier: Barrier in _barriers:
positions.append(barrier.position)
health.append(barrier.health)
maximum_health.append(barrier.maximum_health)
encounter_updated.emit(
progress,
chase_progress,
positions,
health,
maximum_health,
_active_barrier_index,
state != CatchState.IDLE
)

View file

@ -0,0 +1 @@
uid://bp3fvh7q1jw07

View file

@ -0,0 +1,29 @@
class_name CatchDifficultyProfile
extends Resource
@export_range(0, 12, 1) var barrier_count_min: int = 2
@export_range(0, 12, 1) var barrier_count_max: int = 4
@export_range(1, 100, 1) var barrier_health_min: int = 3
@export_range(1, 100, 1) var barrier_health_max: int = 7
@export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15
@export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10
@export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15
@export_range(0.0, 5.0, 0.05) var chase_start_delay: float = 1.0
@export_range(0.01, 0.5, 0.01) var chase_start_offset: float = 0.15
@export_range(0.0, 2.0, 0.01) var chase_speed: float = 0.14
func get_barrier_count_range() -> Vector2i:
var minimum: int = maxi(0, barrier_count_min)
return Vector2i(minimum, maxi(minimum, barrier_count_max))
func get_barrier_health_range() -> Vector2i:
var minimum: int = maxi(1, barrier_health_min)
return Vector2i(minimum, maxi(minimum, barrier_health_max))
func get_generation_interval() -> Vector2:
var start: float = clampf(first_barrier_margin, 0.0, 0.95)
var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0)
return Vector2(start, finish)

View file

@ -0,0 +1 @@
uid://byhefytd4228q

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" load_steps=2 format=3]
[ext_resource type="Script" path="res://fishing/catch_difficulty_profile.gd" id="1_profile"]
[resource]
script = ExtResource("1_profile")
barrier_count_min = 2
barrier_count_max = 4
barrier_health_min = 3
barrier_health_max = 7
first_barrier_margin = 0.15
final_barrier_margin = 0.1
minimum_barrier_spacing = 0.15
chase_start_delay = 1.0
chase_start_offset = 0.15
chase_speed = 0.14

View file

@ -2,11 +2,20 @@ 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 reel_progress_changed(progress: float, visible: bool)
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,
@ -14,7 +23,7 @@ enum FishingState {
CASTING,
WAITING_FOR_BITE,
BITE_ACTIVE,
REELING,
FIGHTING,
COOLDOWN,
}
@ -39,13 +48,11 @@ enum FishingState {
@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 _catch_controller: CatchControllerType = %CatchController
@onready var _presentation: FishingPresentationType = %FishingPresentation
var state: FishingState = FishingState.READY
@ -62,14 +69,15 @@ 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 _reel_start_position: Vector3
var _reel_progress: float = 0.0
var _reel_input_held: bool = false
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)
@ -92,10 +100,9 @@ func _process(delta: float) -> void:
_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.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:
@ -111,7 +118,7 @@ func _unhandled_input(event: InputEvent) -> void:
FishingState.CASTING,
FishingState.WAITING_FOR_BITE,
FishingState.BITE_ACTIVE,
FishingState.REELING,
FishingState.FIGHTING,
]
):
_cancel_attempt()
@ -136,8 +143,8 @@ func _unhandled_input(event: InputEvent) -> void:
)
FishingState.BITE_ACTIVE:
_confirm_hook()
FishingState.REELING:
_reel_input_held = true
FishingState.FIGHTING:
_catch_controller.handle_primary_pressed()
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
@ -151,8 +158,8 @@ func _unhandled_input(event: InputEvent) -> void:
_withdrawal_input_held = false
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
get_viewport().set_input_as_handled()
elif state == FishingState.REELING:
_reel_input_held = false
elif state == FishingState.FIGHTING:
_catch_controller.set_reel_input(false)
get_viewport().set_input_as_handled()
@ -340,58 +347,80 @@ func _activate_bite() -> void:
func _confirm_hook() -> void:
if state != FishingState.BITE_ACTIVE or _active_player == null:
if (
state != FishingState.BITE_ACTIVE
or _active_player == null
or catchable_fish == null
or catchable_fish.catch_profile == null
):
_cancel_attempt()
return
state = FishingState.REELING
state = FishingState.FIGHTING
_state_time_remaining = 0.0
_reel_progress = clampf(hooked_starting_progress, 0.0, 0.95)
_reel_input_held = true
_reel_start_position = _bobber_water_position
_fight_start_position = _bobber_water_position
status_changed.emit("Hold left click to reel")
reel_progress_changed.emit(_reel_progress, true)
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_reeling()
_update_reel_presentation()
_catch_controller.start_encounter(
catchable_fish.catch_profile,
_active_player.reel_speed,
_active_player.click_power
)
_catch_controller.set_reel_input(true)
func _update_reel_progress(delta: float) -> void:
if state != FishingState.REELING:
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
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)
_update_reel_presentation()
if is_equal_approx(_reel_progress, 1.0):
_resolve_catch()
elif is_zero_approx(_reel_progress):
_resolve_escape()
func _update_reel_presentation() -> void:
var desired_position: Vector3 = _reel_start_position.lerp(
var desired_position: Vector3 = _fight_start_position.lerp(
_withdrawal_endpoint,
_reel_progress
progress
)
_bobber_water_position = _get_clamped_reel_position(desired_position)
_presentation.show_reel_position(
_bobber_water_position,
_reel_input_held
)
func _get_clamped_reel_position(desired_position: Vector3) -> Vector3:
return find_last_fishable_position(
_reel_start_position,
_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 _resolve_catch() -> void:
if state != FishingState.REELING or _active_player == null or catchable_fish == null:
func _on_catch_completed() -> void:
if state != FishingState.FIGHTING or _active_player == null or catchable_fish == null:
_cancel_attempt()
return
@ -399,8 +428,8 @@ func _resolve_catch() -> void:
_cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch")
func _resolve_escape() -> void:
if state != FishingState.REELING:
func _on_catch_escaped() -> void:
if state != FishingState.FIGHTING:
return
var fish_name: String = "The fish"
@ -432,10 +461,8 @@ func _cleanup_attempt(
_withdrawal_progress = 0.0
_withdrawal_input_held = false
_bobber_water_position = Vector3.ZERO
_reel_start_position = Vector3.ZERO
_reel_input_held = false
_reel_progress = 0.0
reel_progress_changed.emit(0.0, false)
_fight_start_position = Vector3.ZERO
_catch_controller.reset()
if visual_outcome.is_empty():
_presentation.cleanup()
else:

View file

@ -1,8 +1,9 @@
[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="Resource" path="res://fish/bluegill.tres" id="2_bluegill"]
[ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"]
[ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"]
[sub_resource type="SphereMesh" id="BobberMesh"]
radius = 0.12
@ -42,6 +43,10 @@ size = Vector3(0.72, 0.06, 0.12)
script = ExtResource("1_spot")
catchable_fish = ExtResource("2_bluegill")
[node name="CatchController" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("4_catch")
[node name="FishingPresentation" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("3_presentation")