Compare commits
No commits in common. "main" and "v0.19.0-alpha" have entirely different histories.
main
...
v0.19.0-al
23 changed files with 45 additions and 1984 deletions
|
|
@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc
|
|||
|
||||
Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io
|
||||
|
||||
Repo: https://forge.makearmy.io/woofmeow/straywild
|
||||
Repo: https://forge.makearmy.io/woofmeow/netfishing
|
||||
|
||||
Releases: https://forge.makearmy.io/woofmeow/straywild/releases
|
||||
Releases: https://forge.makearmy.io/woofmeow/netfishing/releases
|
||||
|
||||
|
||||
## Installing release builds
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ for local README files.
|
|||
|
||||
## Related repositories
|
||||
|
||||
- [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md)
|
||||
- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-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/straywild-dedicated-server/src/branch/main/README.md)
|
||||
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-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.
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ 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
|
||||
|
|
@ -223,7 +222,6 @@ 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
|
||||
|
|
@ -244,7 +242,6 @@ 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(
|
||||
|
|
@ -1667,47 +1664,6 @@ 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 []
|
||||
|
|
|
|||
|
|
@ -29,16 +29,12 @@ 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:
|
||||
|
|
@ -54,6 +50,7 @@ func configure(
|
|||
)
|
||||
)
|
||||
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
|
||||
apply_network_state(position, yaw, true)
|
||||
|
||||
|
||||
func apply_network_state(
|
||||
|
|
@ -150,6 +147,7 @@ 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:
|
||||
|
|
@ -161,7 +159,6 @@ 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
|
||||
|
|
|
|||
19
main/main.gd
19
main/main.gd
|
|
@ -920,7 +920,6 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
_network_fishing,
|
||||
_world_time,
|
||||
_world_weather,
|
||||
Callable(_test_world, "get_fishable_water_regions"),
|
||||
)
|
||||
if dedicated:
|
||||
return
|
||||
|
|
@ -1007,8 +1006,7 @@ 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(),
|
||||
Callable(_test_world, "get_water_recovery_position"),
|
||||
_test_world.get_safe_respawn_points()
|
||||
)
|
||||
_ui_pixelation.effective_pixel_size_changed.connect(
|
||||
_on_effective_ui_pixel_size_changed
|
||||
|
|
@ -2346,9 +2344,17 @@ func _on_remote_recovery_requested(
|
|||
peer_id,
|
||||
"Fishing attempt ended."
|
||||
)
|
||||
var target_position := _test_world.get_water_recovery_position(
|
||||
entry_position
|
||||
)
|
||||
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
|
||||
target_position.y += _water_recovery.respawn_height_offset
|
||||
avatar.global_position = target_position
|
||||
avatar.velocity = Vector3.ZERO
|
||||
|
|
@ -2426,7 +2432,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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/straywild"
|
||||
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -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/straywild
|
||||
Source repository: https://forge.makearmy.io/woofmeow/netfishing
|
||||
Exact source revision: ${TAG_COMMIT}
|
||||
Release tag: ${RELEASE_TAG}
|
||||
PortMaster packaging revision: ${HEAD_COMMIT}
|
||||
|
|
|
|||
|
|
@ -416,12 +416,6 @@ 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")))
|
||||
|
|
|
|||
|
|
@ -1356,24 +1356,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -265,15 +265,6 @@ 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())
|
||||
|
||||
|
|
|
|||
|
|
@ -536,11 +536,6 @@ 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
|
||||
|
|
@ -1273,76 +1268,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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:
|
||||
|
|
@ -37,16 +36,12 @@ 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(MENU_TRANSITION_SETTLE_SECONDS).timeout
|
||||
await create_timer(2.2).timeout
|
||||
assert(player_menu.visible)
|
||||
assert(logbook.visible)
|
||||
assert(backdrop.visible)
|
||||
|
|
@ -56,18 +51,6 @@ 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
|
||||
)
|
||||
|
|
@ -85,29 +68,13 @@ 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(MENU_TRANSITION_SETTLE_SECONDS).timeout
|
||||
await create_timer(2.2).timeout
|
||||
assert(not player_menu.visible)
|
||||
|
||||
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
|
||||
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
|
||||
await create_timer(2.2).timeout
|
||||
player_menu.call(
|
||||
"_show_section_immediate", PlayerMenu.Section.PROFILE
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ const CatalogResource: FishPoolType = preload(
|
|||
"res://fish/pools/fish_catalog.tres"
|
||||
)
|
||||
|
||||
var _query_available_id: StringName
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
|
@ -37,7 +35,6 @@ func _run() -> void:
|
|||
quit()
|
||||
return
|
||||
_validate_catalog()
|
||||
_validate_catalog_query()
|
||||
await _validate_page()
|
||||
print("Logbook validation: PASS")
|
||||
quit()
|
||||
|
|
@ -71,348 +68,6 @@ 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()
|
||||
|
|
@ -436,10 +91,6 @@ 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)
|
||||
|
|
@ -507,39 +158,6 @@ 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"
|
||||
|
|
@ -840,19 +458,8 @@ 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()
|
||||
|
|
@ -867,54 +474,6 @@ 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):
|
||||
|
|
|
|||
|
|
@ -100,14 +100,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ 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
|
||||
|
|
@ -27,7 +23,7 @@ func _run() -> void:
|
|||
)
|
||||
assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4)
|
||||
_validate_catalog_statuses()
|
||||
await _validate_billboard_presentation()
|
||||
_validate_billboard_presentation()
|
||||
_validate_envelopes()
|
||||
print("World spawn protocol validation: PASS")
|
||||
quit()
|
||||
|
|
@ -152,27 +148,8 @@ 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()
|
||||
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))
|
||||
hotspot.call("_ensure_water_spurt_visual")
|
||||
var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D
|
||||
assert(hole != null)
|
||||
assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
|
||||
|
|
@ -181,18 +158,6 @@ 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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -260,9 +260,6 @@ 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
|
||||
)
|
||||
|
|
@ -462,7 +459,6 @@ func setup(
|
|||
discovery,
|
||||
player_jobs,
|
||||
world_time,
|
||||
world_weather,
|
||||
world_environment,
|
||||
world_sun,
|
||||
)
|
||||
|
|
@ -1896,7 +1892,7 @@ func _position_storage_prompt(world_anchor: Vector3) -> void:
|
|||
if _player == null:
|
||||
_storage_prompt.hide()
|
||||
return
|
||||
var camera := _player.get_active_gameplay_camera()
|
||||
var camera := _player.get_gameplay_camera()
|
||||
if camera == null or camera.is_position_behind(world_anchor):
|
||||
_storage_prompt.hide()
|
||||
return
|
||||
|
|
@ -2010,7 +2006,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
|
|||
if _player == null:
|
||||
_shop_prompt.hide()
|
||||
return
|
||||
var camera: Camera3D = _player.get_active_gameplay_camera()
|
||||
var camera: Camera3D = _player.get_gameplay_camera()
|
||||
if camera == null or camera.is_position_behind(world_anchor):
|
||||
_shop_prompt.hide()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ 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,
|
||||
|
|
@ -13,66 +10,6 @@ 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:
|
||||
|
|
@ -136,278 +73,3 @@ 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()
|
||||
|
|
|
|||
|
|
@ -6,21 +6,10 @@ 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"
|
||||
)
|
||||
|
|
@ -59,8 +48,6 @@ 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)
|
||||
|
|
@ -74,53 +61,17 @@ 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
|
||||
)
|
||||
|
|
@ -160,31 +111,6 @@ 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:
|
||||
|
|
@ -198,16 +124,10 @@ 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
|
||||
):
|
||||
|
|
@ -218,28 +138,12 @@ 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:
|
||||
|
|
@ -276,17 +180,6 @@ 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
|
||||
|
|
@ -306,19 +199,6 @@ 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
|
||||
|
|
@ -334,9 +214,6 @@ 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()
|
||||
|
|
@ -345,10 +222,6 @@ 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()
|
||||
|
|
@ -374,8 +247,6 @@ 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:
|
||||
|
|
@ -383,43 +254,15 @@ 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 _index_open:
|
||||
_set_controller_zone(ControllerZone.INDEX)
|
||||
elif not _entry_buttons.is_empty():
|
||||
if 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)
|
||||
|
|
@ -486,20 +329,6 @@ 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()
|
||||
|
|
@ -589,176 +418,9 @@ 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
|
||||
|
|
@ -886,30 +548,11 @@ 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:
|
||||
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(),
|
||||
)
|
||||
species = LogbookCatalog.ordered_species(_catalog.candidates)
|
||||
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 = (
|
||||
|
|
@ -929,11 +572,7 @@ func _refresh_catalog() -> void:
|
|||
)
|
||||
)
|
||||
_catalog_grid.add_child(entry)
|
||||
_empty_state.text = (
|
||||
"No entries match this index."
|
||||
if _index_has_active_criteria()
|
||||
else LogbookCatalog.empty_state(_category)
|
||||
)
|
||||
_empty_state.text = LogbookCatalog.empty_state(_category)
|
||||
_empty_state.visible = _entry_buttons.is_empty()
|
||||
_catalog_scroll.visible = not _entry_buttons.is_empty()
|
||||
if (
|
||||
|
|
@ -950,269 +589,10 @@ 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
|
||||
|
|
@ -1283,11 +663,8 @@ 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():
|
||||
|
|
@ -1306,11 +683,6 @@ 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:
|
||||
|
|
@ -1318,15 +690,11 @@ 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:
|
||||
|
|
@ -1382,12 +750,9 @@ 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
|
||||
|
|
@ -1919,9 +1284,7 @@ func _on_collection_changed() -> void:
|
|||
|
||||
|
||||
func _on_inventory_changed() -> void:
|
||||
# Ownership filters and favorite-backed views depend on the live cooler,
|
||||
# while rebuilding the catalog also keeps the selected detail current.
|
||||
_refresh_catalog()
|
||||
_refresh_details(false)
|
||||
|
||||
|
||||
func _unknown_selection_key(fish: FishDataType) -> StringName:
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ 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,
|
||||
|
|
@ -410,9 +409,6 @@ 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))
|
||||
|
|
@ -560,7 +556,6 @@ func setup(
|
|||
discovery: DiscoveryClient,
|
||||
player_jobs: PlayerJobService,
|
||||
world_time: WorldTimeService,
|
||||
world_weather: WorldWeatherService,
|
||||
world_environment: WorldEnvironment,
|
||||
world_sun: DirectionalLight3D,
|
||||
) -> void:
|
||||
|
|
@ -594,14 +589,7 @@ func setup(
|
|||
world_sun,
|
||||
)
|
||||
_players_page.setup(network_player_list, discovery)
|
||||
_catalog_logbook.setup(
|
||||
collection_log,
|
||||
inventory,
|
||||
catalog,
|
||||
fishing_spot,
|
||||
world_time,
|
||||
world_weather,
|
||||
)
|
||||
_catalog_logbook.setup(collection_log, inventory, catalog)
|
||||
_the_net_page.setup(player_jobs, world_time)
|
||||
_network_mail_service.unread_count_changed.connect(
|
||||
_on_mail_unread_count_changed
|
||||
|
|
@ -2791,7 +2779,6 @@ 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:
|
||||
|
|
@ -3746,7 +3733,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -43,12 +43,6 @@ 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),
|
||||
|
|
@ -96,7 +90,6 @@ 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:
|
||||
|
|
@ -197,37 +190,6 @@ 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()
|
||||
|
|
@ -363,10 +325,6 @@ 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()
|
||||
|
|
@ -1303,203 +1261,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -72,24 +72,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -49,16 +49,6 @@ 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ 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
|
||||
|
|
@ -44,7 +43,6 @@ 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
|
||||
|
|
@ -52,7 +50,6 @@ 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 (
|
||||
|
|
@ -76,7 +73,6 @@ 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 (
|
||||
|
|
@ -92,7 +88,6 @@ 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
|
||||
|
|
@ -188,7 +183,17 @@ func _on_fade_transition_completed(
|
|||
|
||||
|
||||
func _respawn_player() -> void:
|
||||
var target_position := _resolve_recovery_position()
|
||||
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
|
||||
target_position.y += respawn_height_offset
|
||||
var respawn_transform: Transform3D = _player.global_transform
|
||||
respawn_transform.basis = _recovery_root_basis
|
||||
|
|
@ -199,23 +204,6 @@ 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue