Make fishing challenge scale with catch quality

This commit is contained in:
Alexander Sellite 2026-08-03 00:43:19 -04:00
parent c806e7ca6f
commit 46df74361a
10 changed files with 128 additions and 32 deletions

View file

@ -16,6 +16,10 @@ const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1
# multipliers later without changing catch serialization or tier identity.
const BASE_ROLL_WEIGHTS: Array[float] = [40.0, 32.0, 18.0, 8.0, 2.0]
const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0]
# Provisional challenge curve. Fish profiles continue to own their baseline
# barrier health; quality scales that authored baseline before player upgrades
# apply damage. This keeps future rods, bait, and lures on one shared seam.
const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25]
const DISPLAY_NAMES: PackedStringArray = [
"boring",
"average",
@ -52,6 +56,24 @@ static func apply_sale_value(base_value: int, quality: int) -> int:
)
static func barrier_health_multiplier(quality: int) -> float:
return (
BARRIER_HEALTH_MULTIPLIERS[quality]
if is_valid(quality)
else BARRIER_HEALTH_MULTIPLIERS[Tier.BORING]
)
static func apply_barrier_health(base_health: int, quality: int) -> int:
return maxi(
ceili(
float(maxi(base_health, 1))
* barrier_health_multiplier(quality)
),
1,
)
static func roll(
rng: RandomNumberGenerator,
weight_multipliers: Array[float] = [],

View file

@ -11,6 +11,3 @@ barrier_health_max = 7
first_barrier_margin = 0.14
final_barrier_margin = 0.1
minimum_barrier_spacing = 0.14
chase_start_delay = 1.0
chase_start_offset = 0.16
chase_speed = 0.145

View file

@ -11,6 +11,3 @@ barrier_health_max = 8
first_barrier_margin = 0.13
final_barrier_margin = 0.09
minimum_barrier_spacing = 0.14
chase_start_delay = 1.1
chase_start_offset = 0.18
chase_speed = 0.15

View file

@ -4,6 +4,7 @@ extends Node
const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd"
)
const FishQualityType = preload("res://fish/fish_quality.gd")
signal encounter_updated(
progress: float,
@ -42,6 +43,13 @@ class Barrier:
maximum_health = barrier_health
# Fight movement uses one shared baseline. Species and quality difficulty comes
# from barrier placement and health, while reel upgrades affect only the
# player's progress speed.
const CHASE_START_DELAY: float = 0.5
const CHASE_START_OFFSET: float = 0.04
const CHASE_SPEED: float = 0.07
@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
@ -60,9 +68,8 @@ 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 _fish_quality: int = FishQualityType.Tier.BORING
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()
@ -103,6 +110,7 @@ func start_encounter(
profile: CatchDifficultyProfileType,
reel_speed: float,
click_power: int,
fish_quality: int = FishQualityType.Tier.BORING,
) -> void:
reset()
if profile == null:
@ -111,10 +119,13 @@ func start_encounter(
_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)
_fish_quality = (
fish_quality
if FishQualityType.is_valid(fish_quality)
else FishQualityType.Tier.BORING
)
_chase_delay_remaining = CHASE_START_DELAY
chase_progress = -CHASE_START_OFFSET
_seed_encounter_rng()
_generate_barriers(profile)
progress = 0.0
@ -127,12 +138,13 @@ func start_authoritative_encounter(
reel_speed: float,
click_power: int,
seed: int,
fish_quality: int = FishQualityType.Tier.BORING,
) -> void:
var previous_test_mode: bool = use_deterministic_test_seed
var previous_seed: int = deterministic_test_seed
use_deterministic_test_seed = true
deterministic_test_seed = seed
start_encounter(profile, reel_speed, click_power)
start_encounter(profile, reel_speed, click_power, fish_quality)
use_deterministic_test_seed = previous_test_mode
deterministic_test_seed = previous_seed
@ -168,9 +180,8 @@ func reset() -> void:
_reel_input_held = false
_reel_speed = 0.0
_click_power = 1
_chase_start_delay = 0.0
_fish_quality = FishQualityType.Tier.BORING
_chase_delay_remaining = 0.0
_chase_speed = 0.0
_auto_click_accumulator = 0.0
emit_signal(
"encounter_updated",
@ -215,7 +226,7 @@ func _update_chase(delta: float) -> void:
if active_delta <= 0.0:
return
chase_progress = minf(
chase_progress + _chase_speed * active_delta,
chase_progress + CHASE_SPEED * active_delta,
1.0
)
@ -302,7 +313,14 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
+ spacing * float(barrier_index)
+ random_offsets[barrier_index] * random_slack
)
var health: int = _rng.randi_range(health_range.x, health_range.y)
var base_health: int = _rng.randi_range(
health_range.x,
health_range.y,
)
var health: int = FishQualityType.apply_barrier_health(
base_health,
_fish_quality,
)
_barriers.append(Barrier.new(position, health))

View file

@ -8,9 +8,6 @@ extends Resource
@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:

View file

@ -11,6 +11,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

@ -919,6 +919,10 @@ func _activate_bite() -> void:
state = FishingState.FIGHTING
_state_time_remaining = 0.0
_withdrawal_input_held = false
_pending_catch = _fish_selector.create_catch(_selected_fish)
if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt()
return
bite_activated.emit()
status_changed.emit("fish on!")
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
@ -926,7 +930,8 @@ func _activate_bite() -> void:
_catch_controller.start_encounter(
_selected_fish.catch_profile,
_get_effective_reel_speed(),
_get_effective_barrier_damage()
_get_effective_barrier_damage(),
_pending_catch.quality,
)
_catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary")
@ -993,7 +998,6 @@ func _on_catch_completed() -> void:
_cancel_attempt()
return
_pending_catch = _fish_selector.create_catch(_selected_fish)
if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt()
return

View file

@ -429,11 +429,21 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void:
return
attempt.phase = NetworkFishingAttempt.Phase.FIGHTING
attempt.encounter_seed = _new_seed()
var selector := FishSelectorType.new()
selector.use_deterministic_test_seed = true
selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF
selector.begin_roll()
var fish_catch: FishCatch = selector.create_catch(fish)
if fish_catch == null or not fish_catch.is_valid():
_cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.")
return
attempt.catch_payload = fish_catch.to_network_dict()
attempt.controller.start_authoritative_encounter(
fish.catch_profile,
attempt.reel_speed,
attempt.barrier_damage,
attempt.encounter_seed
attempt.encounter_seed,
fish_catch.quality,
)
var data: Dictionary = {
"attempt_id": attempt.attempt_id,
@ -617,11 +627,10 @@ func _on_attempt_caught(peer_id: int) -> void:
if fish == null:
_cancel_attempt(peer_id, "Fishing attempt ended.")
return
var selector := FishSelectorType.new()
selector.use_deterministic_test_seed = true
selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF
selector.begin_roll()
var fish_catch: FishCatch = selector.create_catch(fish)
var fish_catch: FishCatch = FishCatchType.from_network_dict(
attempt.catch_payload,
fish,
)
if fish_catch == null or not fish_catch.is_valid():
_cancel_attempt(peer_id, "Fishing attempt ended.")
return

View file

@ -31,6 +31,7 @@ const FishingRodAttachmentScene = preload(
const CHARACTER_IDLE_ANIMATION: StringName = &"idle"
const CHARACTER_WALKING_ANIMATION: StringName = &"walking"
const CHARACTER_SITTING_ANIMATION: StringName = &"sitting"
const BASE_REEL_SPEED: float = 0.16
var appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
@ -69,7 +70,7 @@ class ShowcaseCameraSnapshot:
@export var local_control_enabled: bool = true
@export_category("Fishing Stats")
@export_range(0.01, 2.0, 0.01) var reel_speed: float = 0.32
@export_range(0.01, 2.0, 0.01) var reel_speed: float = BASE_REEL_SPEED
@export_range(1, 100, 1) var click_power: int = 1
@export_category("Showcase")

View file

@ -97,6 +97,60 @@ func _run() -> void:
assert(player.is_movement_enabled())
assert(not bobber.visible)
# A private host rolls and retains the authoritative catch before the fight
# so its quality can scale the same barriers clients receive in snapshots.
fishing_spot.call("_begin_aiming", player)
fishing_spot.set("_cast_charge", 0.32)
fishing_spot.call("_update_cast_charge", 0.0)
fishing_spot.call("_confirm_cast")
var second_wait_deadline: int = Time.get_ticks_msec() + 4000
while (
Time.get_ticks_msec() < second_wait_deadline
and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE
):
await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE)
attempts = service.get("_attempts")
attempt = attempts.get(session.get_local_peer_id())
assert(attempt != null)
service.call("_start_bite", attempt)
await process_frame
assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING)
var catalog: FishPool = main.get("fish_catalog") as FishPool
assert(catalog != null)
var fish: FishData = catalog.get_fish_by_id(attempt.fish_id)
var fish_catch := FishCatch.from_network_dict(
attempt.catch_payload,
fish,
)
assert(fish_catch != null and fish_catch.is_valid())
var baseline_controller := CatchController.new()
root.add_child(baseline_controller)
baseline_controller.start_authoritative_encounter(
fish.catch_profile,
attempt.reel_speed,
attempt.barrier_damage,
attempt.encounter_seed,
FishQuality.Tier.BORING,
)
var quality_barriers: Array = attempt.controller.get("_barriers")
var baseline_barriers: Array = baseline_controller.get("_barriers")
assert(quality_barriers.size() == baseline_barriers.size())
for barrier_index: int in quality_barriers.size():
var quality_barrier: RefCounted = quality_barriers[barrier_index]
var baseline_barrier: RefCounted = baseline_barriers[barrier_index]
assert(
int(quality_barrier.get("maximum_health"))
== FishQuality.apply_barrier_health(
int(baseline_barrier.get("maximum_health")),
fish_catch.quality,
)
)
baseline_controller.queue_free()
service.call("_cancel_attempt", session.get_local_peer_id(), "")
await process_frame
assert(not service.has_local_attempt())
print("Fishing authority validation: PASS")
session.disconnect_session("")
main.queue_free()