Add data-driven fish selection and catch showcase

This commit is contained in:
Alexander Sellite 2026-07-25 15:15:24 -04:00
parent ae2f724fe2
commit bd2768b676
39 changed files with 1226 additions and 58 deletions

View file

@ -0,0 +1,17 @@
class_name CollectionLog
extends Node
signal fish_discovered(fish_id: StringName)
var _discovered: Dictionary[StringName, bool] = {}
func has_discovered(fish_id: StringName) -> bool:
return not fish_id.is_empty() and _discovered.has(fish_id)
func mark_discovered(fish_id: StringName) -> void:
if fish_id.is_empty() or has_discovered(fish_id):
return
_discovered[fish_id] = true
fish_discovered.emit(fish_id)

View file

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

View file

@ -1,10 +0,0 @@
[gd_resource type="Resource" load_steps=3 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
[resource]
script = ExtResource("1_fish_data")
id = &"bluegill"
display_name = "Bluegill"
catch_profile = ExtResource("2_profile")

View file

@ -0,0 +1,10 @@
[gd_resource type="Resource" load_steps=2 format=3]
[ext_resource type="Script" path="res://fish/fish_availability.gd" id="1_availability"]
[resource]
script = ExtResource("1_availability")
allow_day = true
allow_night = true
preferred_bait_tags = Array[StringName]([&"worm"])
preferred_bait_weight_multiplier = 1.35

63
fish/fish_availability.gd Normal file
View file

@ -0,0 +1,63 @@
class_name FishAvailability
extends Resource
const FishingContextType = preload("res://fishing/fishing_context.gd")
@export var allowed_location_tags: Array[StringName] = []
@export var required_event_tags: Array[StringName] = []
@export var forbidden_event_tags: Array[StringName] = []
@export var allow_day: bool = true
@export var allow_night: bool = true
@export var require_rain: bool = false
@export var forbid_rain: bool = false
@export var required_bait_tags: Array[StringName] = []
@export var preferred_bait_tags: Array[StringName] = []
@export_range(0.01, 10.0, 0.01) var preferred_bait_weight_multiplier: float = 1.25
func is_available(context: FishingContextType) -> bool:
if context == null or (require_rain and forbid_rain):
return false
if context.is_night and not allow_night:
return false
if not context.is_night and not allow_day:
return false
if require_rain and not context.is_raining:
return false
if forbid_rain and context.is_raining:
return false
if (
not allowed_location_tags.is_empty()
and not _has_any_match(allowed_location_tags, context.location_tags)
):
return false
for required_tag: StringName in required_event_tags:
if required_tag not in context.active_event_tags:
return false
for forbidden_tag: StringName in forbidden_event_tags:
if forbidden_tag in context.active_event_tags:
return false
if (
not required_bait_tags.is_empty()
and not _has_any_match(required_bait_tags, context.active_bait_tags)
):
return false
return true
func get_bait_weight_multiplier(context: FishingContextType) -> float:
if context == null:
return 1.0
if _has_any_match(preferred_bait_tags, context.active_bait_tags):
return maxf(preferred_bait_weight_multiplier, 0.01)
return 1.0
func _has_any_match(
required_tags: Array[StringName],
active_tags: Array[StringName],
) -> bool:
for tag: StringName in required_tags:
if tag in active_tags:
return true
return false

View file

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

20
fish/fish_catch.gd Normal file
View file

@ -0,0 +1,20 @@
class_name FishCatch
extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
@export var fish: FishDataType
@export var fish_id: StringName
@export var weight_lb: float = 0.0
@export var display_scale: float = 1.0
func is_valid() -> bool:
return (
fish != null
and not fish_id.is_empty()
and fish.id == fish_id
and weight_lb >= fish.get_minimum_weight()
and weight_lb <= fish.get_maximum_weight()
and display_scale > 0.0
)

1
fish/fish_catch.gd.uid Normal file
View file

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

View file

@ -4,8 +4,69 @@ extends Resource
const CatchDifficultyProfileType = preload( const CatchDifficultyProfileType = preload(
"res://fishing/catch_difficulty_profile.gd" "res://fishing/catch_difficulty_profile.gd"
) )
const FishAvailabilityType = preload("res://fish/fish_availability.gd")
enum Rarity {
COMMON,
UNCOMMON,
RARE,
EPIC,
LEGENDARY,
}
@export var id: StringName @export var id: StringName
@export var display_name: String @export var display_name: String
@export var icon: Texture2D @export var rarity: Rarity = Rarity.COMMON
@export_range(0.0, 1000.0, 0.01) var base_catch_weight: float = 1.0
@export var catch_profile: CatchDifficultyProfileType @export var catch_profile: CatchDifficultyProfileType
@export var availability: FishAvailabilityType
@export_range(0.01, 1000.0, 0.01) var weight_min_lb: float = 0.5
@export_range(0.01, 1000.0, 0.01) var weight_max_lb: float = 1.0
@export_range(0.01, 20.0, 0.01) var display_scale_min: float = 0.8
@export_range(0.01, 20.0, 0.01) var display_scale_max: float = 1.2
@export_range(0.01, 10.0, 0.01) var display_scale_curve: float = 1.0
@export var display_texture: Texture2D
func is_selectable() -> bool:
return (
not id.is_empty()
and base_catch_weight > 0.0
and catch_profile != null
and weight_min_lb > 0.0
and weight_max_lb >= weight_min_lb
and display_scale_min > 0.0
and display_scale_max >= display_scale_min
and display_scale_curve > 0.0
)
func get_minimum_weight() -> float:
return maxf(weight_min_lb, 0.01)
func get_maximum_weight() -> float:
return maxf(weight_max_lb, get_minimum_weight())
func get_display_scale_for_weight(weight_lb: float) -> float:
var minimum_weight: float = get_minimum_weight()
var maximum_weight: float = get_maximum_weight()
var normalized_weight: float = 0.0
if maximum_weight > minimum_weight:
normalized_weight = inverse_lerp(
minimum_weight,
maximum_weight,
weight_lb
)
normalized_weight = pow(
clampf(normalized_weight, 0.0, 1.0),
maxf(display_scale_curve, 0.01)
)
var minimum_scale: float = maxf(display_scale_min, 0.01)
var maximum_scale: float = maxf(display_scale_max, minimum_scale)
return lerpf(minimum_scale, maximum_scale, normalized_weight)
func get_rarity_name() -> String:
return Rarity.keys()[rarity].capitalize()

6
fish/fish_pool.gd Normal file
View file

@ -0,0 +1,6 @@
class_name FishPool
extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
@export var candidates: Array[FishDataType] = []

1
fish/fish_pool.gd.uid Normal file
View file

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

78
fish/fish_selector.gd Normal file
View file

@ -0,0 +1,78 @@
class_name FishSelector
extends RefCounted
const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingContextType = preload("res://fishing/fishing_context.gd")
var undiscovered_weight_multiplier: float = 1.5
var use_deterministic_test_seed: bool = false
var deterministic_test_seed: int = 24680
var selection_seed: int = 0
var _rng := RandomNumberGenerator.new()
func begin_roll() -> void:
if use_deterministic_test_seed:
selection_seed = deterministic_test_seed
_rng.seed = deterministic_test_seed
else:
_rng.randomize()
selection_seed = _rng.seed
func select_fish(
pool: FishPoolType,
context: FishingContextType,
collection_log: CollectionLogType,
) -> FishDataType:
if pool == null or context == null or collection_log == null:
return null
var eligible_fish: Array[FishDataType] = []
var weights: Array[float] = []
var total_weight: float = 0.0
for fish: FishDataType in pool.candidates:
if fish == null or not fish.is_selectable():
continue
if fish.availability != null and not fish.availability.is_available(context):
continue
var final_weight: float = fish.base_catch_weight
if fish.availability != null:
final_weight *= fish.availability.get_bait_weight_multiplier(context)
if not collection_log.has_discovered(fish.id):
final_weight *= maxf(undiscovered_weight_multiplier, 0.0)
if final_weight <= 0.0:
continue
eligible_fish.append(fish)
weights.append(final_weight)
total_weight += final_weight
if eligible_fish.is_empty() or total_weight <= 0.0:
return null
var roll: float = _rng.randf() * total_weight
var accumulated_weight: float = 0.0
for index: int in range(eligible_fish.size()):
accumulated_weight += weights[index]
if roll <= accumulated_weight:
return eligible_fish[index]
return eligible_fish.back()
func create_catch(fish: FishDataType) -> FishCatchType:
if fish == null or not fish.is_selectable():
return null
var caught_fish := FishCatchType.new()
caught_fish.fish = fish
caught_fish.fish_id = fish.id
caught_fish.weight_lb = _rng.randf_range(
fish.get_minimum_weight(),
fish.get_maximum_weight()
)
caught_fish.display_scale = fish.get_display_scale_for_weight(
caught_fish.weight_lb
)
return caught_fish

View file

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

View file

@ -0,0 +1,10 @@
[gd_resource type="Resource" load_steps=5 format=3]
[ext_resource type="Script" path="res://fish/fish_pool.gd" id="1_pool"]
[ext_resource type="Resource" path="res://fish/species/bluegill/bluegill.tres" id="2_bluegill"]
[ext_resource type="Resource" path="res://fish/species/bass/bass.tres" id="3_bass"]
[ext_resource type="Resource" path="res://fish/species/carp/carp.tres" id="4_carp"]
[resource]
script = ExtResource("1_pool")
candidates = [ExtResource("2_bluegill"), ExtResource("3_bass"), ExtResource("4_carp")]

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="64" viewBox="0 0 128 64">
<path fill="#39713b" d="M30 32 8 12v40l22-20Z"/>
<ellipse cx="72" cy="32" rx="46" ry="24" fill="#73a94d"/>
<path fill="#2f6237" d="M35 29c24-8 48-7 74 1-27 2-51 5-74 1Z"/>
<circle cx="104" cy="26" r="3" fill="#142518"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2510mmpn6cos"
path="res://.godot/imported/bass.svg-61a5b49cdcc6e48a0677d179d664071b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/bass/bass.svg"
dest_files=["res://.godot/imported/bass.svg-61a5b49cdcc6e48a0677d179d664071b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,28 @@
[gd_resource type="Resource" load_steps=5 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"]
[ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability_script"]
[ext_resource type="Texture2D" path="res://fish/species/bass/bass.svg" id="4_texture"]
[sub_resource type="Resource" id="BassAvailability"]
script = ExtResource("3_availability_script")
allow_day = true
allow_night = true
preferred_bait_tags = Array[StringName]([&"worm", &"minnow"])
preferred_bait_weight_multiplier = 1.4
[resource]
script = ExtResource("1_fish_data")
id = &"bass"
display_name = "Bass"
rarity = 1
base_catch_weight = 4.0
catch_profile = ExtResource("2_profile")
availability = SubResource("BassAvailability")
weight_min_lb = 1.5
weight_max_lb = 8.0
display_scale_min = 0.9
display_scale_max = 1.5
display_scale_curve = 0.9
display_texture = ExtResource("4_texture")

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="64" viewBox="0 0 128 64">
<path fill="#197da8" d="M30 32 8 12v40l22-20Z"/>
<ellipse cx="72" cy="32" rx="46" ry="24" fill="#35bddd"/>
<path fill="#177595" d="M45 18c16-8 38-8 56 2-20-3-38 0-56 8Z"/>
<circle cx="104" cy="26" r="3" fill="#102533"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dye6e87xuabvm"
path="res://.godot/imported/bluegill.svg-e8296ec8dd9d840b3ba713e441cd7e1e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/bluegill/bluegill.svg"
dest_files=["res://.godot/imported/bluegill.svg-e8296ec8dd9d840b3ba713e441cd7e1e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,21 @@
[gd_resource type="Resource" load_steps=5 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
[ext_resource type="Resource" path="res://fish/default_availability.tres" id="3_availability"]
[ext_resource type="Texture2D" path="res://fish/species/bluegill/bluegill.svg" id="4_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"bluegill"
display_name = "Bluegill"
rarity = 0
base_catch_weight = 10.0
catch_profile = ExtResource("2_profile")
availability = ExtResource("3_availability")
weight_min_lb = 0.3
weight_max_lb = 2.0
display_scale_min = 0.7
display_scale_max = 1.1
display_scale_curve = 1.0
display_texture = ExtResource("4_texture")

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="64" viewBox="0 0 128 64">
<path fill="#9b5f20" d="M30 32 8 12v40l22-20Z"/>
<ellipse cx="72" cy="32" rx="46" ry="24" fill="#d89b38"/>
<path fill="#b47728" d="M39 39c20 7 44 7 67-1-21 1-43-2-67-8Z"/>
<circle cx="104" cy="26" r="3" fill="#30200f"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cv0egn63xwn3c"
path="res://.godot/imported/carp.svg-0d5425bc65e17d4f31a20a81115b2a99.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/carp/carp.svg"
dest_files=["res://.godot/imported/carp.svg-0d5425bc65e17d4f31a20a81115b2a99.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,28 @@
[gd_resource type="Resource" load_steps=5 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/carp_catch_profile.tres" id="2_profile"]
[ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability_script"]
[ext_resource type="Texture2D" path="res://fish/species/carp/carp.svg" id="4_texture"]
[sub_resource type="Resource" id="CarpAvailability"]
script = ExtResource("3_availability_script")
allow_day = true
allow_night = true
preferred_bait_tags = Array[StringName]([&"corn", &"bread"])
preferred_bait_weight_multiplier = 1.5
[resource]
script = ExtResource("1_fish_data")
id = &"carp"
display_name = "Carp"
rarity = 2
base_catch_weight = 1.5
catch_profile = ExtResource("2_profile")
availability = SubResource("CarpAvailability")
weight_min_lb = 3.0
weight_max_lb = 18.0
display_scale_min = 1.0
display_scale_max = 1.8
display_scale_curve = 0.85
display_texture = ExtResource("4_texture")

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 = 3
barrier_count_max = 4
barrier_health_min = 4
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

@ -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 = 3
barrier_count_max = 4
barrier_health_min = 5
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

@ -0,0 +1,8 @@
class_name FishingContext
extends RefCounted
var location_tags: Array[StringName] = []
var active_event_tags: Array[StringName] = []
var active_bait_tags: Array[StringName] = []
var is_night: bool = false
var is_raining: bool = false

View file

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

View file

@ -2,6 +2,7 @@ class_name FishingPresentation
extends Node3D extends Node3D
signal cast_completed signal cast_completed
signal outcome_completed(outcome: StringName)
enum VisualMode { enum VisualMode {
NONE, NONE,
@ -304,7 +305,7 @@ func play_outcome(outcome: StringName) -> void:
_: _:
_active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.18) _active_tween.tween_property(_bobber, "scale", Vector3.ZERO, 0.18)
_active_tween.finished.connect(cleanup) _active_tween.finished.connect(_on_outcome_finished.bind(outcome))
func cleanup() -> void: func cleanup() -> void:
@ -323,6 +324,11 @@ func cleanup() -> void:
set_line_mode(LineMode.HIDDEN) set_line_mode(LineMode.HIDDEN)
func _on_outcome_finished(outcome: StringName) -> void:
cleanup()
outcome_completed.emit(outcome)
func _set_cast_sample( func _set_cast_sample(
progress: float, progress: float,
start: Vector3, start: Vector3,

View file

@ -2,9 +2,17 @@ class_name FishingSpot
extends Node3D extends Node3D
const FishDataType = preload("res://fish/fish_data.gd") 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 CatchControllerType = preload("res://fishing/catch_controller.gd")
const FishingContextType = preload("res://fishing/fishing_context.gd")
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd"
)
signal status_changed(status: String) signal status_changed(status: String)
signal catch_display_changed( signal catch_display_changed(
@ -16,6 +24,12 @@ signal catch_display_changed(
active_barrier_index: int, active_barrier_index: int,
visible: bool, visible: bool,
) )
signal showcase_changed(
fish_name: String,
rarity_name: String,
weight_lb: float,
visible: bool,
)
enum FishingState { enum FishingState {
READY, READY,
@ -24,6 +38,7 @@ enum FishingState {
WAITING_FOR_BITE, WAITING_FOR_BITE,
BITE_ACTIVE, BITE_ACTIVE,
FIGHTING, FIGHTING,
SHOWING_CATCH,
COOLDOWN, COOLDOWN,
} }
@ -49,14 +64,24 @@ enum FishingState {
@export_range(0.1, 10.0, 0.1) var bite_window_duration: float = 1.5 @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.1, 10.0, 0.1) var cooldown_duration: float = 1.0
@export_category("Catch") @export_category("Selection")
@export var catchable_fish: FishDataType @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 _catch_controller: CatchControllerType = %CatchController
@onready var _presentation: FishingPresentationType = %FishingPresentation @onready var _presentation: FishingPresentationType = %FishingPresentation
var state: FishingState = FishingState.READY var state: FishingState = FishingState.READY
var _local_player: PlayerType var _local_player: PlayerType
var _local_inventory: FishInventoryType
var _local_collection_log: CollectionLogType
var _active_player: PlayerType var _active_player: PlayerType
var _state_time_remaining: float = 0.0 var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0 var _cast_charge: float = 0.0
@ -72,6 +97,14 @@ var _bobber_water_position: Vector3
var _fight_start_position: Vector3 var _fight_start_position: Vector3
var _cooldown_status: String = "" var _cooldown_status: String = ""
var _fishable_query_shape: SphereShape3D = SphereShape3D.new() var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
var _fish_selector: FishSelectorType = FishSelectorType.new()
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
func _ready() -> void: func _ready() -> void:
@ -79,15 +112,52 @@ func _ready() -> void:
_catch_controller.caught.connect(_on_catch_completed) _catch_controller.caught.connect(_on_catch_completed)
_catch_controller.escaped.connect(_on_catch_escaped) _catch_controller.escaped.connect(_on_catch_escaped)
_presentation.cast_completed.connect(_on_cast_completed) _presentation.cast_completed.connect(_on_cast_completed)
_presentation.outcome_completed.connect(_on_outcome_completed)
func setup(local_player: PlayerType) -> void: func setup(
local_player: PlayerType,
local_inventory: FishInventoryType,
local_collection_log: CollectionLogType,
) -> void:
_local_player = local_player _local_player = local_player
_local_inventory = local_inventory
_local_collection_log = local_collection_log
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: func _process(delta: float) -> void:
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
_new_cast_press_armed = true _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: match state:
FishingState.AIMING_CAST: FishingState.AIMING_CAST:
@ -110,6 +180,13 @@ func _process(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if (
event.is_action_pressed("ui_cancel")
and state == FishingState.SHOWING_CATCH
):
_put_away_catch()
get_viewport().set_input_as_handled()
return
if ( if (
event.is_action_pressed("ui_cancel") event.is_action_pressed("ui_cancel")
and _active_player != null and _active_player != null
@ -148,6 +225,11 @@ func _unhandled_input(event: InputEvent) -> void:
_presentation.set_line_mode( _presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT FishingPresentationType.LineMode.TAUT
) )
FishingState.SHOWING_CATCH:
if _showcase_ready and _put_away_press_armed:
_put_away_catch()
else:
return
_: _:
return return
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -217,10 +299,24 @@ func _on_cast_completed() -> void:
if state != FishingState.CASTING: if state != FishingState.CASTING:
return return
_cast_landing_is_fishable = is_target_fishable(_cast_target) _selected_water_region = get_fishable_water_region(_cast_target)
_cast_landing_is_fishable = _selected_water_region != null
if not _cast_landing_is_fishable: if not _cast_landing_is_fishable:
_cleanup_attempt("Can't fish there.", &"invalid") _cleanup_attempt("Can't fish there.", &"invalid")
return return
_selection_context = _build_fishing_context(_selected_water_region)
_fish_selector.undiscovered_weight_multiplier = undiscovered_weight_multiplier
_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 = FishingState.WAITING_FOR_BITE
_state_time_remaining = wait_time _state_time_remaining = wait_time
@ -350,8 +446,8 @@ func _confirm_hook() -> void:
if ( if (
state != FishingState.BITE_ACTIVE state != FishingState.BITE_ACTIVE
or _active_player == null or _active_player == null
or catchable_fish == null or _selected_fish == null
or catchable_fish.catch_profile == null or _selected_fish.catch_profile == null
): ):
_cancel_attempt() _cancel_attempt()
return return
@ -363,7 +459,7 @@ func _confirm_hook() -> void:
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_reeling() _presentation.begin_reeling()
_catch_controller.start_encounter( _catch_controller.start_encounter(
catchable_fish.catch_profile, _selected_fish.catch_profile,
_active_player.reel_speed, _active_player.reel_speed,
_active_player.click_power _active_player.click_power
) )
@ -420,12 +516,24 @@ func _on_catch_encounter_updated(
func _on_catch_completed() -> void: func _on_catch_completed() -> void:
if state != FishingState.FIGHTING or _active_player == null or catchable_fish == null: if (
state != FishingState.FIGHTING
or _active_player == null
or _selected_fish == null
):
_cancel_attempt() _cancel_attempt()
return return
_active_player.inventory.add_fish(catchable_fish) _pending_catch = _fish_selector.create_catch(_selected_fish)
_cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch") if _pending_catch == null or not _pending_catch.is_valid():
_cancel_attempt()
return
state = FishingState.SHOWING_CATCH
_showcase_ready = 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: func _on_catch_escaped() -> void:
@ -433,11 +541,69 @@ func _on_catch_escaped() -> void:
return return
var fish_name: String = "The fish" var fish_name: String = "The fish"
if catchable_fish != null and not catchable_fish.display_name.is_empty(): if _selected_fish != null and not _selected_fish.display_name.is_empty():
fish_name = "The %s" % catchable_fish.display_name fish_name = "The %s" % _selected_fish.display_name
_cleanup_attempt("%s got away!" % fish_name, &"escape") _cleanup_attempt("%s got away!" % fish_name, &"escape")
func _on_outcome_completed(outcome: StringName) -> void:
if (
outcome != &"catch"
or state != FishingState.SHOWING_CATCH
or _active_player == null
or _pending_catch == null
):
return
_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
)
status_changed.emit("Left click or Escape to put away")
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
var caught_name: String = _pending_catch.fish.display_name
_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)
_showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation
_active_player.end_catch_showcase(
_finish_showcase_put_away.bind(
restore_generation,
"Caught %s!" % caught_name
)
)
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 _miss_bite() -> void: func _miss_bite() -> void:
if state != FishingState.BITE_ACTIVE: if state != FishingState.BITE_ACTIVE:
return return
@ -448,7 +614,9 @@ func _cleanup_attempt(
cooldown_message: String = "", cooldown_message: String = "",
visual_outcome: StringName = &"", visual_outcome: StringName = &"",
) -> void: ) -> void:
_showcase_restore_generation += 1
if _active_player != null: if _active_player != null:
_active_player.end_catch_showcase()
_active_player.set_movement_enabled(true) _active_player.set_movement_enabled(true)
_active_player = null _active_player = null
_state_time_remaining = 0.0 _state_time_remaining = 0.0
@ -462,6 +630,13 @@ func _cleanup_attempt(
_withdrawal_input_held = false _withdrawal_input_held = false
_bobber_water_position = Vector3.ZERO _bobber_water_position = Vector3.ZERO
_fight_start_position = Vector3.ZERO _fight_start_position = Vector3.ZERO
_selected_water_region = null
_selection_context = null
_selected_fish = null
_pending_catch = null
_showcase_ready = false
_put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false)
_catch_controller.reset() _catch_controller.reset()
if visual_outcome.is_empty(): if visual_outcome.is_empty():
_presentation.cleanup() _presentation.cleanup()
@ -531,6 +706,12 @@ func _calculate_cast_target(distance: float) -> Vector3:
func is_target_fishable(target: Vector3) -> bool: func is_target_fishable(target: Vector3) -> bool:
return get_fishable_water_region(target) != null
func get_fishable_water_region(
target: Vector3,
) -> FishableWaterRegionType:
_fishable_query_shape.radius = fishable_query_radius _fishable_query_shape.radius = fishable_query_radius
var query := PhysicsShapeQueryParameters3D.new() var query := PhysicsShapeQueryParameters3D.new()
query.shape = _fishable_query_shape query.shape = _fishable_query_shape
@ -543,9 +724,36 @@ func is_target_fishable(target: Vector3) -> bool:
query.collide_with_bodies = false query.collide_with_bodies = false
var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape( var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape(
query, query,
1 32
) )
return not results.is_empty() var selected_region: FishableWaterRegionType
for result: Dictionary in results:
var collider: Object = result.get("collider")
var region: FishableWaterRegionType = collider as FishableWaterRegionType
if region == null or region.fish_pool == null:
continue
if (
selected_region == null
or region.selection_priority > selected_region.selection_priority
or (
region.selection_priority == selected_region.selection_priority
and region.get_instance_id() < selected_region.get_instance_id()
)
):
selected_region = region
return selected_region
func _build_fishing_context(
region: FishableWaterRegionType,
) -> FishingContextType:
var context := FishingContextType.new()
context.location_tags = region.location_tags.duplicate()
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 find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3: func find_last_fishable_position(from: Vector3, to: Vector3) -> Vector3:

View file

@ -1,7 +1,6 @@
[gd_scene load_steps=12 format=3] [gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"] [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/fishing_presentation.gd" id="3_presentation"]
[ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"] [ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"]
@ -41,7 +40,6 @@ size = Vector3(0.72, 0.06, 0.12)
[node name="FishingSpot" type="Node3D"] [node name="FishingSpot" type="Node3D"]
script = ExtResource("1_spot") script = ExtResource("1_spot")
catchable_fish = ExtResource("2_bluegill")
[node name="CatchController" type="Node" parent="."] [node name="CatchController" type="Node" parent="."]
unique_name_in_owner = true unique_name_in_owner = true

View file

@ -1,21 +1,34 @@
class_name FishInventory class_name FishInventory
extends Node extends Node
const FishDataType = preload("res://fish/fish_data.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
signal contents_changed(fish_id: StringName, count: int) signal contents_changed(fish_id: StringName, count: int)
var _fish_counts: Dictionary[StringName, int] = {} var _catches: Array[FishCatchType] = []
func add_fish(fish: FishDataType, amount: int = 1) -> void: func add_catch(fish_catch: FishCatchType) -> void:
if fish == null or fish.id.is_empty() or amount <= 0: if fish_catch == null or not fish_catch.is_valid():
return return
_catches.append(fish_catch)
var new_count: int = get_count(fish.id) + amount contents_changed.emit(
_fish_counts[fish.id] = new_count fish_catch.fish_id,
contents_changed.emit(fish.id, new_count) get_count(fish_catch.fish_id)
)
func get_count(fish_id: StringName) -> int: func get_count(fish_id: StringName) -> int:
return _fish_counts.get(fish_id, 0) return get_catches_by_fish_id(fish_id).size()
func get_all_catches() -> Array[FishCatchType]:
return _catches.duplicate()
func get_catches_by_fish_id(fish_id: StringName) -> Array[FishCatchType]:
var matching: Array[FishCatchType] = []
for fish_catch: FishCatchType in _catches:
if fish_catch.fish_id == fish_id:
matching.append(fish_catch)
return matching

View file

@ -10,5 +10,9 @@ const PlayerType = preload("res://player/player.gd")
func _ready() -> void: func _ready() -> void:
_fishing_spot.setup(_player) _fishing_spot.setup(
_player,
_player.inventory,
_player.collection_log
)
_game_ui.setup(_player.inventory, _fishing_spot) _game_ui.setup(_player.inventory, _fishing_spot)

View file

@ -2,6 +2,20 @@ class_name Player
extends CharacterBody3D extends CharacterBody3D
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
class ShowcaseCameraSnapshot:
extends RefCounted
var yaw_transform: Transform3D
var yaw_rotation: Vector3
var pitch_rotation: Vector3
var spring_length: float
var target_zoom: float
var camera_input_enabled: bool
var camera_dragging: bool
@export_category("Movement") @export_category("Movement")
@export var walk_speed: float = 5.0 @export var walk_speed: float = 5.0
@ -18,6 +32,15 @@ const FishInventoryType = preload("res://inventory/fish_inventory.gd")
@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 = 0.32
@export_range(1, 100, 1) var click_power: int = 1 @export_range(1, 100, 1) var click_power: int = 1
@export_category("Showcase")
@export_range(0.05, 1.0, 0.01) var showcase_turn_duration: float = 0.28
@export_range(0.05, 1.0, 0.01) var showcase_restore_duration: float = 0.24
@export_range(0.05, 1.5, 0.01) var showcase_camera_transition_duration: float = 0.42
@export_range(-180.0, 180.0, 1.0) var showcase_camera_yaw_offset: float = 180.0
@export_range(-80.0, 80.0, 1.0) var showcase_camera_pitch: float = -8.0
@export_range(1.0, 10.0, 0.1) var showcase_camera_zoom_distance: float = 3.6
@export_range(0.5, 3.0, 0.05) var showcase_camera_target_height: float = 1.45
@export_category("Camera") @export_category("Camera")
@export var mouse_sensitivity: float = 0.005 @export var mouse_sensitivity: float = 0.005
@export var controller_camera_speed: float = 2.5 @export var controller_camera_speed: float = 2.5
@ -35,14 +58,27 @@ const FishInventoryType = preload("res://inventory/fish_inventory.gd")
@onready var _spring_arm: SpringArm3D = %SpringArm3D @onready var _spring_arm: SpringArm3D = %SpringArm3D
@onready var _camera: Camera3D = %Camera3D @onready var _camera: Camera3D = %Camera3D
@onready var inventory: FishInventoryType = %Inventory @onready var inventory: FishInventoryType = %Inventory
@onready var collection_log: CollectionLogType = %CollectionLog
@onready var _cast_origin: Marker3D = %CastOrigin @onready var _cast_origin: Marker3D = %CastOrigin
@onready var _fishing_rod: Node3D = %FishingRod @onready var _fishing_rod: Node3D = %FishingRod
@onready var _fishing_rod_tip: Marker3D = %FishingRodTip @onready var _fishing_rod_tip: Marker3D = %FishingRodTip
@onready var _catch_display: Node3D = %CatchDisplay
@onready var _catch_sprite: Sprite3D = %CatchSprite
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity"))
var _camera_dragging: bool = false var _camera_dragging: bool = false
var _camera_input_enabled: bool = true
var _movement_enabled: bool = true var _movement_enabled: bool = true
var _target_zoom: float = 5.0 var _target_zoom: float = 5.0
var _showcase_rod_visibility: bool = true
var _showcase_rod_state_stored: bool = false
var _showcase_visual_rotation: Vector3
var _showcase_visual_rotation_stored: bool = false
var _showcase_turn_tween: Tween
var _showcase_restore_tween: Tween
var _showcase_camera_tween: Tween
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
var _showcase_restore_generation: int = 0
func _ready() -> void: func _ready() -> void:
@ -98,26 +134,40 @@ func _process(delta: float) -> void:
if not local_control_enabled: if not local_control_enabled:
return return
if _camera_dragging and not Input.is_action_pressed("camera_drag"): if (
_camera_input_enabled
and _camera_dragging
and not Input.is_action_pressed("camera_drag")
):
_camera_dragging = false _camera_dragging = false
var stick: Vector2 = Vector2( if _camera_input_enabled:
Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), var stick: Vector2 = Vector2(
Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y) Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
) Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y)
if stick.length() > controller_camera_deadzone:
var adjusted_strength: float = (
(stick.length() - controller_camera_deadzone)
/ (1.0 - controller_camera_deadzone)
) )
_rotate_camera(stick.normalized() * adjusted_strength * controller_camera_speed * delta) if stick.length() > controller_camera_deadzone:
var adjusted_strength: float = (
(stick.length() - controller_camera_deadzone)
/ (1.0 - controller_camera_deadzone)
)
_rotate_camera(
stick.normalized()
* adjusted_strength
* controller_camera_speed
* delta
)
var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta) var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta)
_spring_arm.spring_length = lerpf(_spring_arm.spring_length, _target_zoom, zoom_weight) _spring_arm.spring_length = lerpf(
_spring_arm.spring_length,
_target_zoom,
zoom_weight
)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not local_control_enabled: if not local_control_enabled or not _camera_input_enabled:
return return
if event.is_action("camera_drag"): if event.is_action("camera_drag"):
@ -192,6 +242,278 @@ func get_cast_origin_position() -> Vector3:
return _cast_origin.global_position return _cast_origin.global_position
func begin_catch_showcase(fish_catch: FishCatchType) -> void:
if fish_catch == null or not fish_catch.is_valid():
return
_kill_showcase_camera_tween()
_kill_showcase_restore_tween()
_capture_showcase_camera_snapshot()
if not _showcase_visual_rotation_stored:
_showcase_visual_rotation = _visuals.rotation
_showcase_visual_rotation_stored = true
var showcase_camera_position: Vector3 = _begin_showcase_camera_transition()
_turn_showcase_toward_position(showcase_camera_position)
if not _showcase_rod_state_stored:
_showcase_rod_visibility = _fishing_rod.visible
_showcase_rod_state_stored = true
_fishing_rod.visible = false
_catch_sprite.texture = fish_catch.fish.display_texture
_catch_display.scale = Vector3.ONE * fish_catch.display_scale
_catch_display.visible = _catch_sprite.texture != null
func end_catch_showcase(
restored_callback: Callable = Callable(),
immediate: bool = false,
) -> void:
_kill_showcase_turn_tween()
_kill_showcase_camera_tween()
_kill_showcase_restore_tween()
_catch_display.visible = false
_catch_display.scale = Vector3.ONE
_catch_sprite.texture = null
if _showcase_rod_state_stored:
_fishing_rod.visible = _showcase_rod_visibility
_showcase_rod_visibility = true
_showcase_rod_state_stored = false
if (
not _showcase_visual_rotation_stored
and _showcase_camera_snapshot == null
):
if restored_callback.is_valid():
restored_callback.call()
return
if immediate:
_complete_showcase_restore(restored_callback)
return
var current_rotation: Vector3 = _visuals.rotation
var restore_target: Vector3 = _showcase_visual_rotation
restore_target.y = (
current_rotation.y
+ wrapf(
_showcase_visual_rotation.y - current_rotation.y,
-PI,
PI
)
)
var restore_generation: int = _showcase_restore_generation
_showcase_restore_tween = create_tween()
_showcase_restore_tween.set_parallel(true)
_showcase_restore_tween.set_trans(Tween.TRANS_QUAD)
_showcase_restore_tween.set_ease(Tween.EASE_IN_OUT)
if _showcase_visual_rotation_stored:
_showcase_restore_tween.tween_property(
_visuals,
"rotation",
restore_target,
maxf(showcase_restore_duration, 0.05)
)
if _showcase_camera_snapshot != null:
var camera_yaw_target: Vector3 = (
_showcase_camera_snapshot.yaw_rotation
)
camera_yaw_target.y = (
_camera_yaw.rotation.y
+ wrapf(
camera_yaw_target.y - _camera_yaw.rotation.y,
-PI,
PI
)
)
var camera_duration: float = maxf(
showcase_camera_transition_duration,
0.05
)
_showcase_restore_tween.tween_property(
_camera_yaw,
"rotation",
camera_yaw_target,
camera_duration
)
_showcase_restore_tween.tween_property(
_camera_yaw,
"position",
_showcase_camera_snapshot.yaw_transform.origin,
camera_duration
)
_showcase_restore_tween.tween_property(
_camera_pitch,
"rotation",
_showcase_camera_snapshot.pitch_rotation,
camera_duration
)
_showcase_restore_tween.tween_property(
_spring_arm,
"spring_length",
_showcase_camera_snapshot.spring_length,
camera_duration
)
_showcase_restore_tween.finished.connect(
_on_showcase_restore_finished.bind(
restore_generation,
restored_callback
)
)
func _turn_showcase_toward_position(target_position: Vector3) -> void:
_kill_showcase_turn_tween()
var camera_direction: Vector3 = (
target_position - _visuals.global_position
)
camera_direction.y = 0.0
if camera_direction.is_zero_approx():
return
camera_direction = camera_direction.normalized()
var target_world_yaw: float = atan2(
-camera_direction.x,
-camera_direction.z
)
var target_local_yaw: float = target_world_yaw - global_rotation.y
var current_yaw: float = _visuals.rotation.y
var shortest_target_yaw: float = (
current_yaw
+ wrapf(target_local_yaw - current_yaw, -PI, PI)
)
_showcase_turn_tween = create_tween()
_showcase_turn_tween.set_trans(Tween.TRANS_QUAD)
_showcase_turn_tween.set_ease(Tween.EASE_IN_OUT)
_showcase_turn_tween.tween_property(
_visuals,
"rotation:y",
shortest_target_yaw,
maxf(showcase_turn_duration, 0.05)
)
_showcase_turn_tween.finished.connect(_on_showcase_turn_finished)
func _kill_showcase_turn_tween() -> void:
if _showcase_turn_tween != null and _showcase_turn_tween.is_valid():
_showcase_turn_tween.kill()
_showcase_turn_tween = null
func _kill_showcase_restore_tween() -> void:
if _showcase_restore_tween != null and _showcase_restore_tween.is_valid():
_showcase_restore_tween.kill()
_showcase_restore_tween = null
_showcase_restore_generation += 1
func _kill_showcase_camera_tween() -> void:
if _showcase_camera_tween != null and _showcase_camera_tween.is_valid():
_showcase_camera_tween.kill()
_showcase_camera_tween = null
func _on_showcase_turn_finished() -> void:
_showcase_turn_tween = null
func _on_showcase_restore_finished(
restore_generation: int,
restored_callback: Callable,
) -> void:
if restore_generation != _showcase_restore_generation:
return
_showcase_restore_tween = null
_complete_showcase_restore(restored_callback)
func _complete_showcase_restore(
restored_callback: Callable,
) -> void:
if _showcase_visual_rotation_stored:
_visuals.rotation = _showcase_visual_rotation
_showcase_visual_rotation = Vector3.ZERO
_showcase_visual_rotation_stored = false
if _showcase_camera_snapshot != null:
_camera_yaw.transform = _showcase_camera_snapshot.yaw_transform
_camera_pitch.rotation = _showcase_camera_snapshot.pitch_rotation
_spring_arm.spring_length = _showcase_camera_snapshot.spring_length
_target_zoom = _showcase_camera_snapshot.target_zoom
_camera_input_enabled = (
_showcase_camera_snapshot.camera_input_enabled
)
_camera_dragging = _showcase_camera_snapshot.camera_dragging
_showcase_camera_snapshot = null
if restored_callback.is_valid():
restored_callback.call()
func _capture_showcase_camera_snapshot() -> void:
if _showcase_camera_snapshot != null:
return
_showcase_camera_snapshot = ShowcaseCameraSnapshot.new()
_showcase_camera_snapshot.yaw_transform = _camera_yaw.transform
_showcase_camera_snapshot.yaw_rotation = _camera_yaw.rotation
_showcase_camera_snapshot.pitch_rotation = _camera_pitch.rotation
_showcase_camera_snapshot.spring_length = _spring_arm.spring_length
_showcase_camera_snapshot.target_zoom = _target_zoom
_showcase_camera_snapshot.camera_input_enabled = _camera_input_enabled
_showcase_camera_snapshot.camera_dragging = _camera_dragging
_camera_input_enabled = false
_camera_dragging = false
func _begin_showcase_camera_transition() -> Vector3:
var target_world_yaw: float = (
_visuals.global_rotation.y
+ deg_to_rad(showcase_camera_yaw_offset)
)
var target_local_yaw: float = target_world_yaw - global_rotation.y
var shortest_target_yaw: float = (
_camera_yaw.rotation.y
+ wrapf(
target_local_yaw - _camera_yaw.rotation.y,
-PI,
PI
)
)
var target_pitch: float = deg_to_rad(showcase_camera_pitch)
var target_position: Vector3 = _camera_yaw.position
target_position.y = showcase_camera_target_height
var target_zoom: float = maxf(showcase_camera_zoom_distance, 0.1)
var camera_duration: float = maxf(
showcase_camera_transition_duration,
0.05
)
_showcase_camera_tween = create_tween()
_showcase_camera_tween.set_parallel(true)
_showcase_camera_tween.set_trans(Tween.TRANS_QUAD)
_showcase_camera_tween.set_ease(Tween.EASE_IN_OUT)
_showcase_camera_tween.tween_property(
_camera_yaw,
"rotation:y",
shortest_target_yaw,
camera_duration
)
_showcase_camera_tween.tween_property(
_camera_yaw,
"position:y",
target_position.y,
camera_duration
)
_showcase_camera_tween.tween_property(
_camera_pitch,
"rotation:x",
target_pitch,
camera_duration
)
_showcase_camera_tween.tween_property(
_spring_arm,
"spring_length",
target_zoom,
camera_duration
)
var horizontal_offset: Vector3 = (
Basis(Vector3.UP, target_world_yaw).z * target_zoom
)
return global_position + target_position + horizontal_offset
func get_facing_direction() -> Vector3: func get_facing_direction() -> Vector3:
var facing: Vector3 = -_visuals.global_basis.z var facing: Vector3 = -_visuals.global_basis.z
facing.y = 0.0 facing.y = 0.0

View file

@ -1,7 +1,8 @@
[gd_scene load_steps=10 format=3] [gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://player/player.gd" id="1_script"]
[ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"]
[ext_resource type="Script" path="res://collection/collection_log.gd" id="3_collection"]
[sub_resource type="CapsuleShape3D" id="PlayerShape"] [sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45 radius = 0.45
@ -73,6 +74,26 @@ position = Vector3(0, 0.9, 0)
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("2_inventory") script = ExtResource("2_inventory")
[node name="CollectionLog" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("3_collection")
[node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"]
unique_name_in_owner = true
position = Vector3(0, 1.45, -1.15)
[node name="CatchDisplay" type="Node3D" parent="Visuals/CatchDisplayAnchor"]
unique_name_in_owner = true
visible = false
[node name="CatchSprite" type="Sprite3D" parent="Visuals/CatchDisplayAnchor/CatchDisplay"]
unique_name_in_owner = true
pixel_size = 0.012
billboard = 1
shaded = false
double_sided = true
texture_filter = 0
[node name="CameraYaw" type="Node3D" parent="."] [node name="CameraYaw" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
position = Vector3(0, 1.35, 0) position = Vector3(0, 1.35, 0)

View file

@ -6,27 +6,42 @@ const FishingSpotType = preload("res://fishing/fishing_spot.gd")
@onready var _status_label: Label = %StatusLabel @onready var _status_label: Label = %StatusLabel
@onready var _bluegill_count_label: Label = %BluegillCountLabel @onready var _bluegill_count_label: Label = %BluegillCountLabel
@onready var _bass_count_label: Label = %BassCountLabel
@onready var _carp_count_label: Label = %CarpCountLabel
@onready var _catch_track: Control = %CatchTrack @onready var _catch_track: Control = %CatchTrack
@onready var _green_catch_progress: ProgressBar = %GreenCatchProgress @onready var _green_catch_progress: ProgressBar = %GreenCatchProgress
@onready var _red_chase_progress: ProgressBar = %RedChaseProgress @onready var _red_chase_progress: ProgressBar = %RedChaseProgress
@onready var _barrier_markers: Control = %BarrierMarkers @onready var _barrier_markers: Control = %BarrierMarkers
@onready var _barrier_summary: Label = %BarrierSummary @onready var _barrier_summary: Label = %BarrierSummary
@onready var _barrier_health: Label = %BarrierHealth @onready var _barrier_health: Label = %BarrierHealth
@onready var _showcase_details: Label = %ShowcaseDetails
var _showcase_active: bool = false
func setup(inventory: FishInventoryType, fishing_spot: FishingSpotType) -> void: func setup(inventory: FishInventoryType, fishing_spot: FishingSpotType) -> void:
inventory.contents_changed.connect(_on_inventory_contents_changed) inventory.contents_changed.connect(_on_inventory_contents_changed)
fishing_spot.status_changed.connect(_on_fishing_status_changed) fishing_spot.status_changed.connect(_on_fishing_status_changed)
fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
fishing_spot.showcase_changed.connect(_on_showcase_changed)
_update_bluegill_count(inventory.get_count(&"bluegill")) _update_bluegill_count(inventory.get_count(&"bluegill"))
_bass_count_label.text = "Bass: %d" % inventory.get_count(&"bass")
_carp_count_label.text = "Carp: %d" % inventory.get_count(&"carp")
func _on_inventory_contents_changed(fish_id: StringName, count: int) -> void: func _on_inventory_contents_changed(fish_id: StringName, count: int) -> void:
if fish_id == &"bluegill": match fish_id:
_update_bluegill_count(count) &"bluegill":
_update_bluegill_count(count)
&"bass":
_bass_count_label.text = "Bass: %d" % count
&"carp":
_carp_count_label.text = "Carp: %d" % count
func _on_fishing_status_changed(status: String) -> void: func _on_fishing_status_changed(status: String) -> void:
if _showcase_active:
return
_status_label.text = status _status_label.text = status
_status_label.visible = not status.is_empty() _status_label.visible = not status.is_empty()
@ -132,5 +147,31 @@ func _clear_barrier_markers() -> void:
marker.queue_free() marker.queue_free()
func _on_showcase_changed(
fish_name: String,
rarity_name: String,
weight_lb: float,
visible: bool,
) -> void:
_showcase_active = visible
if not visible:
_status_label.text = ""
_status_label.visible = false
_showcase_details.text = ""
_showcase_details.visible = false
return
_catch_track.visible = false
_barrier_summary.visible = false
_barrier_health.visible = false
_clear_barrier_markers()
_status_label.text = "You caught a %s!" % fish_name
_status_label.visible = true
_showcase_details.text = (
"%.1f lb • %s\nLeft click or Escape to put away"
% [weight_lb, rarity_name]
)
_showcase_details.visible = true
func _update_bluegill_count(count: int) -> void: func _update_bluegill_count(count: int) -> void:
_bluegill_count_label.text = "Bluegill: %d" % count _bluegill_count_label.text = "Bluegill: %d" % count

View file

@ -21,7 +21,7 @@ script = ExtResource("1_ui")
offset_left = 16.0 offset_left = 16.0
offset_top = 16.0 offset_top = 16.0
offset_right = 176.0 offset_right = 176.0
offset_bottom = 62.0 offset_bottom = 104.0
[node name="MarginContainer" type="MarginContainer" parent="InventoryPanel"] [node name="MarginContainer" type="MarginContainer" parent="InventoryPanel"]
theme_override_constants/margin_left = 12 theme_override_constants/margin_left = 12
@ -29,10 +29,20 @@ theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12 theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8 theme_override_constants/margin_bottom = 8
[node name="BluegillCountLabel" type="Label" parent="InventoryPanel/MarginContainer"] [node name="VBoxContainer" type="VBoxContainer" parent="InventoryPanel/MarginContainer"]
[node name="BluegillCountLabel" type="Label" parent="InventoryPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
text = "Bluegill: 0" text = "Bluegill: 0"
[node name="BassCountLabel" type="Label" parent="InventoryPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
text = "Bass: 0"
[node name="CarpCountLabel" type="Label" parent="InventoryPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
text = "Carp: 0"
[node name="FishingPanel" type="PanelContainer" parent="."] [node name="FishingPanel" type="PanelContainer" parent="."]
anchors_preset = 7 anchors_preset = 7
anchor_left = 0.5 anchor_left = 0.5
@ -61,6 +71,11 @@ visible = false
text = "" text = ""
horizontal_alignment = 1 horizontal_alignment = 1
[node name="ShowcaseDetails" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
horizontal_alignment = 1
[node name="CatchTrack" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer"] [node name="CatchTrack" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false

View file

@ -0,0 +1,8 @@
class_name FishableWaterRegion
extends Area3D
const FishPoolType = preload("res://fish/fish_pool.gd")
@export var location_tags: Array[StringName] = []
@export var fish_pool: FishPoolType
@export var selection_priority: int = 0

View file

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

View file

@ -1,4 +1,7 @@
[gd_scene load_steps=12 format=3] [gd_scene load_steps=14 format=3]
[ext_resource type="Script" path="res://world/fishable_water_region.gd" id="1_water_region"]
[ext_resource type="Resource" path="res://fish/pools/test_water_pool.tres" id="2_fish_pool"]
[sub_resource type="BoxShape3D" id="GroundShape"] [sub_resource type="BoxShape3D" id="GroundShape"]
size = Vector3(30, 1, 30) size = Vector3(30, 1, 30)
@ -77,6 +80,9 @@ position = Vector3(0, 0.2, -29.75)
collision_layer = 4 collision_layer = 4
collision_mask = 0 collision_mask = 0
monitoring = false monitoring = false
script = ExtResource("1_water_region")
location_tags = Array[StringName]([&"test_lake"])
fish_pool = ExtResource("2_fish_pool")
[node name="CollisionShape3D" type="CollisionShape3D" parent="FishableWater"] [node name="CollisionShape3D" type="CollisionShape3D" parent="FishableWater"]
shape = SubResource("FishableWaterShape") shape = SubResource("FishableWaterShape")