diff --git a/collection/collection_log.gd b/collection/collection_log.gd
new file mode 100644
index 0000000..c068a6e
--- /dev/null
+++ b/collection/collection_log.gd
@@ -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)
diff --git a/collection/collection_log.gd.uid b/collection/collection_log.gd.uid
new file mode 100644
index 0000000..a2b229f
--- /dev/null
+++ b/collection/collection_log.gd.uid
@@ -0,0 +1 @@
+uid://dwaig3bnt3xy3
diff --git a/fish/bluegill.tres b/fish/bluegill.tres
deleted file mode 100644
index 572d202..0000000
--- a/fish/bluegill.tres
+++ /dev/null
@@ -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")
diff --git a/fish/default_availability.tres b/fish/default_availability.tres
new file mode 100644
index 0000000..3c0cfec
--- /dev/null
+++ b/fish/default_availability.tres
@@ -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
diff --git a/fish/fish_availability.gd b/fish/fish_availability.gd
new file mode 100644
index 0000000..f593d75
--- /dev/null
+++ b/fish/fish_availability.gd
@@ -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
diff --git a/fish/fish_availability.gd.uid b/fish/fish_availability.gd.uid
new file mode 100644
index 0000000..0a50d9a
--- /dev/null
+++ b/fish/fish_availability.gd.uid
@@ -0,0 +1 @@
+uid://bs7cqi88csolc
diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd
new file mode 100644
index 0000000..f79b3df
--- /dev/null
+++ b/fish/fish_catch.gd
@@ -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
+ )
diff --git a/fish/fish_catch.gd.uid b/fish/fish_catch.gd.uid
new file mode 100644
index 0000000..228bd15
--- /dev/null
+++ b/fish/fish_catch.gd.uid
@@ -0,0 +1 @@
+uid://dnocndoiq3lg4
diff --git a/fish/fish_data.gd b/fish/fish_data.gd
index 2ab3994..8979864 100644
--- a/fish/fish_data.gd
+++ b/fish/fish_data.gd
@@ -4,8 +4,69 @@ extends Resource
const CatchDifficultyProfileType = preload(
"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 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 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()
diff --git a/fish/fish_pool.gd b/fish/fish_pool.gd
new file mode 100644
index 0000000..4021986
--- /dev/null
+++ b/fish/fish_pool.gd
@@ -0,0 +1,6 @@
+class_name FishPool
+extends Resource
+
+const FishDataType = preload("res://fish/fish_data.gd")
+
+@export var candidates: Array[FishDataType] = []
diff --git a/fish/fish_pool.gd.uid b/fish/fish_pool.gd.uid
new file mode 100644
index 0000000..06e813d
--- /dev/null
+++ b/fish/fish_pool.gd.uid
@@ -0,0 +1 @@
+uid://qg6pu6l216ei
diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd
new file mode 100644
index 0000000..837c422
--- /dev/null
+++ b/fish/fish_selector.gd
@@ -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
diff --git a/fish/fish_selector.gd.uid b/fish/fish_selector.gd.uid
new file mode 100644
index 0000000..b0ed527
--- /dev/null
+++ b/fish/fish_selector.gd.uid
@@ -0,0 +1 @@
+uid://csooreckpm0hh
diff --git a/fish/pools/test_water_pool.tres b/fish/pools/test_water_pool.tres
new file mode 100644
index 0000000..96d61a5
--- /dev/null
+++ b/fish/pools/test_water_pool.tres
@@ -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")]
diff --git a/fish/species/bass/bass.svg b/fish/species/bass/bass.svg
new file mode 100644
index 0000000..feeef65
--- /dev/null
+++ b/fish/species/bass/bass.svg
@@ -0,0 +1,6 @@
+
diff --git a/fish/species/bass/bass.svg.import b/fish/species/bass/bass.svg.import
new file mode 100644
index 0000000..2455d7f
--- /dev/null
+++ b/fish/species/bass/bass.svg.import
@@ -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
diff --git a/fish/species/bass/bass.tres b/fish/species/bass/bass.tres
new file mode 100644
index 0000000..028c2cd
--- /dev/null
+++ b/fish/species/bass/bass.tres
@@ -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")
diff --git a/fish/species/bluegill/bluegill.svg b/fish/species/bluegill/bluegill.svg
new file mode 100644
index 0000000..1e8c8aa
--- /dev/null
+++ b/fish/species/bluegill/bluegill.svg
@@ -0,0 +1,6 @@
+
diff --git a/fish/species/bluegill/bluegill.svg.import b/fish/species/bluegill/bluegill.svg.import
new file mode 100644
index 0000000..92d9e25
--- /dev/null
+++ b/fish/species/bluegill/bluegill.svg.import
@@ -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
diff --git a/fish/species/bluegill/bluegill.tres b/fish/species/bluegill/bluegill.tres
new file mode 100644
index 0000000..5681fb0
--- /dev/null
+++ b/fish/species/bluegill/bluegill.tres
@@ -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")
diff --git a/fish/species/carp/carp.svg b/fish/species/carp/carp.svg
new file mode 100644
index 0000000..412ceb9
--- /dev/null
+++ b/fish/species/carp/carp.svg
@@ -0,0 +1,6 @@
+
diff --git a/fish/species/carp/carp.svg.import b/fish/species/carp/carp.svg.import
new file mode 100644
index 0000000..afb7f94
--- /dev/null
+++ b/fish/species/carp/carp.svg.import
@@ -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
diff --git a/fish/species/carp/carp.tres b/fish/species/carp/carp.tres
new file mode 100644
index 0000000..20c3194
--- /dev/null
+++ b/fish/species/carp/carp.tres
@@ -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")
diff --git a/fishing/bass_catch_profile.tres b/fishing/bass_catch_profile.tres
new file mode 100644
index 0000000..077b229
--- /dev/null
+++ b/fishing/bass_catch_profile.tres
@@ -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
diff --git a/fishing/carp_catch_profile.tres b/fishing/carp_catch_profile.tres
new file mode 100644
index 0000000..bc3f5dc
--- /dev/null
+++ b/fishing/carp_catch_profile.tres
@@ -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
diff --git a/fishing/fishing_context.gd b/fishing/fishing_context.gd
new file mode 100644
index 0000000..55b119f
--- /dev/null
+++ b/fishing/fishing_context.gd
@@ -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
diff --git a/fishing/fishing_context.gd.uid b/fishing/fishing_context.gd.uid
new file mode 100644
index 0000000..7d7f2b1
--- /dev/null
+++ b/fishing/fishing_context.gd.uid
@@ -0,0 +1 @@
+uid://chcedxnmhuqb3
diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd
index 44c3a1a..699da74 100644
--- a/fishing/fishing_presentation.gd
+++ b/fishing/fishing_presentation.gd
@@ -2,6 +2,7 @@ class_name FishingPresentation
extends Node3D
signal cast_completed
+signal outcome_completed(outcome: StringName)
enum VisualMode {
NONE,
@@ -304,7 +305,7 @@ func play_outcome(outcome: StringName) -> void:
_:
_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:
@@ -323,6 +324,11 @@ func cleanup() -> void:
set_line_mode(LineMode.HIDDEN)
+func _on_outcome_finished(outcome: StringName) -> void:
+ cleanup()
+ outcome_completed.emit(outcome)
+
+
func _set_cast_sample(
progress: float,
start: Vector3,
diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd
index 4a46fa8..b63cbd7 100644
--- a/fishing/fishing_spot.gd
+++ b/fishing/fishing_spot.gd
@@ -2,9 +2,17 @@ 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 PlayerType = preload("res://player/player.gd")
+const FishableWaterRegionType = preload(
+ "res://world/fishable_water_region.gd"
+)
signal status_changed(status: String)
signal catch_display_changed(
@@ -16,6 +24,12 @@ signal catch_display_changed(
active_barrier_index: int,
visible: bool,
)
+signal showcase_changed(
+ fish_name: String,
+ rarity_name: String,
+ weight_lb: float,
+ visible: bool,
+)
enum FishingState {
READY,
@@ -24,6 +38,7 @@ enum FishingState {
WAITING_FOR_BITE,
BITE_ACTIVE,
FIGHTING,
+ SHOWING_CATCH,
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 cooldown_duration: float = 1.0
-@export_category("Catch")
-@export var catchable_fish: FishDataType
+@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 _local_player: PlayerType
+var _local_inventory: FishInventoryType
+var _local_collection_log: CollectionLogType
var _active_player: PlayerType
var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0
@@ -72,6 +97,14 @@ var _bobber_water_position: Vector3
var _fight_start_position: Vector3
var _cooldown_status: String = ""
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:
@@ -79,15 +112,52 @@ func _ready() -> void:
_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)
-func setup(local_player: PlayerType) -> void:
+func setup(
+ local_player: PlayerType,
+ local_inventory: FishInventoryType,
+ local_collection_log: CollectionLogType,
+) -> void:
_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:
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:
@@ -110,6 +180,13 @@ func _process(delta: float) -> 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 (
event.is_action_pressed("ui_cancel")
and _active_player != null
@@ -148,6 +225,11 @@ func _unhandled_input(event: InputEvent) -> void:
_presentation.set_line_mode(
FishingPresentationType.LineMode.TAUT
)
+ FishingState.SHOWING_CATCH:
+ if _showcase_ready and _put_away_press_armed:
+ _put_away_catch()
+ else:
+ return
_:
return
get_viewport().set_input_as_handled()
@@ -217,10 +299,24 @@ func _on_cast_completed() -> void:
if state != FishingState.CASTING:
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:
_cleanup_attempt("Can't fish there.", &"invalid")
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_time_remaining = wait_time
@@ -350,8 +446,8 @@ func _confirm_hook() -> void:
if (
state != FishingState.BITE_ACTIVE
or _active_player == null
- or catchable_fish == null
- or catchable_fish.catch_profile == null
+ or _selected_fish == null
+ or _selected_fish.catch_profile == null
):
_cancel_attempt()
return
@@ -363,7 +459,7 @@ func _confirm_hook() -> void:
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
_presentation.begin_reeling()
_catch_controller.start_encounter(
- catchable_fish.catch_profile,
+ _selected_fish.catch_profile,
_active_player.reel_speed,
_active_player.click_power
)
@@ -420,12 +516,24 @@ func _on_catch_encounter_updated(
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()
return
- _active_player.inventory.add_fish(catchable_fish)
- _cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch")
+ _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
+ _put_away_press_armed = false
+ _catch_controller.reset()
+ _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
+ _presentation.play_outcome(&"catch")
func _on_catch_escaped() -> void:
@@ -433,11 +541,69 @@ func _on_catch_escaped() -> void:
return
var fish_name: String = "The fish"
- if catchable_fish != null and not catchable_fish.display_name.is_empty():
- fish_name = "The %s" % catchable_fish.display_name
+ 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 (
+ 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:
if state != FishingState.BITE_ACTIVE:
return
@@ -448,7 +614,9 @@ func _cleanup_attempt(
cooldown_message: String = "",
visual_outcome: StringName = &"",
) -> 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
@@ -462,6 +630,13 @@ func _cleanup_attempt(
_withdrawal_input_held = false
_bobber_water_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()
if visual_outcome.is_empty():
_presentation.cleanup()
@@ -531,6 +706,12 @@ func _calculate_cast_target(distance: float) -> Vector3:
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
var query := PhysicsShapeQueryParameters3D.new()
query.shape = _fishable_query_shape
@@ -543,9 +724,36 @@ func is_target_fishable(target: Vector3) -> bool:
query.collide_with_bodies = false
var results: Array[Dictionary] = get_world_3d().direct_space_state.intersect_shape(
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:
diff --git a/fishing/fishing_spot.tscn b/fishing/fishing_spot.tscn
index a2870cd..d9ba7ad 100644
--- a/fishing/fishing_spot.tscn
+++ b/fishing/fishing_spot.tscn
@@ -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="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"]
@@ -41,7 +40,6 @@ size = Vector3(0.72, 0.06, 0.12)
[node name="FishingSpot" type="Node3D"]
script = ExtResource("1_spot")
-catchable_fish = ExtResource("2_bluegill")
[node name="CatchController" type="Node" parent="."]
unique_name_in_owner = true
diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd
index 6fe7456..0c1b964 100644
--- a/inventory/fish_inventory.gd
+++ b/inventory/fish_inventory.gd
@@ -1,21 +1,34 @@
class_name FishInventory
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)
-var _fish_counts: Dictionary[StringName, int] = {}
+var _catches: Array[FishCatchType] = []
-func add_fish(fish: FishDataType, amount: int = 1) -> void:
- if fish == null or fish.id.is_empty() or amount <= 0:
+func add_catch(fish_catch: FishCatchType) -> void:
+ if fish_catch == null or not fish_catch.is_valid():
return
-
- var new_count: int = get_count(fish.id) + amount
- _fish_counts[fish.id] = new_count
- contents_changed.emit(fish.id, new_count)
+ _catches.append(fish_catch)
+ contents_changed.emit(
+ fish_catch.fish_id,
+ get_count(fish_catch.fish_id)
+ )
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
diff --git a/main/main.gd b/main/main.gd
index 9b5b11b..53b0755 100644
--- a/main/main.gd
+++ b/main/main.gd
@@ -10,5 +10,9 @@ const PlayerType = preload("res://player/player.gd")
func _ready() -> void:
- _fishing_spot.setup(_player)
+ _fishing_spot.setup(
+ _player,
+ _player.inventory,
+ _player.collection_log
+ )
_game_ui.setup(_player.inventory, _fishing_spot)
diff --git a/player/player.gd b/player/player.gd
index d7a837a..effd739 100644
--- a/player/player.gd
+++ b/player/player.gd
@@ -2,6 +2,20 @@ class_name Player
extends CharacterBody3D
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 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(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 var mouse_sensitivity: float = 0.005
@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 _camera: Camera3D = %Camera3D
@onready var inventory: FishInventoryType = %Inventory
+@onready var collection_log: CollectionLogType = %CollectionLog
@onready var _cast_origin: Marker3D = %CastOrigin
@onready var _fishing_rod: Node3D = %FishingRod
@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 _camera_dragging: bool = false
+var _camera_input_enabled: bool = true
var _movement_enabled: bool = true
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:
@@ -98,26 +134,40 @@ func _process(delta: float) -> void:
if not local_control_enabled:
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
- var stick: Vector2 = Vector2(
- 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)
+ if _camera_input_enabled:
+ var stick: Vector2 = Vector2(
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_X),
+ Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y)
)
- _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)
- _spring_arm.spring_length = lerpf(_spring_arm.spring_length, _target_zoom, zoom_weight)
+ var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta)
+ _spring_arm.spring_length = lerpf(
+ _spring_arm.spring_length,
+ _target_zoom,
+ zoom_weight
+ )
func _unhandled_input(event: InputEvent) -> void:
- if not local_control_enabled:
+ if not local_control_enabled or not _camera_input_enabled:
return
if event.is_action("camera_drag"):
@@ -192,6 +242,278 @@ func get_cast_origin_position() -> Vector3:
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:
var facing: Vector3 = -_visuals.global_basis.z
facing.y = 0.0
diff --git a/player/player.tscn b/player/player.tscn
index aa59b98..a311247 100644
--- a/player/player.tscn
+++ b/player/player.tscn
@@ -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://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"]
radius = 0.45
@@ -73,6 +74,26 @@ position = Vector3(0, 0.9, 0)
unique_name_in_owner = true
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="."]
unique_name_in_owner = true
position = Vector3(0, 1.35, 0)
diff --git a/ui/game_ui.gd b/ui/game_ui.gd
index e430751..d658061 100644
--- a/ui/game_ui.gd
+++ b/ui/game_ui.gd
@@ -6,27 +6,42 @@ const FishingSpotType = preload("res://fishing/fishing_spot.gd")
@onready var _status_label: Label = %StatusLabel
@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 _green_catch_progress: ProgressBar = %GreenCatchProgress
@onready var _red_chase_progress: ProgressBar = %RedChaseProgress
@onready var _barrier_markers: Control = %BarrierMarkers
@onready var _barrier_summary: Label = %BarrierSummary
@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:
inventory.contents_changed.connect(_on_inventory_contents_changed)
fishing_spot.status_changed.connect(_on_fishing_status_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"))
+ _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:
- if fish_id == &"bluegill":
- _update_bluegill_count(count)
+ match fish_id:
+ &"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:
+ if _showcase_active:
+ return
_status_label.text = status
_status_label.visible = not status.is_empty()
@@ -132,5 +147,31 @@ func _clear_barrier_markers() -> void:
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:
_bluegill_count_label.text = "Bluegill: %d" % count
diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn
index 6d4cc0d..9c6db47 100644
--- a/ui/game_ui.tscn
+++ b/ui/game_ui.tscn
@@ -21,7 +21,7 @@ script = ExtResource("1_ui")
offset_left = 16.0
offset_top = 16.0
offset_right = 176.0
-offset_bottom = 62.0
+offset_bottom = 104.0
[node name="MarginContainer" type="MarginContainer" parent="InventoryPanel"]
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_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
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="."]
anchors_preset = 7
anchor_left = 0.5
@@ -61,6 +71,11 @@ visible = false
text = ""
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"]
unique_name_in_owner = true
visible = false
diff --git a/world/fishable_water_region.gd b/world/fishable_water_region.gd
new file mode 100644
index 0000000..4fb75ac
--- /dev/null
+++ b/world/fishable_water_region.gd
@@ -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
diff --git a/world/fishable_water_region.gd.uid b/world/fishable_water_region.gd.uid
new file mode 100644
index 0000000..0fd4f45
--- /dev/null
+++ b/world/fishable_water_region.gd.uid
@@ -0,0 +1 @@
+uid://b6fp2obu0y2d0
diff --git a/world/test_world.tscn b/world/test_world.tscn
index 57259fb..a8968a1 100644
--- a/world/test_world.tscn
+++ b/world/test_world.tscn
@@ -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"]
size = Vector3(30, 1, 30)
@@ -77,6 +80,9 @@ position = Vector3(0, 0.2, -29.75)
collision_layer = 4
collision_mask = 0
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"]
shape = SubResource("FishableWaterShape")