From f910e6bdbbecad3fd1dd50218ecf6e04cd3d8601 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 26 Aug 2026 00:05:51 -0400 Subject: [PATCH 1/4] Update Forgejo links for Straywild rename --- README.md | 4 ++-- docs/README.md | 4 ++-- scripts/build_playtest.sh | 2 +- scripts/build_portmaster.sh | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 536afab..e228e7b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 5327b30..444ebc7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 1c74943..2347d3d 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -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}" diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh index 0487b5b..a40e0cd 100755 --- a/scripts/build_portmaster.sh +++ b/scripts/build_portmaster.sh @@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" < Date: Sat, 29 Aug 2026 17:41:10 -0400 Subject: [PATCH 2/4] Fix clam spurt origin artifact (#118) --- gathering/world_gatherable.gd | 7 +++-- tests/world_spawn_protocol_validation.gd | 39 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/gathering/world_gatherable.gd b/gathering/world_gatherable.gd index 8a07ba0..1a71477 100644 --- a/gathering/world_gatherable.gd +++ b/gathering/world_gatherable.gd @@ -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 diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index a8365a1..d100260 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -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() From 4575830857c18dd6d9a3b4761c0df7c19cacf18c Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 29 Aug 2026 17:43:11 -0400 Subject: [PATCH 3/4] 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. --- main/main.gd | 18 +- tests/art_tools_validation.gd | 6 + tests/fishing_authority_validation.gd | 9 + tests/generated_world_runtime_validation.gd | 75 ++++++ tests/world_layout_validation.gd | 8 + ui/game_ui.gd | 4 +- world/generation/generated_world_region.gd | 239 ++++++++++++++++++++ world/regions/world_region.gd | 18 ++ world/test_world.gd | 10 + world/water_recovery_controller.gd | 34 ++- 10 files changed, 396 insertions(+), 25 deletions(-) diff --git a/main/main.gd b/main/main.gd index 442c97c..7fae866 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1006,7 +1006,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 +2345,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( - entry_position - ) - if distance < nearest_distance: - nearest_distance = distance - target_position = point.global_position + var target_position := _test_world.get_water_recovery_position( + entry_position + ) target_position.y += _water_recovery.respawn_height_offset avatar.global_position = target_position avatar.velocity = Vector3.ZERO @@ -2432,6 +2425,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, diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 3b9d6a3..4bfab98 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -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"))) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index e7c87bb..b3c17dd 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -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()) diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index f487ae7..6a3f6c8 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -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, diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd index dd40fcf..e6eec39 100644 --- a/tests/world_layout_validation.gd +++ b/tests/world_layout_validation.gd @@ -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) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 6e4241b..7f8f0a6 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -1892,7 +1892,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 +2006,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 diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index aa27a65..780ddb8 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -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: diff --git a/world/regions/world_region.gd b/world/regions/world_region.gd index a0730e4..792e4ed 100644 --- a/world/regions/world_region.gd +++ b/world/regions/world_region.gd @@ -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) diff --git a/world/test_world.gd b/world/test_world.gd index d9d18dc..9f4da5e 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -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() diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index 9626881..0d0c5aa 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -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) From 81decf2b719c975332f0653e94be7511a1adfbc9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 29 Aug 2026 20:19:47 -0400 Subject: [PATCH 4/4] Add logbook catalog index filters --- fishing/fishing_spot.gd | 44 ++ main/main.gd | 1 + ...ontroller_menu_accessibility_validation.gd | 18 + tests/logbook_runtime_validation.gd | 39 +- tests/logbook_validation.gd | 441 ++++++++++++ ui/game_ui.gd | 4 + ui/logbook_catalog.gd | 338 +++++++++ ui/logbook_page.gd | 649 +++++++++++++++++- ui/player_menu.gd | 16 +- 9 files changed, 1540 insertions(+), 10 deletions(-) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 2559af5..8e3e169 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -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 [] diff --git a/main/main.gd b/main/main.gd index 7fae866..e9dc78f 100644 --- a/main/main.gd +++ b/main/main.gd @@ -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 diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index aa2f9e2..37e43d5 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -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) diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index 75fc47f..47e795d 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -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 ) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 4810e0a..431ed2e 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -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): diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 7f8f0a6..3ce0d17 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -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, ) diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd index d5dc639..ac7a910 100644 --- a/ui/logbook_catalog.gd +++ b/ui/logbook_catalog.gd @@ -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() diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 6319dd6..cc490a0 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -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 "z–a" if descending else "a–z" + LogbookCatalog.SortMode.CATALOG_NUMBER: + return "high–low" if descending else "low–high" + LogbookCatalog.SortMode.RARITY: + return ( + "rare–common" if descending else "common–rare" + ) + LogbookCatalog.SortMode.VALUE, LogbookCatalog.SortMode.WEIGHT: + return "high–low" if descending else "low–high" + _: + 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: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 3c5dc77..2f9f96f 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -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: