From a3aea989827847c5c302144b11ef83c0363197c4 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:04:14 -0400 Subject: [PATCH 01/38] Improve multiplayer movement reconciliation --- network/network_session.gd | 34 +++- player/player.gd | 222 +++++++++++++++-------- tests/movement_multiplayer_validation.gd | 73 +++++++- 3 files changed, 240 insertions(+), 89 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index 11217d7..b889678 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -13,6 +13,7 @@ const ENET_TIMEOUT_MAXIMUM_MS: int = 120000 const INPUT_INTERVAL: float = 1.0 / 30.0 const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 +const OWNER_SNAPSHOT_DIVISOR: int = 3 const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2 const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6 const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8 @@ -122,6 +123,7 @@ var _last_input_state_hash: int = 0 var _pending_movement_inputs: Array[Dictionary] = [] var _snapshot_accumulator: float = 0.0 var _movement_snapshot_tick: int = 0 +var _last_local_snapshot_received_msec: int = 0 var _animation_refresh_accumulator: float = 0.0 var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {} @@ -2248,13 +2250,16 @@ func _broadcast_movement_snapshots() -> void: var subject_avatar: Player = _spawn_service.get_avatar(subject_id) if subject_avatar == null: continue - if ( - subject_id != recipient_id - and not _should_send_remote_snapshot( + if subject_id == recipient_id: + # The owner already simulates locally. Its authoritative state is + # an audit and acknowledgement, not a presentation stream, so it + # does not need the full 30 Hz observer snapshot rate. + if _movement_snapshot_tick % OWNER_SNAPSHOT_DIVISOR != 0: + continue + elif not _should_send_remote_snapshot( recipient_avatar.global_position, subject_avatar.global_position, - ) - ): + ): continue var encoded: Array = _encode_movement_snapshot( subject_avatar.make_network_snapshot(subject_id) @@ -2538,8 +2543,10 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) avatar.apply_local_prediction_correction( snapshot, - _pending_movement_inputs, + _input_sequence, INPUT_INTERVAL, + estimated_transit_seconds, + _local_snapshot_delta_seconds(), ) else: avatar.push_network_snapshot( @@ -2557,6 +2564,20 @@ func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: _pending_movement_inputs.pop_front() +func _local_snapshot_delta_seconds() -> float: + var now_msec: int = Time.get_ticks_msec() + if _last_local_snapshot_received_msec <= 0: + _last_local_snapshot_received_msec = now_msec + return 0.0 + var elapsed_seconds: float = clampf( + float(now_msec - _last_local_snapshot_received_msec) / 1000.0, + 0.0, + Player.LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + _last_local_snapshot_received_msec = now_msec + return elapsed_seconds + + @rpc( "authority", "call_remote", @@ -2861,6 +2882,7 @@ func _teardown_peer() -> void: _pending_movement_inputs.clear() _snapshot_accumulator = 0.0 _movement_snapshot_tick = 0 + _last_local_snapshot_received_msec = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/player.gd b/player/player.gd index 6ec490f..860caf3 100644 --- a/player/player.gd +++ b/player/player.gd @@ -145,9 +145,14 @@ const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25 const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75 const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5 -const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12 -const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0 -const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18 +const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 1.5 +const LOCAL_PREDICTION_SNAP_DISTANCE: float = 6.0 +const LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS: float = 0.35 +const LOCAL_PREDICTION_MIN_CORRECTION_AUDITS: int = 3 +const LOCAL_PREDICTION_SOFT_CORRECTION_RATE: float = 2.5 +const LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: float = 0.15 +const LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS: float = 0.15 +const LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE: float = 5.0 # The target Android handheld exposes its physical right trigger through # Godot's left-trigger axis. Keep the role named here so the platform mapping # remains isolated from camera behavior. @@ -529,6 +534,13 @@ var _network_snapshot_age: float = 0.0 var _network_snapshot_jitter: float = 0.0 var _network_simulation_only: bool = false var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO +var _local_reconciliation_camera_offset: Vector3 = Vector3.ZERO +var _local_prediction_error_seconds: float = 0.0 +var _local_prediction_error_audits: int = 0 +var _local_prediction_error_direction: Vector3 = Vector3.ZERO +var _local_prediction_soft_corrections: int = 0 +var _local_prediction_hard_corrections: int = 0 +var _local_prediction_largest_error: float = 0.0 var _authoritative_movement_history: Array[Dictionary] = [] var _local_network_jump_intent_pending: bool = false var _local_network_jump_intent_sequence: int = -1 @@ -1995,6 +2007,11 @@ func reset_network_movement_state() -> void: _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS _network_jump_intent_active = false _last_network_input_sequence = 0 + _reset_local_prediction_error() + _clear_local_reconciliation_offsets() + _local_prediction_soft_corrections = 0 + _local_prediction_hard_corrections = 0 + _local_prediction_largest_error = 0.0 func capture_network_input(sequence: int) -> Dictionary: @@ -2235,8 +2252,10 @@ func push_network_snapshot( func apply_local_prediction_correction( snapshot: Dictionary, - pending_inputs: Array[Dictionary] = [], + latest_input_sequence: int = 0, input_interval_seconds: float = 0.0, + estimated_transit_seconds: float = -1.0, + audit_delta_seconds: float = 0.0, ) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -2258,92 +2277,143 @@ func apply_local_prediction_correction( _clear_local_network_jump_intent() if not _sitting_intent_pending: _set_sitting(bool(parsed["sitting"])) + var authoritative_position: Vector3 = parsed["position"] + var transit_seconds: float = resolve_local_prediction_transit_seconds( + acknowledged_input, + latest_input_sequence, + input_interval_seconds, + estimated_transit_seconds, + ) + if transit_seconds > 0.0: + authoritative_position += ( + (parsed["velocity"] as Vector3) * transit_seconds + ) + var error_offset: Vector3 = authoritative_position - global_position + var error_distance: float = error_offset.length() + _local_prediction_largest_error = maxf( + _local_prediction_largest_error, + error_distance, + ) + if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD: + _reset_local_prediction_error() + return + if error_distance >= LOCAL_PREDICTION_SNAP_DISTANCE: + _clear_local_reconciliation_offsets() + global_position = authoritative_position + velocity = parsed["velocity"] + _local_prediction_hard_corrections += 1 + _reset_local_prediction_error() + return + var error_direction: Vector3 = error_offset.normalized() + if ( + not _local_prediction_error_direction.is_zero_approx() + and _local_prediction_error_direction.dot(error_direction) < 0.5 + ): + _reset_local_prediction_error() + _local_prediction_error_direction = error_direction + _local_prediction_error_audits += 1 + _local_prediction_error_seconds += clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + if ( + _local_prediction_error_audits + < LOCAL_PREDICTION_MIN_CORRECTION_AUDITS + or _local_prediction_error_seconds + < LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS + ): + return + var correction_weight: float = 1.0 - exp( + -LOCAL_PREDICTION_SOFT_CORRECTION_RATE + * clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + ) + var correction: Vector3 = error_offset * correction_weight + if correction.length() > LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: + correction = ( + correction.normalized() + * LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP + ) + _apply_camera_safe_local_correction(correction) + _local_prediction_soft_corrections += 1 + + +func _apply_camera_safe_local_correction(correction: Vector3) -> void: + if correction.is_zero_approx(): + return var previous_visual_position: Vector3 = _visuals.global_position + var previous_camera_position: Vector3 = _camera_yaw.global_position var base_visual_local_position: Vector3 = ( _visuals.position - _local_reconciliation_visual_offset ) - var previous_position: Vector3 = global_position - global_position = parsed["position"] - velocity = parsed["velocity"] - if input_interval_seconds > 0.0: - for input: Dictionary in pending_inputs: - _replay_network_movement_input(input, input_interval_seconds) - var correction_distance: float = previous_position.distance_to( - global_position + var base_camera_local_position: Vector3 = ( + _camera_yaw.position - _local_reconciliation_camera_offset + ) + global_position += correction + _visuals.global_position = previous_visual_position + _camera_yaw.global_position = previous_camera_position + _local_reconciliation_visual_offset = ( + _visuals.position - base_visual_local_position + ) + _local_reconciliation_camera_offset = ( + _camera_yaw.position - base_camera_local_position ) - if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE: - _visuals.global_position = previous_visual_position - _local_reconciliation_visual_offset = ( - _visuals.position - base_visual_local_position - ) - else: - _local_reconciliation_visual_offset = Vector3.ZERO -func _replay_network_movement_input( - data: Dictionary, - delta: float, -) -> void: - var axis_value: Variant = data.get("axis", []) - if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2: - return - if bool(data.get("sitting", false)) or _water_recovery_active: - velocity = Vector3.ZERO - return - var input_vector := Vector2( - float(axis_value[0]), - float(axis_value[1]), - ).limit_length(1.0) - var camera_basis := Basis( - Vector3.UP, - float(data.get("camera_yaw", 0.0)), - ) - var move_direction: Vector3 = ( - camera_basis.x * input_vector.x - + camera_basis.z * input_vector.y - ) - move_direction.y = 0.0 - move_direction = move_direction.normalized() - _network_sprint = bool(data.get("sprint", false)) - _network_sneak = bool(data.get("sneak", false)) - _network_slow_walk = bool(data.get("slow_walk", false)) - # Replay the speed authored by this exact pending input. Consulting the - # current InputMap here would make an older walk replay as a sprint (or the - # reverse) whenever the local button changed while a snapshot was in flight. - var replay_speed: float = walk_speed - if _network_sneak: - replay_speed = sneak_speed - elif _network_slow_walk: - replay_speed = slow_walk_speed - elif _network_sprint: - replay_speed = sprint_speed - if item_effects != null: - replay_speed *= item_effects.get_movement_multiplier() - var input_strength: float = minf(input_vector.length(), 1.0) - velocity.x = move_direction.x * replay_speed * input_strength - velocity.z = move_direction.z * replay_speed * input_strength - if not is_on_floor(): - var gravity_multiplier: float = ( - upward_gravity_multiplier - if velocity.y > 0.0 - else fall_gravity_multiplier - ) - velocity.y -= _gravity * gravity_multiplier * delta - elif bool(data.get("jump", false)): - velocity.y = jump_velocity - move_and_slide() +func _reset_local_prediction_error() -> void: + _local_prediction_error_seconds = 0.0 + _local_prediction_error_audits = 0 + _local_prediction_error_direction = Vector3.ZERO + + +func _clear_local_reconciliation_offsets() -> void: + if not _local_reconciliation_visual_offset.is_zero_approx(): + _visuals.position -= _local_reconciliation_visual_offset + if not _local_reconciliation_camera_offset.is_zero_approx(): + _camera_yaw.position -= _local_reconciliation_camera_offset + _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO func _update_local_reconciliation_visuals(delta: float) -> void: - if _local_reconciliation_visual_offset.is_zero_approx(): + if ( + _local_reconciliation_visual_offset.is_zero_approx() + and _local_reconciliation_camera_offset.is_zero_approx() + ): _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO return - var retained_ratio: float = exp(-14.0 * delta) - var retained_offset: Vector3 = ( + var retained_ratio: float = exp( + -LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE * delta + ) + var retained_visual_offset: Vector3 = ( _local_reconciliation_visual_offset * retained_ratio ) - _visuals.position += retained_offset - _local_reconciliation_visual_offset - _local_reconciliation_visual_offset = retained_offset + var retained_camera_offset: Vector3 = ( + _local_reconciliation_camera_offset * retained_ratio + ) + _visuals.position += ( + retained_visual_offset - _local_reconciliation_visual_offset + ) + _camera_yaw.position += ( + retained_camera_offset - _local_reconciliation_camera_offset + ) + _local_reconciliation_visual_offset = retained_visual_offset + _local_reconciliation_camera_offset = retained_camera_offset + + +func get_local_prediction_metrics() -> Dictionary: + return { + "soft_corrections": _local_prediction_soft_corrections, + "hard_corrections": _local_prediction_hard_corrections, + "largest_error": _local_prediction_largest_error, + "out_of_bounds_audits": _local_prediction_error_audits, + "out_of_bounds_seconds": _local_prediction_error_seconds, + } static func resolve_network_input_stale_timeout_seconds( diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index f331534..c6a98e5 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -71,6 +71,7 @@ func _validate_compact_snapshot_encoding() -> void: 1, ) assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8) + assert(NetworkSession.OWNER_SNAPSHOT_DIVISOR == 3) var encoded_snapshots: Array = [] for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE: encoded_snapshots.append( @@ -283,16 +284,72 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: avatar.set_local_control(true) avatar.global_position = Vector3(0.8, 0.0, 0.0) - var replay_input: Dictionary = _movement_input(2, false) - replay_input["axis"] = [1.0, 0.0] - var pending_inputs: Array[Dictionary] = [replay_input] avatar.apply_local_prediction_correction( moving_snapshot, - pending_inputs, + 2, 1.0 / 30.0, + 0.1, + 0.1, + ) + # Ordinary host/client disagreement is expected while a packet is in flight. + # It must never tug the locally controlled body or camera around. + assert(is_equal_approx(avatar.global_position.x, 0.8)) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 0 + ) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 0 + ) + + # A larger but still plausible mismatch must persist across multiple audits + # before a small correction is allowed. Preserve both visible character and + # camera positions while the collision body catches up. + avatar.global_position = Vector3(3.0, 0.0, 0.0) + var visual_position: Vector3 = avatar.get_node("Visuals").global_position + var camera_position: Vector3 = avatar.get_node("CameraYaw").global_position + var drift_snapshot: Dictionary = _network_snapshot( + Vector3.ZERO, + Vector3.ZERO, + 20, + ) + for _audit: int in 3: + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(is_equal_approx(avatar.global_position.x, 3.0)) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position.x < 3.0) + assert(avatar.global_position.x >= 2.85 - 0.001) + assert(avatar.get_node("Visuals").global_position == visual_position) + assert(avatar.get_node("CameraYaw").global_position == camera_position) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 1 + ) + + # Genuine divergence still recovers immediately, as do the separate reliable + # teleport and water-recovery paths used by gameplay transitions. + avatar.global_position = Vector3(10.0, 0.0, 0.0) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position == Vector3.ZERO) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 1 ) - assert(avatar.global_position.x < 0.8) - assert(avatar.global_position.x > 0.0) func _validate_reliable_jump_intent(avatar: Player) -> void: @@ -305,8 +362,10 @@ func _validate_reliable_jump_intent(avatar: Player) -> void: assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20) avatar.apply_local_prediction_correction( _network_snapshot(avatar.global_position, Vector3.ZERO, 20), - [], + 20, 1.0 / 30.0, + 0.0, + 0.1, ) assert(not bool(avatar.capture_network_input(22)["jump"])) From 867fd431af0343760401c058f1b641bd299948ce Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:04:15 -0400 Subject: [PATCH 02/38] Polish title online and save slot interfaces --- project.godot | 2 +- ...ontroller_menu_accessibility_validation.gd | 106 ++++++++- ui/network/join_game_page.gd | 202 +++++++++++++++++- ui/network/join_game_page.tscn | 41 +++- ui/players_page.gd | 134 ++++++------ ui/save_slots_page.gd | 16 +- ui/save_slots_page.tscn | 22 +- ui/title_screen.gd | 26 ++- 8 files changed, 463 insertions(+), 86 deletions(-) diff --git a/project.godot b/project.godot index 3b69d42..8a49214 100644 --- a/project.godot +++ b/project.godot @@ -30,8 +30,8 @@ enabled=PackedStringArray("res://addons/netfishing_shoreline_baker/plugin.cfg") [gui] -theme/custom="res://ui/game_theme.tres" timers/tooltip_delay_sec=0.0 +theme/custom="res://ui/game_theme.tres" [input] diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index bf856d3..70d2095 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -130,7 +130,28 @@ func _validate_primary_menu_navigation() -> void: and not (title.get_node("%DeleteSaveButton") as Control).visible, "Legacy New/Delete title bubbles are still visible.", ) - _assert_directionally_reachable(title_controls.front(), title_controls) + _assert_neighbor( + title.get_node("%JoinGameButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%SettingsButton") as Control, + &"focus_neighbor_left", + play_button, + ) + _assert_neighbor( + title.get_node("%CreditsButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%QuitButton") as Control, + &"focus_neighbor_left", + play_button, + ) + for title_control: Control in title_controls: + _assert_directionally_reachable(title_control, title_controls) title.queue_free() await process_frame @@ -195,6 +216,13 @@ func _validate_save_slots_navigation() -> void: page.call("_select_page", &"saves", false) await process_frame var import_button := page.get_node("%ImportSlotButton") as Button + var actions := page.get_node("%Actions") as VBoxContainer + var secondary_actions := page.get_node("%SecondaryActions") as GridContainer + var play_slot := page.get_node("%PlaySlotButton") as Button + var rename_slot := page.get_node("%RenameSlotButton") as Button + var duplicate_slot := page.get_node("%DuplicateSlotButton") as Button + var export_slot := page.get_node("%ExportSlotButton") as Button + var delete_slot := page.get_node("%DeleteSlotButton") as Button _expect( saves_tab.find_valid_focus_neighbor(SIDE_BOTTOM) == import_button, "An empty Save Slots page does not lead from its tab to Import Save.", @@ -204,6 +232,29 @@ func _validate_save_slots_navigation() -> void: == page.get_node("%BackButton"), "An empty Save Slots page does not lead from Import Save to Back.", ) + _expect( + is_equal_approx(play_slot.size.x, actions.size.x), + "Save-slot Play does not span the full action width.", + ) + _expect( + secondary_actions.columns == 2 + and is_equal_approx(rename_slot.size.x, duplicate_slot.size.x) + and is_equal_approx(export_slot.size.x, delete_slot.size.x) + and is_equal_approx(rename_slot.position.y, duplicate_slot.position.y) + and is_equal_approx(export_slot.position.y, delete_slot.position.y) + and export_slot.position.y > rename_slot.position.y, + "Save-slot secondary actions are not arranged as a 2 by 2 grid.", + ) + var play_style := play_slot.get_theme_stylebox("normal") as StyleBoxFlat + _expect( + play_style != null and play_style.bg_color == UtilityPageStyle.GREEN, + "Save-slot Play does not use the green primary-action style.", + ) + _assert_neighbor(play_slot, &"focus_neighbor_bottom", rename_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_right", duplicate_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_bottom", export_slot) + _assert_neighbor(duplicate_slot, &"focus_neighbor_bottom", delete_slot) + _assert_neighbor(export_slot, &"focus_neighbor_right", delete_slot) page.queue_free() await process_frame @@ -222,6 +273,9 @@ func _validate_join_game_navigation() -> void: var address := page.get_node("%Address") as LineEdit var name_edit := page.get_node("%NameEdit") as LineEdit var server_list := page.get_node("%ServerList") as ItemList + var presence := page.get_node("%PresenceButton") as Button + var room_open := page.get_node("%RoomOpenButton") as Button + var room_listing := page.get_node("%RoomListingButton") as Button var refresh := page.get_node("%RefreshButton") as Button var join := page.get_node("%JoinButton") as Button var save := page.get_node("%SaveButton") as Button @@ -233,6 +287,7 @@ func _validate_join_game_navigation() -> void: var direct_content := page.get_node("%DirectContent") as Control var list_content := page.get_node("%ListContent") as Control var content_panel := page.get_node("%ContentPanel") as PanelContainer + var actions := page.get_node("%Actions") as HBoxContainer var modes: Array[Control] = [discover, friends, direct, saved, recent] var tab_overlap: float = ( discover.get_global_rect().end.y @@ -265,9 +320,36 @@ func _validate_join_game_navigation() -> void: _assert_neighbor(refresh, &"focus_neighbor_right", join) _assert_neighbor(join, &"focus_neighbor_bottom", back) _assert_neighbor(back, &"focus_neighbor_top", join) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Discover Refresh and Join are not right-justified.", + ) var discover_controls: Array[Control] = modes.duplicate() discover_controls.append_array([server_list, refresh, join, back]) _assert_directionally_reachable(discover, discover_controls) + room_open.show() + room_listing.show() + _set_button_state(room_open, true) + _set_button_state(room_listing, true) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(room_open, &"focus_neighbor_right", room_listing) + _assert_neighbor(room_open, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_bottom", server_list) + var hosted_discover_controls: Array[Control] = modes.duplicate() + hosted_discover_controls.append_array([ + room_open, + room_listing, + server_list, + refresh, + join, + back, + ]) + _assert_directionally_reachable(discover, hosted_discover_controls) var rooms: Array[Dictionary] = [ { "room_id": "controller-default-room", @@ -293,16 +375,36 @@ func _validate_join_game_navigation() -> void: ) page.set("_mode", JoinGamePage.Mode.FRIENDS) + (page.get_node("%OnlineControls") as Control).hide() + presence.show() page.call("_configure_controller_navigation") await process_frame _assert_neighbor(friends, &"focus_neighbor_bottom", server_list) _assert_neighbor(server_list, &"focus_neighbor_top", friends) + _assert_neighbor(server_list, &"focus_neighbor_bottom", presence) + _assert_neighbor(presence, &"focus_neighbor_right", refresh) + _assert_neighbor(refresh, &"focus_neighbor_right", join) + _expect( + is_equal_approx( + presence.get_global_rect().position.x, + actions.get_global_rect().position.x, + ), + "Friend presence is not left-justified in the action row.", + ) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Friends Refresh and Join are not right-justified.", + ) var friend_controls: Array[Control] = modes.duplicate() - friend_controls.append_array([server_list, refresh, join, back]) + friend_controls.append_array([server_list, presence, refresh, join, back]) _assert_directionally_reachable(friends, friend_controls) list_content.hide() direct_content.show() + presence.hide() address.show() address.editable = true server_list.hide() diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 72bf19e..93ba793 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -32,6 +32,10 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _direct_content: Control = %DirectContent @onready var _list_content: Control = %ListContent @onready var _list_title: Label = %ListTitle +@onready var _online_controls: Control = %OnlineControls +@onready var _presence_button: Button = %PresenceButton +@onready var _room_open_button: Button = %RoomOpenButton +@onready var _room_listing_button: Button = %RoomListingButton @onready var _details_panel: PanelContainer = %DetailsPanel @onready var _address: LineEdit = %Address @onready var _address_label: Label = %AddressLabel @@ -89,6 +93,9 @@ func _ready() -> void: _direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT)) _saved_button.pressed.connect(_set_mode.bind(Mode.SAVED)) _recent_button.pressed.connect(_set_mode.bind(Mode.RECENT)) + _presence_button.pressed.connect(_on_presence_pressed) + _room_open_button.pressed.connect(_on_room_open_pressed) + _room_listing_button.pressed.connect(_on_room_listing_pressed) _refresh_button.pressed.connect(_request_discovery_refresh) _join_button.pressed.connect(_request_join) _save_button.pressed.connect(_on_save_pressed) @@ -238,6 +245,10 @@ func setup( _on_peer_count_changed ): _network_session.peer_count_changed.connect(_on_peer_count_changed) + if not _network_session.host_openness_changed.is_connected( + _on_host_openness_changed + ): + _network_session.host_openness_changed.connect(_on_host_openness_changed) if ( _saved_servers != null and not _saved_servers.data_changed.is_connected(_on_store_changed) @@ -278,6 +289,22 @@ func setup( _discovery.social_status_changed.connect( _on_social_status_changed ) + if not _discovery.host_settings_changed.is_connected( + _on_host_settings_changed + ): + _discovery.host_settings_changed.connect( + _on_host_settings_changed + ) + if not _discovery.host_status_changed.is_connected( + _on_host_status_changed + ): + _discovery.host_status_changed.connect(_on_host_status_changed) + if not _discovery.presence_sharing_changed.is_connected( + _on_presence_sharing_changed + ): + _discovery.presence_sharing_changed.connect( + _on_presence_sharing_changed + ) _friend_entries = _discovery.get_friend_presence() _refresh() @@ -792,6 +819,7 @@ func _refresh() -> void: _direct_content.visible = direct_content_visible _list_content.visible = list_content_visible _list_title.text = _current_list_title() + _refresh_online_controls(discovery_mode, friends_mode, connecting) _address.visible = direct or _name_entry_active _address_label.visible = _address.visible _address_helper.visible = _address.visible @@ -916,6 +944,66 @@ func _current_list_title() -> String: return "public rooms" +func _refresh_online_controls( + discovery_mode: bool, + friends_mode: bool, + connecting: bool, +) -> void: + _online_controls.visible = discovery_mode and not _name_entry_active + _presence_button.visible = friends_mode and not _name_entry_active + var presence_enabled: bool = ( + _discovery != null and _discovery.is_presence_sharing() + ) + _presence_button.text = ( + "friends: online" if presence_enabled else "friends: offline" + ) + _presence_button.disabled = ( + connecting or _discovery == null or not _discovery.is_configured() + ) + _presence_button.tooltip_text = ( + "Stay visible to friends until you switch this off. " + + "Your current room is shared only while it is publicly joinable." + if presence_enabled + else "Show as online to friends until you switch this off." + ) + if not _online_controls.visible: + return + var local_host: bool = _network_session.is_host() + _room_open_button.visible = local_host + _room_listing_button.visible = local_host + if not local_host: + return + var room_open: bool = _network_session.is_open_host() + var room_listed: bool = ( + _discovery != null and _discovery.is_discoverable() + ) + _room_open_button.text = "room: open" if room_open else "room: closed" + _room_open_button.disabled = connecting + _room_open_button.tooltip_text = ( + "Close this room to new connections." + if room_open + else "Open this room so other players can connect." + ) + _room_listing_button.text = ( + "listing: on" if room_listed else "listing: off" + ) + _room_listing_button.disabled = ( + connecting + or _discovery == null + or not _discovery.is_configured() + or not room_open + ) + _room_listing_button.tooltip_text = ( + "Remove this room from the public room browser." + if room_listed + else "List this open room in the public room browser." + if room_open and _discovery != null and _discovery.is_configured() + else "Open the room before enabling its public listing." + if _discovery != null and _discovery.is_configured() + else "Room discovery is not configured in this build." + ) + + func _configure_controller_navigation() -> void: var mode_buttons: Array[Control] = [ _discover_button, @@ -924,12 +1012,24 @@ func _configure_controller_navigation() -> void: _saved_button, _recent_button, ] + var online_controls: Array[Control] = [] + for control: Control in [ + _room_open_button, + _room_listing_button, + ]: + if _controller_focus_eligible(control): + online_controls.append(control) var content_controls: Array[Control] = [] - for control: Control in [_address, _name_edit, _server_list]: + for control: Control in [ + _address, + _name_edit, + _server_list, + ]: if _controller_focus_eligible(control): content_controls.append(control) var action_controls: Array[Control] = [] for control: Control in [ + _presence_button, _refresh_button, _join_button, _save_button, @@ -942,12 +1042,16 @@ func _configure_controller_navigation() -> void: action_controls.append(control) var all_controls: Array[Control] = [] all_controls.append_array(mode_buttons) + all_controls.append_array(online_controls) all_controls.append_array(content_controls) all_controls.append_array(action_controls) all_controls.append(_back_button) for control: Control in all_controls: control.focus_mode = Control.FOCUS_ALL for control: Control in [ + _presence_button, + _room_open_button, + _room_listing_button, _address, _name_edit, _server_list, @@ -963,7 +1067,9 @@ func _configure_controller_navigation() -> void: control.focus_mode = Control.FOCUS_NONE _back_button.focus_mode = Control.FOCUS_ALL var primary_content: Control = ( - content_controls.front() + online_controls.front() + if not online_controls.is_empty() + else content_controls.front() if not content_controls.is_empty() else action_controls.front() if not action_controls.is_empty() @@ -978,11 +1084,29 @@ func _configure_controller_navigation() -> void: mode_button, primary_content, ) + for index: int in online_controls.size(): + var online_control: Control = online_controls[index] + var below: Control = ( + content_controls.front() + if not content_controls.is_empty() + else action_controls.front() + if not action_controls.is_empty() + else _back_button + ) + _set_controller_neighbors( + online_control, + online_controls[maxi(index - 1, 0)], + online_controls[mini(index + 1, online_controls.size() - 1)], + mode_buttons[int(_mode)], + below, + ) for index: int in content_controls.size(): var content: Control = content_controls[index] var above: Control = ( content_controls[index - 1] if index > 0 + else online_controls.front() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) var below: Control = ( @@ -1001,6 +1125,8 @@ func _configure_controller_navigation() -> void: action_controls[mini(index + 1, action_controls.size() - 1)], content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)], _back_button, ) @@ -1009,6 +1135,8 @@ func _configure_controller_navigation() -> void: if not action_controls.is_empty() else content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) _set_controller_neighbors( @@ -1270,6 +1398,76 @@ func _on_social_status_changed(message: String, is_error: bool) -> void: _set_status(message, is_error) +func _on_presence_pressed() -> void: + if _discovery == null: + _set_status("Friend presence is not available.", true) + return + var enabling: bool = not _discovery.is_presence_sharing() + if not _discovery.set_presence_sharing(enabling): + return + _set_status( + "You will remain online to friends until you switch this off." + if enabling + else "You now appear offline to friends." + ) + _refresh() + + +func _on_room_open_pressed() -> void: + if _network_session == null or not _network_session.is_host(): + return + var opening: bool = not _network_session.is_open_host() + if not _network_session.set_host_open(opening): + _set_status("The room could not be updated.", true) + return + _set_status( + "The room is open to new connections." + if opening + else "The room is closed to new connections." + ) + _refresh() + + +func _on_room_listing_pressed() -> void: + if _discovery == null: + return + var enabling: bool = not _discovery.is_discoverable() + if not _discovery.set_discoverable(enabling): + return + _set_status( + "The room is being listed publicly." + if enabling + else "The room is no longer listed publicly." + ) + _refresh() + + +func _on_host_openness_changed(_is_open: bool) -> void: + _refresh() + + +func _on_host_settings_changed( + _room_name: String, + _discoverable: bool, +) -> void: + _refresh() + + +func _on_host_status_changed(message: String, is_error: bool) -> void: + if ( + _mode == Mode.DISCOVER + and is_visible_in_tree() + and _network_session != null + and _network_session.is_host() + ): + _set_status(message, is_error) + _refresh() + + +func _on_presence_sharing_changed(_enabled: bool) -> void: + _refresh() + + func _format_result_code(result_code: String) -> String: match result_code.strip_edges().to_upper(): "SUCCESS": diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index 54f354d..9374a85 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -138,9 +138,35 @@ layout_mode = 2 theme_override_font_sizes/font_size = 20 text = "public rooms" +[node name="OnlineControls" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="OnlineControlsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomOpenButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(160, 42) +layout_mode = 2 +focus_mode = 2 +text = "room: closed" + +[node name="RoomListingButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(180, 42) +layout_mode = 2 +focus_mode = 2 +text = "listing: off" + [node name="ServerList" type="ItemList" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] unique_name_in_owner = true -custom_minimum_size = Vector2(0, 180) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_vertical = 3 theme_override_font_sizes/font_size = 17 @@ -235,7 +261,18 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 theme_override_constants/separation = 10 -alignment = 1 + +[node name="PresenceButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(210, 46) +layout_mode = 2 +focus_mode = 2 +text = "friends: offline" + +[node name="ActionsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +layout_mode = 2 +size_flags_horizontal = 3 [node name="RefreshButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true diff --git a/ui/players_page.gd b/ui/players_page.gd index 27c142c..68f25c6 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -44,6 +44,7 @@ var _online_state_label: Label var _discoverable_toggle: Button var _discoverable_state_label: Label var _host_discovery_status: Label +var _reset_artwork_button: Button var _current_tab := 0 var _active: bool = false var _interactive: bool = false @@ -295,6 +296,9 @@ func _build_host_settings(root: VBoxContainer) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) access_row.add_child(_host_discovery_status) + _reset_artwork_button = Button.new() + _configure_session_artwork_reset_button(_reset_artwork_button) + access_row.add_child(_reset_artwork_button) func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button: @@ -389,7 +393,10 @@ func _refresh_host_settings() -> void: return var host_visible: bool = _service.is_local_host() and _current_tab == 0 _host_settings_panel.visible = host_visible - if not host_visible or _discovery == null: + if not host_visible: + return + _update_session_artwork_reset_button(_reset_artwork_button) + if _discovery == null: return if not _room_name_edit.has_focus(): _room_name_edit.text = _discovery.get_room_name() @@ -481,7 +488,9 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: "Open the game before enabling discovery.", ] _host_discovery_status.text = "" if tooltip_only else message - _host_discovery_status.visible = not _host_discovery_status.text.is_empty() + # Keep this expanding label in the row even when there is no status text so + # the session reset action remains pinned to the far-right edge. + _host_discovery_status.visible = true _host_discovery_status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_DANGER @@ -491,7 +500,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: func _build_active_rows() -> void: - if _service.is_local_moderator(): + if _service.is_local_moderator() and not _service.is_local_host(): _build_session_artwork_controls() var entries := _service.get_entries() if entries.is_empty(): @@ -502,35 +511,29 @@ func _build_active_rows() -> void: func _build_active_player_row(entry: PlayerListEntry) -> void: - var row := _make_active_player_row() - var identity_row := HBoxContainer.new() - identity_row.add_theme_constant_override("separation", 8) - row.add_child(identity_row) - + var row := _make_row() var identity := Label.new() identity.clip_text = true identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var markers: Array[String] = [] - if entry.is_host: - markers.append("host") + identity.text = entry.display_name + var roles: Array[String] = ["host" if entry.is_host else "player"] if entry.is_operator: - markers.append("operator") + roles.append("operator") if entry.is_local_player: - markers.append("You") - identity.text = "%s%s · %s\n%s" % [ - entry.display_name, - " [%s]" % ", ".join(markers) if not markers.is_empty() else "", - entry.compact_fingerprint, + roles.append("you") + _configure_identity_tooltip( + identity, + "identity fingerprint:\n%s\nrole: %s\nidentity status: %s" % [ + NetworkIdentityCrypto.format_fingerprint(entry.full_fingerprint), + ", ".join(roles), entry.continuity_state, - ] - identity.tooltip_text = "Full identity fingerprint:\n%s" % ( - entry.full_fingerprint + ], ) identity.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) - identity_row.add_child(identity) + row.add_child(identity) var ping := Label.new() ping.custom_minimum_size.x = 72 ping.text = ( @@ -542,7 +545,7 @@ func _build_active_player_row(entry: PlayerListEntry) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) ping.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - identity_row.add_child(ping) + row.add_child(ping) var actions := HBoxContainer.new() actions.alignment = BoxContainer.ALIGNMENT_END @@ -634,31 +637,38 @@ func _configure_moderation_button( func _build_session_artwork_controls() -> void: - var counts: Vector2i = _service.get_session_artwork_counts() var row := _make_row() - var label := Label.new() - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - label.clip_text = true - label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "session artwork · %d layers · %d painted pixels" % [ - counts.x, counts.y, - ] - label.add_theme_color_override( - "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY - ) - row.add_child(label) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(spacer) var reset := Button.new() - reset.text = "reset paint" - reset.disabled = counts.x == 0 - reset.tooltip_text = "Clears all shared artwork from this session." - reset.pressed.connect(func() -> void: + _configure_session_artwork_reset_button(reset) + _update_session_artwork_reset_button(reset) + row.add_child(reset) + + +func _configure_session_artwork_reset_button(button: Button) -> void: + button.text = "reset paint" + button.tooltip_text = "Clears all shared artwork from this session." + button.pressed.connect(func() -> void: _confirm( "Clear all shared paint from this session?\nThis cannot be undone.", _service.reset_session_artwork, ) ) - UtilityPageStyle.apply_compact_ocean_button(reset) - row.add_child(reset) + UtilityPageStyle.apply_compact_ocean_button(button) + + +func _update_session_artwork_reset_button(button: Button) -> void: + if button == null or _service == null: + return + button.disabled = _service.get_session_artwork_counts().x == 0 + + +func _configure_identity_tooltip(label: Label, text: String) -> void: + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.tooltip_text = text func _build_relationship_rows() -> void: @@ -673,12 +683,16 @@ func _build_relationship_rows() -> void: label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS var fingerprint := str(record["fingerprint"]) - label.text = "%s · %s %s" % [ + label.text = "%s %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), "Blocked" if bool(record.get("blocked", false)) else "Muted", ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -724,16 +738,20 @@ func _build_friend_rows() -> void: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "%s · %s %s" % [ + label.text = "%s %s" % [ display_name, - NetworkIdentityCrypto.compact_suffix(fingerprint), "playing in %s" % str(room.get("room_name", "a public room")) if not room.is_empty() else "Online" if bool(friend.get("online", false)) else "Offline", ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -811,12 +829,16 @@ func _build_ban_rows() -> void: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "%s · %s banned %s" % [ + label.text = "%s banned %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))), ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -841,20 +863,6 @@ func _make_row() -> HBoxContainer: return row -func _make_active_player_row() -> VBoxContainer: - var panel := PanelContainer.new() - panel.clip_contents = true - panel.add_theme_stylebox_override( - "panel", UtilityPageStyle.row_style(false) - ) - _list.add_child(panel) - var row := VBoxContainer.new() - row.custom_minimum_size.y = 88 - row.add_theme_constant_override("separation", 4) - panel.add_child(row) - return row - - func _add_empty(text: String) -> void: var label := Label.new() label.text = text diff --git a/ui/save_slots_page.gd b/ui/save_slots_page.gd index e50c8ee..ba142f7 100644 --- a/ui/save_slots_page.gd +++ b/ui/save_slots_page.gd @@ -124,6 +124,12 @@ func _configure_style() -> void: if node is OrganizerTab: continue UtilityPageStyle.apply_ocean_button(node as BaseButton) + _play_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.GREEN, + ), + ) _delete_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( @@ -639,11 +645,11 @@ func _configure_controller_focus() -> void: _set_neighbors(button, button, _slot_name_edit, top, bottom) _set_neighbors(_import_button, _import_button, _slot_name_edit, last, _back_button) _set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _play_button) - _set_neighbors(_play_button, _import_button, _rename_button, _slot_name_edit, _duplicate_button) - _set_neighbors(_rename_button, _play_button, _rename_button, _slot_name_edit, _export_button) - _set_neighbors(_duplicate_button, _import_button, _export_button, _play_button, _delete_button) - _set_neighbors(_export_button, _duplicate_button, _export_button, _rename_button, _delete_button) - _set_neighbors(_delete_button, _import_button, _delete_button, _duplicate_button, _back_button) + _set_neighbors(_play_button, _import_button, _play_button, _slot_name_edit, _rename_button) + _set_neighbors(_rename_button, _import_button, _duplicate_button, _play_button, _export_button) + _set_neighbors(_duplicate_button, _rename_button, _duplicate_button, _play_button, _delete_button) + _set_neighbors(_export_button, _import_button, _delete_button, _rename_button, _back_button) + _set_neighbors(_delete_button, _export_button, _delete_button, _duplicate_button, _back_button) _set_neighbors(_back_button, _back_button, _back_button, _import_button, _back_button) return _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(_new_slot_name) diff --git a/ui/save_slots_page.tscn b/ui/save_slots_page.tscn index 5f47c8e..87d5f93 100644 --- a/ui/save_slots_page.tscn +++ b/ui/save_slots_page.tscn @@ -165,11 +165,10 @@ scroll_active = false autowrap_mode = 2 vertical_alignment = 1 -[node name="Actions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +[node name="Actions" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +unique_name_in_owner = true layout_mode = 2 -theme_override_constants/h_separation = 10 -theme_override_constants/v_separation = 10 -columns = 2 +theme_override_constants/separation = 10 [node name="PlaySlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] unique_name_in_owner = true @@ -179,7 +178,14 @@ size_flags_horizontal = 3 focus_mode = 2 text = "play" -[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="SecondaryActions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 2 + +[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -187,7 +193,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "rename" -[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -195,7 +201,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "duplicate" -[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -203,7 +209,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "export" -[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 6274f7d..07a61fd 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -464,6 +464,7 @@ func _update_title_layout() -> void: Vector2(field_width, field_height), compact_layout ) + _configure_title_controller_navigation() if not _title_settings_transition_active: call_deferred("_capture_title_bubble_rest_position") if _awaiting_start_input and not _title_entry_transition_active: @@ -1483,9 +1484,28 @@ func _set_title_bubbles_interactive(interactive: bool) -> void: else Control.MOUSE_FILTER_IGNORE ) if interactive: - ControllerFocusNavigationType.configure_spatial_neighbors( - _get_title_buttons() - ) + _configure_title_controller_navigation() + + +func _configure_title_controller_navigation() -> void: + ControllerFocusNavigationType.configure_spatial_neighbors( + _get_title_buttons() + ) + # The large Play bubble overlaps the inward edge of every surrounding + # bubble. Center-only spatial scoring otherwise links the four outside + # bubbles into a ring with no route back into Play. + _join_game_button.focus_neighbor_right = ( + _join_game_button.get_path_to(_play_button) + ) + _settings_button.focus_neighbor_left = ( + _settings_button.get_path_to(_play_button) + ) + _credits_button.focus_neighbor_right = ( + _credits_button.get_path_to(_play_button) + ) + _quit_button.focus_neighbor_left = ( + _quit_button.get_path_to(_play_button) + ) func _capture_title_bubble_rest_position() -> void: From 219dbb5abcf186be7e7709ab35da9ce1c3380468 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:11:30 -0400 Subject: [PATCH 03/38] chore: prepare v0.17.1-alpha release --- docs/README-PLAYTEST.txt | 4 ++-- export_presets.cfg | 26 +++++++++++++------------- project.godot | 2 +- scripts/build_playtest.sh | 6 +++--- ui/title_screen.tscn | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index dbaa032..7b7e285 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.17.0-alpha -Alpha 0.17.0 +v0.17.1-alpha +Alpha 0.17.1 Thank you for trying this early private playtest. diff --git a/export_presets.cfg b/export_presets.cfg index c484b38..cc5d56a 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.17.1-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.17.0.0" -application/product_version="0.17.0.0" +application/file_version="0.17.1.0" +application/product_version="0.17.1.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.17.0-alpha" +application/file_description="NETfishing v0.17.1-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.17.1-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/macos/NETfishing.zip" +export_path="builds/v0.17.1-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.17.0" -application/version="0.17.0" +application/short_version="0.17.1" +application/version="0.17.1" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.17.0-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.17.1-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/android/NETfishing.apk" +export_path="builds/v0.17.1-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=170000 -version/name="v0.17.0-alpha" +version/code=170100 +version/name="v0.17.1-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.17.1-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index 8a49214..c0a7537 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.17.0-alpha" +config/version="0.17.1-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 74a0c8c..ff0628c 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.17.0-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.17.1-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.17.1-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.17.1-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 03275f3..c69f87b 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -227,7 +227,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.17.0-alpha" +text = "v0.17.1-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From a1ea341e8c8ac952e026df32d17fa4fd44f8646f Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:16 -0400 Subject: [PATCH 04/38] Fix standby lure chat input ownership --- fishing/fishing_spot.gd | 7 +++++-- tests/art_tools_validation.gd | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 0fdc5f7..2559af5 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -781,7 +781,10 @@ func confirm_pending_bite() -> void: func _set_bite_confirmation_pending(is_pending: bool) -> void: if is_pending: - _set_fishing_input_priority(true) + # The Standby deliberately pauses before the fight begins. Keep chat and + # menus available during that pause; fishing only owns input once the + # player confirms the bite and enters the actual minigame. + _set_fishing_input_priority(false) if _bite_confirmation_pending == is_pending: if not is_pending: _bite_confirmation_requested = false @@ -1142,7 +1145,6 @@ func _activate_bite(confirmation_override: bool = false) -> void: ): _cancel_attempt() return - _set_fishing_input_priority(true) if ( not confirmation_override and _active_lure_has_effect(&"deferred_fight") @@ -1155,6 +1157,7 @@ func _activate_bite(confirmation_override: bool = false) -> void: _presentation.show_bite() return + _set_fishing_input_priority(true) _set_bite_confirmation_pending(false) _pending_catch = _fish_selector.create_catch(_selected_fish) if _pending_catch == null or not _pending_catch.is_valid(): diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 3bc4633..3b9d6a3 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -305,6 +305,17 @@ func _run() -> void: assert(on_screen_keyboard.is_open()) on_screen_keyboard.call("_close_keyboard", true) assert(typed_chat_entry.has_focus()) + + # A deferred Standby bite has not started the fishing minigame yet. It must + # release fishing's input ownership so chat remains available until the + # player confirms the bite. + chat_fishing_spot.call("_set_fishing_input_priority", true) + chat_fishing_spot.call("_set_bite_confirmation_pending", true) + for _frame: int in 4: + await process_frame + assert(not chat_fishing_spot.is_fishing_input_priority_active()) + assert(chat_ui.is_open()) + chat_fishing_spot.call("_set_bite_confirmation_pending", false) var left_bumper := InputEventJoypadButton.new() left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER left_bumper.pressed = true From c65176b99fc4baad321acc476920e65c629efc18 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:17 -0400 Subject: [PATCH 05/38] Fix airborne sitting and remote animation playback --- network/network_session.gd | 4 +- player/player.gd | 62 +++++++++++++++++++++--- tests/movement_multiplayer_validation.gd | 48 ++++++++++++++++++ 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index b889678..2711976 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -2069,13 +2069,13 @@ func _maybe_send_local_animation_action() -> void: str(action["id"]), int(action["sequence"]), bool(action.get("paused", false)), - avatar.get_network_sitting_state(), + avatar.get_network_sitting_intent(), ] if signature == _last_local_animation_action_signature: return var encoded: Array = _encode_movement_animation_action( action, - avatar.get_network_sitting_state(), + avatar.get_network_sitting_intent(), ) if encoded.is_empty(): return diff --git a/player/player.gd b/player/player.gd index 860caf3..5ae4cac 100644 --- a/player/player.gd +++ b/player/player.gd @@ -1036,7 +1036,13 @@ func _simulate_movement_physics(delta: float) -> void: or (_network_authoritative_simulation and _network_jump_pending) ) ) - if _sit_after_landing and is_on_floor(): + # Sitting deliberately stops movement processing, so never allow a stale + # grounded flag or a reconciled network state to leave an airborne avatar in + # that early-return path. Preserve the intent and sit once landing is real. + if _sitting and not _can_begin_sitting(): + _sit_after_landing = true + _set_sitting(false) + if _sit_after_landing and not jump_requested and _can_begin_sitting(): _sit_after_landing = false _set_sitting(true, local_control_enabled) if _sitting: @@ -1489,8 +1495,20 @@ func _update_character_animation() -> void: _presented_animation_action_id = &"" _presented_animation_action_sequence = -1 _presented_animation_action_paused = false - if _character_animation_name == next_animation and not action_changed: + if ( + _character_animation_playback_matches( + next_animation, + action_selected and animation_action_paused, + ) + and not action_changed + ): return + # The animation name is only a selection cache. A remote player's + # AnimationPlayer can occasionally stop or lose its assigned animation while + # its replicated locomotion state remains valid. Reassert the selected + # presentation here instead of waiting for another network state transition; + # movement simulation and replication remain untouched. + _character_animation_player.active = true _character_animation_player.play(next_animation) _character_animation_name = next_animation if action_selected: @@ -1513,6 +1531,26 @@ func _update_character_animation() -> void: _character_animation_player.pause() +func _character_animation_playback_matches( + animation_name: StringName, + paused_action_selected: bool, +) -> bool: + if ( + _character_animation_name != animation_name + or _character_animation_player.assigned_animation != animation_name + or not _character_animation_player.active + ): + return false + if paused_action_selected: + return true + var animation := _character_animation_player.get_animation(animation_name) + if animation == null or animation.loop_mode == Animation.LOOP_NONE: + # Completed one-shot actions deliberately keep their final pose until the + # authoritative action state advances; they must not be restarted here. + return true + return _character_animation_player.is_playing() + + func _on_character_animation_finished(animation_name: StringName) -> void: if animation_name == CHARACTER_NET_STRIKE_ANIMATION: if local_control_enabled: @@ -1610,7 +1648,7 @@ func toggle_sitting() -> void: ]) ): return - if not is_on_floor(): + if not _can_begin_sitting(): _sit_after_landing = true return _set_sitting(should_sit, local_control_enabled) @@ -1645,7 +1683,15 @@ func is_sitting() -> bool: return _sitting +func _can_begin_sitting() -> bool: + return is_on_floor() and velocity.y <= 0.0 + + func get_network_sitting_state() -> bool: + return _sitting + + +func get_network_sitting_intent() -> bool: return _sitting or _sit_after_landing @@ -1654,7 +1700,9 @@ func apply_network_sitting_state(should_sit: bool) -> void: func apply_authoritative_network_sitting_state(should_sit: bool) -> void: - if should_sit and not is_on_floor(): + if should_sit and ( + not _can_begin_sitting() or _network_jump_pending + ): _sit_after_landing = true _set_sitting(false) return @@ -2038,7 +2086,7 @@ func capture_network_input(sequence: int) -> Dictionary: "sprint": false, "sneak": false, "slow_walk": false, - "sitting": get_network_sitting_state(), + "sitting": get_network_sitting_intent(), "casting": ( _fishing_visual_phase == FishingVisualPhase.CASTING ), @@ -2063,7 +2111,7 @@ func capture_network_input(sequence: int) -> Dictionary: "sprint": Input.is_action_pressed("sprint"), "sneak": Input.is_action_pressed("sneak"), "slow_walk": Input.is_action_pressed("slow_walk"), - "sitting": get_network_sitting_state(), + "sitting": get_network_sitting_intent(), "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "animation_action": _make_animation_action_state(), } @@ -2111,7 +2159,7 @@ func get_network_input_state_hash() -> int: Input.is_action_pressed("sprint"), Input.is_action_pressed("sneak"), Input.is_action_pressed("slow_walk"), - get_network_sitting_state(), + get_network_sitting_intent(), _fishing_visual_phase == FishingVisualPhase.CASTING, _animation_action_id, _animation_action_sequence, diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index c6a98e5..c9af294 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -38,7 +38,9 @@ func _validate_latency_smoothing() -> void: _validate_animation_action_ordering(avatar) _validate_transit_estimation() _validate_remote_snapshot_smoothing(avatar) + _validate_remote_locomotion_playback_recovery(avatar) _validate_reliable_jump_intent(avatar) + _validate_airborne_sitting(avatar) _validate_stale_input_expiry(avatar) avatar.queue_free() await process_frame @@ -141,6 +143,26 @@ func _validate_compact_animation_encoding() -> void: ) +func _validate_airborne_sitting(avatar: Player) -> void: + avatar.reset_network_movement_state() + avatar.set("_sit_after_landing", true) + assert(avatar.get_network_sitting_intent()) + assert(not avatar.get_network_sitting_state()) + assert(not bool(avatar.make_network_snapshot(2)["sitting"])) + assert(bool(avatar.capture_network_input(1)["sitting"])) + + # Even a malformed or stale reconciliation that marks an airborne avatar as + # seated must not bypass gravity and freeze it in place. + avatar.set("_sit_after_landing", false) + avatar.call("_set_sitting", true) + avatar.velocity = Vector3(0.0, 4.0, 0.0) + avatar.call("_simulate_movement_physics", 0.1) + assert(not avatar.is_sitting()) + assert(bool(avatar.get("_sit_after_landing"))) + assert(avatar.velocity.y < 4.0) + avatar.reset_network_movement_state() + + func _validate_animation_action_ordering(avatar: Player) -> void: var draw := NetworkPlayerAnimationProtocol.make_action_state( &"draw", 5, 0.2 @@ -352,6 +374,32 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: ) +func _validate_remote_locomotion_playback_recovery(avatar: Player) -> void: + avatar.configure_network_remote(false) + avatar.push_network_snapshot( + _network_snapshot(Vector3.ZERO, Vector3(4.5, 0.0, 0.0), 21) + ) + avatar.call("_update_character_animation") + var animation_player := avatar.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer + assert(animation_player.assigned_animation == &"running") + assert(animation_player.is_playing()) + + # Reproduce issue #111: locomotion replication still says RUNNING (and the + # dust therefore still emits), but the rig playback has fallen idle. The + # local presentation pass must repair that state without another packet. + animation_player.stop() + assert(not animation_player.is_playing()) + assert( + int(avatar.get("_network_target_locomotion_state")) + == Player.LocomotionState.RUNNING + ) + avatar.call("_update_character_animation") + assert(animation_player.assigned_animation == &"running") + assert(animation_player.is_playing()) + + func _validate_reliable_jump_intent(avatar: Player) -> void: avatar.set_local_control(true) avatar.call("_queue_local_network_jump_intent") From f1c952f5d6fb2113cb5a77783765dd243495d821 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:17 -0400 Subject: [PATCH 06/38] Clarify hotbar zoom control labels --- settings/controller_mapping_manager.gd | 4 ++-- settings/keyboard_mouse_mapping_manager.gd | 4 ++-- tests/controller_mapping_validation.gd | 16 ++++++++++++++++ tests/keyboard_mouse_mapping_validation.gd | 10 ++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 8c17e7f..278022e 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -84,7 +84,7 @@ const ROLE_LABELS: Dictionary = { ROLE_LB: "focus chat or world", ROLE_RB: "primary action", ROLE_POINTER_MODIFIER: "virtual mouse modifier", - ROLE_CAMERA_ZOOM: "camera zoom", + ROLE_CAMERA_ZOOM: "zoom modifier (+RT)", ROLE_SELECT: "chat", ROLE_START: "pause", ROLE_LEFT_STICK_CLICK: "sprint", @@ -96,7 +96,7 @@ const ROLE_LABELS: Dictionary = { ROLE_LEFT_STICK_X: "move left / right", ROLE_LEFT_STICK_Y: "move up / down", ROLE_RIGHT_STICK_X: "camera left / right", - ROLE_RIGHT_STICK_Y: "camera up / down", + ROLE_RIGHT_STICK_Y: "camera up / down (+RT to zoom)", } const ROLE_PROMPTS: Dictionary = { diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd index 591bc52..54dacc8 100644 --- a/settings/keyboard_mouse_mapping_manager.gd +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -86,8 +86,8 @@ const ROLE_LABELS: Dictionary = { ROLE_PRIMARY_ACTION: "primary action", ROLE_ALTERNATE_REEL: "alternate reel", ROLE_CAMERA_DRAG: "rotate camera", - ROLE_CAMERA_ZOOM_IN: "camera zoom in", - ROLE_CAMERA_ZOOM_OUT: "camera zoom out", + ROLE_CAMERA_ZOOM_IN: "hotbar select (+SHIFT to zoom)", + ROLE_CAMERA_ZOOM_OUT: "hotbar select (+SHIFT to zoom)", ROLE_PLAYER_MENU: "player menu", ROLE_TACKLE_BOX: "tackle box", ROLE_PROP_BOOK: "prop book", diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index b93d475..3a4645a 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -14,6 +14,22 @@ func _init() -> void: func _run() -> void: + if ( + ControllerMappingManagerType.ROLE_LABELS[ + ControllerMappingManagerType.ROLE_CAMERA_ZOOM + ] != "zoom modifier (+RT)" + ): + push_error("controller zoom modifier label is misleading") + quit(1) + return + if ( + ControllerMappingManagerType.ROLE_LABELS[ + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ] != "camera up / down (+RT to zoom)" + ): + push_error("controller vertical camera label omits the RT zoom chord") + quit(1) + return var manager := ControllerMappingManagerType.new() root.add_child(manager) await process_frame diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index db0bb71..1b95e01 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -38,6 +38,16 @@ func _run() -> void: KeyboardMouseMappingManagerType.ROLE_INTERACT ) == "e" ) + assert( + KeyboardMouseMappingManagerType.ROLE_LABELS[ + KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_IN + ] == "hotbar select (+SHIFT to zoom)" + ) + assert( + KeyboardMouseMappingManagerType.ROLE_LABELS[ + KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_OUT + ] == "hotbar select (+SHIFT to zoom)" + ) assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) From 5ed4cccf76320bb8f5b01ef612c994633553d049 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:45 -0400 Subject: [PATCH 07/38] Anchor beetles to generated tree surfaces --- gathering/catalog/beetle_stag_common.tres | 2 + gathering/gatherable_data.gd | 24 ++ network/network_world_spawn_service.gd | 15 +- tests/generated_world_runtime_validation.gd | 47 ++-- tests/tree_gathering_prototype_validation.gd | 67 +++++ tests/world_spawn_protocol_validation.gd | 2 + world/generation/generated_world_region.gd | 48 ++-- .../generation/mesh_surface_anchor_sampler.gd | 260 ++++++++++++++++++ .../mesh_surface_anchor_sampler.gd.uid | 1 + .../props/definitions/prop_palm.tres | 1 + .../props/definitions/prop_pine.tres | 2 +- .../props/definitions/prop_pine_large.tres | 2 +- .../props/definitions/prop_tree_1.tres | 2 +- .../props/definitions/prop_tree_2.tres | 2 +- .../props/definitions/prop_tree_3.tres | 2 +- .../props/definitions/prop_tree_large.tres | 2 +- world/generation/terrain_prop_catalog.gd | 7 +- world/generation/terrain_prop_definition.gd | 31 ++- 18 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 world/generation/mesh_surface_anchor_sampler.gd create mode 100644 world/generation/mesh_surface_anchor_sampler.gd.uid diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres index 118d733..65b15df 100644 --- a/gathering/catalog/beetle_stag_common.tres +++ b/gathering/catalog/beetle_stag_common.tres @@ -10,6 +10,8 @@ catch_data = ExtResource("2_catch") required_tool_id = &"crab_net" spawn_anchor_set_id = &"starter_reachable_tree_trunks" population = 3 +spawn_anchor_occupancy_ratio = 0.35 +maximum_anchor_population = 64 requires_sneaking = false movement_speed = 0.0 roam_radius = 0.1 diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd index 9f984cf..d60dcc9 100644 --- a/gathering/gatherable_data.gd +++ b/gathering/gatherable_data.gd @@ -18,6 +18,11 @@ enum PresentationMode { @export var spawn_anchor_set_id: StringName @export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08 @export_range(0, 64, 1) var population: int = 0 +## Anchored gatherables can scale with authored/generated attachment geometry. +## Zero preserves the fixed population above. +@export_range(0.0, 1.0, 0.01) var spawn_anchor_occupancy_ratio := 0.0 +## Zero leaves the anchor count as the only ceiling. +@export_range(0, 256, 1) var maximum_anchor_population := 0 @export var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE @export var requires_sneaking: bool = true @export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.0 @@ -78,6 +83,10 @@ func is_valid() -> bool: == FishDataType.CollectionMethod.DIGGING ) and population > 0 + and ( + maximum_anchor_population == 0 + or maximum_anchor_population >= population + ) and movement_parameters_valid and _quality_multipliers_are_valid( quality_movement_speed_multipliers @@ -102,6 +111,21 @@ func is_stationary_spawn() -> bool: return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty() +func target_population_for_anchor_count(anchor_count: int) -> int: + if spawn_anchor_set_id.is_empty() or spawn_anchor_occupancy_ratio <= 0.0: + return population + if anchor_count <= 0: + return 0 + var target := maxi( + population, + ceili(float(anchor_count) * spawn_anchor_occupancy_ratio), + ) + target = mini(target, anchor_count) + if maximum_anchor_population > 0: + target = mini(target, maximum_anchor_population) + return target + + func can_be_scared() -> bool: return not is_stationary_spawn() and scare_radius > 0.0 diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index 87814ee..b27dd80 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -294,7 +294,7 @@ func _begin_population_if_ready() -> void: _current_season() ): _cache_spawn_surface(entry) - for _spawn_index: int in entry.population: + for _spawn_index: int in _target_population(entry): _spawn_entity(entry) @@ -1383,11 +1383,22 @@ func _reconcile_seasonal_population() -> void: for entry: GatherableDataType in _catalog.get_available_entries(season): _cache_spawn_surface(entry) var current_population: int = _population_for_type(entry.type_id) - while current_population < entry.population: + var target_population := _target_population(entry) + while current_population < target_population: _spawn_entity(entry) current_population += 1 +func _target_population(entry: GatherableDataType) -> int: + if entry == null or entry.spawn_anchor_set_id.is_empty(): + return entry.population if entry != null else 0 + var anchors: PackedVector3Array = _spawn_anchor_positions.get( + entry.type_id, + PackedVector3Array(), + ) + return entry.target_population_for_anchor_count(anchors.size()) + + func _population_for_type(type_id: StringName) -> int: var count: int = 0 for state: Dictionary in _entities.values(): diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 64b6ee1..6e03df7 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -1131,49 +1131,36 @@ func _validate_tree_gatherable_anchors( decorations: Node3D, anchors: GatherableAnchorSet3D, ) -> void: - var eligible_props: Array[Node3D] = [] + var eligible_props: Dictionary[StringName, Node3D] = {} for child: Node in decorations.get_children(): var prop := child as Node3D if prop == null: continue var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - if definition != null and definition.gatherable_anchor_height > 0.0: - eligible_props.append(prop) + if definition != null and definition.has_gatherable_surface(): + eligible_props[prop.name] = prop var positions := anchors.get_spawn_positions() assert(positions.size() == eligible_props.size()) - for prop: Node3D in eligible_props: + assert(positions.size() >= 12) + for child: Node in anchors.get_children(): + var anchor := child as Marker3D + assert(anchor != null) + assert(bool(anchor.get_meta(&"mesh_surface_sampled", false))) + var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &"")) + assert(eligible_props.has(prop_name)) + var prop: Node3D = eligible_props[prop_name] var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - var visual_scale := float( - prop.get_meta(&"terrain_prop_visual_scale", 1.0) - ) - var nearest_anchor := Vector3(INF, INF, INF) - var nearest_distance_squared := INF - for position: Vector3 in positions: - var distance_squared := position.distance_squared_to( - prop.global_position - ) - if distance_squared < nearest_distance_squared: - nearest_distance_squared = distance_squared - nearest_anchor = position - assert(nearest_anchor.is_finite()) - var horizontal_distance := Vector2( - nearest_anchor.x - prop.global_position.x, - nearest_anchor.z - prop.global_position.z, - ).length() + assert(definition != null and definition.has_gatherable_surface()) + var local_anchor := prop.to_local(anchor.global_position) assert( - horizontal_distance - >= definition.gatherable_anchor_surface_radius() * visual_scale + local_anchor.y + >= definition.gatherable_surface_minimum_height - 0.001 ) assert( - absf( - nearest_anchor.y - - ( - prop.global_position.y - + definition.gatherable_anchor_height * visual_scale - ) - ) <= 0.001 + local_anchor.y + <= definition.gatherable_surface_maximum_height + 0.001 ) diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd index d97f67e..bb6d1df 100644 --- a/tests/tree_gathering_prototype_validation.gd +++ b/tests/tree_gathering_prototype_validation.gd @@ -7,6 +7,9 @@ const Gatherables: GatherableCatalog = preload( "res://gathering/catalog/gatherable_catalog.tres" ) const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres") +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) func _initialize() -> void: @@ -15,6 +18,7 @@ func _initialize() -> void: func _run() -> void: _validate_beetle_data() + await _validate_mesh_surface_sampler() await _validate_tree_anchors() _validate_anchored_presentation() _validate_three_dimensional_targeting() @@ -32,11 +36,74 @@ func _validate_beetle_data() -> void: assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") assert(beetle.population == 3) + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) + assert(beetle.target_population_for_anchor_count(8) == 3) + assert(beetle.target_population_for_anchor_count(40) == 14) + assert(beetle.target_population_for_anchor_count(400) == 64) assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) assert(beetle.is_stationary_spawn()) assert(not beetle.can_be_scared()) +func _validate_mesh_surface_sampler() -> void: + var prop := Node3D.new() + # The accessibility band is local to the planted prop, so the same tree on + # a raised cliff remains reachable from that cliff's walkable surface. + prop.position = Vector3(3.0, 12.0, -4.0) + root.add_child(prop) + var visual := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(2.0, 4.0, 2.0) + var wood := StandardMaterial3D.new() + wood.resource_name = "wood" + box.material = wood + visual.mesh = box + visual.position.y = 2.0 + prop.add_child(visual) + await process_frame + var random := RandomNumberGenerator.new() + random.seed = 115 + var sample: Dictionary = MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["wood"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ) + assert(not sample.is_empty()) + var surface_position: Vector3 = sample["surface_position"] + var anchor_position: Vector3 = sample["position"] + assert(surface_position.y >= 0.7 and surface_position.y <= 1.5) + var world_anchor_position := prop.to_global(anchor_position) + assert( + world_anchor_position.y >= 12.7 + and world_anchor_position.y <= 13.5 + ) + assert(is_equal_approx( + maxf(absf(surface_position.x), absf(surface_position.z)), + 1.0, + )) + assert(is_equal_approx( + anchor_position.distance_to(surface_position), + 0.025, + )) + assert(MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["leaf"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ).is_empty()) + prop.queue_free() + + func _validate_tree_anchors() -> void: var region := StarterIslandScene.instantiate() as WorldRegion root.add_child(region) diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index f529389..29f7fde 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -73,6 +73,8 @@ func _validate_catalog_statuses() -> void: assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET) assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) assert(beetle.is_stationary_spawn()) assert(not beetle.is_stationary_hotspot()) assert(not beetle.requires_sneaking) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 5adf173..06406de 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -9,6 +9,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -35,7 +38,6 @@ const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 -const GATHERABLE_ANCHOR_SURFACE_CLEARANCE := 0.02 const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ &"grass_tree", &"grass_detail", @@ -681,25 +683,37 @@ func _instantiate_prop( definition.clearance_radius * visual_scale ) _placed_prop_groups.append(definition.procedural_group) - if definition.gatherable_anchor_height > 0.0: - var anchor := Marker3D.new() - anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() - var local_anchor_position := ( - definition.collision_offset * visual_scale - + Vector3( - 0.0, - definition.gatherable_anchor_height * visual_scale, - -( - definition.gatherable_anchor_surface_radius() - * visual_scale - + GATHERABLE_ANCHOR_SURFACE_CLEARANCE - ), + if definition.has_gatherable_surface(): + var surface_sample: Dictionary = ( + MeshSurfaceAnchorSamplerType.sample_vertical_surface( + visual_root, + prop, + definition.gatherable_surface_material_names, + definition.gatherable_surface_minimum_height, + definition.gatherable_surface_maximum_height, + definition.gatherable_surface_maximum_up_dot, + definition.gatherable_surface_clearance, + random, ) ) - anchor.position = prop.position + local_anchor_position.rotated( - Vector3.UP, - yaw, + if surface_sample.is_empty(): + push_warning( + "No reachable gatherable mesh surface found on %s." + % definition.stable_id + ) + return true + var anchor := Marker3D.new() + anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() + var local_anchor_position: Vector3 = surface_sample["position"] + anchor.position = _tree_anchors.to_local( + prop.to_global(local_anchor_position) ) + anchor.set_meta( + &"terrain_prop_id", + definition.stable_id, + ) + anchor.set_meta(&"terrain_prop_name", prop.name) + anchor.set_meta(&"mesh_surface_sampled", true) _tree_anchors.add_child(anchor) return true diff --git a/world/generation/mesh_surface_anchor_sampler.gd b/world/generation/mesh_surface_anchor_sampler.gd new file mode 100644 index 0000000..3f41f56 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd @@ -0,0 +1,260 @@ +class_name MeshSurfaceAnchorSampler +extends RefCounted + +const HEIGHT_EPSILON := 0.0001 + + +static func sample_vertical_surface( + visual_root: Node3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + clearance: float, + random: RandomNumberGenerator, +) -> Dictionary: + if ( + visual_root == null + or relative_root == null + or material_names.is_empty() + or maximum_height <= minimum_height + or random == null + ): + return {} + var candidates: Array[Dictionary] = [] + _collect_candidates( + visual_root, + relative_root, + material_names, + minimum_height, + maximum_height, + clampf(maximum_up_dot, 0.0, 1.0), + candidates, + ) + while not candidates.is_empty(): + var candidate_index := _weighted_candidate_index(candidates, random) + var candidate: Dictionary = candidates[candidate_index] + candidates.remove_at(candidate_index) + var point := _sample_triangle_height_slice( + candidate["a"], + candidate["b"], + candidate["c"], + minimum_height, + maximum_height, + random, + ) + if not point.is_finite(): + continue + var normal: Vector3 = candidate["normal"] + var surface_normal := Vector3(normal.x, 0.0, normal.z).normalized() + if surface_normal.is_zero_approx(): + continue + return { + "position": point + surface_normal * maxf(clearance, 0.0), + "surface_position": point, + "surface_normal": surface_normal, + } + return {} + + +static func _collect_candidates( + node: Node, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh_instance := node as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + _collect_mesh_candidates( + mesh_instance, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + for child: Node in node.get_children(): + _collect_candidates( + child, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _collect_mesh_candidates( + mesh_instance: MeshInstance3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh := mesh_instance.mesh + var to_relative := ( + relative_root.global_transform.affine_inverse() + * mesh_instance.global_transform + ) + for surface_index: int in mesh.get_surface_count(): + if ( + mesh is ArrayMesh + and (mesh as ArrayMesh).surface_get_primitive_type(surface_index) + != Mesh.PRIMITIVE_TRIANGLES + ): + continue + var material := mesh.surface_get_material(surface_index) + if not _material_matches(material, material_names): + continue + var arrays := mesh.surface_get_arrays(surface_index) + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if indices.is_empty(): + for vertex_index: int in range(0, vertices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[vertex_index], + to_relative * vertices[vertex_index + 1], + to_relative * vertices[vertex_index + 2], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + continue + for index_offset: int in range(0, indices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[indices[index_offset]], + to_relative * vertices[indices[index_offset + 1]], + to_relative * vertices[indices[index_offset + 2]], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _material_matches( + material: Material, + material_names: PackedStringArray, +) -> bool: + if material == null: + return false + var candidate_name := material.resource_name.to_lower() + for configured_name: String in material_names: + if candidate_name == configured_name.to_lower(): + return true + return false + + +static func _add_triangle_candidate( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var cross := (b - a).cross(c - a) + var doubled_area := cross.length() + if doubled_area <= HEIGHT_EPSILON: + return + var normal := cross / doubled_area + if absf(normal.dot(Vector3.UP)) > maximum_up_dot: + return + var triangle_minimum := minf(a.y, minf(b.y, c.y)) + var triangle_maximum := maxf(a.y, maxf(b.y, c.y)) + var overlap := ( + minf(triangle_maximum, maximum_height) + - maxf(triangle_minimum, minimum_height) + ) + if overlap <= HEIGHT_EPSILON: + return + candidates.append({ + "a": a, + "b": b, + "c": c, + "normal": normal, + "weight": doubled_area * 0.5 * overlap, + }) + + +static func _weighted_candidate_index( + candidates: Array[Dictionary], + random: RandomNumberGenerator, +) -> int: + var total_weight := 0.0 + for candidate: Dictionary in candidates: + total_weight += float(candidate.get("weight", 0.0)) + if total_weight <= 0.0: + return random.randi_range(0, candidates.size() - 1) + var roll := random.randf() * total_weight + var cumulative := 0.0 + for index: int in candidates.size(): + cumulative += float(candidates[index].get("weight", 0.0)) + if roll <= cumulative: + return index + return candidates.size() - 1 + + +static func _sample_triangle_height_slice( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + random: RandomNumberGenerator, +) -> Vector3: + var slice_minimum := maxf(minimum_height, minf(a.y, minf(b.y, c.y))) + var slice_maximum := minf(maximum_height, maxf(a.y, maxf(b.y, c.y))) + if slice_maximum - slice_minimum <= HEIGHT_EPSILON: + return Vector3(INF, INF, INF) + var target_height := random.randf_range(slice_minimum, slice_maximum) + var intersections := PackedVector3Array() + _append_edge_intersection(a, b, target_height, intersections) + _append_edge_intersection(b, c, target_height, intersections) + _append_edge_intersection(c, a, target_height, intersections) + if intersections.size() < 2: + return Vector3(INF, INF, INF) + var first := intersections[0] + var second := intersections[1] + var greatest_distance := first.distance_squared_to(second) + for first_index: int in intersections.size(): + for second_index: int in range(first_index + 1, intersections.size()): + var distance := intersections[first_index].distance_squared_to( + intersections[second_index] + ) + if distance > greatest_distance: + greatest_distance = distance + first = intersections[first_index] + second = intersections[second_index] + return first.lerp(second, random.randf()) + + +static func _append_edge_intersection( + a: Vector3, + b: Vector3, + height: float, + intersections: PackedVector3Array, +) -> void: + var minimum := minf(a.y, b.y) + var maximum := maxf(a.y, b.y) + if height < minimum - HEIGHT_EPSILON or height > maximum + HEIGHT_EPSILON: + return + var height_delta := b.y - a.y + if absf(height_delta) <= HEIGHT_EPSILON: + return + var weight := clampf((height - a.y) / height_delta, 0.0, 1.0) + var point := a.lerp(b, weight) + for existing: Vector3 in intersections: + if existing.distance_squared_to(point) <= HEIGHT_EPSILON * HEIGHT_EPSILON: + return + intersections.append(point) diff --git a/world/generation/mesh_surface_anchor_sampler.gd.uid b/world/generation/mesh_surface_anchor_sampler.gd.uid new file mode 100644 index 0000000..a56caf8 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd.uid @@ -0,0 +1 @@ +uid://dlen7b42nxpjv diff --git a/world/generation/props/definitions/prop_palm.tres b/world/generation/props/definitions/prop_palm.tres index 4d1d344..e586f3d 100644 --- a/world/generation/props/definitions/prop_palm.tres +++ b/world/generation/props/definitions/prop_palm.tres @@ -23,3 +23,4 @@ local_overhang_direction = Vector2(-0.883, 0.469) ocean_facing_spread_degrees = 55.0 collision_radius = 0.4 collision_height = 6.0 +gatherable_surface_material_names = PackedStringArray("wood_light") diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 26e9cb0..062dd3b 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 47c8577..5f5d584 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_tree_1.tres b/world/generation/props/definitions/prop_tree_1.tres index bb5d2af..3553988 100644 --- a/world/generation/props/definitions/prop_tree_1.tres +++ b/world/generation/props/definitions/prop_tree_1.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.4 collision_height = 3.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_2.tres b/world/generation/props/definitions/prop_tree_2.tres index a6931be..868e044 100644 --- a/world/generation/props/definitions/prop_tree_2.tres +++ b/world/generation/props/definitions/prop_tree_2.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.5 collision_height = 3.8 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_3.tres b/world/generation/props/definitions/prop_tree_3.tres index 382117e..58e3549 100644 --- a/world/generation/props/definitions/prop_tree_3.tres +++ b/world/generation/props/definitions/prop_tree_3.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.55 collision_height = 4.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_large.tres b/world/generation/props/definitions/prop_tree_large.tres index ebf6f2f..9092ad1 100644 --- a/world/generation/props/definitions/prop_tree_large.tres +++ b/world/generation/props/definitions/prop_tree_large.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.85 collision_height = 7.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index f475563..ed37c68 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -74,11 +74,12 @@ func validation_errors() -> PackedStringArray: % definition.stable_id ) if ( - definition.gatherable_anchor_height > 0.0 - and definition.gatherable_anchor_surface_radius() <= 0.0 + not definition.gatherable_surface_material_names.is_empty() + and definition.gatherable_surface_maximum_height + <= definition.gatherable_surface_minimum_height ): errors.append( - "%s is gatherable but has no trunk-surface radius." + "%s has an invalid gatherable-surface height band." % definition.stable_id ) if ( diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index 1479371..58fdddd 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -47,11 +47,18 @@ extends Resource @export_range(0.0, 20.0, 0.05) var collision_height := 0.0 @export var collision_box_size := Vector3.ZERO @export var collision_offset := Vector3.ZERO -## Values above zero add this prop to the tree-gathering anchor set. -@export_range(0.0, 20.0, 0.05) var gatherable_anchor_height := 0.0 -## Optional distance from the prop origin to the visible trunk surface. A -## zero value derives the distance from the authored collision shape. -@export_range(0.0, 5.0, 0.05) var gatherable_anchor_radius := 0.0 +@export_category("Gatherable Surface") +## Non-empty values explicitly designate this prop's matching mesh surfaces as +## valid attachment geometry. Unlisted props and materials are never sampled. +@export var gatherable_surface_material_names := PackedStringArray() +## Accessibility band measured upward from this prop's planted origin. It +## follows the tree onto hills/cliffs while keeping anchors within net reach. +@export_range(0.0, 20.0, 0.05) var gatherable_surface_minimum_height := 0.7 +@export_range(0.0, 20.0, 0.05) var gatherable_surface_maximum_height := 1.5 +## Reject upward-facing branches and foliage so attachments favor trunk-like +## faces. Zero accepts only vertical faces; one accepts every orientation. +@export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35 +@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: @@ -77,14 +84,12 @@ func has_box_collision() -> bool: ) -func gatherable_anchor_surface_radius() -> float: - if gatherable_anchor_radius > 0.0: - return gatherable_anchor_radius - if has_cylinder_collision(): - return collision_radius - if has_box_collision(): - return maxf(collision_box_size.x, collision_box_size.z) * 0.5 - return 0.0 +func has_gatherable_surface() -> bool: + return ( + not gatherable_surface_material_names.is_empty() + and gatherable_surface_maximum_height + > gatherable_surface_minimum_height + ) func is_procedural() -> bool: From bb75121dcdc7e1801adb06103a40be5497b07a2d Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 11:13:49 -0400 Subject: [PATCH 08/38] Activate full fish catalog --- art/exported/creatures/fish_placeholder.png | Bin 0 -> 1964 bytes .../creatures/fish_placeholder.png.import | 40 +++ fish/pools/generated_lake_pool.tres | 30 ++- fish/pools/generated_pond_pool.tres | 42 +++- fish/pools/generated_river_pool.tres | 66 ++++- fish/pools/starter_ocean_pool.tres | 234 +++++++++++++++--- fish/pools/starter_pond_pool.tres | 136 +++++++--- fish/species/abalone_red/abalone_red.tres | 6 +- .../amberjack_greater/amberjack_greater.tres | 6 +- .../amberjack_lesser/amberjack_lesser.tres | 6 +- .../anchovy_european/anchovy_european.tres | 2 +- .../anchovy_northern/anchovy_northern.tres | 2 +- .../angelfish_freshwater.tres | 6 +- .../angelfish_queen/angelfish_queen.tres | 6 +- .../anglerfish_black_seadevil.tres | 6 +- fish/species/arapaima/arapaima.tres | 6 +- .../arowana_silver/arowana_silver.tres | 6 +- fish/species/axolotl/axolotl.tres | 6 +- fish/species/barbel_common/barbel_common.tres | 6 +- .../barracuda_great/barracuda_great.tres | 6 +- fish/species/barramundi/barramundi.tres | 6 +- .../barreleye_pacific/barreleye_pacific.tres | 6 +- fish/species/bass/bass.tres | 2 +- fish/species/betta_siamese/betta_siamese.tres | 6 +- fish/species/black_drum/black_drum.tres | 6 +- .../blobfish_smooth_head.tres | 6 +- fish/species/bluefish/bluefish.tres | 6 +- fish/species/bluegill/bluegill.tres | 2 +- fish/species/bonefish/bonefish.tres | 6 +- fish/species/bowfin/bowfin.tres | 2 +- .../boxfish_yellow/boxfish_yellow.tres | 6 +- fish/species/bream_common/bream_common.tres | 6 +- .../buffalo_bigmouth/buffalo_bigmouth.tres | 6 +- .../buffalo_smallmouth.tres | 6 +- .../bullhead_black/bullhead_black.tres | 6 +- .../bullhead_brown/bullhead_brown.tres | 6 +- .../bullhead_yellow/bullhead_yellow.tres | 6 +- fish/species/burbot/burbot.tres | 6 +- .../butterflyfish_copperband.tres | 6 +- fish/species/capelin/capelin.tres | 6 +- fish/species/carp/carp.tres | 2 +- fish/species/catfish_blue/catfish_blue.tres | 2 +- .../catfish_channel/catfish_channel.tres | 2 +- .../catfish_flathead/catfish_flathead.tres | 2 +- .../catfish_walking/catfish_walking.tres | 6 +- fish/species/catfish_white/catfish_white.tres | 2 +- fish/species/char_arctic/char_arctic.tres | 6 +- .../chromis_blue_green.tres | 6 +- fish/species/chub_creek/chub_creek.tres | 6 +- fish/species/chub_european/chub_european.tres | 2 +- fish/species/chub_flame/chub_flame.tres | 2 +- fish/species/chub_lake/chub_lake.tres | 2 +- fish/species/cisco/cisco.tres | 6 +- fish/species/clam_geoduck/clam_geoduck.tres | 6 +- fish/species/clam_giant/clam_giant.tres | 6 +- .../clownfish_ocellaris.tres | 6 +- fish/species/cobia/cobia.tres | 6 +- fish/species/cod_atlantic/cod_atlantic.tres | 6 +- fish/species/cod_pacific/cod_pacific.tres | 6 +- .../coelacanth_west_indian.tres | 6 +- fish/species/conch_queen/conch_queen.tres | 6 +- .../cornetfish_red/cornetfish_red.tres | 6 +- .../cowfish_longhorn/cowfish_longhorn.tres | 6 +- .../crab_japanese_spider.tres | 6 +- fish/species/crab_red_king/crab_red_king.tres | 6 +- fish/species/crappie_black/crappie_black.tres | 6 +- fish/species/crappie_white/crappie_white.tres | 6 +- .../crayfish_red_swamp.tres | 6 +- .../croaker_atlantic/croaker_atlantic.tres | 6 +- .../croaker_yellowfin/croaker_yellowfin.tres | 6 +- fish/species/cusk/cusk.tres | 6 +- .../cuttlefish_common/cuttlefish_common.tres | 6 +- .../cuttlefish_flamboyant.tres | 6 +- .../damselfish_sergeant_major.tres | 6 +- fish/species/danio_zebra/danio_zebra.tres | 6 +- fish/species/discus/discus.tres | 6 +- .../dolphin_bottlenose.tres | 6 +- fish/species/dorado_golden/dorado_golden.tres | 6 +- fish/species/dugong/dugong.tres | 6 +- fish/species/eel_american/eel_american.tres | 6 +- fish/species/eel_european/eel_european.tres | 6 +- fish/species/electric_eel/electric_eel.tres | 6 +- fish/species/fallfish/fallfish.tres | 6 +- .../fangtooth_common/fangtooth_common.tres | 6 +- .../flounder_peacock/flounder_peacock.tres | 6 +- .../flounder_summer/flounder_summer.tres | 6 +- .../flounder_winter/flounder_winter.tres | 6 +- .../flyingfish_tropical.tres | 6 +- .../foureyes_largescale.tres | 6 +- .../freshwater_drum/freshwater_drum.tres | 6 +- fish/species/gar_alligator/gar_alligator.tres | 6 +- fish/species/gar_longnose/gar_longnose.tres | 2 +- fish/species/gar_spotted/gar_spotted.tres | 2 +- fish/species/goby_round/goby_round.tres | 2 +- fish/species/goldeye/goldeye.tres | 6 +- fish/species/goldfish/goldfish.tres | 2 +- .../goldfish_bubbleeye.tres | 2 +- fish/species/gourami_giant/gourami_giant.tres | 6 +- .../grayling_arctic/grayling_arctic.tres | 6 +- fish/species/green_sunfish/green_sunfish.tres | 6 +- .../grouper_atlantic_goliath.tres | 6 +- fish/species/grouper_giant/grouper_giant.tres | 6 +- fish/species/grouper_gulf/grouper_gulf.tres | 2 +- .../grouper_nassau/grouper_nassau.tres | 6 +- fish/species/grouper_red/grouper_red.tres | 2 +- .../grunt_bluestriped/grunt_bluestriped.tres | 6 +- fish/species/grunt_french/grunt_french.tres | 6 +- fish/species/gulper_eel/gulper_eel.tres | 6 +- fish/species/guppy/guppy.tres | 6 +- fish/species/haddock/haddock.tres | 6 +- fish/species/hake_pacific/hake_pacific.tres | 6 +- fish/species/hake_silver/hake_silver.tres | 6 +- .../halfbeak_ballyhoo/halfbeak_ballyhoo.tres | 6 +- .../halibut_atlantic/halibut_atlantic.tres | 6 +- .../halibut_pacific/halibut_pacific.tres | 6 +- fish/species/hellbender/hellbender.tres | 6 +- .../herring_atlantic/herring_atlantic.tres | 6 +- .../herring_pacific/herring_pacific.tres | 6 +- fish/species/hogfish/hogfish.tres | 6 +- fish/species/ide/ide.tres | 6 +- fish/species/jack_crevalle/jack_crevalle.tres | 6 +- fish/species/jelly_crystal/jelly_crystal.tres | 6 +- .../jelly_lions_mane/jelly_lions_mane.tres | 6 +- fish/species/jelly_moon/jelly_moon.tres | 6 +- .../jelly_pacific_sea_nettle.tres | 6 +- .../jelly_sea_wasp/jelly_sea_wasp.tres | 6 +- .../knifefish_clown/knifefish_clown.tres | 6 +- fish/species/koi/koi.tres | 6 +- .../krill_antarctic/krill_antarctic.tres | 6 +- fish/species/ladyfish/ladyfish.tres | 6 +- .../lanternfish_spotted.tres | 6 +- .../largemouth_bass/largemouth_bass.tres | 6 +- fish/species/lingcod/lingcod.tres | 6 +- fish/species/lionfish_red/lionfish_red.tres | 6 +- fish/species/loach_clown/loach_clown.tres | 6 +- fish/species/loach_weather/loach_weather.tres | 6 +- .../lobster_american/lobster_american.tres | 6 +- .../lobster_caribbean_spiny.tres | 6 +- .../lungfish_west_african.tres | 2 +- .../mackerel_atlantic/mackerel_atlantic.tres | 2 +- fish/species/mackerel_cero/mackerel_cero.tres | 2 +- fish/species/mackerel_chub/mackerel_chub.tres | 2 +- fish/species/mackerel_king/mackerel_king.tres | 2 +- .../mackerel_spanish/mackerel_spanish.tres | 2 +- .../madtom_tadpole/madtom_tadpole.tres | 6 +- fish/species/mahi_mahi/mahi_mahi.tres | 6 +- .../mahseer_golden/mahseer_golden.tres | 6 +- .../manatee_west_indian.tres | 6 +- .../manta_ray_giant/manta_ray_giant.tres | 6 +- fish/species/marlin_black/marlin_black.tres | 2 +- fish/species/marlin_blue/marlin_blue.tres | 2 +- fish/species/marlin_white/marlin_white.tres | 2 +- .../menhaden_atlantic/menhaden_atlantic.tres | 6 +- fish/species/milkfish/milkfish.tres | 6 +- .../minnow_fathead/minnow_fathead.tres | 6 +- fish/species/monkfish/monkfish.tres | 6 +- fish/species/mooneye/mooneye.tres | 6 +- fish/species/moray_green/moray_green.tres | 6 +- .../mudskipper_atlantic.tres | 6 +- fish/species/mullet_red/mullet_red.tres | 6 +- .../mullet_striped/mullet_striped.tres | 6 +- fish/species/muskellunge/muskellunge.tres | 6 +- .../nautilus_chambered.tres | 6 +- .../needlefish_hound/needlefish_hound.tres | 6 +- fish/species/oarfish_giant/oarfish_giant.tres | 6 +- .../ocean_perch_pacific.tres | 6 +- .../octopus_common/octopus_common.tres | 6 +- fish/species/octopus_day/octopus_day.tres | 6 +- .../octopus_giant_pacific.tres | 6 +- .../octopus_greater_blue_ringed.tres | 6 +- fish/species/opah/opah.tres | 6 +- fish/species/oscar/oscar.tres | 6 +- .../oyster_black_lip_pearl.tres | 6 +- fish/species/paddlefish/paddlefish.tres | 2 +- .../parrotfish_rainbow.tres | 6 +- .../parrotfish_stoplight.tres | 6 +- fish/species/peacock_bass/peacock_bass.tres | 6 +- fish/species/permit/permit.tres | 6 +- .../pickerel_chain/pickerel_chain.tres | 6 +- .../pickerel_grass/pickerel_grass.tres | 6 +- fish/species/pike_northern/pike_northern.tres | 6 +- fish/species/pipefish_bay/pipefish_bay.tres | 6 +- .../piranha_red_bellied.tres | 6 +- .../plaice_european/plaice_european.tres | 6 +- .../pollock_alaska/pollock_alaska.tres | 6 +- .../pollock_atlantic/pollock_atlantic.tres | 6 +- fish/species/pomfret_black/pomfret_black.tres | 2 +- .../pomfret_chinese/pomfret_chinese.tres | 2 +- .../pomfret_golden/pomfret_golden.tres | 2 +- fish/species/pomfret_white/pomfret_white.tres | 2 +- .../pompano_african/pompano_african.tres | 6 +- .../pompano_florida/pompano_florida.tres | 6 +- .../porcupinefish_spotted.tres | 6 +- fish/species/porgy_scup/porgy_scup.tres | 6 +- .../pufferfish_guineafowl.tres | 6 +- fish/species/pumpkinseed/pumpkinseed.tres | 6 +- .../queenfish_talang/queenfish_talang.tres | 6 +- fish/species/quillback/quillback.tres | 6 +- fish/species/red_drum/red_drum.tres | 6 +- .../redear_sunfish/redear_sunfish.tres | 6 +- .../redhorse_golden/redhorse_golden.tres | 6 +- fish/species/roach_common/roach_common.tres | 6 +- .../rockfish_black/rockfish_black.tres | 6 +- .../rockfish_canary/rockfish_canary.tres | 6 +- .../rockfish_yelloweye.tres | 6 +- fish/species/roosterfish/roosterfish.tres | 6 +- fish/species/rudd/rudd.tres | 6 +- fish/species/sablefish/sablefish.tres | 6 +- fish/species/sailfish/sailfish.tres | 2 +- .../salmon_atlantic/salmon_atlantic.tres | 2 +- fish/species/salmon_chum/salmon_chum.tres | 2 +- fish/species/salmon_coho/salmon_coho.tres | 2 +- fish/species/salmon_pink/salmon_pink.tres | 2 +- .../salmon_sockeye/salmon_sockeye.tres | 2 +- fish/species/sand_dollar/sand_dollar.tres | 6 +- .../sand_lance_american.tres | 6 +- .../sardine_european/sardine_european.tres | 6 +- .../sardine_pacific/sardine_pacific.tres | 6 +- fish/species/sauger/sauger.tres | 2 +- fish/species/saugeye/saugeye.tres | 2 +- .../sawfish_largetooth.tres | 6 +- .../scorpionfish_red/scorpionfish_red.tres | 6 +- .../sea_bass_black/sea_bass_black.tres | 6 +- .../sea_bass_chilean/sea_bass_chilean.tres | 6 +- .../sea_cucumber_giant_california.tres | 6 +- fish/species/sea_otter/sea_otter.tres | 6 +- .../sea_star_crown_of_thorns.tres | 6 +- .../sea_star_sunflower.tres | 6 +- .../seadragon_leafy/seadragon_leafy.tres | 6 +- .../seadragon_weedy/seadragon_weedy.tres | 6 +- .../seahorse_lined/seahorse_lined.tres | 6 +- fish/species/seal_harbor/seal_harbor.tres | 6 +- .../shark_blacktip/shark_blacktip.tres | 6 +- .../shark_great_hammerhead.tres | 6 +- .../shark_great_white/shark_great_white.tres | 6 +- fish/species/shark_tiger/shark_tiger.tres | 6 +- fish/species/shark_whale/shark_whale.tres | 6 +- fish/species/sheepshead/sheepshead.tres | 6 +- fish/species/shiner_common/shiner_common.tres | 6 +- .../shiner_emerald/shiner_emerald.tres | 6 +- fish/species/shiner_golden/shiner_golden.tres | 6 +- .../shrimp_peacock_mantis.tres | 6 +- .../shrimp_skunk_cleaner.tres | 6 +- .../skate_barndoor/skate_barndoor.tres | 6 +- .../smallmouth_bass/smallmouth_bass.tres | 6 +- .../smelt_eulachon/smelt_eulachon.tres | 6 +- fish/species/smelt_rainbow/smelt_rainbow.tres | 6 +- .../snakehead_giant/snakehead_giant.tres | 6 +- fish/species/snapper_lane/snapper_lane.tres | 2 +- .../snapper_mangrove/snapper_mangrove.tres | 2 +- .../snapper_mutton/snapper_mutton.tres | 2 +- fish/species/snapper_red/snapper_red.tres | 2 +- fish/species/snook_common/snook_common.tres | 6 +- fish/species/sole_dover/sole_dover.tres | 6 +- .../sprat_european/sprat_european.tres | 6 +- .../squid_bigfin_reef/squid_bigfin_reef.tres | 6 +- .../squid_colossal/squid_colossal.tres | 6 +- fish/species/squid_giant/squid_giant.tres | 6 +- .../squid_humboldt/squid_humboldt.tres | 6 +- .../stingray_ocellate_river.tres | 6 +- .../stingray_southern/stingray_southern.tres | 6 +- fish/species/sturgeon_lake/sturgeon_lake.tres | 2 +- .../sturgeon_shovelnose.tres | 2 +- .../sturgeon_white/sturgeon_white.tres | 6 +- fish/species/sucker_white/sucker_white.tres | 6 +- fish/species/sunfish/sunfish.tres | 2 +- .../surgeonfish_blue/surgeonfish_blue.tres | 6 +- fish/species/swordfish/swordfish.tres | 2 +- fish/species/tambaqui/tambaqui.tres | 6 +- fish/species/tang_yellow/tang_yellow.tres | 6 +- .../tarpon_atlantic/tarpon_atlantic.tres | 6 +- fish/species/tautog/tautog.tres | 6 +- fish/species/tench/tench.tres | 6 +- fish/species/tetra_neon/tetra_neon.tres | 6 +- .../tigerfish_goliath/tigerfish_goliath.tres | 6 +- fish/species/tilapia_nile/tilapia_nile.tres | 6 +- .../tilefish_blueline/tilefish_blueline.tres | 6 +- .../tilefish_golden/tilefish_golden.tres | 6 +- .../trevally_bluefin/trevally_bluefin.tres | 6 +- .../trevally_giant/trevally_giant.tres | 6 +- .../triggerfish_gray/triggerfish_gray.tres | 6 +- .../triggerfish_queen/triggerfish_queen.tres | 6 +- fish/species/tripletail/tripletail.tres | 6 +- fish/species/trout_brown/trout_brown.tres | 6 +- .../trout_cutthroat/trout_cutthroat.tres | 2 +- fish/species/trout_golden/trout_golden.tres | 2 +- fish/species/trout_rainbow/trout_rainbow.tres | 2 +- .../trout_steelhead/trout_steelhead.tres | 2 +- .../trumpetfish_atlantic.tres | 6 +- fish/species/tuna_albacore/tuna_albacore.tres | 2 +- fish/species/tuna_bigeye/tuna_bigeye.tres | 2 +- fish/species/tuna_bluefin/tuna_bluefin.tres | 2 +- fish/species/tuna_skipjack/tuna_skipjack.tres | 2 +- .../tuna_yellowfin/tuna_yellowfin.tres | 2 +- fish/species/turbot/turbot.tres | 6 +- fish/species/turtle_green/turtle_green.tres | 6 +- .../turtle_hawksbill/turtle_hawksbill.tres | 6 +- .../turtle_leatherback.tres | 6 +- .../turtle_loggerhead/turtle_loggerhead.tres | 6 +- fish/species/urchin_purple/urchin_purple.tres | 6 +- fish/species/wahoo/wahoo.tres | 6 +- fish/species/walleye/walleye.tres | 2 +- fish/species/walrus/walrus.tres | 6 +- fish/species/warmouth/warmouth.tres | 6 +- fish/species/weakfish/weakfish.tres | 6 +- fish/species/whale_blue/whale_blue.tres | 6 +- .../whale_humpback/whale_humpback.tres | 6 +- fish/species/whale_killer/whale_killer.tres | 6 +- fish/species/whale_sperm/whale_sperm.tres | 6 +- fish/species/white_perch/white_perch.tres | 6 +- .../whitefish_lake/whitefish_lake.tres | 6 +- .../wolffish_atlantic/wolffish_atlantic.tres | 6 +- .../wolffish_spotted/wolffish_spotted.tres | 6 +- fish/species/wrasse_ballan/wrasse_ballan.tres | 6 +- .../wrasse_cleaner/wrasse_cleaner.tres | 6 +- fish/species/wreckfish/wreckfish.tres | 6 +- fish/species/yellow_perch/yellow_perch.tres | 6 +- tests/economy_regression_validation.gd | 2 +- tests/fish_catalog_content_validation.gd | 95 ++++--- tests/logbook_runtime_validation.gd | 46 ++-- tests/logbook_validation.gd | 6 +- 321 files changed, 1611 insertions(+), 706 deletions(-) create mode 100644 art/exported/creatures/fish_placeholder.png create mode 100644 art/exported/creatures/fish_placeholder.png.import diff --git a/art/exported/creatures/fish_placeholder.png b/art/exported/creatures/fish_placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..de69afb7d7040b41193b957beb8527c5d396e67b GIT binary patch literal 1964 zcmV;d2UGZoP)9@C00001b5ch_0olnc ze*gdg1ZP1_K>z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B00(qQO+^Rl2pA3&B*x{qdjJ3j z0!c(cRCwC$oNH_pRTRg6ce^buyWO@(Tl#=%p@EkVlB`@AInO&yW@wsE!*yPoPEzO z`%ju~c4qI~x&M0}_uO-?APb=BECH4P%~H@z7C~gd;8y`R0m}dnPy$Q`oaz=&$Y+3l zV71+e(sU|-Dxe$al!Ab53DWdY{ip&~0Zl+7P#j~kXMtPnNR+1Y5b!W?IdB4a0r*%7 zd}$|0k>LP}fHGh*upGD%m;+QK_>s0EO4IpLX@s$O9e8@IB#9JutN@q-%vCdIj*^gB zim+rO?L=uh?Z7mXTYiv&>r+OMl9ma|W~>HoSH$F}aT2GLC`~6HSOM&?_>-lfeG^Gi zUoJIM8Uali_=z?%1t}p)(|HeA7sXG0l7hyB5Txnkjj<&aK)uqjOVl;HqZ_y_(L`xF zD}n7Xj6o2nm4eQg2~zf?8dwM{0IpDKtQN>i!XWA?u$S$?g5iOQb4~VzS`NgDbyBb; zDuR@>lmhdV%37g5=aXG{Mhad{6EVa1Rti>FB1lO~4X_BfP5m#8w+F$&0{(5U;QYx# zCQk2T!jzMCBB>A1{!i_xMf57=B6||}{C+8n_Jqm93za*(N_k*c0R@S8y8lcW$6l;w zVCx0ptA4ki%BF*MlcqK)Xc$LYHUjgLxJ0Oyf=&_P;Z9&Ga4Ya2Fw<&_0k50WJEzgJ z-9^oYZanjkM3kKFUG@BRPq+}yZarc*NdoYzN!kTmn*?bwwL>BTIoAL$0XK|^ahc*= zTP?p|R~z>I)u(?9lb(a~E=NBXVanK3iqeJ8q+rch@tgs?p&S5bG?Mer*7C^8d~bbj zsAI!aLiMMn;|3m8u5(lv8YrUooe2@IotC3jS)R*F&E~Ta?=4+)Do4u&%p4&|)0qf7 z0^A?Vdc*1VQL_Gg#A}na(PTIZ1}R<8h5Igln0m9J{YfV-8S&yYoyUPq@w8^u24#qw zZ3{?&5SmUcupcOow?QjjTn^C_v=aC_6JNmBQN_tmD*>oj(?!v=&a5NIi4o_@IQ+4G zKHg6*IGQ~h>zMRZC3R2yl2s%*f!~1h82dKn-dbM?zN2LX&gD^1ex3mmw-(g! ztQ*WGN8wLG!#PsWtm*uM5w(sQ{(TM(T~mj1?o=+h+lPDZ0Ipf5Ol?%a>&APioPicM zfdL0YeGdL@aWdHAAb2Xe^|EX25Yu1YpNVR0ybaFd5w{yOoj)a=g1dfN@SWUaqbPfWuBV9YQyMf~yCJ|^)euAo_R;Tv0vy?78k(R}A ziZPPBC@f~IJPo{J=81JYTaJHkV%46jX$a3^A0-QWvM$bMCRtKk$o&vemVx|jW$ofS zQefFj3-$8y+(C*Od=%FBD5&VY7$gO=l&hE$7d0IpP-ry*5W3D8MxHxpX`OVDVkBuX zR)QSBN5GxAu}q$O9d~ZFrqc*~Lt-*1b1={&g|XE=;vB`%*w4UXU|()r6Na5(-Yc>1 z_631f;AW-nBt~X_4zc5Xl2rn!Y}1v%a~L_?=~$Tu6Hs083~(1lp;;=?Aq90QsZrH* zYJhh!Qf5U4&EW5)F!BN;g}PGd93;X9nW?`MQm`7R1GZ&|>f10(&Um3Nl3Vi9nYJ=~ zB~iCyl$(2ONKUg9EVUXmPB6NpV2W)HvQVWPj{z%fQ2f;otdSzzdrBcm)n-+F!UjjU z+^gL6`V@xvbzn2_mJ}Se*w@x1I1a0%;43oWD439HwI+ISWjVDj6($pyh)mCqI void: ) assert(player != null) assert(catalog != null and catalog.candidates.size() == 316) - assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 63) + assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 313) assert(sale_service != null) assert(shop_service != null) assert(session != null and session.is_host()) diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index fb5f9e4..4a252b0 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -37,6 +37,18 @@ const PelicanBuyer = preload("res://economy/buyers/pelicans.tres") const StarterRegionScene = preload( "res://world/regions/starter_island_region.tscn" ) +const SharedFishPlaceholder: Texture2D = preload( + "res://art/exported/creatures/fish_placeholder.png" +) + +const ACTIVE_CATALOG_COUNT: int = 313 +const INACTIVE_CATALOG_COUNT: int = 3 +const FISHING_SPECIES_COUNT: int = 310 +const GENERATED_POND_COUNT: int = 33 +const GENERATED_LAKE_COUNT: int = 20 +const GENERATED_RIVER_COUNT: int = 53 +const STARTER_POND_COUNT: int = 106 +const OCEAN_COUNT: int = 204 const ORIGINAL_IDS: Array[StringName] = [ &"bluegill", &"bass", &"carp", &"sunfish", @@ -82,23 +94,6 @@ const NEW_FRESH_WATER_IDS: Array[StringName] = [ &"bowfin", &"sturgeon_lake", &"gar_longnose", &"paddlefish", &"sturgeon_shovelnose", &"gar_spotted", &"lungfish_west_african", ] -const GENERATED_POND_IDS: Array[StringName] = [ - &"bowfin", &"gar_spotted", &"lungfish_west_african", - &"catfish_channel", &"catfish_white", &"carp", - &"goldfish_bubbleeye", &"goldfish", &"bluegill", -] -const GENERATED_LAKE_IDS: Array[StringName] = [ - &"sturgeon_lake", &"trout_golden", &"chub_lake", &"saugeye", - &"walleye", &"goby_round", -] -const GENERATED_RIVER_IDS: Array[StringName] = [ - &"gar_longnose", &"paddlefish", &"sturgeon_shovelnose", - &"trout_cutthroat", &"trout_rainbow", &"catfish_blue", - &"catfish_flathead", &"chub_european", &"chub_flame", &"sauger", - &"trout_steelhead", -] - - func _initialize() -> void: call_deferred("_run") @@ -115,21 +110,35 @@ func _run() -> void: func _validate_generated_habitat_pools() -> void: - var expected_by_pool: Dictionary = { - GeneratedPondPool: GENERATED_POND_IDS, - GeneratedLakePool: GENERATED_LAKE_IDS, - GeneratedRiverPool: GENERATED_RIVER_IDS, + var expected_counts: Dictionary = { + GeneratedPondPool: GENERATED_POND_COUNT, + GeneratedLakePool: GENERATED_LAKE_COUNT, + GeneratedRiverPool: GENERATED_RIVER_COUNT, } - for pool: FishPoolType in expected_by_pool: - var expected_ids: Array[StringName] = expected_by_pool[pool] - assert(pool.candidates.size() == expected_ids.size()) - for fish_id: StringName in expected_ids: - var fish: FishDataType = Catalog.get_fish_by_id(fish_id) + var freshwater_ids: Dictionary[StringName, bool] = {} + for pool: FishPoolType in expected_counts: + assert(pool.candidates.size() == int(expected_counts[pool])) + for fish: FishDataType in pool.candidates: assert(fish != null and fish.is_fishable()) - assert(pool.get_fish_by_id(fish_id) == fish) + assert(not freshwater_ids.has(fish.id)) + freshwater_ids[fish.id] = true assert(fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) - assert(GeneratedPondPool.get_fish_by_id(&"pomfret_white") == null) - assert(GeneratedPondPool.get_fish_by_id(&"mackerel_atlantic") == null) + assert(freshwater_ids.size() == STARTER_POND_COUNT) + assert(PondPool.candidates.size() == STARTER_POND_COUNT) + for fish: FishDataType in PondPool.candidates: + assert(freshwater_ids.has(fish.id)) + assert(OceanPool.candidates.size() == OCEAN_COUNT) + var ocean_ids: Dictionary[StringName, bool] = {} + for fish: FishDataType in OceanPool.candidates: + assert(fish != null and fish.is_fishable()) + assert(not ocean_ids.has(fish.id)) + ocean_ids[fish.id] = true + assert(fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) + for fish: FishDataType in Catalog.candidates: + if not fish.is_fishable(): + continue + assert(freshwater_ids.has(fish.id) != ocean_ids.has(fish.id)) + assert(freshwater_ids.size() + ocean_ids.size() == FISHING_SPECIES_COUNT) assert(GeneratedPondPool.get_fish_by_id(&"gar_longnose") == null) assert(GeneratedRiverPool.get_fish_by_id(&"gar_longnose") != null) assert(GeneratedRiverPool.get_fish_by_id(&"goby_round") == null) @@ -143,7 +152,7 @@ func _validate_weight_based_display_scale() -> void: continue assert(fish.is_selectable()) assert(fish.weight_min_lb > 0.0) - assert(fish.weight_max_lb <= 1000.0) + assert(fish.weight_max_lb <= 500000.0) var minimum_scale: float = fish.get_display_scale_for_weight( fish.weight_min_lb ) @@ -172,10 +181,11 @@ func _validate_weight_based_display_scale() -> void: func _validate_catalog_and_pools() -> void: assert(Catalog.candidates.size() == 316) - assert(PondPool.candidates.size() == 26) - assert(OceanPool.candidates.size() == 34) + assert(PondPool.candidates.size() == STARTER_POND_COUNT) + assert(OceanPool.candidates.size() == OCEAN_COUNT) var active_count: int = 0 var inactive_count: int = 0 + var fishing_count: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in Catalog.candidates: assert(fish != null and not fish.id.is_empty()) @@ -194,8 +204,13 @@ func _validate_catalog_and_pools() -> void: inactive_count += 1 assert(not fish.is_selectable()) assert(fish.display_texture == null) - assert(active_count == 63) - assert(inactive_count == 253) + if fish.collection_method == FishDataType.CollectionMethod.FISHING: + fishing_count += 1 + assert(fish.active) + assert(fish.display_texture == SharedFishPlaceholder) + assert(active_count == ACTIVE_CATALOG_COUNT) + assert(inactive_count == INACTIVE_CATALOG_COUNT) + assert(fishing_count == FISHING_SPECIES_COUNT) var chum: FishDataType = Catalog.get_fish_by_id(&"salmon_chum") assert(chum != null) assert(chum.get_season_text() == "fall") @@ -233,12 +248,12 @@ func _validate_catalog_and_pools() -> void: ) == chum ) seasonal_collection.free() - var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"mudskipper_atlantic") + var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"crab_blue") assert(inactive_fish != null and not inactive_fish.active) var inactive_pool := FishPoolType.new() inactive_pool.candidates = [inactive_fish] var inactive_context := FishingContextType.new() - inactive_context.water_type = WaterType.Type.FRESH_WATER + inactive_context.water_type = WaterType.Type.SALT_WATER var inactive_collection := CollectionLogType.new() var inactive_selector := FishSelectorType.new() inactive_selector.use_deterministic_test_seed = true @@ -424,11 +439,15 @@ func _validate_authoritative_water_filter() -> void: ocean_region.location_tags = [&"coast", &"ocean"] var stale_salt_pool_region := FishableWaterRegion.new() stale_salt_pool_region.water_type = WaterType.Type.FRESH_WATER - stale_salt_pool_region.fish_pool = OceanPool + var salt_only_pool := FishPoolType.new() + salt_only_pool.candidates = [Catalog.get_fish_by_id(&"bass")] + stale_salt_pool_region.fish_pool = salt_only_pool stale_salt_pool_region.location_tags = [&"starter_pond"] var stale_fresh_pool_region := FishableWaterRegion.new() stale_fresh_pool_region.water_type = WaterType.Type.SALT_WATER - stale_fresh_pool_region.fish_pool = PondPool + var fresh_only_pool := FishPoolType.new() + fresh_only_pool.candidates = [Catalog.get_fish_by_id(&"bluegill")] + stale_fresh_pool_region.fish_pool = fresh_only_pool stale_fresh_pool_region.location_tags = [&"coast", &"ocean"] var evidence := {"discovered_fish_ids": []} assert( diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index e411fef..8b19f92 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -49,20 +49,20 @@ func _run() -> void: assert(not hotbar.visible) var entry_buttons: Dictionary = logbook.get("_entry_buttons") - assert(entry_buttons.size() == 26) + assert(entry_buttons.size() == 108) await _capture_if_requested("-unknown") logbook.call( "_select_category", WaterType.Type.FRESH_WATER ) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 26) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 108) await _capture_if_requested("-fresh") logbook.call("_select_category", WaterType.Type.SALT_WATER) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 34) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 202) logbook.call("_select_category", WaterType.Type.FRESH_WATER) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 26) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 108) player.collection_log.mark_discovered(&"bluegill") await process_frame logbook.call("_select_entry", &"bluegill", &"bluegill") @@ -110,11 +110,24 @@ func _validate_save_round_trip( assert(catalog != null) assert(catalog.candidates.size() == 316) var active_species := LogbookCatalog.ordered_species(catalog.candidates) - assert(active_species.size() == 63) - # This test intentionally round-trips one catch for every active species. - # Give the fixture enough combined capacity, then bypass per-catch carried - # inventory admission while constructing it. The inventory layout still - # reconciles and persists the resulting placements. + assert(active_species.size() == 313) + # Keep this runtime save fixture within maximum player capacity while the + # focused catalog validation exercises every active species individually. + var round_trip_species: Array[FishData] = [] + for fish: FishData in active_species: + if round_trip_species.size() >= 59: + break + round_trip_species.append(fish) + for fish_id: StringName in [ + &"catfish_blue", + &"catfish_channel", + &"catfish_flathead", + &"catfish_white", + ]: + var catfish: FishData = catalog.get_fish_by_id(fish_id) + if catfish not in round_trip_species: + round_trip_species.append(catfish) + assert(round_trip_species.size() == 63) player.inventory_layout.restore_backpack_level( PlayerInventoryLayout.MAX_BACKPACK_LEVEL ) @@ -126,7 +139,7 @@ func _validate_save_round_trip( assert(player.cooler_capacity.get_level() == PlayerCoolerCapacity.MAX_LEVEL) player.inventory.set_inventory_layout(null) for index: int in 4: - _add_test_catch(player, active_species[index]) + _add_test_catch(player, round_trip_species[index]) assert(save_manager.save_now()) var no_catches: Array[FishCatch] = [] var no_discoveries: Array[StringName] = [] @@ -135,18 +148,21 @@ func _validate_save_round_trip( assert(save_manager.load_player_data()) assert(player.inventory.get_all_catches().size() == 4) for index: int in 4: - var original_fish: FishData = active_species[index] + var original_fish: FishData = round_trip_species[index] assert(player.inventory.get_count(original_fish.id) == 1) assert(player.collection_log.has_discovered(original_fish.id)) - for index: int in range(4, active_species.size()): - _add_test_catch(player, active_species[index]) + for index: int in range(4, round_trip_species.size()): + _add_test_catch(player, round_trip_species[index]) assert(save_manager.save_now()) assert(player.inventory.replace_all_catches(no_catches, 1)) assert(player.collection_log.replace_discovered_ids(no_discoveries)) assert(save_manager.load_player_data()) - assert(player.inventory.get_all_catches().size() == 63) - for fish: FishData in active_species: + assert( + player.inventory.get_all_catches().size() + == round_trip_species.size() + ) + for fish: FishData in round_trip_species: assert(player.inventory.get_count(fish.id) == 1) assert(player.collection_log.has_discovered(fish.id)) for fish_id: StringName in [ diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 7eca559..a621a8a 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -43,7 +43,7 @@ func _run() -> void: func _validate_catalog() -> void: assert(CatalogResource.candidates.size() == 316) var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates) - assert(ordered.size() == 63) + assert(ordered.size() == 313) var previous_number: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in CatalogResource.candidates: @@ -171,7 +171,7 @@ func _validate_page() -> void: var entries: Dictionary = page.get("_entry_buttons") assert( entries.size() - == (26 if category == LogbookCatalog.Category.FRESH_WATER else 34) + == (108 if category == LogbookCatalog.Category.FRESH_WATER else 202) ) for fish: FishDataType in LogbookCatalog.ordered_species( CatalogResource.candidates @@ -222,7 +222,7 @@ func _validate_page() -> void: candidate.display_name ) ) - assert(silhouette_count == 60) + assert(silhouette_count == 310) page.call("_select_category", LogbookCatalog.Category.SHELLFISH) await create_timer(0.25).timeout From 7f5a522ae57ea3b910a6387a6dc5d29d758ff6e0 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 12:01:20 -0400 Subject: [PATCH 09/38] Add finished creature artwork --- art/exported/creatures/101.png | Bin 0 -> 6688 bytes art/exported/creatures/101.png.import | 42 ++++ art/exported/creatures/102.png | Bin 0 -> 8589 bytes art/exported/creatures/102.png.import | 42 ++++ art/exported/creatures/103.png | Bin 0 -> 2764 bytes art/exported/creatures/103.png.import | 42 ++++ art/exported/creatures/104.png | Bin 0 -> 7444 bytes art/exported/creatures/104.png.import | 42 ++++ art/exported/creatures/105.png | Bin 0 -> 2799 bytes art/exported/creatures/105.png.import | 42 ++++ art/exported/creatures/106.png | Bin 0 -> 3777 bytes art/exported/creatures/106.png.import | 42 ++++ art/exported/creatures/107.png | Bin 0 -> 2873 bytes art/exported/creatures/107.png.import | 42 ++++ art/exported/creatures/3906.png | Bin 0 -> 2742 bytes art/exported/creatures/3906.png.import | 42 ++++ art/exported/creatures/4401.png | Bin 0 -> 2936 bytes art/exported/creatures/4401.png.import | 42 ++++ art/exported/creatures/4701.png | Bin 0 -> 3770 bytes art/exported/creatures/4701.png.import | 42 ++++ art/exported/creatures/5702.png | Bin 0 -> 1760 bytes art/exported/creatures/5702.png.import | 42 ++++ art/exported/creatures/5901.png | Bin 0 -> 1940 bytes art/exported/creatures/5901.png.import | 42 ++++ art/exported/creatures/6103.png | Bin 0 -> 5998 bytes art/exported/creatures/6103.png.import | 42 ++++ art/exported/creatures/6804.png | Bin 0 -> 4219 bytes art/exported/creatures/6804.png.import | 42 ++++ art/exported/creatures/6905.png | Bin 0 -> 6838 bytes art/exported/creatures/6905.png.import | 42 ++++ art/exported/creatures/7403.png | Bin 0 -> 3144 bytes art/exported/creatures/7403.png.import | 42 ++++ art/exported/creatures/7508.png | Bin 0 -> 2019 bytes art/exported/creatures/7508.png.import | 42 ++++ docs/ATTRIBUTION.md | 36 +-- fish/species/betta_siamese/betta_siamese.tres | 2 +- fish/species/bowfin/bowfin.tres | 2 +- .../chromis_blue_green.tres | 2 +- .../clownfish_ocellaris.tres | 2 +- fish/species/crab_brown/crab_brown.tres | 2 +- fish/species/gar_longnose/gar_longnose.tres | 2 +- fish/species/gar_spotted/gar_spotted.tres | 2 +- fish/species/hogfish/hogfish.tres | 2 +- fish/species/ladyfish/ladyfish.tres | 2 +- .../lungfish_west_african.tres | 2 +- fish/species/mahi_mahi/mahi_mahi.tres | 2 +- .../mullet_striped/mullet_striped.tres | 2 +- fish/species/paddlefish/paddlefish.tres | 2 +- .../salmon_atlantic/salmon_atlantic.tres | 2 +- fish/species/sheepshead/sheepshead.tres | 2 +- fish/species/sturgeon_lake/sturgeon_lake.tres | 2 +- .../sturgeon_shovelnose.tres | 2 +- tests/fish_catalog_content_validation.gd | 34 ++- tools/art/export_creature_art.py | 230 ++++++++++++++++++ 54 files changed, 1013 insertions(+), 35 deletions(-) create mode 100644 art/exported/creatures/101.png create mode 100644 art/exported/creatures/101.png.import create mode 100644 art/exported/creatures/102.png create mode 100644 art/exported/creatures/102.png.import create mode 100644 art/exported/creatures/103.png create mode 100644 art/exported/creatures/103.png.import create mode 100644 art/exported/creatures/104.png create mode 100644 art/exported/creatures/104.png.import create mode 100644 art/exported/creatures/105.png create mode 100644 art/exported/creatures/105.png.import create mode 100644 art/exported/creatures/106.png create mode 100644 art/exported/creatures/106.png.import create mode 100644 art/exported/creatures/107.png create mode 100644 art/exported/creatures/107.png.import create mode 100644 art/exported/creatures/3906.png create mode 100644 art/exported/creatures/3906.png.import create mode 100644 art/exported/creatures/4401.png create mode 100644 art/exported/creatures/4401.png.import create mode 100644 art/exported/creatures/4701.png create mode 100644 art/exported/creatures/4701.png.import create mode 100644 art/exported/creatures/5702.png create mode 100644 art/exported/creatures/5702.png.import create mode 100644 art/exported/creatures/5901.png create mode 100644 art/exported/creatures/5901.png.import create mode 100644 art/exported/creatures/6103.png create mode 100644 art/exported/creatures/6103.png.import create mode 100644 art/exported/creatures/6804.png create mode 100644 art/exported/creatures/6804.png.import create mode 100644 art/exported/creatures/6905.png create mode 100644 art/exported/creatures/6905.png.import create mode 100644 art/exported/creatures/7403.png create mode 100644 art/exported/creatures/7403.png.import create mode 100644 art/exported/creatures/7508.png create mode 100644 art/exported/creatures/7508.png.import create mode 100644 tools/art/export_creature_art.py diff --git a/art/exported/creatures/101.png b/art/exported/creatures/101.png new file mode 100644 index 0000000000000000000000000000000000000000..e6e431c4e13d5ce8ee18de9eeab71b38914e5e14 GIT binary patch literal 6688 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 WD@hOa1pmHNfSHlCVa*-axc>otn_ZFs literal 0 HcmV?d00001 diff --git a/art/exported/creatures/103.png.import b/art/exported/creatures/103.png.import new file mode 100644 index 0000000..2972cde --- /dev/null +++ b/art/exported/creatures/103.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfdsg4tevv0jm" +path.s3tc="res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.s3tc.ctex" +path.etc2="res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/103.png" +dest_files=["res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.s3tc.ctex", "res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/104.png b/art/exported/creatures/104.png new file mode 100644 index 0000000000000000000000000000000000000000..09eec3c33e1b572627fb7755a6b28f0182b67312 GIT binary patch literal 7444 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/104.png.import b/art/exported/creatures/104.png.import new file mode 100644 index 0000000..01a9393 --- /dev/null +++ b/art/exported/creatures/104.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pgm364g5qlgs" +path.s3tc="res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.s3tc.ctex" +path.etc2="res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/104.png" +dest_files=["res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.s3tc.ctex", "res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/105.png b/art/exported/creatures/105.png new file mode 100644 index 0000000000000000000000000000000000000000..3ba1000ed6a4247eb2659d96327c55a39e68f614 GIT binary patch literal 2799 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 H?g{?`DnM*Y literal 0 HcmV?d00001 diff --git a/art/exported/creatures/105.png.import b/art/exported/creatures/105.png.import new file mode 100644 index 0000000..de797a7 --- /dev/null +++ b/art/exported/creatures/105.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwanip2j561n7" +path.s3tc="res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.s3tc.ctex" +path.etc2="res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/105.png" +dest_files=["res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.s3tc.ctex", "res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/106.png b/art/exported/creatures/106.png new file mode 100644 index 0000000000000000000000000000000000000000..61285cbcfb617d3e4526c1b0c8eeba1e0db0e2bd GIT binary patch literal 3777 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 F{ttQRICcO4 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/106.png.import b/art/exported/creatures/106.png.import new file mode 100644 index 0000000..3032c38 --- /dev/null +++ b/art/exported/creatures/106.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr7tc11ievso5" +path.s3tc="res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.s3tc.ctex" +path.etc2="res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/106.png" +dest_files=["res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.s3tc.ctex", "res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/107.png b/art/exported/creatures/107.png new file mode 100644 index 0000000000000000000000000000000000000000..c843bbe1f4572dcf00a3e31e422fdee3768e1c48 GIT binary patch literal 2873 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 N^GjAHRVde}{{l7{NXq~K literal 0 HcmV?d00001 diff --git a/art/exported/creatures/107.png.import b/art/exported/creatures/107.png.import new file mode 100644 index 0000000..c4d28e8 --- /dev/null +++ b/art/exported/creatures/107.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8rf4tnxx8mov" +path.s3tc="res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.s3tc.ctex" +path.etc2="res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/107.png" +dest_files=["res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.s3tc.ctex", "res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/3906.png b/art/exported/creatures/3906.png new file mode 100644 index 0000000000000000000000000000000000000000..6cc1d1b359f7ef7f8bd2c6b76fa426b72eb91558 GIT binary patch literal 2742 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 wJ%g{Tr_ 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/4401.png.import b/art/exported/creatures/4401.png.import new file mode 100644 index 0000000..809a146 --- /dev/null +++ b/art/exported/creatures/4401.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7dbcbicxwvlo" +path.s3tc="res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.s3tc.ctex" +path.etc2="res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/4401.png" +dest_files=["res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.s3tc.ctex", "res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/4701.png b/art/exported/creatures/4701.png new file mode 100644 index 0000000000000000000000000000000000000000..afd6a853fd29bca87048640a75ea6109ea855820 GIT binary patch literal 3770 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/4701.png.import b/art/exported/creatures/4701.png.import new file mode 100644 index 0000000..5f4ed32 --- /dev/null +++ b/art/exported/creatures/4701.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://do6hcv3omivv5" +path.s3tc="res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.s3tc.ctex" +path.etc2="res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/4701.png" +dest_files=["res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.s3tc.ctex", "res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/5702.png b/art/exported/creatures/5702.png new file mode 100644 index 0000000000000000000000000000000000000000..2da58085c4f18faaec6247554c7ac0cef8264627 GIT binary patch literal 1760 zcmV<61|Ru}P)q8OD_Du_l~ zBUJyO1fxVrOej$>p-~DLB0)fj;ub75MSDVzef!4zW;<`+zCC-9roK-yeaFt5H{ajP z?>E1B1x!p#OiWBnOiWBnOiWBn`U%4U@G#{83~+dyi8utOB~h5k%)|p!J42GYi<0-R(p`})2mW=ngik} z?->lyBUn!xAhAB5f?10uMXExbv~->q19(Zim@(`xIe-b{ry_5`JwXqoJ8h^~?hI*Y z=I71u@!r*7>&e51%a3w>=W7e?sP;IJozn)+ffb)|RMjffLDB8KyGoFf;u3X9xRKMV zQlh@^$A<&IPe0TwPkB!aVL=Z@e&w2_LiF~Ti8!-51w+Otm^Qx!o~sTF$x-mbYrY^+ zR+cD)hZCP*?b`dPU4y~M5WDV(0m@3t57NlX>G)i}icy(u@LWg~*IC(ZXlrKLIdwF_ z0zAKYZNxS^eBJ2@P+1UUgw36XNbXYuOc+0PH63a6X{2e|4;`z(>GYxQstw8MDw^x8 zsQV+}VU;WW9nD`lC8WKoTk-Q#h@6mMm~g^NE& zt>Z58M*aP-rcjq4@pdcVD`BD#h0`3^F~dRP>GE>8wpgSHFRe+@okU*0=76dOEk7a9 z3(4xSh~@@TOte`b4J6x`dH+PHstTu#p;h79)GsKu;_X>ZeVY6z zE?+K-Bp&g-h0G@i5>I@8>>~CS?GxuN2k_lBMf5qEF)hX$`ZSirO23XptR$wZ#99`= z{kkgT-kBZKu(9xIEYGoHL?IWhKyhl{Zwih+F5L8&m_e|i>7R&#{;o~Hog!Ldv@O?x z`O9)~;+wPDY^70l7u^=;N3I6PkXOFqGX71BX@E3WhB!8PIb8N&muYYBcH$T}BW0Vl_#2|ly8Wx-JM*fm1V)JscCbS{9y92d4H&E4pqbe$3 zi(`W19cqT%ZVxe>Ty`S`EIGpIqkI@%6uJ^+WQF!uzg%|`Vp$gMfy!g&`JBa*MI4|E zFEcZc;6z&ni6z-~yfGtuE+C(!t#DduI_ew#4eR@3(iuj&fJ=2iO*0P~;hF{@=VdMo zURGKz3@442>h>WaiN`IT2s>tWn_cTLSN+1{5l>WjyrSN+Bb1K&;fA%Ja~>jI{`Sce z3=8k&NKEF~umJFW_S}K}Uuv$=SZjsnSDPkUCqD8nELtiY<)b7a zzS0jocG%? zFMko82U?Vd0;B`!6z{;cZ4!wqRjDHbsz?utMg&qasT>E zxKdKl)ZD0DQ>rbqO(L3`$sAvgZkl&X8$-|GTV`9ZWwt+ak((i>Q$=g5Kg29N+=6Cr zR)ewjsON3zY-yA2Mx)X>0}VdNJy^P)k=nmNN7Xwy+*u6L<8!^+$>YWTD(;y;01-}S1(T69p4f+A8h9F4S@ftAR{|XJbe~jo6 z!f4f~e8$GD%ztfR;3o@Gc-tQTY5|}Iy#z!5f`2eC3HezJRx8Kt+g2^O`g{x{i#vL~ z-xl;A#FU;s%qlirNl7Y(xjVGL>K^aylR>kfpK|l29f^smwyJb4cfCGi8Tt=_q@4x6 zMD^3Y_9@0lpWU~wGchqSF)=YQF)=YQF)_jajsF0=A}aS51z-9A0000x literal 0 HcmV?d00001 diff --git a/art/exported/creatures/5702.png.import b/art/exported/creatures/5702.png.import new file mode 100644 index 0000000..527c367 --- /dev/null +++ b/art/exported/creatures/5702.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxwr7yv5h30s" +path.s3tc="res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.s3tc.ctex" +path.etc2="res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/5702.png" +dest_files=["res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.s3tc.ctex", "res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/5901.png b/art/exported/creatures/5901.png new file mode 100644 index 0000000000000000000000000000000000000000..a75039feaf883b458065c96d8f5165848b42e9d4 GIT binary patch literal 1940 zcmV;F2W$9=P)6GiVquKhfvTkeB#HkNdkL?a z8AUo822ddM8Xg&Zz-W;uT15TS{tDaa0v!3d7itS-=2HUJ-OV+$qQ3BpP8Mr zyXWqn{eE}P?w(Bm8yg!N8yg!N8yg#&u0!}kw}W?a{eq2~a7fu$vbMV$(G}#v>Aa+D zEGZ(<^6GFj>**W82+>Ns>6-68Tm;5^*Rv5KSako*rSdxMFQ0}q<$5t9!6YVRP#&4O zFIfEG%{z;Nj5v+|BQ}Q@%={s;DKuBmYY}EtgbHUmtMRM@W$OX-FjCKb1|+2dSFUKa za;^nxu1^J8T3VOBPz9_`Yn{irPkGC|PR*L0o##Hhw&0a{>7so9-NPn7_sK7&&8RAz z{qs3kRJ|4}lCs;`ei2-Tq3uiqP-3K3G)6Bm^gE#6MS#=12cV$`@NFXSz0=<;0lXOS zQYYVic&#gQRCe%H-V7LFMT0)ST=iRKp>U{*A`4GDf_71H50L8w)FzpX9g}{%DJj{5 z(yin8qL+3o9yRsC;lGw7?WZu zKra<)`D`|{WxBV)ojEt0I2J{GDS#;vhrGGR50(RC($OX|Qh~Aq;qQMu2w0K~B>nUh zF8(tCmulkucTNDfGeil^kl}X&)t*{hI@33jP8OwkqLuiPEa1>~V3`-V^)xWRgDxd! z9`L8vf!7WI+vY}V@6Q1IO~%MjGS-4dW=TB#YGEvfU)HiJd$Y*B)>(X%0Jw&lb7*)~ zn9?)?Dvi|Ma)5D7Ja?$@wJ6aH;9>HAWW*N5O7X<00dZ3NowZFs4b6*P%tVjY0r^#W zPI=IVNE1)LdBv-Vf8iOKmA|E8y_Hptg~=EAqX&5-&tqxtIxQz774frXl8wX&-*>P3$#hHy2;}q@)%Bjh`jes0$04jj)rH#M06Ki2l|EDt%QV!$#2~A>O$nd ze*)vPerM^S&NtQoKL1|73+N#jZW7#Dx30S7so%c--X<$QWfAU)Wn0To*CSCTjLFc- zhKAxFmup{x?I5Em7Gs;Vl_!|GbN|6x3uoZcg|ooTNkHKmp#MNXRRBc|Jb&DaW!Seo zqa*tOLiFtI#D$Jqhem7 zwo=e%Zj|Mc%nQfzDR)6t1Lp|olb_iCKvvFiiu-&jE0~%36oBsm#QXRD&4HHFS%i<; zAs|H9p~zqjVK$+;sS&eA{#eHUuA_}gIdc4JoR&GVTUO01jUm3nWQH5t$ z7Rwt)_m{!hw?CH8nTUFCV4hg9`y}$WerfcwIfN6ha3+OQAbvA`m?oZUCn>SL;u`ed zl6cOQaI-E=2FxGcjwFm*G{7)n+JaSBGGi<}HQKRU+5?Xu=Z*Klxq_Z?eISI8y-&gQ z*uC0v8U22#r)TfoNJ=ZM`DA9+;Kj8<(7S}$45^kBh;2=D2^DU-alE2m+!v+6&RA-T3jTu zMkS)DA)q%HWnJ;Q#c{)YXRA-e6)l)P(=_?vni`P*&Y>M7p01SfucH#zNHAoHi!Sa8!TucPyAPwk^-6&%)c#iLiE*ktR^YYy`11Gj%F0hXfgN4chX< z{dL%75>Iiu3gJUaT83dr1ZbAN*)&uCAmZC=03*S<#pQHfDAA3WxBA_3*W+WP8H6Jy zMtynHu97#)D&JU3R#>U3R#>NKS ag#Q7^p7{8#1%TE70000 zczv(;FL*zk^L#rW&vRe*7wxwyWW;pD004kYO;uUvUk3f}5aR#aeR7tg006DHnlebw zKl?C;AcIQ3Y4DL+>a9h@ixd`#B9?OJo}l(y_NdBO;?fNNKVGxq)zz4p%g(*(wz@yM zi>5vzm69=egxuVDR_}AU@k!Y&6+}rT%AjrS<*R;fvsGRCs6Ns@_R|MUbalD0zA0>? zbuSxwR7I!2nyAYD?2GdME2Maxplb>$R_3Loe4}-AL32H)mK!bf%NR3`sv#^gZX9#L z6Nbk)?K%B7c5Bvjv>xoGm{d^16F;ImA-BQ1)P`p{EkO_&gG~r7h3DrWsIyd_y8v*Xugh(=CPb_y@ zc-b>*%U`B&OC=i@x%o z5IK9KS@XZ2lUa=8>6LywaNK|7G$hqwZ+5xZuskTKKf51cZlh2mD465oMnT4}u8;G@ zIP!KDa7>$f($%xAl{N#TDGX#+HSuHRi>sNZMn68RCkPRs(+whSn*D~;tmMdgST?(= zgaTHS7^Q@5rUI+OVE3P`D1(mR>ks5T6jqjpm59k4&M$m_qFK^Xv7V1Khuchd37@r1h=!5y3ne9D z@&}%#fT&Fgd7eOUK@0OIHaZH|CR$S<6|zg0hurs9l=IZKx`c_518zk^WixSd&whb%F?FF5k#%=LRZYoe}D z8`&S3c4xB^lN)@hp z#=2wOgO8iD=iYRndalvOw=k6+IhMN`t~A{hi%Ulg=7}a0bs|;EI>50;Z+Mf=sM+$8 z{%qjLxU`rL8KBY-5kLrS<1f)_Lp(KXlGfbMMUbq&03|OceZ$fyNUd`349i2jT|voS zT6Q3N7*mY(=PgV*Gez6Kna{kG6Y1-X2yi1@3cD)RCD+u{Ho;$K>b=qj9H}hlNuvbO z0Z`86ceL?^%QX|hEyv+|<^!k+8InHix&98~Uuux2zo@pIqKyw4(i(z@*J=-O9WAn) z%)CVPY?7cawVP;q8Ubc}U*Bb%8zNbvf&{P}~NTtE+{quTdQTBr=*H};%twwU_SiI6ZrRS%Nf>aPH3;f0+Ilk8p=aOHGRw``rIWDBb ztj-t?RDH?9G}tv8MApIiCSLxU5zbVERVT)U|-f1WWk620gn-C?w;nmht znC4&UD#CVt$z_UnGHoo5{H}LeNHkVf^s&@PMSzF)*B|sDGq~3JEQ?sVjQ_val8Bb-f0KjZpW9PVHQn z&TL-hg)1!7b!YipOzoM;xl@xGY7n*HJtgW<)G2+K=S?2cF}*;>*!PzeJC?mGSHk*~ zrb#iWilqM4Xv1%d9+vr3&S^U^Hh402K;n5IpKB}q&Fp>D=%^P3?-~py3oNI)C+X8L z(nzYZ64DrAS5V+D?F(H;d)_C|&S;xg^Z==q&onY31Qfuhnce~pEYF*a8_K`II^ShV%%JQCCoOp5gh77O1LkoJ_A0l~`Ihf1INQ^}Vu;a>VoVJ%LBM!{uDKc-!9&X==yZHlU z7p|H&WJM(8BO8@hlRjqm%u|ZsjRkSkW3HIjv!?gaJksQJ0C!VD4nl2FdMto!c-a?^L%2a$?l<*yf-Q&8A z_vsi#nEN74Fhf;>G%{o73r%K5moGt@+%>>}pT}M{D7~5M^ka@GS9tV(TS!e3FCUXp z-d2&~#0+>zIOk~7Y??+lbqKyMP18lHsW0M!r*IY5^+P2HQ8_Zz9(0hChi`puQx~myh z8%ytyYlBt3zBeqC3|_b8Kbc31_Gv$@d1H(#6^X4ZXgFu>AtmCiDm>^7lYt~CuP}779}P@HbQ2Kx-5 zrBtgo%ndKbtKi^_Utt86OJJ-897^h}CL5}RxoO2W!u71C2rv=;-RrsE1Z&&!zpHc! z+7yd|wAfW2zG{xu;<3J@)o*m+W4YJ@t9o*)Zfo<@v%hI00GYmiQZF+uWh!b=yt&!T zyWoqCsU2cC!KdsfpmWJdJH3YY7E$z}oF|b3!OlU(_DiT0hH>0!T`2SfN z)9-#zt3~O4WpFhij42cTaqV%P_j@?OJN^ZU`jNZ(G%bO6nh?Aw3uIyi~&OaK?m|8 z%Ic>t*5~DY?tCv-;%Ci9zP^dRo?ydQdoN6U06!i4@;5ExhWj3EG%A#av(`*tlwF44 zV7WT@o;eJokRmHe>7FanLpf91=8Y{ncUj&%%|m6Yd$3!TcBLb4Mp6QkW!QS@bhM>E z`2M$|pB7e{TeRg_WSn2~e#%zc-bM*qduzR#QG%iC9;vdQVld-PfYW7JYT;MptOBX{ z=%6rdj~dKZ?xGLT4|48U^C`E!I;SF;O$)hvEBeKL^~|f_`EqfTz^IdxC+b6h77cxk zWOZL_kUA%P^ba=S2b?Tt1B;EY;C)~f&dzS@qMd0?6lGM|zRi^|`fz6?&VcGf zJjJpwX=IM3*9RA3{XB>S%9rWu1M9bTM@!@xN;EZ)tX3@SzAzpl;>$Q0<%ZKe??-}Iy!ocI6DsO%uw~mF(6u5~ zqk=dD8cRcSv{e;93VcT2J)aUO?8t>4zxS5zMakP@zn%33eZq-Ln6T>ILrQ?+S(+ZW zx1VIhOdw!@*^=n3iFQ(HO^a{IGHr+5)e{cdDej(Oj}9tGzy+1-ihS0;>Q2B@Wk0BG zwQD_4=aJV+l#OXp^9TtlgANq>>8eiGX_z;TYyvzR-u}|N;N8a=o%uT1sCt#?AXZj* zy=Ly3L~U3v8P1aK5&|-?^VKSOYU23&n(@Qc*U|+ma&!V8Gvs^MR+cU*P4EX{$Bx&B zy9uSA(cak#w!h4d*#i1WREz4yIhf)2CnA|atFI}gJ%29lFD^H0+(3VTQq_`gJ{MFn z$4088v-ab=xiDOkw!QfEAfwD`_1r=IzR^ z3-1fqi7n?CiU)LDE%nE2lpjLl4iS+P)lj~o<@-|A5cFog~wv5G|*%_ z_BWija?tirj$Qkl|^UnEo4e5n(9;iIs67&ooS~gIny&7%Wvxe^R_0I7nQ#=0yGc zoZ`vvG2+SXk(;fl`_FDKCAltzzQec}j^xTUpVu(@5o}|1Ja-3I@hgTmwu{<F-dZXAVlU^Uog&ASa95(Ge&6wp8*=zE3%V`MF3`d%0eBV(k8L1-i6Cr3Sid(Z6;454LDnObKl0IuykZrXBT& z@f#CReRjR}BYo5TG^?4wq$^s$Vk3ipGpUA8-2) z%P-(X$1#jEdF(3oyOcp*X3)siARm1MsOwwU0$j*@4Y6-s!RMUeF7SC*_AV07p>iHJ zGU{br^k3S>UvJPPsV=x&5S^`vRRCLQB}lj0n39ltfHS_VWBI-prq79+`DN^y0un!J zeHS`AFHv2zLWJ>t9&%=S(=+XBvvw!jKfj~2cqytw!uDJ)VRs4`=b#E=_TueWS`}or z{v0c8OwKOzNJfgf$T8gA9KZkYeTKExV)K_k%*%O_!6{M0qVHyF zpYR&til`PzaHuPAEB2cE?*(FJ6B3r8gy}W*)ZeLs_;s_bs7ciMnk{PR@)oS$K&s`wr^FHr&iG+l;>R+YII- zS-g#lKkPjo+_OaM8=uSy;@C4yG5Ez6eVI1HG%D||&f(djd@g7MUTF4vd=P|QR2r*u z2c;es04WV+f*1!16l#JaRfeEo<8`y4rV`hNzJ&*r$K&rpw80?+0N`vK3Bm%4i3SkL zASPMIZF%%%F2$QM5%1ou1jHR2n&7oKX$ZbHuTLG2(2Zb<_98z(c)Ir0P42C>{=BaO z23u90OTg+E>G2kp)xI>2hcXJ>FQwog^T|{7Kp=>r+gmCRo`5&nQ5VXt&0Go(xg(#5 zm5#Q|>6p{RWBvw&0J39>LPGIxPv<>b1gWr+$W{_4M3tWCp&T{3^ZXO_ zlgE*4x9?$4>JR5EQ8K|W*3?1Rc-n*Yc$aRz5{v2n}b*%sm+u;%)$dCNvy zK~|#y3t9k}iO*uuQ2xs%MMH_zA+ej0@4w+o9FiP0W41?QV35Tv2~`T^GWT$B|LfSF zFh*QU75mXiWH;xt%M~T~RU()LEkb}t#23!D`zhiT#MKS$7gjeQ!T`bwi_Dc`M@SNc zv%R(6dIC&j+unW=Rbtp{D*I3nEVWt92I4gWnwO(5q&0HdBqWEX>V$yxqYNAJ+! z+5ox!PtmZ5>7C2&)`q^x!eWH7Bgd11yJ@}IrOPV~=f9`jHo2S7yT1onh;e@uG&wEW zS*wYcGrfMNRSez)JkCv9oZiW~s&0WVWnPzG+C%*S16VEwzp@d}MLn&53|G(xPH3wu zE(+4^Ckn;7SQ^F@7-+?`{4|EmkJA}ncY9U}Cp6-Ho}8@MrZ;j3>NfXcoOBRMfi+%w zkq9^NN0FEK4kUb81$bEZDx2Cl`9z)DO+;jDv71W(6CPDnKJFNMIiyPl3lOR!rbqLY zr;f=pgQbrU5iFGZsQ8!Cq?FPJVz^B1o8~u26>oNi5Q;^O$8oS&<-Z?pY%AI3yn9X; zSi3EMDz^8{3G4UQ+VFT zSo}e1o|7#8jg(;VPqg!>JlP}VF3OJZZ#b;TZ<^D4JNhj04-hNU?oFWj_l|Qo=yr6C oPkKlp<^QLk|IbF>L6rCa(lEM)bPQ0C28jWZ5()xJ=Z185gS2$l z>-#r+pXc0je!k~E=l*bFbhK0`NLfe$004!Ws-oUM4gUWjCj7^pa+ZAn05wcaQNh6X z^ZsWNUjxJMn1|+fA=EHj&}6V`m#&VyNtggvxTcXVy>IZEFsU~FVjdR{dtMwEFHR2M z2c)P>ZD16ltH@zV_Wki*G&MFSA;Qx?w@T}xd7CoZ^XY;~dZ~F!)_e;x&10xk->`V+ zGl0BP_D2uIZED39 z!V}^1H@_~nIQ=)$zrkf^99?;ELWIJZ9{L1;IC zpSCuWpv3V`3Z()#CjcS!MDr0@O*d-Q3a@3+6C<^+f^$lslDIRHDqOk1TV}D$9#3>CD)!=tv8FqeOYI#(dc)YP$rDtM zqKI~$q7}OBMVCSn)67HHW4{1Td(J%f?pEaE?Ph;B9qX?fClcoUKA8B)P&8o5>j?PX z-c2adGMgIRg=gLIdxHVsYBVxKGqvR@%@nU(_0-YJG>%1+zxu_`pGsagCT6R83kxuxQ|^l z&^a>#Uf@j}(d*QGj@(2D%Ce_y#12W2SxVOK3SLcT^qJ%zjXOL(rQskZ^Ps<(P-5qj z+5F>7SuVuQ)<~GO@Mi!WPq>^!f%w9we()X;c-_^oqEkNM0s}v{TD2;)S&v~9{AJTo zv>zjxo)EqRV_$eh{-7$OBud=g?A-f~Z$sIZFs+7EBKHytHX;$gI&c(FnkhsEOI)Z|Q;#Q7{L zr1pPDN(uhjgJMEHwh|`wh8A5D2j0z?uahZjnTE33&$#djOH@%i%3TAQGk#W^I&-e8 zw}yt++KmX%Cem1L%^y8G{d+GX8v7wwZ#zrE<=br6-RSq&1dlO5Qa5SR2+FE}88|EP z+VaQDhPn=aicT5BaESp7nPgF#Ov=&3E#h!YVW*#bP4sW0jJBNSN6H$zdLd0YnUU-l zPrxr;;NlUTF_^B!U%hXeCt$adZ+*rj$4xnATh*Y+1X1wPCHi z08Wh|k7{oBDUb0A8F?IOfp^Y$b^CIh=e!P24Rai+n)RiSJ72Nz1OU^35x04NGH5Mf z*dmTi?3LWbXg9xPdOG(GZ{9YN9xP&JDut{H>ix~(+Jtiw&eR%7~ zSdV(iH_;L@998I|Q7dxZyJ8hR@dGZ>pMFwTAR)6occTyoQfe0@Ivw3mYt|}PRt&QU zpBN+AE?O)x;#E9IleHtTcs`S7kcg?k2Nhv-KPjIYY&L%tXZ;FfMa2WrG)~?!p=C-Rfyhn!;ta*P#QJAXk&MB- zysp$)7EOP?l|~8%$B3m+)CP>==SU)UI41*ZmBa}uDXnet-lnA*MmOPxWmQjbkJ(mG z!&FQymR4d*%%P`s9`_JeF%*hCT^K=L1YIzcdqxsmNI>iglgAw{b|l+_W}=4uEwMvG zj2+We+Kh2bvwamoNc%N;QouraM|B?mLV&kImiPvl+8g*69aw>3zOHXHlYIibbfl!~ z<;(cA29MIMg-`l*Zj1DaN~t{{ER=6|Igwf*dx3z$^t@-Bc`+gwKUlOUDeU!scjx9C zYeED5q|XKIT~K(8xp^Jp-c&JHn78&(Ryaye%JN3jkd}cG^=U;+G75Z>(tPAJD+*SM zXagRdS9bCt?QvFNB(^mJ+tntH6=`(I=G83D4LQ1CapP+!OVq)|ZJb=^=IXM~OB;Qi z$>#-$!|)3+57wACrf~+3`+uKHW$r&d2%HOriKLyz|c?kQ`@e$99H>YGM(n_e%9^u6g{L{R59X4_E-cY-{8 z3hjag)fnr7_@Yr7s+pUt2<3UKKybS?O|H_5B9<>IRjvZcFq4cKY%f23%S(mCqq^Xs zvWwOpvUimY1Ru4y&1v2nmL2!0bp8g-wPK=m!-Op?*qv_O$%3FZ^|yDm&=04gq~Mkl zqjUKTK%>NKaTn~}tNIn9CP}aKuLOYze>0AK>aixYPQi*LC-bZF>3#2472X`t1oaxP zYA=y9^n2G=anZMTl}N{W$HiCIR8H6Hk0scbF+;0}g9U%lzel`<{!J!2p<|3M)(6*0 zD`)E*@R8QC5>S-ha&u&pSwR7yQM60OZLi+F*+?&-@X?zqxCyfxno5 zi4T9bD!eUen-sXhD?wR}AQ@i>1CT$07xL~hG)#0@Wdd#;MqPrJypfbgS?$zr-R0G2 z>|zl^wU`q_`AxI?zTX(C(6K_#(LWQTy=-8savHS9z!d*yPLyb&rs)u2LiDR&q}&j& zzG6Pplg5($-L;^H93fNpw|T~nwLs-tdo-q}HKc=cX>?veS`aAY3v5^Tb!@JTKBQ-$ z)5+gi|LI8sE_0czSO^Da6XsTCgf#_*y@oGNwTOKhlI{o093tSRu{}@E_hdr+VxbsT z4-D6nB7)gE&wj;0As%JCcUou&C7WEBNmY>9cAX(UY(Hth$9bK3=%W%HlKYd8H5NnI zb3vnDPDjbsonn4R2GF&;m`Hn?k4Sb6p)(iGaBlGDqS~Ih*X~Gk$K}Rfzm&ac?F--3 z0&QfAWhJ_wqoNho*>i%|oqi`bUReBcSpqKIa*d`U;18VnSz0_1TORJ{GFo;L_+^aO8Qv=bK(wS* zS^6Rc(j}N#*fo?j^8g7M*-=w(9|}*?MW*4mO=nfZ4zg*6dMlNbHBW(6^U1Ry804Dl6`N>JU;KFi@2R0B0kIbl;+W4vE7{b zoX}RlT;1N0crkc{y`5FWo2kxuCgKzAN~nQq@rV7xC^a}TbN|1Z8Ks@{o~w0DBHXD< z6(t>PCYynJfxdM1*4%E;-hw&$&nfQ;ogI!_&7GBZ94thHm(fiiW)hbC5}c~jn;9ia zk%HDdaqEK0wVG`Xr6gE@7R`hiGTp~rZK?xGAiwqPCirNMPHZMWHhf&5gvXG?s0>=( zkDdu<3AR8oZENtkt94CqsHyhH>Z8&Faz6zwj+L<)`%4D|L`!u<$_9v2- z12iiZV)~`)dH`y<5%9sRNytvrft+<559tqGo`?ctDv-OXM{J`!ErWvKiEQ_K7T~iR z+NrTJQI$#d3T9uNL)>i}md}Jiq#y7y8yUo}kI=-VqcMd^yOJ5-qn{7@y`Q^aV@UZ+ zMH&ISq#Gz;av(-8}wIZy=IF z6O3Bic@Z4I>%biR!jShw)$9INLj~ox7Flt5^J&}n@KN*pOZ$d#*%r?_JUE zaM+oQWO7D@;y@5tNk`_ftH^O1%<|?QEEOqw^xySfiWSe4K%#+<8zp?w{ z)8??!%Jg%pwZrC@h*L~<Q$-TFquu*bYogI?Pgih`K zZd-?`#E$Y-Kb6Llr0N?Y?Lw=ezsUTjdP4_5C}x7W9H?18XyfwMU%67%`_5e)MD($6 zxyEa$*L3+P5osoL@@u{L35gTmSKzS25BL9urJ)Y^s3IxWhgV*iKmVE0`(aCB?!2$Y zgNtR-N3>;zTTVNd?c+;EtTs^ckMji0UtbEa0tF5G)SwthU90SYjJiqZ^Fhm~G3iQX zKQOT{QBLT!QjkbbKqLE_Ot;Ox8^zo!8Jk?j!7NTF)DlBmPZJeIqnf>@^@w1am$h`v z{%A-@@qjZGkWk!dAR@ilnvCcai#b_@datZ`J(Qg~fO;g%0*su?Ch@c^d0s!Uzd;J7 z?n7AuyzZvRp$*%khd-e9iIz-}{|kQ7T}r&{XcmYhd{h3fQ~;Q-{oJa zwGRaU)r8`CO>X(cO~pQ$2X6x3xRT_#l9bxjox5{Ye~u^#W{e5ktwARR?oid#|5wTx z*f?Tibc^AK+JI`ZeYG~px?yyKuRW2NW%FycdTZo{#!-s}8c2Fl*j=6hN|28|Z>$k- ztQzbC=RcD5ThCSmXJ@+Xv-CbJ?cVSLQ$r#ODemyNt5!)fCfkU_fUhCL>+$V~px#!1 z@bAmjD{op1ML%`Dli;i@;W5)r)Qt_nv$ba)E+^D_J`wJ3B$0{=$-dT6W3ONo_jxqx zkr!HT5p~}wBXtB*5vH}gJix7Rz`^F3N}bi9u!B)+x}$wFDHa)5Jea=aV|jqp_Tm?m zyzVZFK=Nr(ac5gw{V6-PDcf<*%yEa@>BZih{%6NGF2@DL+fE-tTJO7)u(Y%Zg4{od zdEdtl(U8h|a|7Ee0K#KAJufRfuQz&+mi2n^S&0bBUMxD*%ha72w^s_B-}*3Gx^`2T z&zAF8gQt&J^-wIopFPtXZF==~S10S&Bly~thCvWvF{!C_ksG0u*+#nL$ z&R#H{+Xq=b9Ny{^3wTld97Lw=hLU@{Y_@VfcuO7@n@`4&KJIql9j8A!+lQb&h#eM( z${=w`&2zb*cd2~k|e zWEINAhHvygEs^#*mC?;GYt4gndU8G#KJrP+t$B-Ji4ps)ntHdVWAIW{%^Ta^vOcU8c@z&@Lbg z#$Lj~ECvRk**!`QD=3kT=wk&{q15k{d)|_hEuwizGuGn{!=CWG_^$rK6hW&GMAvVz zwV509=-j3M3ib1vnvffFOl30P5IM;MG^hf9J3ZN*_K%Je==Gcy8Dq*3FJSC}HSQAJ z{$U=yW8A)P54FU>SwX%^``VA>=WTZPMAeuu1pYiOR>M0c2Frc9h6tJWw7S3lioas= zBMf;Jm^<;w=fbswp8J4})arAllf3YbjzOS0kA&vq!B(Ew+Ikg`R4Rmfh(qWJb>hv1 zWRa^Db0ffgt9ZWGs4v^;Z_-~SW(z~Nkk&Y`$E8qg++6@LX*!V4G&|C@k%%&*fz-sr zWU1L5HF6Xy9X^LKrur5V6N=Zn5%O!7&i0g&WGOcBh33x5lU-^w#?(bU}DfWcT%rvkV!}BOVjs1PTKc&Yy zqKwJOwlGJJ9psMMupHv6_Qbk5LHT65vri;TCs-lcWWerY3}!q?!!xB z5&-7l!`iFcG&=$@0T+ovN4uxN;i%gxPpzFdlsOqz9p)B6)d^zEZh|15Xgf1&5AIrH zSr_$sS)#7DRo1QI8DT~of_;V{xUH1Bp4M1M_1(g!;$pO}_gI`<+gJ6#mAgB%4vW3` zi{Z{=wr4|eN9I5so&^us;eGxOB z%nU!YW0lRRrYNABrhD7ug1XI-_F|!b0F}Yj2LxHO)`x&N;ogsCj1_D(Q`-X z7H-ebxzTCKgn<#|%nhPiUTgy-;lhIa;MYg|N6|Ft9R~nSjuVXVf*lPuPcIPR*rSj^ zs{@4ZtcV|NlNY|wySRuZEj`$*v#vFZA*QS|v+>zZLeg(CJk~BeK@wG-l9hvb@b~$p zx9@~CdGe@wf*eVWwK`;p*LWplcP`WcpD?ZQ>-S#?hJpLF{vp!PuzDjDl-vOri22S+ zaw6^(ii6g}Pw$4*e2g{+;Sd8c_}WpVd=O2@f-zlY{rz-g{x8J?UxY@TM~m(m>3Qj9 zCYW4EAU3D{cZlj=}=v3>SwWa$<%5cDDBZv7@lz&9c0X`q;JM%q3FT z>M+%8`r3pORo47z0t9~=bW6=723KJFOz!C$#(!%JXO^^W<8#G`aq!ezr9*Ihm))pj zXQQjVjaNG196>E$C2X!MxRnaRe%WfOg~~?Gj22G2>acbCi;V2j`%(oHH676I73EmM zO#i~X19(5k+o|)X%Ga_|(Ve-%eLhY%`9_-gS0$348ZQ(}owRFWpBo(rJdI zDras5P{!^U{%$46KPvPMqRF!yuAe@J(~6Z3~d;aCrPbYjrwwW2Yhc~ zXTXT}T2sya>A5z*{K=pHmb($TwGt7XS63K+STP;KJ>pWm$|;?PHu#(MkF_b4hgawP zy0VDi=De2mDUa`PFlBZyxs=_Y@3qEQ0|l6duSZLc{{xip(qdH1m`?mfbf3h;umio- zl1Qw)`DKT~jqV=J6(jk@7RxIF-pzAijb`js_4PJeKkSt1jIu1Yx|KVX0@O zG|%$)MUR%M#7mdmEbml*fO*8Yj&4S_Ayz;_FhSfEQyiCdyef9>YTn0KmvNSR(?Ov_ z<&&d(QK99z5Jdir3hArz9K5CHM{v5#Ra{a}c9QUSJ>W`n?3c-M-F0jd&DmqhUyR+7 zhFU0~n18EK_#a7?r=L7KA)sR9!D6edj;!DZvTlNQXd+iVQI5;1!vvd~?wHLTFHZ}swQFtOYihcceYos=b|umdX9saZMAx&P_m zr0!pJ?sE6L${fkI&l2@FvpRj(t6q-2lvy9ySYRMo(ZNv$sLRfgxHOZ)0zIz0KBHTl z(}an2QQOz;;x893^pWCP1OZ?}FTF45I4?cPxLSIZaU8~75k!0xgLJAjhqA~!4>4LG zTQs2mbW(Q4zcsdxmdA(hDi6g^$LG0OC{%QG5V9^rsnD0(o+NKOosSJ}*PJ*P5f|7? zB&h3fIhZ@$4XhBtu+GLzh}xJ}%2Z7I2#~1aCxzs`&==>Wj?R|VyU|9sBbm?`5fp7~ zG9&_f^2(ILc{bLY5OlhEPz*r9TXH#1FWAzHRh93zlUn{(gL?uiL)Pvtk1>S#sumfjl0i$}n@eT`XZbMJgR{bZAZMsqEfiRi>+&ZsSh9z2O;J~rxcl@O3pL9+ zx|~$G6+|iw7S-h9(|rEz=jpqhJP`yZauk}#S#1g@3{h1d)YW0{{kea&WS^u0e{q=S zn9l={4w<8cf=@$Hg;IOUyC(Lpy-dZvj!_X4=Y6D}E4G}M zL6qtd70E}2ww3-R%Ud-Stbd|O`CepBo8eQT#po}PHaNP%+{pjBof8BmT8Nw8Op$-hoy`B83QiV> z)j@P_2=!Zd*7jkBjEH6-zns_^l3s0_2QL@4(=gAO|2542lf_I$E$_pP^apKh1t zerB8<5@JbAo@pC(S94|M41^|jxeF!>xG@~oI3HY00KRnB5 zKfDC7XSn4`QSP^b&yuvEY3F?v4~6wE6o<&Qe(pjGPHTNsw{kcN_**{yg-uQ5lN`%2 z*mpwMlIR+UkpP`kR^|-YZH^yo2xAU&_uzdzb7#VMOlEm>yI>u<=2cNbW|A5OT++Hg zTJs56t9&`=yT}=oaSsh=)~YKc#h%adHhScjbAocoE&oHl6v9%rm-Vij`Or6Tj`hpb zNaIKl?pUKr&=wSsLeo=6EC^D;84Bnc$jME2sGQliM1+KTM#X2OjEjO4#gAM5Oip|= zwN%q4m0AjgqYOxj%a+csDk23pkBxtNZ2LNb=jq>qbH^mi5v7BVPP*2L$EXw#6LUBy z+5T-491`}SPfm$AJP$xeD9Vk6x#-O8_TKLe&)Wk!sxspGimQ7-Kw%J`WbKdxeju{2 zAd|(ULoxxDDeC?56g0 z#P5723bNBXoJE=h=^_MGs{ff|alQc85X&Ay|VuF4f_%h1|g88s2<^5mhZ)U_ICfUU^hH+ht_5FnM zd(!#w6-^#+91RD}*5H&=$8Egm=%kTx0TBKTFQ-dBg^r+6`d}M1Qfgj|El?sc?+#6V zsI|$YC97Y;%|~YTB;FyxYR9*|p|-R9`>xImrr&X7NJ%59A6MSWUrx9Zrj2k9NB8mR z8H(#18{WIlc3fjrNofmAdYyQv-O(TN5D}%-4X7rS0SIwR9C1bEQ$tkgCpO30H?$So zgvESITZ#Zp@{KwT6+$GC?NBiEA3%Q_FaPGGk1ReYK^3+XAxJ^?5$kW`Zhe6N@v4RLNSC*+yqX7Fes(n;<_Z}Q{ zka1T7Pit%jQj!dNTdM8feUc^?o^oW)3ruuUR*qkv4ypH>ssi%$I9&$B?hLCoZT*P( zj`W!CLRV8WiD$FCcrLUdBhSo-yA|5$b?U*`6e1s2KaJLP&~{qGc(l3rLL~Cse>Xhd zIN5Q9v9-S+dbA|hYL5-4*=TODF)Uu>%yZ={|0$aah{WpC{o$WbaY*ztX7L2KsM4gKb9?vPjz<}K1OGlx)CqhoM z0ZwEFSXBMBG@uM~xnaE9HT4;#`%nCC(&YOUf#?x=X0|FN44L}SWlgGYLRtiof-L2L zNp`)6AYa3|xKeS5A2Ajxy{SsRIia5bGdI&m@!Wg4>)1ozER@VRAlk6^J!-Sr>_q8U zQ8HkZoiOSTS>fEp^pZw{ttM;Hf*yffUZ>O6%D`%)<5Q*wFM^AsLB4g|1^jk_c6f8x z^{@%T7Gw0V6uP8eRaR!^kFd>Q_^`T+dB16`(IaUtS$g$*jCo?zhmMH|#BrdoN1o3c zeeLa}M7bQJFA#av5g%ndq_B?gfGltO;JMq+Edd)QUc(jtEgYTQ@O!?EILE^mA9e4f zTiWd$H%^6u)zpjb^Lft4!1U_c%R2jQ&{#%T7%rxYd^Mf(@(RN*jUe~T&E#w`826VQ z;`<23loD%*#c{|7xb z%cAu#SZ?5g;bFez_XXn+Xg8A#^yCPKI$^Kk%3m1Vl2+3G(nMsyRD~bpRwY)t@}79y z>q{c}G)wKKt94^OXWI$qNBay9s>8cU)d7_9rl#ezzBe~CS)$N|-8W4)^Y`6|`=7=L z*&D8qAShW09x>Wq|p>0hx{WhFiO_?Zc9{CWF-h3b0*{#Vwbi!KgI&Na^h?zAP41WIy;ha1NhiuG4WG4;^D~ z2Th+3N&h&k5Ep-ssID1#Pys$dw7E5BYnL^n<~nLz^PRApewIxJavO0%`eTMTAyvAR zS>|h@;rvbh^*B8%Z7?FzCo7&-|C}uN2$H|=PGQR9R&Z`W#MR;Xk3x$95=qLx4r^>^ z($oxcZHbYetAAR~gs3+h^pw>RlVFsT&c~q#*G{*65Jb=2=@~7R)uo%Kcu8k|%||Qt zw0n$uyXKB1e^K`s7Y8Q+A5c(&rUtEa*9^WnDME7iuzRdF=bNyq>U2PwM~}^mo8qBu zh|DA9Un#^TY8MO5lb^liXt!s7Wsrrf(b};*OK--qnnC;P@&hD2J=0g)9BhwC?wvP> zRG#`zfArb>YbFaYRZ@`M7)K<(e};bE_%?>12HUseT4ayc_jkg4sUVm2j{yCt(k>|r z#gE6ICZP9)RRCnln+fcae#Qo9v~Dd|C>MBz)fkX5_^h+1?7&&a{ZCB^lv~! z_>O=MY_1+2f$Gn(PaRHAPXC3o`mxfml;rOT4|uSX7RVkl%E?dmP2=fu9(AXBx}>OC z{t+c?B{pWy-aN?4T3SK%qX7l@04Xz#G%QY=Ktm`=eFJ_ zqI}Htg%SoBjU=9(R!YzjDXzA3bKUmC!pfMIa_7jbYdmvoUV)5E|2 zU9Q3eEj>R#n(z*Kt1Q!zA}W$CQc&+~a&%)&bcs>SV_)m}L?u}N7$!OiTGe#u+CXzA z)6gXdWOqu)awAyv@C$y3h_g6my^Q=l?Nya*K-H^E^KGEMWy1huN&+hv%A@kj`zeFQ zUF+hh(?TyxQONP;0VjNv#8boey(PfBjz{aZJ#6LoyeH4}FiD1{igb?O&JS9His}&K zC03JJH3@tZAdiFCtgZA$hDmcbqEWnGDwZ-n*D%3oEfP%6q9}y!c4T7Dbzyii9*u?! zS%_fP&*z6v0j+TDh83UUj4=cfR_aSH=Z0MfI7_qf&c{oF8<%LECASZ1ApekJ{zCjuDN&K0xVYd zP@JiLW|7&WNwoP(#AOg!1I&m5JQ8DjPPHf8t3T+onAKIkqB=W4!Mf0$0aE~fZ=}n= z=y*H&`;0oR{>ONS0UXD95fe56wT+OprWrP0p2)P_0r1}0x!JqpKp%z3QG&+#{8Y2( zW2Ue<7l}wW(Gtft@2#Johm=(@G+Y-K0f}aw$V9Weh&h8rlI-iM!l+w)<3h}Sj%d2k>Fuo zw`;^URgI{(4IR>_RELVtI3-Y6Jw;QX2>#6|8~JWdJHA*^P!giuQV}msHv+Zx>l)3L zdY=Whqo^u=s51P6+UVP5LUBybn>(={F@2M(w8fB8zR-?8WTjDFfn>!UFa55x;N7Pb z08wAPLQy~x-02*|^W`d5c2a*ysWAQ=m&oUiw1twm7=sH}Kg=UN*)>RC{gG0}GJ}JN z{co4yviurskaJ}EC8gQ-wQ&PdfF5i{&qjzSoUOCNdT}K8I$DHud}yp6=AXHcd?pkk z5Q6jUd@Q!|)Z72WJzc0mWVlP@CD>kh+%xSF`R@v1G`S=aD_G=S=PkaZ77XKUh&O2)O_-xWeSr4!3> z+w6v^X#oXDvmr^Di0oo~*SFk*<%USIcNQI51Nj98GoI{%UqO&f z{4dpSC;AqJD4^dl^!X#I;Qy*p$K(!vFom;8hP&n4JfKxNqDU&vvdT(5Gly!S@*GK8 zJ9=yu0KJ0ie=_2EMa2`#+&`?uJM`BGL`Z}N%IQ~-lN~Qb>Np3KVemqES3{)DIiwb< zi|^?~)xjU&fK>VFtx3kKJsq=I-kcqn^ZT$${dz8OQ@KVtqp7(FleZV$L9Xg^f4BfX zpJ#M)sh>Np2}bp;V$@}n9`0WWG5BeiQWZDj!lPhXti2l_MTE1ASq9!^zw({byR5VQ zF}Ou9@2dU{Y-fm%j?O<-)z#KSiSvuxa&qktYFK>pahz{?!ZnibK$l z=*PXDB4dA1$#KU24F@dvixZuh?4Xk{Qar`R(miLok@I05_H%T?I=7(4-c3!xM8su_ zC3VR3*h+DxgE&_4J8wzVb3%*-6wsAV#oUBg90KWcXmLy_U=p(zqN#?pPLhp%gw+z` zjTk0P+x=+V&!zo9ex|tgqvt+STf2T>a$p0p^PxschGKAbEB?3=lDx&S=03n;BU3{Tk70d&)rb6;ea&7vL`-sh< z&+_aWo2UI!6jJE!uX6Qt*tLbysP^b@ZP>nF-41s=ALCA?u;jgHG^61S5rB9SWEr!6 zLLY#!um$?IR!oCBoqTQ09lEM}SD7Y1S&kiyY<2tCZa>CaH`~HRGRjc=np8x4O!aoVW<* z^6{~b4^|ZyS~*D8cl7J6ErXSrYzie5hTph%^w|n^_;tmhp0;$b)=_vP?Iw@2T{~g( z!=Ge5l(`#Bnp-EZhvnDx#3WzAp1G1cs`j5)beMjkiaPTqfk-+Bey5v@{Q;Odoa+N) zu^%=lC>+`M!PME!uTC3>t>5b+jd{#gEzJj$?{A5OC3;Zz=l#2lK> zXoyXCUW4V2;2g$o4FX}$Iv6dOK7+YE(y!aOFBAc;3o32J<=+#bIGN4NZKRof2dN?T zK9}X%oxlkEWVeF`(TQNwdXH9RbTHMp8Q!Miz6u{~3wIyo_<^mC*vv{-ihjbyjvv!A zj`|k9+{vd9>CO6^og@@Lba{7jYM2(9KDiJPvc5cjY(Xa`OPI-Z_w?*iBA2qfq0mEv zlLX*u(SUGwqpxm5U#1>;VG2F9J6bHZmNz}@vO@H){>oU**ThA}|1qn1Qi|}W+`4$B zTysX&ZTSg=_m}^$+d#17N4t1@ckqmv-DJ zNG@Wru;{4E)%=ley6l7a-)L62O$hKC_44E2nE@W9(;g}=KTMwL-FVjCD@VFt?Mv9; ze5YEICu|!gI{Z9%zGyrt=H9buYsMrJIEC3&(+Se6I_Qz-kHP_7R73!%yQ{fw7|QX7~6rZg+Tk27%g@|-Za)< zIWMcKMD-mn{7e;|W3Q>H0qU3DCCpwilJte{H2T{@zM0lCZ+HdRc@CZw+|+inLyf2g z)@(dythw$ut{SSb2F*XeN!Ysg=YCt*?hD24lrp2(dJY891nJ>AlP%48BC{29xY7T# z`aD_)@aQ@EC^~#$DMsbx4OwYv^y{N{DHl6tA?IpegR6zodg^J1CHf+A`Ev4%vz*_R zW4$PSMWvkruLe^lD>LJ4)cEa&rbI25J({OKC|X6g4x5yPxzJZ*>3is7+J0AZ3kF+J z=ry~$)6zZ!jkA1#)8yBq7D?2zzsT5;QckLcs3?6lN437DuQ0mm?>;Sa&si9@?vnov z1o&~Dgbf}qe`+efxIAAx?QvAInHJk)2hKjVnp*;U^%Dt~e_c2sxaJHkAp8`)ICJ1ZVqBYMnm-hQ!X9h4FQ3c?~*;{ccryHGn@QW zd&a)xeq12Iw@!4QikFpRu+kFPYw2<&nip~-+HFZZ@7>ekT=Wl&Sw00oaWb#J{nXmF z=DdCY(?KNj%1tzoOp&ZZ;O1%CR>E;^=ZMmmwO>uaw0T){{u>j~M_SHO!lWSX#_Ycj O0f5v)>DJw{!~YMRHRwqI literal 0 HcmV?d00001 diff --git a/art/exported/creatures/7403.png.import b/art/exported/creatures/7403.png.import new file mode 100644 index 0000000..bef32c4 --- /dev/null +++ b/art/exported/creatures/7403.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl7ru6gm5xnpb" +path.s3tc="res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.s3tc.ctex" +path.etc2="res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/7403.png" +dest_files=["res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.s3tc.ctex", "res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/7508.png b/art/exported/creatures/7508.png new file mode 100644 index 0000000000000000000000000000000000000000..3f6c7f33ea36a99ace7405f1f1dbe1ee7d5e2d34 GIT binary patch literal 2019 zcmV<92ORi`P)? z|L7lr#4n(tu|p(83yDM^0aRKCIYLnz;RFax9MJ^l!@2r=7rmeUlFJW&~ULQ@q+965a!{X163vYxur z_dtGVmtD|r0L3@>2V_UVw2w>MUL-|Or3M{chvdp1_}5`%zPuNd>@I1;GiyM}ZNVK_ zr@^yN+JhCfVfqIkI_Xu!1y=%vRT{NTd}U`J_HO?a`AIXBtdI+IvNn+Cv~Uo1#Q%UC zYX;9g4p_rj#}>=F?!X7h2>Sc;G9?)N`1vL4{$ zAP_oPYhb4)IJQ~?t|Er?1=v;p7&N0t4tXIJL1C3lkD0gpWl~O%)b6daMDgpl0mjY5zp(x`3M7D)yo2uOWZtL$6ah0bt%aO$fFt3&Lha z{{Mp`-r?=zkbM?oFi|VRfa>r!>nnVVye#h{4b-6yLURE4x7idF5C}!yi=g9htl{?<)Z0qrt5z?^RB*8OFfu3l z!E+lVN!K_!gvIaT7(}1*3;Av4`p@t&aXP})tcO*mJ=_2G?}GjOyA54PYgdO7sfPI3 z;cwCZ&;POuvl+cF|3u1_B8tf?c@6H61wr28ze7jYtHBeb1pxn_ zB&^1>;}N@GC8s2y8C#r?-LD8eOaV1@vwVc*_99JgL65HnC0+!WIC8-_{O@D(3#9G{9D_nEjagz~ghm^l1dJ7q zrWq)?wJdQ^7Mi{TmbnVtT8ODaj3LHvFtQjK-0LbpkSC)NsNW1Q1AKU`Z^x;qRLO*r z%7xlgVGbhyHmW*%IJ*f9rnLOU(cuHR=kbR5X`$m}H#DQn{eZ_|=I<>L6FUN?_4#CK z8;ji|hzkYuZU3XMP7pLFJBxZ7hi1ItwkyY#LdbJ!d<~!)*8{jNkF|gjFJSS+ezcFi z?aI9#VW;&q(ij7)Uo1c813{qqhIf$u?3bu(Y{kT^wt@oU8v*?~pAx*sqS7Uc%93|i znA>N_A*fbgxkT~Xz1&y>CGr1BK8pAGV5rruL|*Lx0kI1z^^;G31X$x$C13Oa8E;+r zGzRt-&XFP*M&4WY;>Q%jI&#`na9_nRv-_c^?n3)L%dw|t6IMO45iB;}Ch0N4MVOpQ zwpb4mUe&U^<{&hyk zms7x9nd5uxWR`G)MG&qn?%e9aR$tT=CqNWlZ?@(4vjuM3Zrwg|vcKdubSE~s3-IN^ zM`tWML}77cx!j+aq|Ep@N>{2I!31_TF9GL`u=001{R@|CpMQ@Fp0U&zRk8uT z3WxCf_3Lr+?1n%Esj$;azDKO4$P&^pnEnoyp4o>5+9JG_-zSZ$iZdQ!i(o*L?LC3l zjOGQYS_Oz?=Rz3xs_|todI{!tEuh$HUmH)%9iSX8lb!|tP0&+!U~SsACt}MBnNqSt zUgXT@Vg|tiWFj?-d2Ok})H4suOs^m>L(bv867o#n>6VMhi)ZYNuZ_0E%~-2lFGJVv z`UfRhu?xhiUrqJssS&Y^#Eg!Q5CIu{=u4nCEfVpU@9wRjV(+j-g{sp)_!XOv zL_o0SNv~b7_X2v5@Z=Y7;_#kl3XPh?`@Tttgn~`3yBxvCSnQg=1dc?y1)k5E;K}XQ zvC?WIsgm6n^z9IZ)Rny9HBvtZ_x+QG@AA5h6s05FtW@2oWMgh=5BC-vjvtExV>1hf4qe002ovPDHLkV1l=M B+}8jA literal 0 HcmV?d00001 diff --git a/art/exported/creatures/7508.png.import b/art/exported/creatures/7508.png.import new file mode 100644 index 0000000..a14d42f --- /dev/null +++ b/art/exported/creatures/7508.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bnw0dw14iyxbh" +path.s3tc="res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.s3tc.ctex" +path.etc2="res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/7508.png" +dest_files=["res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.s3tc.ctex", "res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.etc2.ctex"] + +[params] + +compress/mode=2 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index 2eac3e5..d24f1ca 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -12,14 +12,14 @@ publicly credited as Voyager and Endeavour and operating collectively as the independent team Woofmeow. - Voyager: co-owner, developer, project director, creator of the game's - current provisional fish artwork and original 3D work except where another - creator is credited, and composer of the original title track + provisional fish placeholder artwork and original 3D work except where + another creator is credited, and composer of the original title track `audio/music/title/as_in_four_wolves.ogg`, dusk/world track `audio/music/world/craft.mp3`, and the synthesized robot animalese tones. - Endeavour: co-owner, developer, and 2D artist. Her incorporated work includes - character pattern/channel-map art, environment and UI art, and item art. - Planned work is not described as incorporated until its files enter the - project. + character pattern/channel-map art, environment and UI art, item art, and + finished fish and creature artwork. Planned work is not described as + incorporated until its files enter the project. - chillnfill: contributor of original 3D models for the character bodies and arms, ears and tails, and multiple world props and decorative assets, including trees and bridges. The contributor requested to be credited as @@ -132,17 +132,18 @@ locations only and must never appear in scenes or resources. ## Project-created artwork and music -The current in-game fish images are provisional artwork by Voyager. Endeavour -is creating replacement fish artwork, but work that has not been incorporated -into this repository is not presented here as part of the game or attributed -as a shipped asset. +Fish entries without finished replacement artwork use provisional placeholder +artwork by Voyager. Incorporated finished fish and creature artwork is by +Endeavour; work that has not entered this repository is not presented here as +part of the game or attributed as a shipped asset. Endeavour's incorporated 2D contributions include the inventory notepad, -environment and UI artwork, item artwork, and character customization pattern -and channel-map artwork. Voyager's incorporated original work includes 3D -assets, the provisional fish artwork, the title and dusk music identified -above, and the robot animalese tones. Specific contributor exceptions are -recorded below and in the in-game credits. +environment and UI artwork, item artwork, finished fish and creature artwork, +and character customization pattern and channel-map artwork. Voyager's +incorporated original work includes 3D assets, the provisional fish +placeholder artwork, the title and dusk music identified above, and the robot +animalese tones. Specific contributor exceptions are recorded below and in the +in-game credits. These credits intentionally describe work by creator and contribution family instead of maintaining a second file-by-file asset catalog. Runtime resources @@ -192,9 +193,10 @@ https://creativecommons.org/licenses/by/3.0/legalcode. ### Endeavour 2D contribution record Endeavour created the incorporated character customization patterns and -channel maps as well as additional UI, environment, and item artwork. The -current provisional fish artwork is not part of this credit. New or replacement -art is added to this record only after it is accepted into the repository. +channel maps as well as additional UI, environment, item, fish, and creature +artwork. Voyager's provisional fish placeholder is not part of this credit. +New or replacement art is added to this record only after it is accepted into +the repository. The exact current filenames and bytes are intentionally not copied into this document. The character appearance resources and Git history are the diff --git a/fish/species/betta_siamese/betta_siamese.tres b/fish/species/betta_siamese/betta_siamese.tres index d8e7ee5..4c9ca3c 100644 --- a/fish/species/betta_siamese/betta_siamese.tres +++ b/fish/species/betta_siamese/betta_siamese.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/7508.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/bowfin/bowfin.tres b/fish/species/bowfin/bowfin.tres index b87370e..7b2881f 100644 --- a/fish/species/bowfin/bowfin.tres +++ b/fish/species/bowfin/bowfin.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/101.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/chromis_blue_green/chromis_blue_green.tres b/fish/species/chromis_blue_green/chromis_blue_green.tres index 17061f8..65f35b4 100644 --- a/fish/species/chromis_blue_green/chromis_blue_green.tres +++ b/fish/species/chromis_blue_green/chromis_blue_green.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/5901.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres b/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres index 25bea8f..16566cb 100644 --- a/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres +++ b/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/5702.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/crab_brown/crab_brown.tres b/fish/species/crab_brown/crab_brown.tres index 8f31b9c..7dec427 100644 --- a/fish/species/crab_brown/crab_brown.tres +++ b/fish/species/crab_brown/crab_brown.tres @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] -[ext_resource type="Texture2D" path="res://fish/species/crab_brown/crab_brown.png" id="3_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/3906.png" id="3_texture"] [resource] script = ExtResource("1_fish_data") diff --git a/fish/species/gar_longnose/gar_longnose.tres b/fish/species/gar_longnose/gar_longnose.tres index 28f6e7b..aaabbfe 100644 --- a/fish/species/gar_longnose/gar_longnose.tres +++ b/fish/species/gar_longnose/gar_longnose.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/103.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/gar_spotted/gar_spotted.tres b/fish/species/gar_spotted/gar_spotted.tres index faf4afc..3405ee9 100644 --- a/fish/species/gar_spotted/gar_spotted.tres +++ b/fish/species/gar_spotted/gar_spotted.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/106.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/hogfish/hogfish.tres b/fish/species/hogfish/hogfish.tres index bd4373d..0e94a20 100644 --- a/fish/species/hogfish/hogfish.tres +++ b/fish/species/hogfish/hogfish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6103.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/ladyfish/ladyfish.tres b/fish/species/ladyfish/ladyfish.tres index 55aa31a..57c277c 100644 --- a/fish/species/ladyfish/ladyfish.tres +++ b/fish/species/ladyfish/ladyfish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/7403.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/lungfish_west_african/lungfish_west_african.tres b/fish/species/lungfish_west_african/lungfish_west_african.tres index 342a8f8..f049cff 100644 --- a/fish/species/lungfish_west_african/lungfish_west_african.tres +++ b/fish/species/lungfish_west_african/lungfish_west_african.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/107.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/mahi_mahi/mahi_mahi.tres b/fish/species/mahi_mahi/mahi_mahi.tres index c7bd6dd..691a52a 100644 --- a/fish/species/mahi_mahi/mahi_mahi.tres +++ b/fish/species/mahi_mahi/mahi_mahi.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/4701.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/mullet_striped/mullet_striped.tres b/fish/species/mullet_striped/mullet_striped.tres index 409d7d6..a285ee7 100644 --- a/fish/species/mullet_striped/mullet_striped.tres +++ b/fish/species/mullet_striped/mullet_striped.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6804.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/paddlefish/paddlefish.tres b/fish/species/paddlefish/paddlefish.tres index 80cebe6..498730b 100644 --- a/fish/species/paddlefish/paddlefish.tres +++ b/fish/species/paddlefish/paddlefish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/104.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/salmon_atlantic/salmon_atlantic.tres b/fish/species/salmon_atlantic/salmon_atlantic.tres index 122c3e3..1c77325 100644 --- a/fish/species/salmon_atlantic/salmon_atlantic.tres +++ b/fish/species/salmon_atlantic/salmon_atlantic.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"] [ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/4401.png" id="4_texture"] [sub_resource type="Resource" id="AtlanticSalmonAvailability"] script = ExtResource("3_availability_script") diff --git a/fish/species/sheepshead/sheepshead.tres b/fish/species/sheepshead/sheepshead.tres index 5e3fcc0..d4b8cfa 100644 --- a/fish/species/sheepshead/sheepshead.tres +++ b/fish/species/sheepshead/sheepshead.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6905.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/sturgeon_lake/sturgeon_lake.tres b/fish/species/sturgeon_lake/sturgeon_lake.tres index cf73e25..eef7789 100644 --- a/fish/species/sturgeon_lake/sturgeon_lake.tres +++ b/fish/species/sturgeon_lake/sturgeon_lake.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/102.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres b/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres index 03434b0..9f02593 100644 --- a/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres +++ b/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/105.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index 4a252b0..d7174a8 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -40,6 +40,25 @@ const StarterRegionScene = preload( const SharedFishPlaceholder: Texture2D = preload( "res://art/exported/creatures/fish_placeholder.png" ) +const FINAL_CREATURE_TEXTURE_PATHS: Dictionary[StringName, String] = { + &"bowfin": "res://art/exported/creatures/101.png", + &"sturgeon_lake": "res://art/exported/creatures/102.png", + &"gar_longnose": "res://art/exported/creatures/103.png", + &"paddlefish": "res://art/exported/creatures/104.png", + &"sturgeon_shovelnose": "res://art/exported/creatures/105.png", + &"gar_spotted": "res://art/exported/creatures/106.png", + &"lungfish_west_african": "res://art/exported/creatures/107.png", + &"crab_brown": "res://art/exported/creatures/3906.png", + &"salmon_atlantic": "res://art/exported/creatures/4401.png", + &"mahi_mahi": "res://art/exported/creatures/4701.png", + &"clownfish_ocellaris": "res://art/exported/creatures/5702.png", + &"chromis_blue_green": "res://art/exported/creatures/5901.png", + &"hogfish": "res://art/exported/creatures/6103.png", + &"mullet_striped": "res://art/exported/creatures/6804.png", + &"sheepshead": "res://art/exported/creatures/6905.png", + &"ladyfish": "res://art/exported/creatures/7403.png", + &"betta_siamese": "res://art/exported/creatures/7508.png", +} const ACTIVE_CATALOG_COUNT: int = 313 const INACTIVE_CATALOG_COUNT: int = 3 @@ -186,6 +205,8 @@ func _validate_catalog_and_pools() -> void: var active_count: int = 0 var inactive_count: int = 0 var fishing_count: int = 0 + var final_art_count: int = 0 + var placeholder_fishing_count: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in Catalog.candidates: assert(fish != null and not fish.id.is_empty()) @@ -204,13 +225,24 @@ func _validate_catalog_and_pools() -> void: inactive_count += 1 assert(not fish.is_selectable()) assert(fish.display_texture == null) + if FINAL_CREATURE_TEXTURE_PATHS.has(fish.id): + var expected_texture: Texture2D = load( + FINAL_CREATURE_TEXTURE_PATHS[fish.id] + ) as Texture2D + assert(expected_texture != null) + assert(fish.display_texture == expected_texture) + final_art_count += 1 if fish.collection_method == FishDataType.CollectionMethod.FISHING: fishing_count += 1 assert(fish.active) - assert(fish.display_texture == SharedFishPlaceholder) + if not FINAL_CREATURE_TEXTURE_PATHS.has(fish.id): + assert(fish.display_texture == SharedFishPlaceholder) + placeholder_fishing_count += 1 assert(active_count == ACTIVE_CATALOG_COUNT) assert(inactive_count == INACTIVE_CATALOG_COUNT) assert(fishing_count == FISHING_SPECIES_COUNT) + assert(final_art_count == FINAL_CREATURE_TEXTURE_PATHS.size()) + assert(placeholder_fishing_count == 294) var chum: FishDataType = Catalog.get_fish_by_id(&"salmon_chum") assert(chum != null) assert(chum.get_season_text() == "fall") diff --git a/tools/art/export_creature_art.py b/tools/art/export_creature_art.py new file mode 100644 index 0000000..fb44e52 --- /dev/null +++ b/tools/art/export_creature_art.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Export cataloged creature artwork with consistent transparent margins.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + +from PIL import Image + + +TABLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" +TABLE_ATTRIBUTE = "{%s}" % TABLE_NAMESPACE +NAMESPACES = {"table": TABLE_NAMESPACE} +VALID_EXPORT_SIZES = {64, 128, 256, 512} +SAFE_AREA_RATIO = 0.875 +KNOWN_SOURCE_ALIASES = { + # The delivered filename predates the catalog's authoritative species ID. + "bowfish": "bowfin", +} + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Export PNG creature art according to the catalog's recommended " + "canvas size, using alpha-bound trimming and nearest-neighbor scaling." + ) + ) + parser.add_argument("--tracker", required=True, type=Path) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--skip", + action="append", + default=[], + help="Catalog creature ID or source filename stem to skip; repeat as needed.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate and report the batch without writing output files.", + ) + return parser.parse_args() + + +def normalize_name(value: str) -> str: + normalized = value.strip().lower().replace("&", " and ") + normalized = re.sub(r"[^a-z0-9]+", "_", normalized) + return normalized.strip("_") + + +def expanded_row_values(row: ET.Element) -> list[str]: + values: list[str] = [] + for cell in row.findall("table:table-cell", NAMESPACES): + repeat = int( + cell.attrib.get( + TABLE_ATTRIBUTE + "number-columns-repeated", + "1", + ) + ) + values.extend(["".join(cell.itertext()).strip()] * repeat) + return values + + +def load_catalog(tracker_path: Path) -> list[dict[str, str]]: + with zipfile.ZipFile(tracker_path) as archive: + root = ET.fromstring(archive.read("content.xml")) + table = root.find(".//table:table", NAMESPACES) + if table is None: + raise ValueError("tracker does not contain a table") + rows = table.findall("table:table-row", NAMESPACES) + if not rows: + raise ValueError("tracker table is empty") + headers = expanded_row_values(rows[0]) + required_headers = { + "catalog_number", + "display_name", + "id", + "recommended_export_canvas_px", + } + missing_headers = required_headers.difference(headers) + if missing_headers: + raise ValueError( + "tracker is missing required columns: " + + ", ".join(sorted(missing_headers)) + ) + records: list[dict[str, str]] = [] + for row in rows[1:]: + values = expanded_row_values(row) + if not any(values): + continue + record = dict(zip(headers, values)) + if record.get("id", "").strip(): + records.append(record) + return records + + +def catalog_lookup(records: list[dict[str, str]]) -> dict[str, dict[str, str]]: + lookup: dict[str, dict[str, str]] = {} + for record in records: + keys = { + normalize_name(record["id"]), + normalize_name(record["display_name"]), + } + for key in keys: + previous = lookup.get(key) + if previous is not None and previous["id"] != record["id"]: + raise ValueError( + f"ambiguous normalized catalog name {key!r}: " + f"{previous['id']} and {record['id']}" + ) + lookup[key] = record + return lookup + + +def resolve_record( + source_path: Path, + lookup: dict[str, dict[str, str]], +) -> dict[str, str]: + source_key = normalize_name(source_path.stem) + catalog_key = KNOWN_SOURCE_ALIASES.get(source_key, source_key) + record = lookup.get(catalog_key) + if record is None: + raise ValueError( + f"cannot map source {source_path.name!r} to one catalog creature" + ) + return record + + +def normalize_artwork(source_path: Path, canvas_size: int) -> tuple[Image.Image, tuple[int, int, int, int], tuple[int, int]]: + with Image.open(source_path) as source_image: + artwork = source_image.convert("RGBA") + alpha_bounds = artwork.getchannel("A").getbbox() + if alpha_bounds is None: + raise ValueError(f"source {source_path.name!r} is fully transparent") + visible = artwork.crop(alpha_bounds) + safe_long_side = round(canvas_size * SAFE_AREA_RATIO) + scale = safe_long_side / max(visible.width, visible.height) + scaled_size = ( + max(1, round(visible.width * scale)), + max(1, round(visible.height * scale)), + ) + visible = visible.resize(scaled_size, Image.Resampling.NEAREST) + canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) + offset = ( + (canvas_size - scaled_size[0]) // 2, + (canvas_size - scaled_size[1]) // 2, + ) + canvas.alpha_composite(visible, offset) + return canvas, alpha_bounds, scaled_size + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main() -> int: + arguments = parse_arguments() + records = load_catalog(arguments.tracker) + lookup = catalog_lookup(records) + skip_keys = {normalize_name(value) for value in arguments.skip} + source_paths = sorted(arguments.source.glob("*.png")) + if not source_paths: + raise ValueError(f"no PNG files found under {arguments.source}") + + resolved: list[tuple[Path, dict[str, str]]] = [] + output_names: set[str] = set() + for source_path in source_paths: + record = resolve_record(source_path, lookup) + source_key = normalize_name(source_path.stem) + creature_key = normalize_name(record["id"]) + if source_key in skip_keys or creature_key in skip_keys: + print(f"SKIP {source_path.name} -> {record['id']}") + continue + output_name = f"{record['catalog_number']}.png" + if output_name in output_names: + raise ValueError(f"duplicate output filename {output_name}") + output_names.add(output_name) + resolved.append((source_path, record)) + + if not arguments.dry_run: + arguments.output.mkdir(parents=True, exist_ok=True) + + for source_path, record in resolved: + canvas_size = int(record["recommended_export_canvas_px"]) + if canvas_size not in VALID_EXPORT_SIZES: + raise ValueError( + f"{record['id']} has invalid export size {canvas_size}" + ) + image, alpha_bounds, scaled_size = normalize_artwork( + source_path, + canvas_size, + ) + output_path = arguments.output / f"{record['catalog_number']}.png" + if not arguments.dry_run: + image.save(output_path, format="PNG", optimize=False, compress_level=6) + output_hash = sha256(output_path) + else: + output_hash = "dry-run" + print( + "EXPORT " + f"{source_path.name} -> {output_path.name} " + f"id={record['id']} canvas={canvas_size}x{canvas_size} " + f"alpha_bounds={alpha_bounds} visible={scaled_size[0]}x{scaled_size[1]} " + f"source_sha256={sha256(source_path)} output_sha256={output_hash}" + ) + + print( + f"Complete: {len(resolved)} export(s), " + f"{len(source_paths) - len(resolved)} skipped." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, zipfile.BadZipFile) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) From 8b13ba1d7d114101cbb600da776017cdfb2c0562 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 12:01:33 -0400 Subject: [PATCH 10/38] Normalize logbook creature portraits --- tests/logbook_validation.gd | 34 +++++++++++++++++++-- ui/components/logbook_portrait.gd | 51 +++++++++++++++++++++++++++++++ ui/logbook_page.gd | 9 ++++-- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index a621a8a..74736fa 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -162,6 +162,8 @@ func _validate_page() -> void: var shared_material: Material var silhouette_count: int = 0 + var smallest_silhouette_area: float = INF + var largest_silhouette_area: float = 0.0 for category: LogbookCatalog.Category in [ LogbookCatalog.Category.FRESH_WATER, LogbookCatalog.Category.SALT_WATER, @@ -192,8 +194,15 @@ func _validate_page() -> void: assert(portrait != null) assert(portrait.source_texture == fish.display_texture) assert( - portrait.custom_minimum_size - == LogbookPage.CATALOG_PORTRAIT_SIZE + portrait.custom_minimum_size.x + <= LogbookPage.CATALOG_PORTRAIT_SIZE.x + and portrait.custom_minimum_size.y + <= LogbookPage.CATALOG_PORTRAIT_SIZE.y + ) + assert(portrait.get_parent() is CenterContainer) + assert( + (portrait.get_parent() as CenterContainer).custom_minimum_size + == LogbookPage.CATALOG_PORTRAIT_SIZE ) assert( portrait.expand_mode @@ -209,6 +218,19 @@ func _validate_page() -> void: else: assert(portrait.material == shared_material) assert(_texture_has_transparency(portrait.texture)) + var silhouette_area: float = ( + portrait.custom_minimum_size.x + * portrait.custom_minimum_size.y + ) + assert(silhouette_area > 0.0) + smallest_silhouette_area = minf( + smallest_silhouette_area, + silhouette_area, + ) + largest_silhouette_area = maxf( + largest_silhouette_area, + silhouette_area, + ) silhouette_count += 1 for candidate: FishDataType in CatalogResource.candidates: assert(not entry.text.contains(candidate.display_name)) @@ -223,6 +245,9 @@ func _validate_page() -> void: ) ) assert(silhouette_count == 310) + assert( + largest_silhouette_area / smallest_silhouette_area <= 1.12 + ) page.call("_select_category", LogbookCatalog.Category.SHELLFISH) await create_timer(0.25).timeout @@ -269,6 +294,11 @@ func _validate_page() -> void: assert(known_portrait != null) assert(known_portrait.source_texture == bluegill.display_texture) assert(known_portrait.material == null) + assert(known_portrait.get_parent() is CenterContainer) + assert( + (known_portrait.get_parent() as CenterContainer).custom_minimum_size + == LogbookPage.CATALOG_PORTRAIT_SIZE + ) assert(known.button_pressed) _validate_handwritten_logbook_font(page) diff --git a/ui/components/logbook_portrait.gd b/ui/components/logbook_portrait.gd index ef23922..fb5fa4a 100644 --- a/ui/components/logbook_portrait.gd +++ b/ui/components/logbook_portrait.gd @@ -3,6 +3,7 @@ extends TextureRect const ENTRY_FRAME_SIZE := Vector2(86.0, 40.0) const DETAIL_FRAME_SIZE := Vector2(240.0, 132.0) +const UNIFORM_FOOTPRINT_AREA_RATIO: float = 0.6 static var _normalized_textures: Dictionary[String, Texture2D] = {} @@ -52,6 +53,53 @@ func configure_fitted( custom_minimum_size = texture_size * fit_scale +func configure_uniform_footprint( + portrait_texture: Texture2D, + maximum_size: Vector2, + portrait_material: Material = null, +) -> void: + source_texture = portrait_texture + material = portrait_material + texture = _normalize_visible_bounds(portrait_texture) + if texture == null: + custom_minimum_size = Vector2.ZERO + return + custom_minimum_size = uniform_footprint_size( + texture.get_size(), + maximum_size, + ) + + +static func uniform_footprint_size( + visible_size: Vector2, + maximum_size: Vector2, +) -> Vector2: + if ( + visible_size.x <= 0.0 + or visible_size.y <= 0.0 + or maximum_size.x <= 0.0 + or maximum_size.y <= 0.0 + ): + return Vector2.ZERO + var target_area: float = ( + maximum_size.x + * maximum_size.y + * UNIFORM_FOOTPRINT_AREA_RATIO + ) + var area_scale: float = sqrt( + target_area / (visible_size.x * visible_size.y) + ) + var fit_scale: float = minf( + maximum_size.x / visible_size.x, + maximum_size.y / visible_size.y, + ) + var scale: float = minf(area_scale, fit_scale) + return Vector2( + minf(maximum_size.x, maxf(1.0, roundf(visible_size.x * scale))), + minf(maximum_size.y, maxf(1.0, roundf(visible_size.y * scale))), + ) + + static func _normalize_visible_bounds( portrait_texture: Texture2D, ) -> Texture2D: @@ -67,6 +115,9 @@ static func _normalize_visible_bounds( if image == null or image.is_empty(): _normalized_textures[cache_key] = portrait_texture return portrait_texture + if image.is_compressed() and image.decompress() != OK: + _normalized_textures[cache_key] = portrait_texture + return portrait_texture var full_rect := Rect2i(Vector2i.ZERO, image.get_size()) var visible_rect: Rect2i = image.get_used_rect() if visible_rect.size == Vector2i.ZERO or visible_rect == full_rect: diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index a79239d..c5528e4 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -666,13 +666,18 @@ func _add_entry_content( content.add_theme_constant_override("separation", 4) content_margin.add_child(content) + var portrait_frame := CenterContainer.new() + portrait_frame.custom_minimum_size = CATALOG_PORTRAIT_SIZE + portrait_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE + content.add_child(portrait_frame) + var portrait_view := LogbookPortraitType.new() - portrait_view.configure( + portrait_view.configure_uniform_footprint( portrait, CATALOG_PORTRAIT_SIZE, _silhouette_material if unknown else null, ) - content.add_child(portrait_view) + portrait_frame.add_child(portrait_view) var name_label := _label(_entry_label_text(entry_name), 16) name_label.custom_minimum_size.y = 38.0 From 4dc87235ec3094c634d5c2d402d7521218736401 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:50:57 -0400 Subject: [PATCH 11/38] Restore catch quality inventory styling --- tests/unified_inventory_validation.gd | 54 +++++++++++++++++++++ ui/components/general_inventory_slot.gd | 62 +++++++++++++++++++++---- ui/components/shop_sale_tray_slot.gd | 26 ++++++++++- ui/shop_sell_inventory.gd | 10 +++- 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/tests/unified_inventory_validation.gd b/tests/unified_inventory_validation.gd index dacc5a0..e9a0d85 100644 --- a/tests/unified_inventory_validation.gd +++ b/tests/unified_inventory_validation.gd @@ -62,6 +62,7 @@ func _run() -> void: fish_catch.sale_value = Bluegill.get_sale_value_for_weight( fish_catch.weight_lb ) + fish_catch.quality = FishQuality.Tier.IMPRESSIVE assert(catches.add_catch(fish_catch)) assert(layout.get_inventory_count() == 2) assert(layout.move_entry_to_first_free( @@ -106,8 +107,53 @@ func _run() -> void: == Color(UtilityPageStyle.OCEAN_SELECTED, 0.92) ) staged_slot.set_staged(false) + var quality_slot := GeneralInventorySlot.new() + root.add_child(quality_slot) + quality_slot.set_presentation_size(Vector2(78.0, 78.0)) + quality_slot.configure( + 0, + PlayerInventoryLayout.InventoryContainer.STORAGE, + false, + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + ) + assert(quality_slot.entry_identity == fish_catch.catch_id) + assert( + (quality_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color + == GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + ) + quality_slot.set_staged(true) + assert( + (quality_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color + == GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.SELECTED, + ) + ) + var distinct_quality_colors: Dictionary[Color, bool] = {} + for quality: int in FishQuality.TIER_COUNT: + var quality_color := GeneralInventorySlot.quality_background_color( + quality, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + assert(not distinct_quality_colors.has(quality_color)) + distinct_quality_colors[quality_color] = true + assert(distinct_quality_colors.size() == FishQuality.TIER_COUNT) var sale_tray_slot := ShopSaleTraySlot.new() root.add_child(sale_tray_slot) + sale_tray_slot.configure( + "catch:%s" % fish_catch.catch_id, + fish_catch.fish.display_texture, + fish_catch.fish.display_name, + 1, + fish_catch.quality, + ) assert( sale_tray_slot.custom_minimum_size == GeneralInventoryGrid.DEFAULT_SLOT_SIZE @@ -117,6 +163,14 @@ func _run() -> void: ) as StyleBoxFlat assert(sale_tray_style != null) assert(sale_tray_style.corner_radius_top_left == 26) + var expected_tray_quality_color := ( + GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + ) + expected_tray_quality_color.a = 0.96 + assert(sale_tray_style.bg_color == expected_tray_quality_color) var wallet := PlayerWallet.new() root.add_child(wallet) assert(wallet.restore_balance(15000)) diff --git a/ui/components/general_inventory_slot.gd b/ui/components/general_inventory_slot.gd index 9204d0a..cf9ccc4 100644 --- a/ui/components/general_inventory_slot.gd +++ b/ui/components/general_inventory_slot.gd @@ -11,6 +11,12 @@ const LockedContentPresentationType = preload( "res://ui/components/locked_content_presentation.gd" ) +enum QualityEmphasis { + NORMAL, + HOVER, + SELECTED, +} + var slot_index: int = -1 var container: int = -1 var entry_kind: int = -1 @@ -29,6 +35,7 @@ var _context_text: String = "" var _context_hovered: bool = false var _context_focused: bool = false var _staged: bool = false +var _quality_tier: int = -1 func _ready() -> void: @@ -101,10 +108,12 @@ func refresh() -> void: entry_kind = -1 entry_identity = StringName() _context_text = "" + _quality_tier = -1 _icon.texture = null _quantity.text = "" disabled = _locked _apply_icon_geometry() + _apply_style() if _locked: _icon.texture = LockedContentPresentationType.ICON _icon.modulate = LockedContentPresentationType.icon_modulate() @@ -152,12 +161,14 @@ func refresh() -> void: ) if fish_catch == null: return + _quality_tier = fish_catch.quality _icon.texture = fish_catch.fish.display_texture _context_text = _catch_context_text(fish_catch) var catch_name: String = FishQuality.qualified_name( fish_catch.fish.display_name, fish_catch.quality ) accessibility_name = "%s, slot %d" % [catch_name, slot_index + 1] + _apply_style() func _on_pressed() -> void: @@ -306,27 +317,62 @@ func _drop_data(_at_position: Vector2, data: Variant) -> void: func _apply_style() -> void: var radius: int = roundi(minf(_presentation_size.x, _presentation_size.y) * 0.5) + var normal_color: Color = quality_background_color( + _quality_tier, + QualityEmphasis.SELECTED if _staged else QualityEmphasis.NORMAL, + ) var normal := UtilityPageStyle.rounded_style( - Color( - UtilityPageStyle.OCEAN_SELECTED - if _staged else UtilityPageStyle.OCEAN_FIELD, - 0.92 if _staged else 0.88, - ), + normal_color, radius, ) var hover := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_SELECTED, 0.92), radius + quality_background_color(_quality_tier, QualityEmphasis.HOVER), + radius, + ) + var selected := UtilityPageStyle.rounded_style( + quality_background_color(_quality_tier, QualityEmphasis.SELECTED), + radius, ) var locked := UtilityPageStyle.rounded_style( LockedContentPresentationType.disabled_background_color(), radius ) - for state: StringName in [&"normal", &"pressed"]: - add_theme_stylebox_override(state, normal) + add_theme_stylebox_override("normal", normal) + add_theme_stylebox_override( + "pressed", selected if FishQuality.is_valid(_quality_tier) else normal + ) add_theme_stylebox_override("disabled", locked) for state: StringName in [&"hover", &"focus"]: add_theme_stylebox_override(state, hover) +static func quality_background_color( + quality: int, + emphasis: QualityEmphasis, +) -> Color: + var base_color: Color + var alpha: float + var quality_mix: float + match emphasis: + QualityEmphasis.HOVER: + base_color = UtilityPageStyle.OCEAN_SELECTED + alpha = 0.92 + quality_mix = 0.64 + QualityEmphasis.SELECTED: + base_color = UtilityPageStyle.OCEAN_SELECTED + alpha = 0.92 + quality_mix = 0.82 + _: + base_color = UtilityPageStyle.OCEAN_FIELD + alpha = 0.88 + quality_mix = 0.46 + if not FishQuality.is_valid(quality): + return Color(base_color, alpha) + return Color( + base_color.lerp(UIPalette.get_quality_color(quality), quality_mix), + alpha, + ) + + func _apply_presentation() -> void: _apply_icon_geometry() var is_large: bool = _presentation_size.x >= 70.0 diff --git a/ui/components/shop_sale_tray_slot.gd b/ui/components/shop_sale_tray_slot.gd index 5deb62f..0ac87bf 100644 --- a/ui/components/shop_sale_tray_slot.gd +++ b/ui/components/shop_sale_tray_slot.gd @@ -7,6 +7,7 @@ signal drop_requested(payload: Dictionary) var entry_key: String = "" var _icon: TextureRect var _quantity: Label +var _quality_tier: int = -1 func _ready() -> void: @@ -32,11 +33,29 @@ func _ready() -> void: _quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_quantity) + _apply_style() + + +func _apply_style() -> void: + var normal_color := GeneralInventorySlot.quality_background_color( + _quality_tier, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + var hover_color := GeneralInventorySlot.quality_background_color( + _quality_tier, + GeneralInventorySlot.QualityEmphasis.SELECTED, + ) + # Preserve the tray's established opacity while sharing the inventory's + # quality colors. Ordinary items and the empty drop target stay unchanged. + normal_color.a = 0.96 + hover_color.a = 0.96 var normal := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_FIELD, 0.96), 26 + normal_color, + 26, ) var hover := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_SELECTED, 0.96), 26 + hover_color, + 26, ) for state: StringName in [&"normal", &"pressed", &"disabled"]: add_theme_stylebox_override(state, normal) @@ -49,12 +68,15 @@ func configure( icon: Texture2D, label: String, quantity: int = 1, + quality: int = -1, ) -> void: entry_key = key + _quality_tier = quality _icon.texture = icon _quantity.text = "×%d" % quantity if quantity > 1 else "" tooltip_text = "%s · select to remove" % label accessibility_name = tooltip_text + _apply_style() func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: diff --git a/ui/shop_sell_inventory.gd b/ui/shop_sell_inventory.gd index f4e9a43..9e39850 100644 --- a/ui/shop_sell_inventory.gd +++ b/ui/shop_sell_inventory.gd @@ -205,12 +205,14 @@ func _refresh_tray() -> void: var identity := StringName(str(record["identity"])) var icon: Texture2D var label: String + var quality: int = -1 if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH: var fish_catch := _fish_inventory.get_catch_by_id(identity) if fish_catch == null: continue icon = fish_catch.fish.display_texture label = fish_catch.fish.display_name + quality = fish_catch.quality else: var item := _item_catalog.get_item_by_id(identity) if item == null: @@ -219,7 +221,13 @@ func _refresh_tray() -> void: label = item.display_name var slot := ShopSaleTraySlot.new() _tray_grid.add_child(slot) - slot.configure(key, icon, label, int(record.get("quantity", 1))) + slot.configure( + key, + icon, + label, + int(record.get("quantity", 1)), + quality, + ) slot.remove_requested.connect(_on_remove_requested) slot.drop_requested.connect(_on_drop_payload) var drop_slot := ShopSaleTraySlot.new() From 966968e54d1ab21a494b7ad8a789b96b6557ebd9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:51:11 -0400 Subject: [PATCH 12/38] Restrict generated clam spots to visible sand --- tests/digging_prototype_validation.gd | 56 +++++++++++++++++++++++++++ world/digging/diggable_area_3d.gd | 19 ++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/digging_prototype_validation.gd b/tests/digging_prototype_validation.gd index c19bf0b..21f8629 100644 --- a/tests/digging_prototype_validation.gd +++ b/tests/digging_prototype_validation.gd @@ -11,6 +11,15 @@ const Gatherables: GatherableCatalog = preload( "res://gathering/catalog/gatherable_catalog.tres" ) +class PrimaryTerrainProvider: + extends Node3D + + var primary_mesh: MeshInstance3D + + + func get_primary_terrain_meshes() -> Array[MeshInstance3D]: + return [primary_mesh] if primary_mesh != null else [] + func _initialize() -> void: call_deferred("_run") @@ -20,6 +29,7 @@ func _run() -> void: _validate_catalog_content() _validate_flat_shovel() await _validate_beach_authoring() + _validate_primary_terrain_provider() print("Digging prototype validation: PASS") quit() @@ -81,6 +91,52 @@ func _validate_beach_authoring() -> void: region.queue_free() +func _validate_primary_terrain_provider() -> void: + var fixture := Node3D.new() + fixture.name = "PrimaryTerrainFixture" + root.add_child(fixture) + var provider := PrimaryTerrainProvider.new() + provider.name = "Provider" + fixture.add_child(provider) + provider.primary_mesh = _flat_sand_triangle(0.0) + provider.add_child(provider.primary_mesh) + var buried_base := _flat_sand_triangle(10.0) + buried_base.name = "BuriedSandBase" + provider.add_child(buried_base) + var area := DiggableArea3D.new() + area.name = "DiggableArea" + area.area_id = &"provider_test" + area.terrain_source = NodePath("../Provider") + area.surface_materials = [&"sand"] + area.generation_bounds = Rect2(-20.0, -20.0, 40.0, 40.0) + fixture.add_child(area) + var triangles := area.get_surface_triangles() + assert(triangles.size() == 1) + var center := (triangles[0][0] + triangles[0][1] + triangles[0][2]) / 3.0 + assert(center.x < 2.0) + fixture.free() + + +func _flat_sand_triangle(x_offset: float) -> MeshInstance3D: + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([ + Vector3(x_offset, 0.0, 0.0), + Vector3(x_offset, 0.0, 1.0), + Vector3(x_offset + 1.0, 0.0, 0.0), + ]) + arrays[Mesh.ARRAY_INDEX] = PackedInt32Array([0, 1, 2]) + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var material := StandardMaterial3D.new() + material.resource_name = "sand" + mesh.surface_set_material(0, material) + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "PrimarySand" + mesh_instance.mesh = mesh + return mesh_instance + + func _collect_meshes( root_node: Node, result: Array[MeshInstance3D], diff --git a/world/digging/diggable_area_3d.gd b/world/digging/diggable_area_3d.gd index 1c34070..47270b1 100644 --- a/world/digging/diggable_area_3d.gd +++ b/world/digging/diggable_area_3d.gd @@ -18,7 +18,7 @@ func get_surface_triangles() -> Array[PackedVector3Array]: var terrain_root: Node = get_node_or_null(terrain_source) if terrain_root == null: return triangles - for mesh_instance: MeshInstance3D in _collect_mesh_instances(terrain_root): + for mesh_instance: MeshInstance3D in _terrain_mesh_instances(terrain_root): var mesh: Mesh = mesh_instance.mesh if mesh == null: continue @@ -92,6 +92,23 @@ func _append_triangle( result.append(PackedVector3Array([a, b, c])) +func _terrain_mesh_instances(terrain_root: Node) -> Array[MeshInstance3D]: + # Generated terrain can contain authored base layers beneath raised visual + # overlays. Those meshes are useful for closing terrain seams, but they are + # not necessarily the visible surface and must not produce buried dig spots. + # A terrain provider may therefore expose its authoritative primary meshes. + if terrain_root.has_method(&"get_primary_terrain_meshes"): + var provided: Variant = terrain_root.call(&"get_primary_terrain_meshes") + var meshes: Array[MeshInstance3D] = [] + if provided is Array: + for value: Variant in provided: + var mesh_instance := value as MeshInstance3D + if mesh_instance != null: + meshes.append(mesh_instance) + return meshes + return _collect_mesh_instances(terrain_root) + + func _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]: var meshes: Array[MeshInstance3D] = [] if root is MeshInstance3D: From a7c71213cc8cf5e5ec69ae053578c2be2a3e50e3 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:51:21 -0400 Subject: [PATCH 13/38] Improve beetle and net target visibility --- gathering/catalog/beetle_stag_common.tres | 2 +- gathering/gathering_controller.gd | 2 ++ tests/gathering_marker_surface_validation.gd | 2 ++ tests/generated_world_runtime_validation.gd | 4 ++++ tests/tree_gathering_prototype_validation.gd | 4 ++-- world/generation/terrain_prop_definition.gd | 5 ++++- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres index 65b15df..c8b7d85 100644 --- a/gathering/catalog/beetle_stag_common.tres +++ b/gathering/catalog/beetle_stag_common.tres @@ -19,7 +19,7 @@ scare_radius = 0.1 capture_radius = 0.34 interaction_range = 2.8 charge_duration = 1.0 -sprite_pixel_size = 0.005 +sprite_pixel_size = 0.0075 sprite_tilt_degrees = 0.0 capture_respawn_min_seconds = 90.0 capture_respawn_max_seconds = 150.0 diff --git a/gathering/gathering_controller.gd b/gathering/gathering_controller.gd index 2859145..c061db5 100644 --- a/gathering/gathering_controller.gd +++ b/gathering/gathering_controller.gd @@ -341,10 +341,12 @@ func _build_marker() -> void: _marker_invalid_material.albedo_color = MARKER_INVALID_COLOR _marker_invalid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _marker_invalid_material.roughness = 1.0 + _marker_invalid_material.no_depth_test = true _marker_valid_material = StandardMaterial3D.new() _marker_valid_material.albedo_color = MARKER_VALID_COLOR _marker_valid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _marker_valid_material.roughness = 1.0 + _marker_valid_material.no_depth_test = true var disc := CylinderMesh.new() disc.top_radius = marker_radius disc.bottom_radius = marker_radius diff --git a/tests/gathering_marker_surface_validation.gd b/tests/gathering_marker_surface_validation.gd index 8ce3b4a..72f15f0 100644 --- a/tests/gathering_marker_surface_validation.gd +++ b/tests/gathering_marker_surface_validation.gd @@ -44,6 +44,8 @@ func _run() -> void: assert(invalid_material.albedo_color == Color.WHITE) assert(is_equal_approx(invalid_material.albedo_color.a, 1.0)) assert(is_equal_approx(valid_material.albedo_color.a, 1.0)) + assert(invalid_material.no_depth_test) + assert(valid_material.no_depth_test) assert( invalid_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 6e03df7..6c4343f 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -943,6 +943,10 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(instance.position.is_zero_approx()) assert(_find_mesh_instance(instance) != null) instance.free() + if definition.has_gatherable_surface(): + assert( + definition.gatherable_surface_clearance >= 0.12 + ) var mushroom := catalog.definition_for_id(&"prop_mushroom") assert(mushroom != null and mushroom.has_collision()) assert(mushroom.visual_offset.y < 0.0) diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd index bb6d1df..2814e19 100644 --- a/tests/tree_gathering_prototype_validation.gd +++ b/tests/tree_gathering_prototype_validation.gd @@ -41,7 +41,7 @@ func _validate_beetle_data() -> void: assert(beetle.target_population_for_anchor_count(8) == 3) assert(beetle.target_population_for_anchor_count(40) == 14) assert(beetle.target_population_for_anchor_count(400) == 64) - assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) + assert(is_equal_approx(beetle.sprite_pixel_size, 0.0075)) assert(beetle.is_stationary_spawn()) assert(not beetle.can_be_scared()) @@ -128,7 +128,7 @@ func _validate_anchored_presentation() -> void: var sprite := presentation.get_node("GatherableSprite") as Sprite3D assert(sprite != null) assert(is_zero_approx(sprite.position.y)) - assert(is_equal_approx(sprite.pixel_size, 0.005)) + assert(is_equal_approx(sprite.pixel_size, 0.0075)) assert(not sprite.shaded) assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED) assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST) diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index 58fdddd..d2524eb 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -58,7 +58,10 @@ extends Resource ## Reject upward-facing branches and foliage so attachments favor trunk-like ## faces. Zero accepts only vertical faces; one accepts every orientation. @export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35 -@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025 +## Keep camera-facing creature billboards clear of the sampled trunk as they +## rotate. This is a physical anchor offset, so creatures remain hidden by the +## rest of the tree instead of rendering through it as an overlay. +@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.12 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: From 3608be41ab7faef94af34218f78475738974f32c Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 14:34:58 -0400 Subject: [PATCH 14/38] Improve tree gathering and rain occlusion --- tests/generated_world_runtime_validation.gd | 26 +++++++++ tests/world_weather_validation.gd | 31 ++++++++++ world/environment/precipitation_occlusion.gd | 56 +++++++++++++++++++ world/generation/generated_world_region.gd | 15 +++++ .../props/definitions/prop_pine.tres | 1 - .../props/definitions/prop_pine_large.tres | 1 - world/regions/starter_island_region.gd | 6 ++ world/world_time_visual_controller.gd | 31 ++++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 world/environment/precipitation_occlusion.gd diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 6c4343f..b8ea847 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -12,6 +12,9 @@ const GeneratedLakePool: FishPool = preload( const GeneratedRiverPool: FishPool = preload( "res://fish/pools/generated_river_pool.tres" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FIRST_SEED := 13001 const SECOND_SEED := 13002 const RIVER_FALLBACK_SEED := 13012 @@ -502,6 +505,12 @@ func _validate_generated_region( assert(PROCEDURAL_PROP_IDS.has(prop_id)) var definition := region.get_prop_catalog().definition_for_id(prop_id) assert(definition != null) + if definition.procedural_group in [&"grass_tree", &"sand_tree"]: + assert( + _has_precipitation_occlusion_layer(child), + "Precipitation occlusion layer missing from %s (%s)." + % [child.name, prop_id], + ) var biome_id := StringName( child.get_meta(&"terrain_biome_id", &"") ) @@ -967,10 +976,12 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(pine != null) assert(pine.minimum_visual_scale < pine.maximum_visual_scale) assert(pine.material_variants.is_empty()) + assert(not pine.has_gatherable_surface()) var large_pine := catalog.definition_for_id(&"prop_pine_large") assert(large_pine != null) assert(large_pine.minimum_visual_scale < large_pine.maximum_visual_scale) assert(large_pine.material_variants.is_empty()) + assert(not large_pine.has_gatherable_surface()) var palm := catalog.definition_for_id(&"prop_palm") assert(palm != null) assert(is_equal_approx(palm.minimum_visual_scale, 0.5)) @@ -1180,6 +1191,21 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D: return null +func _has_precipitation_occlusion_layer(root_node: Node) -> bool: + var mesh_instance := root_node as MeshInstance3D + if ( + mesh_instance != null + and mesh_instance.get_layer_mask_value( + PrecipitationOcclusionType.RENDER_LAYER_NUMBER + ) + ): + return true + for child: Node in root_node.get_children(): + if _has_precipitation_occlusion_layer(child): + return true + return false + + func _has_material_variant_override( root_node: Node, variants: Array[Material], diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index c346e93..9a53eff 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -10,6 +10,9 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeVisualControllerType = preload( "res://world/world_time_visual_controller.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishAvailabilityType = preload("res://fish/fish_availability.gd") @@ -526,6 +529,14 @@ func _validate_weather_presentation() -> void: )) var rain_material := rain.process_material as ParticleProcessMaterial assert(rain_material != null) + assert( + rain_material.collision_mode + == ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) + assert(is_equal_approx( + rain.collision_base_size, + WorldTimeVisualControllerType.RAIN_COLLISION_BASE_SIZE, + )) assert(rain_material.emission_box_extents.is_equal_approx( WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS )) @@ -542,6 +553,26 @@ func _validate_weather_presentation() -> void: assert(rain_mesh.size.is_equal_approx( WorldTimeVisualControllerType.RAIN_DROP_SIZE )) + var rain_collision := visuals.get_node( + "LocalRainCanopyCollision" + ) as GPUParticlesCollisionHeightField3D + assert(rain_collision != null) + assert(rain_collision.follow_camera_enabled) + assert( + rain_collision.resolution + == GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + assert( + rain_collision.update_mode + == GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + assert(rain_collision.size.is_equal_approx( + WorldTimeVisualControllerType.RAIN_COLLISION_SIZE + )) + assert( + rain_collision.heightfield_mask + == PrecipitationOcclusionType.RENDER_LAYER_MASK + ) visuals.call( "_on_weather_changed", WorldWeatherServiceType.Weather.SUNNY, diff --git a/world/environment/precipitation_occlusion.gd b/world/environment/precipitation_occlusion.gd new file mode 100644 index 0000000..8d27a68 --- /dev/null +++ b/world/environment/precipitation_occlusion.gd @@ -0,0 +1,56 @@ +class_name PrecipitationOcclusion +extends RefCounted + +## A secondary visual layer used only when building the local rain height field. +## Canopy meshes remain on layer 1 for ordinary cameras. +const RENDER_LAYER_NUMBER: int = 20 +const RENDER_LAYER_MASK: int = 1 << (RENDER_LAYER_NUMBER - 1) +const CANOPY_MATERIAL_NAMES: Array[String] = [ + "leaf", + "leaf_light", + "leaf_mid", + "leaf_dark", + "pine", + "tree", +] + + +static func mark_canopy_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null and _has_canopy_material(mesh_instance): + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_canopy_meshes(child) + return marked_count + + +## Generated tree definitions are already explicit terrain metadata, so every +## mesh in their visual scene can safely participate. This also supports older +## combined tree meshes whose imported material name does not identify leaves. +static func mark_tree_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null: + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_tree_meshes(child) + return marked_count + + +static func _has_canopy_material(mesh_instance: MeshInstance3D) -> bool: + if mesh_instance == null or mesh_instance.mesh == null: + return false + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.mesh.surface_get_material(surface_index) + if material == null: + continue + if material.resource_name.to_lower() in CANOPY_MATERIAL_NAMES: + return true + return false diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 06406de..c816c3b 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -12,6 +12,9 @@ const PlayerStorageInteractionType = preload( const MeshSurfaceAnchorSamplerType = preload( "res://world/generation/mesh_surface_anchor_sampler.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -292,9 +295,21 @@ func _on_generation_completed(summary: Dictionary) -> void: _place_spawn_amenities(spawn_position) _configure_fresh_water(records) _configure_diggable_area() + # Imported prop instances may finish applying their scene state while the + # generated world is assembled. Mark the final decoration tree once every + # prop and material variant is in place so rain canopy occlusion persists. + _mark_precipitation_occluders() world_generated.emit(_current_seed, summary) +func _mark_precipitation_occluders() -> void: + for prop: Node in _decorations.get_children(): + var prop_group := prop.get_meta(&"terrain_prop_group", &"") as StringName + if prop_group not in [&"grass_tree", &"sand_tree"]: + continue + PrecipitationOcclusionType.mark_tree_meshes(prop) + + func _assign_biomes( records: Array[Dictionary], summary: Dictionary, diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 062dd3b..b3224a9 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 5f5d584..df9076d 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index b787ff1..3af3010 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -11,6 +11,9 @@ const PlayerStorageInteractionType = preload( const FOLIAGE_WIND_SHADER: Shader = preload( "res://world/materials/foliage_wind.gdshader" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FOLIAGE_MATERIAL_NAMES: Array[StringName] = [ &"leaf", &"leaf_light", @@ -60,6 +63,9 @@ var _foliage_wind_enabled: bool = false func _ready() -> void: if rebuild_terrain_collision_on_ready or not has_terrain_collision(): _build_terrain_collision() + PrecipitationOcclusionType.mark_canopy_meshes( + get_node_or_null(terrain_visual_root_path) + ) _set_foliage_wind_enabled(true) if Engine.is_editor_hint(): update_configuration_warnings() diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index fd743ed..5328c54 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -17,6 +17,8 @@ const LIGHT_RAIN_FIXED_FPS: int = 8 const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) +const RAIN_COLLISION_BASE_SIZE: float = 0.08 +const RAIN_COLLISION_SIZE := Vector3(28.0, 24.0, 28.0) const FOG_DAYLIGHT_SCENE_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_WATER_BRIGHTNESS: float = 0.42 @@ -37,6 +39,9 @@ const FRESH_WATER_MATERIAL: ShaderMaterial = preload( const LocalStormCloudLayerType = preload( "res://world/environment/local_storm_cloud_layer.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) @@ -88,6 +93,7 @@ var _rain_camera_provider: Callable var _environment: Environment var _sky_material: ShaderMaterial var _rain: GPUParticles3D +var _rain_collision: GPUParticlesCollisionHeightField3D var _storm_clouds: LocalStormCloudLayer var _elapsed: float = 0.0 var _weather_from: WorldWeatherService.Weather = ( @@ -211,6 +217,7 @@ func _prepare_rain() -> void: LIGHT_RAIN_FIXED_FPS if _light_performance_profile else 30 ) _rain.local_coords = false + _rain.collision_base_size = RAIN_COLLISION_BASE_SIZE _rain.visibility_aabb = RAIN_VISIBILITY_AABB var process_material := ParticleProcessMaterial.new() process_material.emission_shape = ( @@ -222,6 +229,9 @@ func _prepare_rain() -> void: process_material.initial_velocity_min = RAIN_VELOCITY_MIN process_material.initial_velocity_max = RAIN_VELOCITY_MAX process_material.gravity = Vector3(0.0, -2.0, 0.0) + process_material.collision_mode = ( + ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) _rain.process_material = process_material var drop_mesh := BoxMesh.new() drop_mesh.size = RAIN_DROP_SIZE @@ -232,9 +242,30 @@ func _prepare_rain() -> void: drop_mesh.material = drop_material _rain.draw_pass_1 = drop_mesh add_child(_rain) + _prepare_rain_collision() _update_rain_position() +func _prepare_rain_collision() -> void: + _rain_collision = GPUParticlesCollisionHeightField3D.new() + _rain_collision.name = "LocalRainCanopyCollision" + _rain_collision.size = RAIN_COLLISION_SIZE + _rain_collision.resolution = ( + GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + _rain_collision.update_mode = ( + GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + _rain_collision.follow_camera_enabled = true + _rain_collision.heightfield_mask = ( + PrecipitationOcclusionType.RENDER_LAYER_MASK + ) + # Local rain remains on its normal layer; unrelated particle systems do not + # need to query this weather-specific collision field. + _rain_collision.cull_mask = 1 + add_child(_rain_collision) + + func _prepare_storm_clouds() -> void: _storm_clouds = LocalStormCloudLayerType.new() _storm_clouds.name = "LocalStormClouds" From 05838406d0e627bf54da175b069184dd937ddf8c Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 16:31:55 -0400 Subject: [PATCH 15/38] Refresh generated terrain assets and projection --- tests/generated_world_runtime_validation.gd | 85 +++++++++++++++--- tools/blender/export_terrain_chunks.py | 68 ++++++++++++-- world/generation/chunks/assets/chunk_0000.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0001.glb | Bin 7660 -> 7660 bytes world/generation/chunks/assets/chunk_0002.glb | Bin 14472 -> 14472 bytes world/generation/chunks/assets/chunk_0003.glb | Bin 24372 -> 24348 bytes world/generation/chunks/assets/chunk_0004.glb | Bin 27748 -> 25744 bytes world/generation/chunks/assets/chunk_0005.glb | Bin 17972 -> 20480 bytes world/generation/chunks/assets/chunk_0006.glb | Bin 33760 -> 33952 bytes world/generation/chunks/assets/chunk_0007.glb | Bin 19004 -> 19004 bytes world/generation/chunks/assets/chunk_0008.glb | Bin 17996 -> 17996 bytes world/generation/chunks/assets/chunk_0009.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0010.glb | Bin 19908 -> 20036 bytes world/generation/chunks/assets/chunk_0011.glb | Bin 19428 -> 19420 bytes world/generation/chunks/assets/chunk_0012.glb | Bin 30804 -> 30816 bytes world/generation/chunks/assets/chunk_0013.glb | Bin 27908 -> 27908 bytes world/generation/chunks/assets/chunk_0014.glb | Bin 19420 -> 19420 bytes world/generation/chunks/assets/chunk_0015.glb | Bin 19908 -> 20040 bytes world/generation/chunks/assets/chunk_0016.glb | Bin 26224 -> 26200 bytes world/generation/chunks/assets/chunk_0017.glb | Bin 27692 -> 27632 bytes world/generation/chunks/assets/chunk_0018.glb | Bin 26616 -> 26528 bytes world/generation/chunks/assets/chunk_0019.glb | Bin 6996 -> 6972 bytes world/generation/chunks/assets/chunk_0020.glb | Bin 25916 -> 25852 bytes world/generation/chunks/assets/chunk_0021.glb | Bin 25860 -> 26724 bytes world/generation/chunks/assets/chunk_0022.glb | Bin 25988 -> 26292 bytes world/generation/chunks/assets/chunk_0023.glb | Bin 25988 -> 26292 bytes world/generation/chunks/assets/chunk_0024.glb | Bin 28540 -> 28544 bytes world/generation/chunks/assets/chunk_0025.glb | Bin 30292 -> 30760 bytes world/generation/chunks/assets/chunk_0026.glb | Bin 19988 -> 19980 bytes world/generation/chunks/assets/chunk_0027.glb | Bin 21096 -> 21160 bytes world/generation/chunks/assets/chunk_0028.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0029.glb | Bin 36656 -> 36644 bytes world/generation/chunks/assets/chunk_0030.glb | Bin 36656 -> 36648 bytes world/generation/chunks/assets/chunk_0031.glb | Bin 37872 -> 38052 bytes world/generation/chunks/assets/chunk_0032.glb | Bin 37868 -> 38052 bytes world/generation/chunks/assets/chunk_0033.glb | Bin 25700 -> 25700 bytes world/generation/chunks/assets/chunk_0034.glb | Bin 30684 -> 30680 bytes world/generation/chunks/assets/chunk_0035.glb | Bin 30304 -> 30292 bytes world/generation/chunks/assets/chunk_0036.glb | Bin 29524 -> 29524 bytes world/generation/chunks/assets/chunk_0037.glb | Bin 27692 -> 27592 bytes world/generation/generated_world_region.gd | 64 ++++++------- world/generation/props/assets/prop_palm.glb | Bin 26408 -> 26244 bytes world/generation/props/assets/prop_pine.glb | Bin 9408 -> 10168 bytes .../props/assets/prop_pine_large.glb | Bin 9624 -> 9500 bytes world/generation/props/assets/prop_tree_1.glb | Bin 8416 -> 8416 bytes world/generation/props/assets/prop_tree_2.glb | Bin 6324 -> 6328 bytes world/generation/props/assets/prop_tree_3.glb | Bin 7316 -> 7284 bytes .../props/assets/prop_tree_large.glb | Bin 4304 -> 13200 bytes .../generation/terrain_chunk_edge_profile.gd | 38 ++++++++ world/generation/terrain_chunk_generator.gd | 67 +++++++++++++- world/materials/generated_terrain_dirt.tres | 16 ++++ world/materials/generated_terrain_grass.tres | 16 ++++ world/materials/generated_terrain_sand.tres | 16 ++++ 53 files changed, 319 insertions(+), 51 deletions(-) create mode 100644 world/materials/generated_terrain_dirt.tres create mode 100644 world/materials/generated_terrain_grass.tres create mode 100644 world/materials/generated_terrain_sand.tres diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index b8ea847..ef933bf 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -481,6 +481,9 @@ func _validate_generated_region( assert(river_body_count == river_placement_count) _validate_authored_chunk_surfaces(region, generator) + _validate_projected_terrain_materials( + generator.get_generated_chunks_root() + ) var decorations := region.get_node("Decorations") as Node3D var anchors := region.get_node( @@ -1147,21 +1150,28 @@ func _validate_tree_gatherable_anchors( anchors: GatherableAnchorSet3D, ) -> void: var eligible_props: Dictionary[StringName, Node3D] = {} + var expected_anchor_count := 0 for child: Node in decorations.get_children(): var prop := child as Node3D if prop == null: continue var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - if definition != null and definition.has_gatherable_surface(): + var socket_count := _count_authored_beetle_sockets(prop) + if ( + definition != null + and definition.has_gatherable_surface() + and socket_count > 0 + ): eligible_props[prop.name] = prop + expected_anchor_count += socket_count var positions := anchors.get_spawn_positions() - assert(positions.size() == eligible_props.size()) + assert(positions.size() == expected_anchor_count) assert(positions.size() >= 12) for child: Node in anchors.get_children(): var anchor := child as Marker3D assert(anchor != null) - assert(bool(anchor.get_meta(&"mesh_surface_sampled", false))) + assert(bool(anchor.get_meta(&"authored_beetle_socket", false))) var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &"")) assert(eligible_props.has(prop_name)) var prop: Node3D = eligible_props[prop_name] @@ -1169,14 +1179,17 @@ func _validate_tree_gatherable_anchors( var definition := region.get_prop_catalog().definition_for_id(prop_id) assert(definition != null and definition.has_gatherable_surface()) var local_anchor := prop.to_local(anchor.global_position) - assert( - local_anchor.y - >= definition.gatherable_surface_minimum_height - 0.001 - ) - assert( - local_anchor.y - <= definition.gatherable_surface_maximum_height + 0.001 - ) + assert(prop_id != &"prop_palm") + assert(local_anchor.y >= 0.25 and local_anchor.y <= 2.0) + + +func _count_authored_beetle_sockets(root_node: Node) -> int: + var result := 0 + if String(root_node.name).to_lower().contains("beetle_socket"): + result += 1 + for child: Node in root_node.get_children(): + result += _count_authored_beetle_sockets(child) + return result func _find_mesh_instance(root_node: Node) -> MeshInstance3D: @@ -1357,6 +1370,56 @@ func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: return result +func _validate_projected_terrain_materials(root: Node) -> void: + var expected_sizes := { + "grass_lite": 1.75, + "sand": 2.6, + "dirt": 2.5, + } + var surface_counts := { + "grass_lite": 0, + "sand": 0, + "dirt": 0, + } + _validate_projected_material_node(root, expected_sizes, surface_counts) + for material_name: String in expected_sizes: + assert(surface_counts[material_name] > 0) + + +func _validate_projected_material_node( + root: Node, + expected_sizes: Dictionary, + surface_counts: Dictionary, +) -> void: + var mesh_instance := root as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.get_active_material(surface_index) + if material == null or not expected_sizes.has(material.resource_name): + continue + var shader_material := material as ShaderMaterial + assert(shader_material != null and shader_material.shader != null) + assert( + shader_material.shader.resource_path + == "res://world/materials/terrain_surface_projection.gdshader" + ) + assert( + is_equal_approx( + float(shader_material.get_shader_parameter( + &"tile_world_size" + )), + float(expected_sizes[material.resource_name]), + ) + ) + surface_counts[material.resource_name] += 1 + for child: Node in root.get_children(): + _validate_projected_material_node( + child, + expected_sizes, + surface_counts, + ) + + func _validate_pond_collision( region: GeneratedWorldRegion, pond: MeshInstance3D, diff --git a/tools/blender/export_terrain_chunks.py b/tools/blender/export_terrain_chunks.py index 2c35982..ced081e 100644 --- a/tools/blender/export_terrain_chunks.py +++ b/tools/blender/export_terrain_chunks.py @@ -10,10 +10,11 @@ Run through Blender rather than a standalone Python interpreter: Collections use ``chunk_####_description`` and contain one primary terrain mesh named ``chunk_####``. Additional production objects may live in the same collection. Reusable procedural props use individual ``prop_description`` -mesh objects. They may be arranged anywhere in the source file because each -object's authored origin becomes the exported runtime anchor. A same-named -``prop_description`` collection remains supported for multi-object props. -Unrelated objects and collections are ignored. +mesh objects. Their child empties are exported with them so authored sockets +remain attached to the prop. Props may be arranged anywhere in the source file +because each root object's authored origin becomes the exported runtime anchor. +A same-named ``prop_description`` collection remains supported for multi-object +props. Unrelated objects and collections are ignored. """ from __future__ import annotations @@ -43,6 +44,12 @@ PROP_COLLECTION_PATTERN = re.compile( r"^prop_(?P