Compare commits

...

4 commits

Author SHA1 Message Date
81decf2b71 Add logbook catalog index filters 2026-08-29 20:19:47 -04:00
4575830857 Improve nearby water recovery and camera prompts
Recover players on generated worlds to the nearest safe grass or sand surface. Use the active gameplay camera when positioning shop and storage prompts.
2026-08-29 17:43:11 -04:00
ae802493a0 Fix clam spurt origin artifact (#118) 2026-08-29 17:41:10 -04:00
f910e6bdbb Update Forgejo links for Straywild rename 2026-08-26 00:05:51 -04:00
23 changed files with 1984 additions and 45 deletions

View file

@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc
Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io
Repo: https://forge.makearmy.io/woofmeow/netfishing
Repo: https://forge.makearmy.io/woofmeow/straywild
Releases: https://forge.makearmy.io/woofmeow/netfishing/releases
Releases: https://forge.makearmy.io/woofmeow/straywild/releases
## Installing release builds

View file

@ -17,10 +17,10 @@ for local README files.
## Related repositories
- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md)
- [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md)
stays with the service implementation and defines its versioned HTTP
contract.
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md)
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/straywild-dedicated-server/src/branch/main/README.md)
stays with its installer and update scripts. It consumes this repository's
attribution and legal notices from the exact pinned game commit instead of
maintaining copies.

View file

@ -150,6 +150,7 @@ var _network_session: NetworkSessionType
var _network_fishing: NetworkFishingServiceType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _fishable_water_regions_resolver := Callable()
var _active_player: PlayerType
var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0
@ -222,6 +223,7 @@ func setup(
network_fishing: NetworkFishingServiceType = null,
world_time: WorldTimeServiceType = null,
world_weather: WorldWeatherServiceType = null,
fishable_water_regions_resolver: Callable = Callable(),
) -> void:
_local_player = local_player
_local_inventory = local_inventory
@ -242,6 +244,7 @@ func setup(
_network_fishing = network_fishing
_world_time = world_time
_world_weather = world_weather
_fishable_water_regions_resolver = fishable_water_regions_resolver
if (
_network_session != null
and not _network_session.state_changed.is_connected(
@ -1664,6 +1667,47 @@ func build_network_context(
return _build_fishing_context(region)
func is_species_available_now(fish: FishDataType) -> bool:
if fish == null or not fish.is_selectable():
return false
var season := (
_world_time.get_season()
if _world_time != null
else CalendarSeason.UNKNOWN
)
if not fish.is_available_in_season(season):
return false
if fish.collection_method != FishDataType.CollectionMethod.FISHING:
return true
for region: FishableWaterRegionType in _get_logbook_water_regions():
if (
region == null
or region.fish_pool == null
or region.fish_pool.get_fish_by_id(fish.id) == null
):
continue
var context := _build_fishing_context(region)
if not fish.is_allowed_in_water(context.water_type):
continue
if fish.availability == null or fish.availability.is_available(context):
return true
return false
func _get_logbook_water_regions() -> Array[FishableWaterRegionType]:
var regions: Array[FishableWaterRegionType] = []
if not _fishable_water_regions_resolver.is_valid():
return regions
var resolved: Variant = _fishable_water_regions_resolver.call()
if resolved is not Array:
return regions
for value: Variant in resolved:
var region := value as FishableWaterRegionType
if region != null and is_instance_valid(region):
regions.append(region)
return regions
func _get_active_bait_tags() -> Array[StringName]:
if _local_player == null or _item_catalog == null:
return []

View file

@ -29,12 +29,16 @@ func configure(
entity_id = configured_entity_id
data = configured_data
type_id = data.type_id if data != null else StringName()
# Establish the authoritative transform before constructing transient visuals.
# Effects emitted before this point would begin at the default world origin.
apply_network_state(position, yaw, true)
if data != null and data.is_stationary_hotspot():
_ensure_water_spurt_visual()
_water_spurt_elapsed = (
float(abs(hash(entity_id)) % 1000) / 1000.0
* WATER_SPURT_INTERVAL_SECONDS
)
_emit_water_spurt()
else:
_ensure_visual()
if data != null and data.catch_data != null and _sprite != null:
@ -50,7 +54,6 @@ func configure(
)
)
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
apply_network_state(position, yaw, true)
func apply_network_state(
@ -147,7 +150,6 @@ func _ensure_water_spurt_visual() -> void:
hole.mesh = hole_mesh
hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_water_spurt_root.add_child(hole)
_emit_water_spurt()
func _emit_water_spurt() -> void:
@ -159,6 +161,7 @@ func _emit_water_spurt() -> void:
particles.amount = 6
particles.lifetime = 0.42
particles.one_shot = true
particles.local_coords = true
particles.explosiveness = 1.0
particles.direction = Vector3.UP
particles.spread = 32.0

View file

@ -920,6 +920,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_fishing,
_world_time,
_world_weather,
Callable(_test_world, "get_fishable_water_regions"),
)
if dedicated:
return
@ -1006,7 +1007,8 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui,
_game_ui.get_screen_fade(),
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points()
_test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
)
_ui_pixelation.effective_pixel_size_changed.connect(
_on_effective_ui_pixel_size_changed
@ -2344,17 +2346,9 @@ func _on_remote_recovery_requested(
peer_id,
"Fishing attempt ended."
)
var target_position: Vector3 = _test_world.get_player_spawn_transform().origin
var nearest_distance: float = INF
for point: SafeRespawnPoint in _test_world.get_safe_respawn_points():
if point == null or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
var target_position := _test_world.get_water_recovery_position(
entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
target_position.y += _water_recovery.respawn_height_offset
avatar.global_position = target_position
avatar.velocity = Vector3.ZERO
@ -2432,6 +2426,7 @@ func _apply_world(
spawn_transform,
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
)
_shoreline_ambience.configure(
_player,

View file

@ -9,7 +9,7 @@ readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild"
readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}"

View file

@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" <<EOF
straywild code is licensed under GPL-3.0-or-later.
Source repository: https://forge.makearmy.io/woofmeow/netfishing
Source repository: https://forge.makearmy.io/woofmeow/straywild
Exact source revision: ${TAG_COMMIT}
Release tag: ${RELEASE_TAG}
PortMaster packaging revision: ${HEAD_COMMIT}

View file

@ -416,6 +416,12 @@ func _run() -> void:
)
assert(normal_camera.is_position_behind(chat_anchor))
assert(not free_camera.is_position_behind(chat_anchor))
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var storage_prompt := game_ui.get_node("%StoragePrompt") as Control
game_ui.set_shop_prompt_visible(true, chat_anchor)
game_ui.set_storage_prompt_visible(true, chat_anchor)
assert(shop_prompt.visible)
assert(storage_prompt.visible)
chat_ui.show_local_speech("hello there friend")
await process_frame
assert(bool(player.get("_speech_mouth_active")))

View file

@ -1356,6 +1356,24 @@ func _validate_player_menu_nested_controller_back() -> void:
== LogbookPageType.ControllerZone.ENTRIES,
"Global player-menu Back did not return Logbook to creature selection.",
)
logbook_page.call("_set_index_open", true, false)
logbook_page.set(
"_controller_zone",
LogbookPageType.ControllerZone.INDEX,
)
_expect(
bool(menu.call("consume_escape")),
"Global player-menu Back did not consume the Logbook index zone.",
)
_expect(
menu.visible and not bool(logbook_page.get("_index_open")),
"Global player-menu Back did not return from the Logbook index page.",
)
_expect(
int(logbook_page.get("_controller_zone"))
== LogbookPageType.ControllerZone.TABS,
"Global player-menu Back did not return Logbook index focus to tabs.",
)
var net_page := menu.get("_the_net_page") as Control
net_page.set("_active", true)

View file

@ -265,6 +265,15 @@ func _run() -> void:
assert(bool(water_recovery.get("_prior_movement_enabled")))
assert(bool(water_recovery.get("_prior_camera_input_enabled")))
assert(not pause_menu.visible)
var test_world := main.get_node("TestWorld") as TestWorld
assert(test_world != null)
var recovery_entry := water_recovery.get("_entry_position") as Vector3
var resolved_recovery := water_recovery.call(
"_resolve_recovery_position"
) as Vector3
assert(resolved_recovery.is_equal_approx(
test_world.get_water_recovery_position(recovery_entry)
))
water_recovery.call("_finish_recovery")
assert(player.is_movement_enabled())

View file

@ -536,6 +536,11 @@ func _validate_generated_region(
-INF,
0.35,
)
_validate_water_recovery_positions(
region,
grass_prop_surface_triangles,
sand_prop_surface_triangles,
)
var anchors := region.get_node(
"GatherableAnchors/ReachableTreeTrunks"
) as GatherableAnchorSet3D
@ -1268,6 +1273,76 @@ func _triangle_surface_height_at(
return highest
func _validate_water_recovery_positions(
region: GeneratedWorldRegion,
grass_triangles: Array[PackedVector3Array],
sand_triangles: Array[PackedVector3Array],
) -> void:
var fresh_water_root := region.get_node(
"WaterBodies/FreshWaterBodies"
) as Node3D
assert(fresh_water_root != null and fresh_water_root.get_child_count() > 0)
var fresh_water := fresh_water_root.get_child(0) as WaterBodyAuthoring
assert(fresh_water != null)
var entry_position := fresh_water.global_position
var fallback_position := region.get_player_spawn_transform().origin
var recovery_position := region.get_water_recovery_position(
entry_position,
fallback_position,
)
assert(recovery_position.is_finite())
assert(
_horizontal_distance_squared(entry_position, recovery_position)
< _horizontal_distance_squared(entry_position, fallback_position),
"Fresh-water recovery returned the distant generated-world spawn.",
)
assert(
recovery_position.y
> GeneratedWorldRegion.WATER_HEIGHT
+ GeneratedWorldRegion.WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
)
var recovery_triangles: Array[PackedVector3Array] = []
recovery_triangles.append_array(grass_triangles)
recovery_triangles.append_array(sand_triangles)
var center_height := _triangle_surface_height_at(
recovery_position,
recovery_triangles,
)
assert(center_height > -INF)
assert(absf(center_height - recovery_position.y) <= 0.01)
for direction: Vector2 in GeneratedWorldRegion.PROP_SURFACE_SAMPLE_DIRECTIONS:
var footprint_position := recovery_position + Vector3(
direction.x * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
0.0,
direction.y * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
)
var footprint_height := _triangle_surface_height_at(
footprint_position,
recovery_triangles,
)
assert(footprint_height > -INF)
assert(
absf(footprint_height - recovery_position.y)
<= GeneratedWorldRegion.PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE
)
assert(
region.get_water_recovery_position(
entry_position,
fallback_position,
).is_equal_approx(recovery_position)
)
assert(
region.get_water_recovery_position(
Vector3(INF, 0.0, 0.0),
fallback_position,
).is_equal_approx(fallback_position)
)
func _horizontal_distance_squared(a: Vector3, b: Vector3) -> float:
return Vector2(a.x - b.x, a.z - b.z).length_squared()
func _validate_tree_gatherable_anchors(
region: GeneratedWorldRegion,
decorations: Node3D,

View file

@ -2,6 +2,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn")
const FishCatchType = preload("res://fish/fish_catch.gd")
const MENU_TRANSITION_SETTLE_SECONDS := 3.0
func _initialize() -> void:
@ -36,12 +37,16 @@ func _run() -> void:
var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage
var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect
var hotbar := game_ui.get_node("%Hotbar") as Control
var fishing_spot := main.get("_fishing_spot") as FishingSpot
assert(logbook.get("_fishing_spot") == fishing_spot)
assert(logbook.get("_world_time") == main.get("_world_time"))
assert(logbook.get("_world_weather") == main.get("_world_weather"))
var shortcut := InputEventKey.new()
shortcut.pressed = true
shortcut.physical_keycode = KEY_L
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
assert(player_menu.visible)
assert(logbook.visible)
assert(backdrop.visible)
@ -51,6 +56,18 @@ func _run() -> void:
var entry_buttons: Dictionary = logbook.get("_entry_buttons")
assert(entry_buttons.size() == 108)
await _capture_if_requested("-unknown")
logbook.call("_set_index_open", true, false)
await process_frame
assert(bool(logbook.get("_index_open")))
assert((logbook.get("_index_body") as Control).visible)
await _capture_if_requested("-index")
logbook.set_compact_presentation(true)
await process_frame
await _capture_if_requested("-index-compact")
logbook.set_compact_presentation(false)
await process_frame
logbook.call("_set_index_open", false, false)
await process_frame
logbook.call(
"_select_category", WaterType.Type.FRESH_WATER
)
@ -68,13 +85,29 @@ func _run() -> void:
logbook.call("_select_entry", &"bluegill", &"bluegill")
await create_timer(0.2).timeout
await _capture_if_requested("-known")
var bluegill := (
main.get("fish_catalog") as FishPool
).get_fish_by_id(&"bluegill")
var bluegill_available_now := fishing_spot.is_species_available_now(bluegill)
var index_options := logbook.get("_index_options") as Dictionary
index_options["availability"] = (
LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
)
logbook.notify_availability_context_changed()
await process_frame
assert(
(logbook.get("_entry_buttons") as Dictionary).has(&"bluegill")
== bluegill_available_now
)
logbook.call("_reset_index")
await process_frame
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
assert(not player_menu.visible)
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
player_menu.call(
"_show_section_immediate", PlayerMenu.Section.PROFILE
)

View file

@ -17,6 +17,8 @@ const CatalogResource: FishPoolType = preload(
"res://fish/pools/fish_catalog.tres"
)
var _query_available_id: StringName
func _initialize() -> void:
call_deferred("_run")
@ -35,6 +37,7 @@ func _run() -> void:
quit()
return
_validate_catalog()
_validate_catalog_query()
await _validate_page()
print("Logbook validation: PASS")
quit()
@ -68,6 +71,348 @@ func _validate_catalog() -> void:
)
func _validate_catalog_query() -> void:
var collection := CollectionLogType.new()
var inventory := FishInventoryType.new()
var fresh_species: Array[FishDataType] = []
for fish: FishDataType in LogbookCatalog.ordered_species(
CatalogResource.candidates
):
if LogbookCatalog.category_for(fish) == LogbookCatalog.Category.FRESH_WATER:
fresh_species.append(fish)
assert(fresh_species.size() == 108)
var first := fresh_species[0]
var second := fresh_species[1]
var unknown := fresh_species[2]
for quality: int in FishQualityType.TIER_COUNT:
collection.record_catch(first.id, quality)
collection.record_catch(second.id, FishQualityType.Tier.BORING)
var held := _make_test_catch(first, true)
assert(inventory.add_catch(held))
var options := _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
var filtered := LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.size() == 2)
assert(first in filtered and second in filtered)
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.size() == fresh_species.size() - 2)
options["sort"] = LogbookCatalog.SortMode.CATALOG_NUMBER
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.front() == fresh_species.back())
assert(filtered.back() == unknown)
options["show"] = LogbookCatalog.DiscoveryFilter.INCOMPLETE
options["descending"] = false
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [second])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["sort"] = LogbookCatalog.SortMode.CATCH_COUNT
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first, second])
options = _default_query_options()
options["search"] = unknown.display_name
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.is_empty())
options["search"] = str(unknown.catalog_number)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [unknown])
options = _default_query_options()
options["holding"] = LogbookCatalog.HoldingFilter.FAVORITED
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first])
options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["group"] = String(first.collection_group)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.collection_group == first.collection_group)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["rarity"] = (
int(first.rarity) + LogbookCatalog.RarityFilter.COMMON
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.rarity == first.rarity)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["method"] = (
int(first.collection_method) + LogbookCatalog.MethodFilter.FISHING
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.collection_method == first.collection_method)
var first_season := -1
for season: int in 4:
if first.is_available_in_season(season):
first_season = season
break
assert(first_season >= 0)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["season"] = (
first_season + LogbookCatalog.SeasonFilter.SPRING
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.is_available_in_season(first_season))
var first_is_available_by_day := (
first.availability == null or first.availability.allow_day
)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["time"] = (
LogbookCatalog.TimeFilter.DAY
if first_is_available_by_day
else LogbookCatalog.TimeFilter.NIGHT
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["sort"] = LogbookCatalog.SortMode.QUALITY_PROGRESS
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first, second])
for sort_mode: LogbookCatalog.SortMode in [
LogbookCatalog.SortMode.NAME,
LogbookCatalog.SortMode.RARITY,
LogbookCatalog.SortMode.VALUE,
LogbookCatalog.SortMode.WEIGHT,
]:
options = _default_query_options()
options["sort"] = sort_mode
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered[0] in [first, second])
assert(filtered[1] in [first, second])
assert(filtered[0] != filtered[1])
assert(filtered[2] == unknown)
_query_available_id = second.id
options = _default_query_options()
options["availability"] = (
LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
Callable(self, "_query_species_available_now"),
)
assert(filtered == [second])
_query_available_id = unknown.id
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["rarity"] = (
int(unknown.rarity) + LogbookCatalog.RarityFilter.COMMON
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
Callable(self, "_query_species_available_now"),
)
assert(filtered == [unknown])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["group"] = String(unknown.collection_group)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(unknown in filtered)
assert(
String(unknown.collection_group)
in LogbookCatalog.available_groups(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
)
)
var unknown_is_available_by_day := (
unknown.availability == null or unknown.availability.allow_day
)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["time"] = (
LogbookCatalog.TimeFilter.DAY
if unknown_is_available_by_day
else LogbookCatalog.TimeFilter.NIGHT
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(unknown in filtered)
inventory.free()
collection.free()
func _default_query_options() -> Dictionary:
return {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
func _make_test_catch(fish: FishDataType, favorited: bool) -> FishCatchType:
var fish_catch := FishCatchType.new()
fish_catch.fish = fish
fish_catch.fish_id = fish.id
fish_catch.weight_lb = fish.get_minimum_weight()
fish_catch.display_scale = fish.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.quality = FishQualityType.Tier.BORING
fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb)
fish_catch.is_favorited = favorited
fish_catch.ensure_identity()
return fish_catch
func _query_species_available_now(fish: FishDataType) -> bool:
return fish != null and fish.id == _query_available_id
func _validate_page() -> void:
var collection := CollectionLogType.new()
var inventory := FishInventoryType.new()
@ -91,6 +436,10 @@ func _validate_page() -> void:
assert(outer_margin.get_theme_constant("margin_right") == 160)
assert(outer_margin.get_theme_constant("margin_bottom") == 106)
assert(is_equal_approx(book.offset_top, 65.0))
page.call("_set_index_open", true, false)
await process_frame
_validate_index_content_bounds(page)
page.call("_set_index_open", false, false)
page.set_compact_presentation(false)
await process_frame
assert(outer_margin.get_theme_constant("margin_left") == 52)
@ -158,6 +507,39 @@ func _validate_page() -> void:
)
var initial_detail_body := page.get("_detail_body") as VBoxContainer
assert(not initial_detail_body.get_parent() is ScrollContainer)
var index_tab := page.get("_index_tab") as Button
var index_body := page.get("_index_body") as VBoxContainer
assert(index_tab != null and index_body != null)
assert(index_tab.size == LogbookPage.INDEX_TAB_SIZE)
assert(
not index_tab.get_global_rect().intersects(
(category_tabs.back() as Button).get_global_rect()
)
)
page.call("_set_index_open", true, false)
await process_frame
assert(index_body.visible)
assert(not initial_detail_body.visible)
assert((page.get("_index_option_buttons") as Dictionary).size() == 10)
_validate_index_content_bounds(page)
page.call(
"_cycle_index_option",
&"show",
1,
)
await process_frame
assert((page.get("_entry_buttons") as Dictionary).is_empty())
assert(
(page.get("_empty_state") as Label).text
== "No entries match this index."
)
page.call("_reset_index")
await process_frame
assert((page.get("_entry_buttons") as Dictionary).size() == 108)
page.call("_set_index_open", false, false)
await process_frame
assert(not index_body.visible)
assert(initial_detail_body.visible)
assert(
page.call("_entry_label_text", "flathead catfish")
== "flathead\ncatfish"
@ -458,8 +840,19 @@ func _validate_page() -> void:
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame
var live_index_options := page.get("_index_options") as Dictionary
live_index_options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER
page.call("_refresh_catalog")
await process_frame
assert(
(page.get("_entry_buttons") as Dictionary).keys()
== [&"bluegill"]
)
inventory.remove_catch_by_id(fish_catch.catch_id)
await process_frame
assert((page.get("_entry_buttons") as Dictionary).is_empty())
page.call("_reset_index")
await process_frame
assert(not _detail_text(page).contains("number owned"))
page.queue_free()
collection.queue_free()
@ -474,6 +867,54 @@ func _detail_text(page: LogbookPage) -> String:
return "\n".join(values)
func _validate_index_content_bounds(page: LogbookPage) -> void:
var body := page.get("_index_body") as VBoxContainer
assert(body != null and body.visible)
var right_page := body.get_parent() as Control
assert(right_page != null)
var page_rect := right_page.get_global_rect()
var body_rect := body.get_global_rect()
var protected_rect := Rect2(
page_rect.position + Vector2(
LogbookPage.INDEX_CONTENT_LEFT_INSET,
LogbookPage.INDEX_CONTENT_TOP_INSET,
),
page_rect.size - Vector2(
LogbookPage.INDEX_CONTENT_LEFT_INSET
+ LogbookPage.INDEX_CONTENT_RIGHT_INSET,
LogbookPage.INDEX_CONTENT_TOP_INSET
+ LogbookPage.INDEX_CONTENT_BOTTOM_INSET,
),
)
assert(
body_rect.position.x
>= page_rect.position.x + LogbookPage.INDEX_CONTENT_LEFT_INSET - 1.0
)
assert(
body_rect.end.x
<= page_rect.end.x - LogbookPage.INDEX_CONTENT_RIGHT_INSET + 1.0
)
assert(
body_rect.position.y >= page_rect.position.y,
"Index starts above the authored page: body=%s page=%s"
% [body_rect, page_rect],
)
assert(
body_rect.end.y <= page_rect.end.y,
"Index extends below the authored page: body=%s page=%s"
% [body_rect, page_rect],
)
var controls: Array = page.get("_index_controls") as Array
for value: Variant in controls:
var control := value as Control
if control != null and control.visible:
assert(
protected_rect.grow(2.0).encloses(control.get_global_rect()),
"Index control crossed the authored right-page boundary: %s"
% control.name,
)
func _descendant_label_text(parent: Node) -> String:
var values: PackedStringArray = []
for node: Node in parent.find_children("*", "Label", true, false):

View file

@ -100,6 +100,14 @@ func _validate_world_switching() -> void:
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
assert(not world.get_fishable_water_regions().is_empty())
var starter_safe_points := world.get_safe_respawn_points()
assert(not starter_safe_points.is_empty())
var authored_safe_point: SafeRespawnPoint = starter_safe_points.back()
assert(
world.get_water_recovery_position(
authored_safe_point.global_position
).is_equal_approx(authored_safe_point.global_position)
)
assert(world.activate_world(WorldLayout.GENERATED, 24680))
assert(world.get_world_layout() == WorldLayout.GENERATED)
assert(world.get_generation_seed() == 24680)

View file

@ -16,6 +16,10 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
func _initialize() -> void:
call_deferred(&"_run")
func _run() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 11)
assert(
NetworkWorldSpawnProtocol.CAPABILITY
@ -23,7 +27,7 @@ func _initialize() -> void:
)
assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4)
_validate_catalog_statuses()
_validate_billboard_presentation()
await _validate_billboard_presentation()
_validate_envelopes()
print("World spawn protocol validation: PASS")
quit()
@ -148,8 +152,27 @@ func _validate_billboard_presentation() -> void:
assert(not sprite.shaded)
gatherable.free()
var unconfigured_hotspot := WorldGatherableType.new()
unconfigured_hotspot.call("_ensure_water_spurt_visual")
assert(
unconfigured_hotspot.get_node_or_null(
"WaterSpurt/WaterDroplets"
) == null
)
unconfigured_hotspot.free()
var clam: GatherableData = Gatherables.get_entry(&"clam_manila")
assert(clam != null)
var hotspot := WorldGatherableType.new()
hotspot.call("_ensure_water_spurt_visual")
root.add_child(hotspot)
var configured_position := Vector3(24.0, 0.3, -11.0)
hotspot.configure(
"world:clam-presentation-regression",
clam,
configured_position,
0.0,
)
assert(hotspot.global_position.is_equal_approx(configured_position))
var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D
assert(hole != null)
assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
@ -158,6 +181,18 @@ func _validate_billboard_presentation() -> void:
assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED)
assert(is_equal_approx(hole_material.albedo_color.a, 1.0))
var particles := hotspot.get_node(
"WaterSpurt/WaterDroplets"
) as CPUParticles3D
assert(particles != null and particles.emitting)
assert(particles.local_coords)
assert(particles.global_position.is_equal_approx(
configured_position + Vector3.UP * 0.035
))
hotspot.set_process(false)
await create_timer(0.6).timeout
await process_frame
assert(hotspot.get_node_or_null("WaterSpurt/WaterDroplets") == null)
hotspot.queue_free()

View file

@ -260,6 +260,9 @@ func _ready() -> void:
_player_menu.controller_hotbar_management_ended.connect(
_on_controller_hotbar_management_ended
)
_player_menu.controller_text_entry_requested.connect(
request_controller_text_entry_for
)
_title_settings_panel.panel_visibility_changed.connect(
_on_settings_visibility_changed
)
@ -459,6 +462,7 @@ func setup(
discovery,
player_jobs,
world_time,
world_weather,
world_environment,
world_sun,
)
@ -1892,7 +1896,7 @@ func _position_storage_prompt(world_anchor: Vector3) -> void:
if _player == null:
_storage_prompt.hide()
return
var camera := _player.get_gameplay_camera()
var camera := _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor):
_storage_prompt.hide()
return
@ -2006,7 +2010,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
if _player == null:
_shop_prompt.hide()
return
var camera: Camera3D = _player.get_gameplay_camera()
var camera: Camera3D = _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor):
_shop_prompt.hide()
return

View file

@ -2,6 +2,9 @@ class_name LogbookCatalog
extends RefCounted
const FishDataType = preload("res://fish/fish_data.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const CalendarSeasonType = preload("res://world/calendar_season.gd")
enum Category {
FRESH_WATER,
@ -10,6 +13,66 @@ enum Category {
SHELLFISH,
}
enum DiscoveryFilter {
ALL,
CAUGHT,
MISSING,
INCOMPLETE,
}
enum SortMode {
CATALOG_NUMBER,
NAME,
RARITY,
CATCH_COUNT,
QUALITY_PROGRESS,
VALUE,
WEIGHT,
}
enum AvailabilityFilter {
ANY,
AVAILABLE_NOW,
}
enum SeasonFilter {
ANY,
CURRENT,
SPRING,
SUMMER,
FALL,
WINTER,
}
enum TimeFilter {
ANY,
CURRENT,
DAY,
NIGHT,
}
enum RarityFilter {
ANY,
COMMON,
UNCOMMON,
RARE,
EPIC,
LEGENDARY,
}
enum MethodFilter {
ANY,
FISHING,
NET,
DIGGING,
}
enum HoldingFilter {
ANY,
IN_COOLER,
FAVORITED,
}
static func category_for(fish: FishDataType) -> Category:
if fish == null:
@ -73,3 +136,278 @@ static func ordered_species(
return a.catalog_number < b.catalog_number
)
return ordered
static func filtered_species(
candidates: Array[FishDataType],
category: Category,
collection_log: CollectionLogType,
inventory: FishInventoryType,
options: Dictionary,
available_now_resolver: Callable = Callable(),
current_season: int = CalendarSeasonType.UNKNOWN,
current_is_night: bool = false,
) -> Array[FishDataType]:
var filtered: Array[FishDataType] = []
for fish: FishDataType in ordered_species(candidates):
if category_for(fish) != category:
continue
if not _matches_filters(
fish,
collection_log,
inventory,
options,
available_now_resolver,
current_season,
current_is_night,
):
continue
filtered.append(fish)
var sort_mode := int(options.get("sort", SortMode.CATALOG_NUMBER))
var descending := bool(options.get("descending", false))
filtered.sort_custom(
func(first: FishDataType, second: FishDataType) -> bool:
return _species_less_than(
first,
second,
collection_log,
sort_mode,
descending,
)
)
return filtered
static func available_groups(
candidates: Array[FishDataType],
category: Category,
_collection_log: CollectionLogType,
) -> Array[String]:
var groups: Array[String] = []
for fish: FishDataType in ordered_species(candidates):
if category_for(fish) != category:
continue
var group := _group_label(fish)
if not group.is_empty() and group not in groups:
groups.append(group)
groups.sort_custom(
func(first: String, second: String) -> bool:
return first.naturalnocasecmp_to(second) < 0
)
return groups
static func quality_progress(
collection_log: CollectionLogType,
fish_id: StringName,
) -> int:
if collection_log == null:
return 0
var mask: int = collection_log.get_quality_mask(fish_id)
var count: int = 0
while mask > 0:
count += mask & 1
mask >>= 1
return count
static func _matches_filters(
fish: FishDataType,
collection_log: CollectionLogType,
inventory: FishInventoryType,
options: Dictionary,
available_now_resolver: Callable,
current_season: int,
current_is_night: bool,
) -> bool:
var discovered := (
collection_log != null and collection_log.has_discovered(fish.id)
)
match int(options.get("show", DiscoveryFilter.ALL)):
DiscoveryFilter.CAUGHT:
if not discovered:
return false
DiscoveryFilter.MISSING:
if discovered:
return false
DiscoveryFilter.INCOMPLETE:
if (
not discovered
or collection_log.has_mastered(fish.id)
):
return false
var search_text := str(options.get("search", "")).strip_edges().to_lower()
if not search_text.is_empty():
var number_text := str(catalog_number(fish))
var number_query := search_text.trim_prefix("#")
var number_matches := (
number_query.is_valid_int()
and number_text == number_query
)
var identity_matches := false
if discovered:
identity_matches = " ".join([
fish.display_name,
_group_label(fish),
fish.get_habitat_label(),
]).to_lower().contains(search_text)
if not number_matches and not identity_matches:
return false
if (
int(options.get("availability", AvailabilityFilter.ANY))
== AvailabilityFilter.AVAILABLE_NOW
):
if available_now_resolver.is_valid():
var resolved: Variant = available_now_resolver.call(fish)
if typeof(resolved) != TYPE_BOOL or not bool(resolved):
return false
elif not _matches_season(fish, current_season):
return false
var season_filter := int(options.get("season", SeasonFilter.ANY))
if season_filter != SeasonFilter.ANY:
var requested_season := current_season
if season_filter >= SeasonFilter.SPRING:
requested_season = season_filter - SeasonFilter.SPRING
if not _matches_season(fish, requested_season):
return false
var time_filter := int(options.get("time", TimeFilter.ANY))
if time_filter != TimeFilter.ANY:
var requested_night := current_is_night
if time_filter == TimeFilter.DAY:
requested_night = false
elif time_filter == TimeFilter.NIGHT:
requested_night = true
if not _matches_time(fish, requested_night):
return false
var rarity_filter := int(options.get("rarity", RarityFilter.ANY))
if (
rarity_filter != RarityFilter.ANY
and fish.rarity != rarity_filter - RarityFilter.COMMON
):
return false
var method_filter := int(options.get("method", MethodFilter.ANY))
if (
method_filter != MethodFilter.ANY
and fish.collection_method != method_filter - MethodFilter.FISHING
):
return false
var holding_filter := int(options.get("holding", HoldingFilter.ANY))
if holding_filter != HoldingFilter.ANY:
var favorites_only := holding_filter == HoldingFilter.FAVORITED
if not _has_current_catch(inventory, fish.id, favorites_only):
return false
var requested_group := str(options.get("group", ""))
if (
not requested_group.is_empty()
and _group_label(fish).nocasecmp_to(requested_group) != 0
):
return false
return true
static func _species_less_than(
first: FishDataType,
second: FishDataType,
collection_log: CollectionLogType,
sort_mode: int,
descending: bool,
) -> bool:
var first_discovered := (
collection_log != null and collection_log.has_discovered(first.id)
)
var second_discovered := (
collection_log != null and collection_log.has_discovered(second.id)
)
if sort_mode != SortMode.CATALOG_NUMBER:
if first_discovered != second_discovered:
return first_discovered
if not first_discovered and not second_discovered:
return _catalog_less_than(first, second)
var comparison: int = 0
match sort_mode:
SortMode.NAME:
comparison = first.display_name.naturalnocasecmp_to(
second.display_name
)
SortMode.RARITY:
comparison = first.rarity - second.rarity
SortMode.CATCH_COUNT:
comparison = (
collection_log.get_catch_count(first.id)
- collection_log.get_catch_count(second.id)
)
SortMode.QUALITY_PROGRESS:
comparison = (
quality_progress(collection_log, first.id)
- quality_progress(collection_log, second.id)
)
SortMode.VALUE:
comparison = first.sell_value_max - second.sell_value_max
SortMode.WEIGHT:
comparison = _compare_floats(
first.get_maximum_weight(),
second.get_maximum_weight(),
)
_:
comparison = first.catalog_number - second.catalog_number
if comparison != 0:
return comparison > 0 if descending else comparison < 0
return _catalog_less_than(first, second)
static func _catalog_less_than(first: FishDataType, second: FishDataType) -> bool:
if first.catalog_number == second.catalog_number:
return String(first.id) < String(second.id)
return first.catalog_number < second.catalog_number
static func _compare_floats(first: float, second: float) -> int:
if is_equal_approx(first, second):
return 0
return -1 if first < second else 1
static func _matches_season(fish: FishDataType, season: int) -> bool:
return fish.is_available_in_season(season)
static func _matches_time(fish: FishDataType, requested_night: bool) -> bool:
if (
fish.collection_method != FishDataType.CollectionMethod.FISHING
or fish.availability == null
):
return true
return (
fish.availability.allow_night
if requested_night
else fish.availability.allow_day
)
static func _has_current_catch(
inventory: FishInventoryType,
fish_id: StringName,
favorites_only: bool,
) -> bool:
if inventory == null:
return false
for fish_catch in inventory.get_catches_by_fish_id(fish_id):
if fish_catch != null and (not favorites_only or fish_catch.is_favorited):
return true
return false
static func _group_label(fish: FishDataType) -> String:
if fish == null:
return ""
var group := String(fish.collection_group).strip_edges()
return group if not group.is_empty() else fish.get_habitat_label()

View file

@ -6,10 +6,21 @@ const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd"
)
const SILHOUETTE_SHADER: Shader = preload(
"res://ui/logbook_silhouette.gdshader"
)
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const NOTEPAD_INK_ACTION_SCENE: PackedScene = preload(
"res://ui/components/bubble_menu/notepad_ink_action.tscn"
)
const InventoryNotepadType = preload(
"res://ui/components/inventory_notepad.gd"
)
@ -48,6 +59,8 @@ const LOGBOOK_TAB_LEFT_INSET: float = 92.0
const LOGBOOK_TAB_SIZE := Vector2(108.0, 38.0)
const LOGBOOK_TAB_SEPARATION: float = 6.0
const LOGBOOK_TAB_TEXT_SIDE_INSET: float = 10.0
const INDEX_TAB_SIZE := Vector2(88.0, 38.0)
const INDEX_TAB_RIGHT_INSET: float = 72.0
const PAGE_CONTENT_SCALE: float = 0.97
const WIDE_OUTER_MARGINS := Rect2(52.0, 85.0, 52.0, 18.0)
const COMPACT_OUTER_MARGINS := Rect2(160.0, 85.0, 160.0, 106.0)
@ -61,17 +74,53 @@ const DETAIL_SECTION_SEPARATION: int = 10
const DETAIL_QUALITY_SECTION_SIZE := Vector2(300.0, 104.0)
const DETAIL_STATS_SECTION_SIZE := Vector2(400.0, 160.0)
const DETAIL_STATS_ROW_SEPARATION: int = 8
const INDEX_OPTION_HEIGHT: float = 34.0
const INDEX_CONTENT_LEFT_INSET: float = 44.0
const INDEX_CONTENT_TOP_INSET: float = 4.0
const INDEX_CONTENT_RIGHT_INSET: float = 22.0
const INDEX_CONTENT_BOTTOM_INSET: float = 10.0
const INDEX_SHOW_LABELS := [
"all entries", "caught", "missing", "needs quality",
]
const INDEX_SORT_LABELS := [
"catalog no.", "name", "rarity", "number caught",
"quality progress", "value", "weight",
]
const INDEX_AVAILABILITY_LABELS := [
"any time", "available now",
]
const INDEX_SEASON_LABELS := [
"any season", "current season", "spring", "summer", "fall", "winter",
]
const INDEX_TIME_LABELS := [
"any time", "current time", "day", "night",
]
const INDEX_RARITY_LABELS := [
"any rarity", "common", "uncommon", "rare", "epic", "legendary",
]
const INDEX_METHOD_LABELS := [
"any method", "fishing", "netting", "digging",
]
const INDEX_HOLDING_LABELS := [
"any ownership", "in cooler", "favorited",
]
signal controller_text_entry_requested(control: Control)
enum ControllerZone {
TABS,
ENTRIES,
DETAILS,
INDEX,
OVERLAY,
}
var _collection_log: CollectionLogType
var _inventory: FishInventoryType
var _catalog: FishPoolType
var _fishing_spot: FishingSpotType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _category: LogbookCatalog.Category = (
LogbookCatalog.Category.FRESH_WATER
)
@ -111,6 +160,31 @@ var _portrait_overlay_stats_view: Control
var _portrait_overlay_stats: GridContainer
var _overlay_return_focus: Control
var _controller_zone: ControllerZone = ControllerZone.TABS
var _index_tab: OrganizerTabType
var _index_body: VBoxContainer
var _index_search: LineEdit
var _index_summary: Label
var _index_note: Label
var _index_reset: Button
var _index_option_buttons: Dictionary[StringName, Button] = {}
var _index_controls: Array[Control] = []
var _index_open: bool = false
var _index_update_in_progress: bool = false
var _index_visible_count: int = 0
var _index_category_total: int = 0
var _index_options: Dictionary = {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
func _ready() -> void:
@ -124,10 +198,16 @@ func setup(
collection_log: CollectionLogType,
inventory: FishInventoryType,
catalog: FishPoolType,
fishing_spot: FishingSpotType = null,
world_time: WorldTimeServiceType = null,
world_weather: WorldWeatherServiceType = null,
) -> void:
_collection_log = collection_log
_inventory = inventory
_catalog = catalog
_fishing_spot = fishing_spot
_world_time = world_time
_world_weather = world_weather
if not _collection_log.fish_discovered.is_connected(
_on_fish_discovered
):
@ -138,12 +218,28 @@ func setup(
_collection_log.collection_changed.connect(_on_collection_changed)
if not _inventory.catches_changed.is_connected(_on_inventory_changed):
_inventory.catches_changed.connect(_on_inventory_changed)
if (
_world_time != null
and not _world_time.time_changed.is_connected(
_on_logbook_context_changed
)
):
_world_time.time_changed.connect(_on_logbook_context_changed)
_world_time.calendar_date_changed.connect(_on_logbook_context_changed)
if (
_world_weather != null
and not _world_weather.weather_changed.is_connected(
_on_logbook_context_changed
)
):
_world_weather.weather_changed.connect(_on_logbook_context_changed)
_refresh_catalog()
func set_compact_presentation(compact: bool) -> void:
_compact_presentation = compact
_apply_presentation_margins()
_apply_index_presentation()
func activate() -> void:
@ -180,6 +276,17 @@ func set_interactive(value: bool) -> void:
if _interactive and not tab.button_pressed
else Control.MOUSE_FILTER_IGNORE
)
if _index_tab != null:
_index_tab.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.TABS
else Control.FOCUS_NONE
)
_index_tab.mouse_filter = (
Control.MOUSE_FILTER_STOP
if _interactive
else Control.MOUSE_FILTER_IGNORE
)
for entry: Button in _entry_buttons.values():
entry.focus_mode = (
Control.FOCUS_ALL
@ -199,6 +306,19 @@ func set_interactive(value: bool) -> void:
if _interactive and _controller_zone == ControllerZone.DETAILS
else Control.FOCUS_NONE
)
for index_control: Control in _index_controls:
if not is_instance_valid(index_control):
continue
index_control.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.INDEX
else Control.FOCUS_NONE
)
index_control.mouse_filter = (
Control.MOUSE_FILTER_STOP
if _interactive and _index_open
else Control.MOUSE_FILTER_IGNORE
)
if _portrait_overlay_backdrop != null:
_portrait_overlay_backdrop.focus_mode = (
Control.FOCUS_ALL
@ -214,6 +334,9 @@ func focus_initial() -> void:
_portrait_overlay_backdrop.grab_focus()
return
if _controller_zone == ControllerZone.TABS:
if _index_open and _index_tab != null:
_index_tab.grab_focus()
return
var category_tab_index: int = _category_tab_categories.find(_category)
if category_tab_index >= 0:
_category_tabs[category_tab_index].grab_focus()
@ -222,6 +345,10 @@ func focus_initial() -> void:
if not _detail_buttons.is_empty():
_detail_buttons.front().grab_focus()
return
if _controller_zone == ControllerZone.INDEX:
if _index_search != null:
_index_search.grab_focus()
return
var selected := _entry_buttons.get(_selected_entry_key) as Button
if selected != null:
selected.grab_focus()
@ -247,6 +374,8 @@ func handle_controller_input(event: InputEvent) -> bool:
_hide_portrait_overlay()
ControllerZone.DETAILS:
_set_controller_zone(ControllerZone.ENTRIES)
ControllerZone.INDEX:
_set_index_open(false)
ControllerZone.ENTRIES:
_set_controller_zone(ControllerZone.TABS)
ControllerZone.TABS:
@ -254,15 +383,43 @@ func handle_controller_input(event: InputEvent) -> bool:
return true
if _controller_zone == ControllerZone.TABS:
if event.is_action_pressed("ui_left"):
if _index_open:
_set_index_open(false, false)
_category_tabs.back().grab_focus()
return true
_select_adjacent_controller_category(-1)
return true
if event.is_action_pressed("ui_right"):
if (
_category_tab_categories.find(_category)
== _category_tab_categories.size() - 1
):
_set_index_open(true, false)
_index_tab.grab_focus()
return true
_select_adjacent_controller_category(1)
return true
if accept_pressed:
if not _entry_buttons.is_empty():
if _index_open:
_set_controller_zone(ControllerZone.INDEX)
elif not _entry_buttons.is_empty():
_set_controller_zone(ControllerZone.ENTRIES)
return true
if _controller_zone == ControllerZone.INDEX:
var focus_owner := get_viewport().gui_get_focus_owner()
if accept_pressed and focus_owner == _index_search:
controller_text_entry_requested.emit(_index_search)
return true
if event.is_action_pressed("ui_left"):
var previous_key := _index_key_for_control(focus_owner)
if not previous_key.is_empty():
_cycle_index_option(previous_key, -1)
return true
if event.is_action_pressed("ui_right") or accept_pressed:
var next_key := _index_key_for_control(focus_owner)
if not next_key.is_empty():
_cycle_index_option(next_key, 1)
return true
if _controller_zone == ControllerZone.ENTRIES and accept_pressed:
if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty():
_set_controller_zone(ControllerZone.DETAILS)
@ -329,6 +486,20 @@ func _build_interface() -> void:
tabs.add_child(tab)
_category_tabs.append(tab)
_category_tab_categories.append(category)
_index_tab = OrganizerTabType.new()
_index_tab.name = "CatalogIndexTab"
_index_tab.anchor_left = 1.0
_index_tab.anchor_right = 1.0
_index_tab.offset_left = -INDEX_TAB_RIGHT_INSET - INDEX_TAB_SIZE.x
_index_tab.offset_top = 37.0
_index_tab.offset_right = -INDEX_TAB_RIGHT_INSET
_index_tab.offset_bottom = 37.0 + INDEX_TAB_SIZE.y
_index_tab.text = "index"
_index_tab.palette_index = 0
_index_tab.tooltip_text = "Open catalog index"
_index_tab.accessibility_name = "Catalog index"
_index_tab.pressed.connect(_toggle_index_page)
stack.add_child(_index_tab)
_configure_category_focus()
var book := Control.new()
@ -418,9 +589,176 @@ func _build_interface() -> void:
right_page.add_child(_detail_body)
_apply_page_content_scale(_detail_body)
_show_no_selection()
_build_index_page(right_page)
_build_portrait_overlay()
func _build_index_page(right_page: Control) -> void:
_index_body = VBoxContainer.new()
_index_body.name = "CatalogIndex"
_index_body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_index_body.offset_left = INDEX_CONTENT_LEFT_INSET
_index_body.offset_top = INDEX_CONTENT_TOP_INSET
_index_body.offset_right = -INDEX_CONTENT_RIGHT_INSET
_index_body.offset_bottom = -INDEX_CONTENT_BOTTOM_INSET
_index_body.add_theme_constant_override("separation", 4)
_index_body.visible = false
right_page.add_child(_index_body)
_apply_page_content_scale(_index_body)
_apply_index_presentation()
var heading := _label("catalog index", 28)
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_body.add_child(heading)
_index_summary = _field_label("", 14)
_index_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_summary.add_theme_color_override("font_color", MUTED_INK)
_index_body.add_child(_index_summary)
var search_stack := VBoxContainer.new()
search_stack.add_theme_constant_override("separation", 0)
_index_body.add_child(search_stack)
var search_label := _field_label("find by name, group, or catalog no.", 13)
search_label.add_theme_color_override("font_color", MUTED_INK)
search_stack.add_child(search_label)
_index_search = LineEdit.new()
_index_search.name = "CatalogIndexSearch"
_index_search.custom_minimum_size.y = 34.0
_index_search.placeholder_text = "write here..."
_index_search.max_length = 48
_index_search.add_theme_font_override(
"font", InventoryNotepadType.HANDWRITTEN_FONT
)
_index_search.add_theme_font_size_override("font_size", 18)
_index_search.add_theme_color_override("font_color", INK)
_index_search.add_theme_color_override("caret_color", INK)
_index_search.add_theme_color_override(
"font_placeholder_color", Color(MUTED_INK, 0.72)
)
for style_name: StringName in [
&"normal", &"focus", &"read_only",
]:
_index_search.add_theme_stylebox_override(
style_name,
_index_search_style(style_name == &"focus"),
)
_index_search.text_changed.connect(_on_index_search_changed)
search_stack.add_child(_index_search)
_index_controls.append(_index_search)
var options_grid := GridContainer.new()
options_grid.columns = 2
options_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
options_grid.add_theme_constant_override("h_separation", 18)
options_grid.add_theme_constant_override("v_separation", 2)
_index_body.add_child(options_grid)
for descriptor: Dictionary in [
{"key": &"show", "label": "show"},
{"key": &"rarity", "label": "rarity"},
{"key": &"sort", "label": "sort by"},
{"key": &"availability", "label": "availability"},
{"key": &"descending", "label": "order"},
{"key": &"season", "label": "season"},
{"key": &"method", "label": "method"},
{"key": &"time", "label": "time of day"},
{"key": &"holding", "label": "ownership"},
{"key": &"group", "label": "collection group"},
]:
options_grid.add_child(_make_index_option(
descriptor["key"],
str(descriptor["label"]),
))
var footer := HBoxContainer.new()
footer.alignment = BoxContainer.ALIGNMENT_CENTER
_index_body.add_child(footer)
_index_reset = NOTEPAD_INK_ACTION_SCENE.instantiate() as Button
_index_reset.name = "CatalogIndexReset"
_index_reset.custom_minimum_size = Vector2(126.0, 34.0)
_index_reset.text = "clear index"
_apply_index_value_font(_index_reset)
_index_reset.tooltip_text = "Clear every catalog filter"
_index_reset.accessibility_name = "Clear catalog index filters"
_index_reset.pressed.connect(_reset_index)
footer.add_child(_index_reset)
_index_controls.append(_index_reset)
_index_note = _field_label(
"silhouettes keep names hidden in filtered results",
12,
)
_index_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_index_note.add_theme_color_override("font_color", Color(MUTED_INK, 0.82))
_index_body.add_child(_index_note)
_update_index_controls()
_configure_index_focus.call_deferred()
func _make_index_option(key: StringName, label_text: String) -> Control:
var stack := VBoxContainer.new()
stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
stack.add_theme_constant_override("separation", -3)
var label := _field_label(label_text, 12)
label.add_theme_color_override("font_color", MUTED_INK)
stack.add_child(label)
var button := NOTEPAD_INK_ACTION_SCENE.instantiate() as Button
button.name = "CatalogIndex%s" % String(key).capitalize().replace(" ", "")
button.custom_minimum_size = Vector2(0.0, INDEX_OPTION_HEIGHT)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.clip_text = true
_apply_index_value_font(button)
button.tooltip_text = "Change %s; use left or right on a controller" % label_text
button.pressed.connect(_cycle_index_option.bind(key, 1))
button.gui_input.connect(_on_index_option_gui_input.bind(key))
stack.add_child(button)
_index_option_buttons[key] = button
_index_controls.append(button)
return stack
func _apply_index_value_font(button: Button) -> void:
button.add_theme_font_override(
"font", InventoryNotepadType.HANDWRITTEN_FONT
)
button.add_theme_font_size_override("font_size", 18)
for color_name: StringName in [
&"font_color",
&"font_hover_color",
&"font_focus_color",
&"font_pressed_color",
]:
button.add_theme_color_override(color_name, INK)
func _index_search_style(focused: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.97, 0.82, 0.12 if focused else 0.0)
style.border_color = Color(INK, 0.72 if focused else 0.42)
style.border_width_bottom = 2
style.content_margin_left = 6
style.content_margin_right = 22
style.content_margin_top = 2
style.content_margin_bottom = 3
return style
func _configure_index_focus() -> void:
var controls: Array[Control] = []
for control: Control in _index_controls:
if control != null and is_instance_valid(control):
controls.append(control)
if not controls.is_empty():
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
func _apply_index_presentation() -> void:
if _index_body == null:
return
var scale_value := 0.82 if _compact_presentation else PAGE_CONTENT_SCALE
_index_body.scale = Vector2.ONE * scale_value
func _apply_presentation_margins() -> void:
if _outer_margin == null:
return
@ -548,11 +886,30 @@ func _refresh_catalog() -> void:
var preserved_entry_key: StringName = _selected_entry_key
_clear_entries()
var species: Array[FishDataType] = []
var category_total: int = 0
if _catalog != null:
species = LogbookCatalog.ordered_species(_catalog.candidates)
for fish: FishDataType in LogbookCatalog.ordered_species(
_catalog.candidates
):
if LogbookCatalog.category_for(fish) == _category:
category_total += 1
var available_now_resolver := Callable()
if _fishing_spot != null and is_instance_valid(_fishing_spot):
available_now_resolver = Callable(
_fishing_spot,
"is_species_available_now",
)
species = LogbookCatalog.filtered_species(
_catalog.candidates,
_category,
_collection_log,
_inventory,
_index_options,
available_now_resolver,
_current_season(),
_current_is_night(),
)
for fish: FishDataType in species:
if LogbookCatalog.category_for(fish) != _category:
continue
var discovered: bool = _collection_log.has_discovered(fish.id)
var entry: Button = _make_entry(fish, discovered)
var selection_key: StringName = (
@ -572,7 +929,11 @@ func _refresh_catalog() -> void:
)
)
_catalog_grid.add_child(entry)
_empty_state.text = LogbookCatalog.empty_state(_category)
_empty_state.text = (
"No entries match this index."
if _index_has_active_criteria()
else LogbookCatalog.empty_state(_category)
)
_empty_state.visible = _entry_buttons.is_empty()
_catalog_scroll.visible = not _entry_buttons.is_empty()
if (
@ -589,10 +950,269 @@ func _refresh_catalog() -> void:
_selected_entry_key = StringName()
_refresh_selection_styles()
_refresh_details(false)
_update_index_controls(species.size(), category_total)
set_interactive(_interactive)
_refresh_catalog_scroll_indicators.call_deferred()
func _toggle_index_page() -> void:
_set_index_open(not _index_open)
func _set_index_open(open: bool, restore_focus: bool = true) -> void:
_index_open = open
if _index_tab != null:
_index_tab.set_selected(open)
_index_tab.tooltip_text = (
"Return to catch record" if open else "Open catalog index"
)
if _index_body != null:
_index_body.visible = open
if _detail_body != null:
_detail_body.visible = not open
if not open:
_refresh_details(false)
_update_index_controls()
if restore_focus and _active and _interactive:
if open:
_controller_zone = ControllerZone.INDEX
set_interactive(true)
_index_search.grab_focus()
elif _controller_zone == ControllerZone.INDEX:
_controller_zone = ControllerZone.TABS
set_interactive(true)
_index_tab.grab_focus()
func _on_index_search_changed(value: String) -> void:
if _index_update_in_progress:
return
_index_options["search"] = value
_refresh_catalog()
func _cycle_index_option(key: StringName, direction: int = 1) -> void:
if not _index_options.has(key):
return
if key == &"descending":
_index_options[key] = not bool(_index_options[key])
elif key == &"group":
var groups: Array[String] = [""]
if _catalog != null:
groups.append_array(LogbookCatalog.available_groups(
_catalog.candidates,
_category,
_collection_log,
))
var current_index := groups.find(str(_index_options[key]))
if current_index < 0:
current_index = 0
_index_options[key] = groups[wrapi(
current_index + direction,
0,
groups.size(),
)]
else:
var option_count := _index_option_count(key)
if option_count <= 0:
return
_index_options[key] = wrapi(
int(_index_options[key]) + direction,
0,
option_count,
)
_refresh_catalog()
func _on_index_option_gui_input(
event: InputEvent,
key: StringName,
) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed:
return
if mouse_event.button_index in [MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_WHEEL_UP]:
_cycle_index_option(key, -1)
get_viewport().set_input_as_handled()
elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_cycle_index_option(key, 1)
get_viewport().set_input_as_handled()
func _index_key_for_control(control: Control) -> StringName:
for key: StringName in _index_option_buttons:
if _index_option_buttons[key] == control:
return key
return StringName()
func _reset_index() -> void:
_index_options = {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
_index_update_in_progress = true
_index_search.text = ""
_index_update_in_progress = false
_refresh_catalog()
func _update_index_controls(
visible_count: int = -1,
category_total: int = -1,
) -> void:
if visible_count >= 0:
_index_visible_count = visible_count
if category_total >= 0:
_index_category_total = category_total
if _index_summary != null:
_index_summary.text = "%d shown of %d" % [
_index_visible_count,
_index_category_total,
]
for key: StringName in _index_option_buttons:
var button := _index_option_buttons[key]
if button == null:
continue
button.text = _index_option_label(key)
button.accessibility_name = "%s: %s" % [
String(key).replace("_", " "),
button.text,
]
if _index_tab != null:
var marked := _index_has_active_criteria()
_index_tab.text = "index •" if marked else "index"
_index_tab.accessibility_name = (
"Catalog index, filters active" if marked else "Catalog index"
)
if _index_reset != null:
_index_reset.disabled = not _index_has_active_criteria()
func _index_option_label(key: StringName) -> String:
match key:
&"show":
return INDEX_SHOW_LABELS[int(_index_options[key])]
&"sort":
return INDEX_SORT_LABELS[int(_index_options[key])]
&"availability":
return INDEX_AVAILABILITY_LABELS[int(_index_options[key])]
&"season":
return INDEX_SEASON_LABELS[int(_index_options[key])]
&"time":
return INDEX_TIME_LABELS[int(_index_options[key])]
&"rarity":
return INDEX_RARITY_LABELS[int(_index_options[key])]
&"method":
return INDEX_METHOD_LABELS[int(_index_options[key])]
&"holding":
return INDEX_HOLDING_LABELS[int(_index_options[key])]
&"group":
var group := str(_index_options[key])
return group if not group.is_empty() else "any group"
&"descending":
var descending := bool(_index_options[key])
match int(_index_options["sort"]):
LogbookCatalog.SortMode.NAME:
return "za" if descending else "az"
LogbookCatalog.SortMode.CATALOG_NUMBER:
return "highlow" if descending else "lowhigh"
LogbookCatalog.SortMode.RARITY:
return (
"rarecommon" if descending else "commonrare"
)
LogbookCatalog.SortMode.VALUE, LogbookCatalog.SortMode.WEIGHT:
return "highlow" if descending else "lowhigh"
_:
return "most first" if descending else "least first"
return "any"
func _index_option_count(key: StringName) -> int:
match key:
&"show":
return INDEX_SHOW_LABELS.size()
&"sort":
return INDEX_SORT_LABELS.size()
&"availability":
return INDEX_AVAILABILITY_LABELS.size()
&"season":
return INDEX_SEASON_LABELS.size()
&"time":
return INDEX_TIME_LABELS.size()
&"rarity":
return INDEX_RARITY_LABELS.size()
&"method":
return INDEX_METHOD_LABELS.size()
&"holding":
return INDEX_HOLDING_LABELS.size()
return 0
func _index_has_active_criteria() -> bool:
return (
not str(_index_options["search"]).is_empty()
or int(_index_options["show"])
!= LogbookCatalog.DiscoveryFilter.ALL
or int(_index_options["sort"])
!= LogbookCatalog.SortMode.CATALOG_NUMBER
or bool(_index_options["descending"])
or int(_index_options["availability"])
!= LogbookCatalog.AvailabilityFilter.ANY
or int(_index_options["season"])
!= LogbookCatalog.SeasonFilter.ANY
or int(_index_options["time"])
!= LogbookCatalog.TimeFilter.ANY
or int(_index_options["rarity"])
!= LogbookCatalog.RarityFilter.ANY
or int(_index_options["method"])
!= LogbookCatalog.MethodFilter.ANY
or int(_index_options["holding"])
!= LogbookCatalog.HoldingFilter.ANY
or not str(_index_options["group"]).is_empty()
)
func _current_season() -> int:
return (
_world_time.get_season()
if _world_time != null
else CalendarSeason.UNKNOWN
)
func _current_is_night() -> bool:
return _world_time != null and _world_time.is_night_period()
func notify_availability_context_changed() -> void:
_on_logbook_context_changed()
func _on_logbook_context_changed(
_first: Variant = null,
_second: Variant = null,
) -> void:
if (
int(_index_options["availability"])
== LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
or int(_index_options["season"])
== LogbookCatalog.SeasonFilter.CURRENT
or int(_index_options["time"])
== LogbookCatalog.TimeFilter.CURRENT
):
_refresh_catalog()
func _make_entry(fish: FishDataType, discovered: bool) -> Button:
var entry := Button.new()
entry.custom_minimum_size = CATALOG_ENTRY_SIZE
@ -663,8 +1283,11 @@ func _add_entry_content(
func _select_category(category: LogbookCatalog.Category) -> void:
if category == _category:
if _index_open:
_set_index_open(false)
return
_category = category
_set_index_open(false, false)
_selected_id = StringName()
_selected_entry_key = StringName()
for index: int in _category_tabs.size():
@ -683,6 +1306,11 @@ func _configure_category_focus() -> void:
tab.focus_neighbor_right = tab.get_path_to(
_category_tabs[mini(index + 1, _category_tabs.size() - 1)]
)
if not _category_tabs.is_empty() and _index_tab != null:
var final_tab: Button = _category_tabs.back()
final_tab.focus_neighbor_right = final_tab.get_path_to(_index_tab)
_index_tab.focus_neighbor_left = _index_tab.get_path_to(final_tab)
_index_tab.focus_neighbor_right = _index_tab.get_path_to(_index_tab)
func _animate_tab_entry() -> void:
@ -690,11 +1318,15 @@ func _animate_tab_entry() -> void:
(_category_tabs[index] as OrganizerTabType).animate_entrance(
float(index) * 0.025
)
if _index_tab != null:
_index_tab.animate_entrance(float(_category_tabs.size()) * 0.025)
func _settle_tabs_for_close() -> void:
for tab: OrganizerTabType in _category_tabs:
tab.settle_for_close()
if _index_tab != null:
_index_tab.settle_for_close()
func _begin_category_transition() -> void:
@ -750,9 +1382,12 @@ func _set_category_content_alpha(alpha: float) -> void:
_empty_state.modulate.a = alpha
if _detail_body != null:
_detail_body.modulate.a = alpha
if _index_body != null:
_index_body.modulate.a = alpha
func _select_entry(entry_key: StringName, fish_id: StringName) -> void:
_set_index_open(false, false)
if fish_id.is_empty():
_selected_id = StringName()
_selected_entry_key = entry_key
@ -1284,7 +1919,9 @@ func _on_collection_changed() -> void:
func _on_inventory_changed() -> void:
_refresh_details(false)
# Ownership filters and favorite-backed views depend on the live cooler,
# while rebuilding the catalog also keeps the selected detail current.
_refresh_catalog()
func _unknown_selection_key(fish: FishDataType) -> StringName:

View file

@ -105,6 +105,7 @@ signal controller_hotbar_management_requested(initial_slot: int)
signal controller_hotbar_management_ended
signal menu_exit_started
signal shop_cooler_modal_changed(is_open: bool)
signal controller_text_entry_requested(control: Control)
enum Section {
COOLER,
@ -409,6 +410,9 @@ func _ready() -> void:
_logbook_tab.pressed.connect(
_show_section.bind(Section.LOGBOOK)
)
_catalog_logbook.controller_text_entry_requested.connect(
controller_text_entry_requested.emit
)
_the_net_tab.pressed.connect(_show_section.bind(Section.NET))
_mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
_profile_tab.pressed.connect(_show_section.bind(Section.PROFILE))
@ -556,6 +560,7 @@ func setup(
discovery: DiscoveryClient,
player_jobs: PlayerJobService,
world_time: WorldTimeService,
world_weather: WorldWeatherService,
world_environment: WorldEnvironment,
world_sun: DirectionalLight3D,
) -> void:
@ -589,7 +594,14 @@ func setup(
world_sun,
)
_players_page.setup(network_player_list, discovery)
_catalog_logbook.setup(collection_log, inventory, catalog)
_catalog_logbook.setup(
collection_log,
inventory,
catalog,
fishing_spot,
world_time,
world_weather,
)
_the_net_page.setup(player_jobs, world_time)
_network_mail_service.unread_count_changed.connect(
_on_mail_unread_count_changed
@ -2779,6 +2791,7 @@ func _toggle_active_tackle() -> void:
func _on_active_bait_changed(_item_id: StringName) -> void:
_update_tackle_detail()
_catalog_logbook.notify_availability_context_changed()
func _on_active_lure_changed(_item_id: StringName) -> void:
@ -3733,6 +3746,7 @@ func _refresh_all() -> void:
func _on_bag_changed() -> void:
_refresh_bag()
_refresh_tackle_box()
_catalog_logbook.notify_availability_context_changed()
func _on_hotbar_changed() -> void:

View file

@ -43,6 +43,12 @@ const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE := 0.35
const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000
const WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE := 0.05
const WATER_RECOVERY_MINIMUM_UP_DOT := 0.6
const WATER_RECOVERY_FOOTPRINT_RADIUS := 0.6
const WATER_RECOVERY_INSET_STEP := 0.25
const WATER_RECOVERY_MAXIMUM_INSET := 1.5
const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
Vector2(1.0, 0.0),
Vector2(0.70710678, 0.70710678),
@ -90,6 +96,7 @@ var _placed_prop_clearance_radii: Array[float] = []
var _placed_prop_groups: Array[StringName] = []
var _placed_group_coordinates: Dictionary[StringName, Array] = {}
var _biome_assignments: Dictionary[Vector2i, StringName] = {}
var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {}
func _ready() -> void:
@ -190,6 +197,37 @@ func get_spawn_surface_triangles(
return triangles
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if (
not entry_position.is_finite()
or _water_recovery_triangles_by_coordinate.is_empty()
):
return super(entry_position, fallback_position)
var nearest_surface_distance := _nearest_recovery_surface_distance(
entry_position
)
if not is_finite(nearest_surface_distance):
return super(entry_position, fallback_position)
for expansion: float in WATER_RECOVERY_SEARCH_EXPANSIONS:
var maximum_distance := nearest_surface_distance + expansion
var result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
maximum_distance,
)
if result is Vector3:
return result as Vector3
var map_wide_result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
get_playable_half_extents().length() * 2.0,
)
if map_wide_result is Vector3:
return map_wide_result as Vector3
return super(entry_position, fallback_position)
func _on_generation_completed(summary: Dictionary) -> void:
_diggable_beach.invalidate_surface_cache()
var records: Array[Dictionary] = _generator.placement_records()
@ -325,6 +363,10 @@ func _on_generation_completed(summary: Dictionary) -> void:
grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate,
)
_cache_water_recovery_surfaces(
grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate,
)
_place_spawn_amenities(spawn_position)
_configure_fresh_water(records)
_configure_diggable_area()
@ -1261,6 +1303,203 @@ func _terrain_surface_triangles_by_coordinate(
return triangles_by_coordinate
func _cache_water_recovery_surfaces(
grass_triangles: Dictionary[Vector2i, Array],
sand_triangles: Dictionary[Vector2i, Array],
) -> void:
_water_recovery_triangles_by_coordinate.clear()
_append_water_recovery_surfaces(grass_triangles)
_append_water_recovery_surfaces(sand_triangles)
func _append_water_recovery_surfaces(
source: Dictionary[Vector2i, Array],
) -> void:
for coordinate: Vector2i in source:
var eligible: Array = _water_recovery_triangles_by_coordinate.get(
coordinate,
[],
)
for triangle: PackedVector3Array in source[coordinate]:
if triangle.size() != 3:
continue
if minf(
triangle[0].y,
minf(triangle[1].y, triangle[2].y),
) <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE:
continue
var cross := (triangle[1] - triangle[0]).cross(
triangle[2] - triangle[0]
)
if (
cross.length_squared() <= 0.0000001
or absf(cross.normalized().dot(Vector3.UP))
< WATER_RECOVERY_MINIMUM_UP_DOT
):
continue
eligible.append(triangle)
if not eligible.is_empty():
_water_recovery_triangles_by_coordinate[coordinate] = eligible
func _nearest_recovery_surface_distance(entry_position: Vector3) -> float:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var nearest_distance_squared := INF
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
nearest_distance_squared = minf(
nearest_distance_squared,
entry_horizontal.distance_squared_to(closest),
)
return sqrt(nearest_distance_squared)
func _nearest_safe_recovery_surface_position(
entry_position: Vector3,
maximum_distance: float,
) -> Variant:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var maximum_distance_squared := maximum_distance * maximum_distance
var best_distance_squared := INF
var best_position: Variant = null
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
if (
entry_horizontal.distance_squared_to(closest)
> maximum_distance_squared
):
continue
var centroid := Vector2(
(triangle[0].x + triangle[1].x + triangle[2].x) / 3.0,
(triangle[0].z + triangle[1].z + triangle[2].z) / 3.0,
)
var inset_distance := minf(
closest.distance_to(centroid),
WATER_RECOVERY_MAXIMUM_INSET,
)
var inset_steps := ceili(
inset_distance / WATER_RECOVERY_INSET_STEP
)
for inset_index: int in inset_steps + 1:
var candidate_horizontal := closest.move_toward(
centroid,
minf(
float(inset_index) * WATER_RECOVERY_INSET_STEP,
inset_distance,
),
)
var distance_squared := entry_horizontal.distance_squared_to(
candidate_horizontal
)
if (
distance_squared > maximum_distance_squared
or distance_squared >= best_distance_squared
):
continue
var candidate := Vector3(
candidate_horizontal.x,
entry_position.y,
candidate_horizontal.y,
)
var safe_position: Variant = _safe_recovery_surface_position(
candidate
)
if safe_position is Vector3:
best_distance_squared = distance_squared
best_position = safe_position
return best_position
func _safe_recovery_surface_position(candidate: Vector3) -> Variant:
var nearby_triangles := _water_recovery_surface_triangles_near(candidate)
var surface_height := _surface_height_at(candidate, nearby_triangles)
if (
surface_height <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
or not _surface_supports_prop_footprint(
candidate,
surface_height,
WATER_RECOVERY_FOOTPRINT_RADIUS,
nearby_triangles,
)
or not _has_prop_clearance(
candidate,
WATER_RECOVERY_FOOTPRINT_RADIUS,
)
):
return null
candidate.y = surface_height
return candidate
func _water_recovery_surface_triangles_near(
position: Vector3,
) -> Array[PackedVector3Array]:
var result: Array[PackedVector3Array] = []
if _generator.catalog == null or _generator.catalog.chunk_size <= 0.0:
return result
var local_position := _generator.to_local(position)
var half_grid := Vector2(
float(_generator.grid_size.x - 1) * 0.5,
float(_generator.grid_size.y - 1) * 0.5,
)
var coordinate := Vector2i(
roundi(local_position.x / _generator.catalog.chunk_size + half_grid.x),
roundi(local_position.z / _generator.catalog.chunk_size + half_grid.y),
)
for offset_x: int in range(-1, 2):
for offset_y: int in range(-1, 2):
var nearby_coordinate := coordinate + Vector2i(offset_x, offset_y)
result.append_array(
_water_recovery_triangles_by_coordinate.get(
nearby_coordinate,
[],
)
)
return result
func _closest_horizontal_point_on_triangle(
point: Vector2,
triangle: PackedVector3Array,
) -> Vector2:
var polygon := PackedVector2Array([
Vector2(triangle[0].x, triangle[0].z),
Vector2(triangle[1].x, triangle[1].z),
Vector2(triangle[2].x, triangle[2].z),
])
if Geometry2D.is_point_in_polygon(point, polygon):
return point
var closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[0],
polygon[1],
)
var nearest_distance_squared := point.distance_squared_to(closest)
for edge_index: int in range(1, 3):
var edge_closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[edge_index],
polygon[(edge_index + 1) % 3],
)
var distance_squared := point.distance_squared_to(edge_closest)
if distance_squared < nearest_distance_squared:
nearest_distance_squared = distance_squared
closest = edge_closest
return closest
func _terrain_coordinate_for(mesh_instance: MeshInstance3D) -> Vector2i:
var current: Node = mesh_instance
while current != null and current != _generator:

View file

@ -72,6 +72,24 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
return points
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if not entry_position.is_finite():
return fallback_position
var target_position := fallback_position
var nearest_distance := INF
for point: SafeRespawnPoint in get_safe_respawn_points():
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func get_diggable_areas() -> Array[DiggableArea3D]:
var areas: Array[DiggableArea3D] = []
var root: Node = get_node_or_null(diggable_area_root)

View file

@ -49,6 +49,16 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
)
func get_water_recovery_position(entry_position: Vector3) -> Vector3:
if _active_region == null:
return global_position
var fallback_position := get_player_spawn_transform().origin
return _active_region.get_water_recovery_position(
entry_position,
fallback_position,
)
func get_fishing_shop() -> FishingShopInteractionType:
return _active_region.get_fishing_shop()

View file

@ -25,6 +25,7 @@ var _game_ui: GameUI
var _screen_fade: ScreenFade
var _water_triggers: Array[PlayerWaterTrigger] = []
var _safe_points: Array[SafeRespawnPoint] = []
var _recovery_position_resolver := Callable()
var _initial_spawn_transform: Transform3D
var _entry_position: Vector3
var _bob_base_position: Vector3
@ -43,6 +44,7 @@ func setup(
screen_fade: ScreenFade,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
_player = player
_fishing_spot = fishing_spot
@ -50,6 +52,7 @@ func setup(
_screen_fade = screen_fade
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
_initial_spawn_transform = player.global_transform
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
@ -73,6 +76,7 @@ func update_world_context(
initial_spawn_transform: Transform3D,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
@ -88,6 +92,7 @@ func update_world_context(
_initial_spawn_transform = initial_spawn_transform
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
@ -183,17 +188,7 @@ func _on_fade_transition_completed(
func _respawn_player() -> void:
var target_position: Vector3 = _initial_spawn_transform.origin
var nearest_distance: float = INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
_entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
var target_position := _resolve_recovery_position()
target_position.y += respawn_height_offset
var respawn_transform: Transform3D = _player.global_transform
respawn_transform.basis = _recovery_root_basis
@ -204,6 +199,23 @@ func _respawn_player() -> void:
local_respawn_completed.emit(_entry_position)
func _resolve_recovery_position() -> Vector3:
if _recovery_position_resolver.is_valid():
var resolved: Variant = _recovery_position_resolver.call(_entry_position)
if resolved is Vector3 and (resolved as Vector3).is_finite():
return resolved as Vector3
var target_position := _initial_spawn_transform.origin
var nearest_distance := INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(_entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func _finish_recovery() -> void:
_player.restore_gameplay_orientation_after_recovery()
_player.set_water_recovery_active(false)