diff --git a/art/exported/characters/base/netfishing_base_character.glb b/art/exported/characters/base/netfishing_base_character.glb index 71373ea..ef871b4 100644 Binary files a/art/exported/characters/base/netfishing_base_character.glb and b/art/exported/characters/base/netfishing_base_character.glb differ diff --git a/art/source/characters/base/netfishing_character_source.blend b/art/source/characters/base/netfishing_character_source.blend index de588af..7374eb9 100644 Binary files a/art/source/characters/base/netfishing_character_source.blend and b/art/source/characters/base/netfishing_character_source.blend differ diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index cf8da57..cdd4d4f 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -67,7 +67,6 @@ signal showcase_changed( signal bite_activated signal bite_prompt_changed(is_visible: bool) signal ready_for_equipment_refresh -signal fish_showcase_toggle_requested signal art_ui_toggle_requested enum FishingState { @@ -510,13 +509,6 @@ func _unhandled_input(event: InputEvent) -> void: FishingState.READY: if not _new_cast_press_armed: return - if ( - _local_hotbar != null - and not _local_hotbar.get_selected_fish_catch_id().is_empty() - ): - fish_showcase_toggle_requested.emit() - get_viewport().set_input_as_handled() - return var active_item: ItemDataType = _get_active_item() if ( active_item != null @@ -1134,7 +1126,7 @@ func _on_catch_completed() -> void: return _stop_fight_audio() _active_player.set_fighting_visual(false) - _active_player.set_fishing_visual(false) + _active_player.set_retract_visual() state = FishingState.SHOWING_CATCH _showcase_ready = false _showcase_outcome_completed = false @@ -1156,6 +1148,12 @@ func _on_catch_escaped() -> void: func _on_outcome_completed(outcome: StringName) -> void: + var retracting_player := _active_player + if ( + retracting_player != null + and not retracting_player.is_retract_visual_complete() + ): + await retracting_player.retract_visual_finished if state == FishingState.RETURNING: var cleanup_message: String = _pending_cleanup_message _pending_cleanup_message = "" @@ -1260,7 +1258,15 @@ func _cleanup_attempt( _stop_reeling_audio() if _active_player != null: _active_player.set_fighting_visual(false) - _active_player.set_fishing_visual(false) + if visual_outcome.is_empty(): + _active_player.set_fishing_visual(false) + elif visual_outcome == &"catch": + # A successful catch already retracted the line before the fish + # showcase. Returning from the pocket transition should settle back + # to idle instead of replaying the retract animation. + _active_player.set_fishing_visual(false) + else: + _active_player.set_retract_visual() if not visual_outcome.is_empty(): if state == FishingState.RETURNING: return @@ -1746,6 +1752,8 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void: PackedInt32Array(), -1, false ) _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) + if _active_player != null: + _active_player.set_retract_visual() _presentation.play_outcome(&"catch") diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd index 980a298..cb479b9 100644 --- a/fishing/remote_fishing_presentation.gd +++ b/fishing/remote_fishing_presentation.gd @@ -100,7 +100,7 @@ func show_bite() -> void: func play_return(showcase_catch: FishCatch = null) -> void: if _owner != null and is_instance_valid(_owner): _owner.set_fighting_visual(false) - _owner.set_fishing_visual(false) + _owner.set_retract_visual() if not _active or _bobber == null: cleanup() return_completed.emit() @@ -199,6 +199,12 @@ func _on_return_finished() -> void: _bobber.visible = false _line.visible = false _line_mesh.clear_surfaces() + if ( + _owner != null + and is_instance_valid(_owner) + and not _owner.is_retract_visual_complete() + ): + await _owner.retract_visual_finished if ( _return_showcase_catch != null and _owner != null @@ -216,7 +222,12 @@ func _on_return_finished() -> void: func _on_showcase_finished() -> void: _showcase_tween = null if _owner != null and is_instance_valid(_owner): - _owner.end_catch_showcase() + _owner.end_catch_showcase(_on_showcase_put_away_finished) + return + _on_showcase_put_away_finished() + + +func _on_showcase_put_away_finished() -> void: return_completed.emit() diff --git a/main/main.gd b/main/main.gd index 460ac62..29396b7 100644 --- a/main/main.gd +++ b/main/main.gd @@ -50,6 +50,7 @@ const PlayerSpawnServiceType = preload( const NetworkFishingServiceType = preload( "res://network/network_fishing_service.gd" ) +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const NetworkSaleServiceType = preload( "res://network/network_sale_service.gd" ) @@ -645,16 +646,13 @@ func _initialize_after_data_root() -> void: pause_menu.menu_visibility_changed.connect( _game_ui.set_system_menu_open ) - _player.hotbar.selected_slot_changed.connect( + _player.hotbar.selected_assignment_changed.connect( _on_active_hotbar_item_changed ) _player.bag.contents_changed.connect(_refresh_active_hotbar_item) _fishing_spot.ready_for_equipment_refresh.connect( _refresh_active_hotbar_item ) - _fishing_spot.fish_showcase_toggle_requested.connect( - _network_fish_showcase.toggle_selected_fish - ) _water_recovery.recovery_starting.connect( _on_water_recovery_starting ) @@ -664,10 +662,7 @@ func _initialize_after_data_root() -> void: _water_recovery.local_respawn_completed.connect( _on_local_respawn_completed ) - _on_active_hotbar_item_changed( - _player.hotbar.get_selected_slot(), - _player.hotbar.get_selected_item_id() - ) + _refresh_active_hotbar_item() _show_title_music(true) @@ -1633,17 +1628,23 @@ func _on_remote_recovery_presentation_changed( func _on_active_hotbar_item_changed( _slot_index: int, - item_id: StringName, + kind: int, + identity: StringName, ) -> void: if _fishing_spot.state != FishingSpotType.FishingState.READY: return + var item_id: StringName = ( + identity + if kind == PlayerHotbarType.AssignmentKind.ITEM + else StringName() + ) var item = item_catalog.get_item_by_id(item_id) var active_is_rod: bool = ( item != null and item.category == ItemDataType.Category.ROD and _player.bag.owns_item(item_id) ) - _player.set_active_item_is_rod(active_is_rod) + _player.set_active_item_is_rod(active_is_rod, true) _player.set_active_art_kit( item.icon if item != null else null, item_id == ArtShopStockType.ART_KIT_ITEM_ID @@ -1657,9 +1658,18 @@ func _on_active_hotbar_item_changed( func _refresh_active_hotbar_item() -> void: + var kind: int = ( + _player.hotbar.get_selected_assignment_kind() + ) + var identity: StringName = StringName() + if kind == PlayerHotbarType.AssignmentKind.ITEM: + identity = _player.hotbar.get_selected_item_id() + elif kind == PlayerHotbarType.AssignmentKind.FISH: + identity = _player.hotbar.get_selected_fish_catch_id() _on_active_hotbar_item_changed( _player.hotbar.get_selected_slot(), - _player.hotbar.get_selected_item_id() + kind, + identity, ) diff --git a/network/network_fish_showcase_service.gd b/network/network_fish_showcase_service.gd index 4908e4f..068ee7d 100644 --- a/network/network_fish_showcase_service.gd +++ b/network/network_fish_showcase_service.gd @@ -39,6 +39,19 @@ func setup( _local_hotbar.selected_assignment_changed.connect( _on_selected_assignment_changed ) + var selected_kind: int = ( + _local_hotbar.get_selected_assignment_kind() + ) + var selected_identity: StringName = StringName() + if selected_kind == PlayerHotbarType.AssignmentKind.ITEM: + selected_identity = _local_hotbar.get_selected_item_id() + elif selected_kind == PlayerHotbarType.AssignmentKind.FISH: + selected_identity = _local_hotbar.get_selected_fish_catch_id() + _on_selected_assignment_changed( + _local_hotbar.get_selected_slot(), + selected_kind, + selected_identity, + ) func toggle_selected_fish() -> bool: @@ -180,7 +193,7 @@ func _apply_state(data: Dictionary) -> void: if avatar == null: return if not bool(data["visible"]): - avatar.set_held_fish(null, 1.0, false) + avatar.set_held_fish(null, 1.0, false, true) return var fish: FishDataType = _fish_catalog.get_fish_by_id( StringName(str(data["fish_id"])) @@ -197,13 +210,16 @@ func _on_selected_assignment_changed( kind: int, identity: StringName, ) -> void: - if ( - _local_visible - and ( - kind != PlayerHotbarType.AssignmentKind.FISH - or identity != _local_catch_id - ) - ): + if kind == PlayerHotbarType.AssignmentKind.FISH: + if _local_visible and identity == _local_catch_id: + return + var fish_catch: FishCatchType = _local_inventory.get_catch_by_id(identity) + if fish_catch != null and fish_catch.is_valid(): + _submit_local_state(fish_catch, true) + elif _local_visible: + _submit_local_state(null, false) + return + if _local_visible: _submit_local_state(null, false) diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 34b0530..f39d198 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -353,7 +353,10 @@ func _apply_equipped(data: Dictionary) -> void: if avatar != null: var item_id := StringName(str(data["item_id"])) var item: ItemData = _catalog.get_item_by_id(item_id) - avatar.set_active_item_is_rod(int(data["category"]) == ItemData.Category.ROD) + avatar.set_active_item_is_rod( + int(data["category"]) == ItemData.Category.ROD, + true, + ) avatar.set_active_art_kit( item.icon if item != null else null, item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]), diff --git a/player/fishing_rod_attachment.tscn b/player/fishing_rod_attachment.tscn index b7f18f0..46da419 100644 --- a/player/fishing_rod_attachment.tscn +++ b/player/fishing_rod_attachment.tscn @@ -10,10 +10,9 @@ albedo_color = Color(0.22, 0.12, 0.06, 1) roughness = 0.8 [node name="FishingRodAttachment" type="BoneAttachment3D"] -bone_name = "hand.R" +bone_name = "rod_socket" [node name="FishingRod" type="Node3D" parent="."] -rotation_degrees = Vector3(65, 0, 0) [node name="RodMesh" type="MeshInstance3D" parent="FishingRod"] position = Vector3(0, 0.5, 0) diff --git a/player/player.gd b/player/player.gd index 5e2176f..bfe9b25 100644 --- a/player/player.gd +++ b/player/player.gd @@ -35,14 +35,26 @@ const HeldItemAttachmentScene = preload( "res://player/held_item_attachment.tscn" ) +signal retract_visual_finished + const CHARACTER_IDLE_ANIMATION: StringName = &"idle" const CHARACTER_IDLE_SHOW_ANIMATION: StringName = &"idle_show" const CHARACTER_IDLE_SIT_ANIMATION: StringName = &"idle_sit" const CHARACTER_IDLE_SIT_SHOW_ANIMATION: StringName = &"idle_sit_show" const CHARACTER_WALKING_ANIMATION: StringName = &"walking" const CHARACTER_WALKING_SHOW_ANIMATION: StringName = &"walking_show" +const CHARACTER_RUNNING_ANIMATION: StringName = &"running" +const CHARACTER_RUNNING_SHOW_ANIMATION: StringName = &"running_show" const CHARACTER_CASTING_ANIMATION: StringName = &"casting" +const CHARACTER_CASTING_SIT_ANIMATION: StringName = &"casting_sit" const CHARACTER_RELEASE_ANIMATION: StringName = &"release" +const CHARACTER_RELEASE_SIT_ANIMATION: StringName = &"release_sit" +const CHARACTER_RETRACT_ANIMATION: StringName = &"retract" +const CHARACTER_RETRACT_SIT_ANIMATION: StringName = &"retract_sit" +const CHARACTER_POCKET_IDLE_IDLE_ANIMATION: StringName = &"pocket_idle_idle" +const CHARACTER_POCKET_IDLE_SHOW_ANIMATION: StringName = &"pocket_idle_show" +const CHARACTER_POCKET_SHOW_SHOW_ANIMATION: StringName = &"pocket_show_show" +const CHARACTER_POCKET_SHOW_IDLE_ANIMATION: StringName = &"pocket_show_idle" const CHARACTER_FISHING_ANIMATION: StringName = &"fishing" const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit" const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting" @@ -52,8 +64,17 @@ enum FishingVisualPhase { NONE, CASTING, RELEASE, + RETRACT, FISHING, } + +enum PocketVisualTarget { + NONE, + IDLE_ITEM, + HELD_FISH, + ART_KIT, + CATCH_SHOWCASE, +} const FIGHTING_EYES_ID: String = "alligator_eyes" const BASE_REEL_SPEED: float = 0.16 # The target Android handheld exposes its physical right trigger through @@ -130,6 +151,18 @@ func set_fighting_visual(active: bool) -> void: func set_fishing_visual(active: bool) -> void: + if ( + active + and _fishing_visual_phase == FishingVisualPhase.RELEASE + and _character_animation_name in [ + CHARACTER_RELEASE_ANIMATION, + CHARACTER_RELEASE_SIT_ANIMATION, + ] + and _character_animation_player != null + and _character_animation_player.is_playing() + ): + _fishing_after_release_pending = true + return _set_fishing_visual_phase( FishingVisualPhase.FISHING if active else FishingVisualPhase.NONE ) @@ -143,9 +176,32 @@ func set_release_visual() -> void: _set_fishing_visual_phase(FishingVisualPhase.RELEASE) +func set_retract_visual() -> void: + _retract_animation_completed = false + _set_fishing_visual_phase(FishingVisualPhase.RETRACT) + if ( + _character_animation_player == null + or _character_animation_name not in [ + CHARACTER_RETRACT_ANIMATION, + CHARACTER_RETRACT_SIT_ANIMATION, + ] + or not _character_animation_player.is_playing() + ): + _complete_retract_animation() + + func _set_fishing_visual_phase(phase: FishingVisualPhase) -> void: + if phase != FishingVisualPhase.NONE and _pocket_visual_active: + _complete_pocket_visual() if _fishing_visual_phase == phase: return + if ( + _fishing_visual_phase == FishingVisualPhase.RETRACT + and phase != FishingVisualPhase.RETRACT + ): + _complete_retract_animation() + if phase != FishingVisualPhase.RELEASE: + _fishing_after_release_pending = false _fishing_visual_phase = phase _fishing_visual_active = phase != FishingVisualPhase.NONE _character_animation_name = &"" @@ -304,19 +360,39 @@ var _sit_after_landing: bool = false var _sitting_intent_pending: bool = false var _sitting_intent_sequence: int = -1 var _held_fish_visible: bool = false +var _pending_held_fish_texture: Texture2D +var _pending_held_fish_scale: Vector3 = Vector3.ONE +var _held_art_kit_visible: bool = false var _showcase_animation_active: bool = false +var _pocket_visual_active: bool = false +var _pocket_visual_target: PocketVisualTarget = PocketVisualTarget.NONE +var _pocket_visual_animation: StringName = &"" +var _pocket_visual_finished_callback: Callable +var _pocket_visual_callback_runs_on_interrupt: bool = true +var _pocket_visual_midpoint_callback: Callable +var _pocket_visual_midpoint_called: bool = false +var _pocket_visual_generation: int = 0 var _fighting_visual_active: bool = false var _fishing_visual_active: bool = false var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE +var _fishing_after_release_pending: bool = false +var _retract_animation_completed: bool = true var _fishing_rod: Node3D var _fishing_rod_tip: Marker3D -var _fishing_rod_skeleton: Skeleton3D -var _fishing_rod_hand_bone: int = -1 -var _fishing_rod_relative_basis: Basis = Basis.IDENTITY +var _active_item_is_rod: bool = false var _controller_mapping_manager: ControllerMappingManagerType func _ready() -> void: + if ( + _character_animation_player != null + and not _character_animation_player.animation_finished.is_connected( + _on_character_animation_finished + ) + ): + _character_animation_player.animation_finished.connect( + _on_character_animation_finished + ) if not bag.contents_changed.is_connected(_on_bag_contents_changed): bag.contents_changed.connect(_on_bag_contents_changed) _apply_presented_appearance() @@ -362,38 +438,6 @@ func _initialize_fishing_rod() -> void: _fishing_rod_tip = attachment.get_node( "FishingRod/FishingRodTip" ) as Marker3D - _fishing_rod_skeleton = skeleton - _fishing_rod_hand_bone = skeleton.find_bone(&"hand.R") - if _fishing_rod_hand_bone < 0: - push_error("Player character right-hand bone is unavailable.") - return - _fishing_rod_relative_basis = ( - skeleton.global_basis.orthonormalized().inverse() - * _fishing_rod.global_basis.orthonormalized() - ) - _fishing_rod.top_level = true - _sync_fishing_rod_transform() - - -func _sync_fishing_rod_transform() -> void: - if ( - _fishing_rod == null - or _fishing_rod_skeleton == null - or _fishing_rod_hand_bone < 0 - ): - return - var skeleton_transform := _fishing_rod_skeleton.global_transform - var hand_pose := _fishing_rod_skeleton.get_bone_global_pose( - _fishing_rod_hand_bone - ) - var fixed_basis := ( - skeleton_transform.basis.orthonormalized() - * _fishing_rod_relative_basis - ) - _fishing_rod.global_transform = Transform3D( - fixed_basis.scaled(skeleton_transform.basis.get_scale().abs()), - (skeleton_transform * hand_pose).origin, - ) func _initialize_held_item_attachment() -> void: @@ -423,7 +467,6 @@ func _initialize_held_item_attachment() -> void: func _physics_process(delta: float) -> void: - _sync_fishing_rod_transform() if _network_interpolation_enabled: _update_network_interpolation(delta) return @@ -516,7 +559,6 @@ func _physics_process(delta: float) -> void: func _process(delta: float) -> void: - _sync_fishing_rod_transform() _update_character_animation() # The hand bone supplies the attachment position, but its animated wrist # rotation should not turn the flat fish/catch artwork edge-on. Keep each @@ -613,8 +655,22 @@ func _update_character_animation() -> void: velocity.x * velocity.x + velocity.z * velocity.z ) var is_walking: bool = horizontal_speed_squared > 0.0025 + var fastest_non_sprint_speed := maxf( + walk_speed, + maxf(sneak_speed, slow_walk_speed) + ) + var running_threshold := fastest_non_sprint_speed + 0.5 + var is_running := ( + is_walking + and horizontal_speed_squared > running_threshold * running_threshold + ) + var held_show_item_visible: bool = ( + _held_fish_visible or _held_art_kit_visible + ) var requested_animation: Array[StringName] = [] - if _showcase_animation_active: + if _pocket_visual_active: + requested_animation = [_pocket_visual_animation] + elif _showcase_animation_active: if _sitting: requested_animation = [ CHARACTER_IDLE_SIT_SHOW_ANIMATION, @@ -644,11 +700,32 @@ func _update_character_animation() -> void: ] elif _fishing_visual_active: if _sitting: - requested_animation = [ - CHARACTER_FISHING_SIT_ANIMATION, - CHARACTER_IDLE_SIT_ANIMATION, - &"idle_sit_loop", - ] + match _fishing_visual_phase: + FishingVisualPhase.CASTING: + requested_animation = [ + CHARACTER_CASTING_SIT_ANIMATION, + CHARACTER_FISHING_SIT_ANIMATION, + CHARACTER_IDLE_SIT_ANIMATION, + ] + FishingVisualPhase.RELEASE: + requested_animation = [ + CHARACTER_RELEASE_SIT_ANIMATION, + CHARACTER_FISHING_SIT_ANIMATION, + CHARACTER_IDLE_SIT_ANIMATION, + ] + FishingVisualPhase.RETRACT: + requested_animation = [ + CHARACTER_RETRACT_SIT_ANIMATION, + CHARACTER_RETRACT_ANIMATION, + CHARACTER_FISHING_SIT_ANIMATION, + CHARACTER_IDLE_SIT_ANIMATION, + ] + _: + requested_animation = [ + CHARACTER_FISHING_SIT_ANIMATION, + CHARACTER_IDLE_SIT_ANIMATION, + &"idle_sit_loop", + ] else: match _fishing_visual_phase: FishingVisualPhase.CASTING: @@ -663,13 +740,19 @@ func _update_character_animation() -> void: CHARACTER_FISHING_ANIMATION, CHARACTER_IDLE_ANIMATION, ] + FishingVisualPhase.RETRACT: + requested_animation = [ + CHARACTER_RETRACT_ANIMATION, + CHARACTER_FISHING_ANIMATION, + CHARACTER_IDLE_ANIMATION, + ] _: requested_animation = [ CHARACTER_FISHING_ANIMATION, CHARACTER_IDLE_ANIMATION, ] elif _sitting: - if _held_fish_visible: + if held_show_item_visible: requested_animation = [ CHARACTER_IDLE_SIT_SHOW_ANIMATION, &"idle_sit_show_loop", @@ -684,12 +767,26 @@ func _update_character_animation() -> void: &"sitting", &"idle_loop_sit", ] - elif is_walking and _held_fish_visible: + elif is_running and held_show_item_visible: + requested_animation = [ + CHARACTER_RUNNING_SHOW_ANIMATION, + &"running_show_loop", + CHARACTER_WALKING_SHOW_ANIMATION, + &"walking_show_loop", + ] + elif is_running: + requested_animation = [ + CHARACTER_RUNNING_ANIMATION, + &"running_loop", + CHARACTER_WALKING_ANIMATION, + &"walking_loop", + ] + elif is_walking and held_show_item_visible: requested_animation = [ CHARACTER_WALKING_SHOW_ANIMATION, &"walking_show_loop", ] - elif _held_fish_visible: + elif held_show_item_visible: requested_animation = [ CHARACTER_IDLE_SHOW_ANIMATION, &"idle_show_loop", @@ -716,6 +813,45 @@ func _update_character_animation() -> void: _character_animation_name = next_animation +func _on_character_animation_finished(animation_name: StringName) -> void: + if animation_name in [ + CHARACTER_POCKET_IDLE_IDLE_ANIMATION, + CHARACTER_POCKET_IDLE_SHOW_ANIMATION, + CHARACTER_POCKET_SHOW_SHOW_ANIMATION, + CHARACTER_POCKET_SHOW_IDLE_ANIMATION, + ]: + if _pocket_visual_active: + _complete_pocket_visual() + return + if animation_name in [ + CHARACTER_RETRACT_ANIMATION, + CHARACTER_RETRACT_SIT_ANIMATION, + ]: + _complete_retract_animation() + return + if ( + not _fishing_after_release_pending + or animation_name not in [ + CHARACTER_RELEASE_ANIMATION, + CHARACTER_RELEASE_SIT_ANIMATION, + ] + ): + return + _fishing_after_release_pending = false + _set_fishing_visual_phase(FishingVisualPhase.FISHING) + + +func is_retract_visual_complete() -> bool: + return _retract_animation_completed + + +func _complete_retract_animation() -> void: + if _retract_animation_completed: + return + _retract_animation_completed = true + retract_visual_finished.emit() + + func toggle_sitting() -> void: if _sit_after_landing: _sit_after_landing = false @@ -1006,6 +1142,9 @@ func capture_network_input(sequence: int) -> Dictionary: "sneak": false, "slow_walk": false, "sitting": _sitting, + "casting": ( + _fishing_visual_phase == FishingVisualPhase.CASTING + ), } var axis: Vector2 = Input.get_vector( "move_left", @@ -1022,6 +1161,7 @@ func capture_network_input(sequence: int) -> Dictionary: "sneak": Input.is_action_pressed("sneak"), "slow_walk": Input.is_action_pressed("slow_walk"), "sitting": _sitting, + "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, } @@ -1039,6 +1179,7 @@ func apply_authoritative_network_input(data: Dictionary) -> void: _network_sprint = bool(data.get("sprint", false)) _network_sneak = bool(data.get("sneak", false)) _network_slow_walk = bool(data.get("slow_walk", false)) + _apply_network_casting(bool(data.get("casting", false))) var sitting_requested: bool = bool(data.get("sitting", false)) if sitting_requested and not is_on_floor(): _sit_after_landing = true @@ -1057,6 +1198,7 @@ func make_network_snapshot(peer_id: int) -> Dictionary: "visual_yaw": _visuals.rotation.y, "grounded": is_on_floor(), "sitting": _sitting, + "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, } @@ -1068,6 +1210,7 @@ func push_network_snapshot(snapshot: Dictionary) -> void: _network_target_velocity = parsed["velocity"] _network_target_visual_yaw = parsed["visual_yaw"] _network_snapshot_age = 0.0 + _apply_network_casting(bool(parsed["casting"])) _set_sitting(bool(parsed["sitting"])) if not _network_snapshot_ready: global_position = _network_target_position @@ -1108,6 +1251,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void: global_position = parsed["position"] velocity = parsed["velocity"] _visuals.rotation.y = parsed["visual_yaw"] + _apply_network_casting(bool(parsed["casting"])) _set_sitting(bool(parsed["sitting"])) _network_target_position = global_position _network_target_velocity = velocity @@ -1151,6 +1295,9 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: if snapshot.has("sitting") and typeof(snapshot.get("sitting")) != TYPE_BOOL: return {} var sitting: bool = bool(snapshot.get("sitting", false)) + if snapshot.has("casting") and typeof(snapshot.get("casting")) != TYPE_BOOL: + return {} + var casting: bool = bool(snapshot.get("casting", false)) if ( not parsed_position.is_finite() or not parsed_velocity.is_finite() @@ -1163,9 +1310,18 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: "visual_yaw": visual_yaw, "acknowledged_input": acknowledged_input, "sitting": sitting, + "casting": casting, } +func _apply_network_casting(casting: bool) -> void: + if casting: + if _fishing_visual_phase == FishingVisualPhase.NONE: + set_casting_visual() + elif _fishing_visual_phase == FishingVisualPhase.CASTING: + set_fishing_visual(false) + + func _update_network_interpolation(delta: float) -> void: if not _network_snapshot_ready: return @@ -1324,41 +1480,274 @@ func get_fishing_rod() -> Node3D: return _fishing_rod -func set_active_item_is_rod(active_is_rod: bool) -> void: +func set_active_item_is_rod( + active_is_rod: bool, + animate_transition: bool = false, +) -> void: + _active_item_is_rod = active_is_rod if _showcase_rod_state_stored: _showcase_rod_visibility = active_is_rod - else: + return + var visibility_changed: bool = _fishing_rod.visible != active_is_rod + if not animate_transition or not visibility_changed: _fishing_rod.visible = active_is_rod + return + if _has_held_show_item() or _showcase_animation_active: + if not active_is_rod: + _fishing_rod.visible = false + return + if active_is_rod: + _fishing_rod.visible = false + _begin_pocket_visual( + PocketVisualTarget.IDLE_ITEM, + false, + false, + _apply_active_rod_visibility, + ) + + +func _apply_active_rod_visibility() -> void: + if _showcase_rod_state_stored: + _showcase_rod_visibility = _active_item_is_rod + return + _fishing_rod.visible = _active_item_is_rod func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: - _held_art_kit_sprite.texture = icon if should_show else null - _held_art_kit_sprite.visible = should_show and icon != null + var new_visible: bool = should_show and icon != null + if not new_visible: + if _held_art_kit_visible: + _begin_pocket_visual( + PocketVisualTarget.ART_KIT, + true, + _held_fish_visible, + _clear_active_art_kit, + true, + _apply_pending_held_fish, + ) + else: + _clear_active_art_kit() + return + var previous_show_pose: bool = _has_held_show_item() + var item_changed: bool = ( + not _held_art_kit_visible + or _held_art_kit_sprite.texture != icon + ) + _held_art_kit_visible = true + _held_art_kit_sprite.texture = icon + _held_art_kit_sprite.visible = true + _fishing_rod.visible = false + if item_changed: + _begin_pocket_visual( + PocketVisualTarget.ART_KIT, + previous_show_pose, + true, + Callable(), + ) + else: + _update_character_animation() func set_held_fish( fish: FishDataType, display_scale: float, should_show: bool, + animate_put_away: bool = false, ) -> void: if not should_show or fish == null or fish.display_texture == null: - _held_fish_visible = false - _held_fish_display.visible = false - _held_fish_display.scale = Vector3.ONE - _held_fish_sprite.texture = null - _update_character_animation() + if animate_put_away and _held_fish_visible: + _begin_pocket_visual( + PocketVisualTarget.HELD_FISH, + true, + _held_art_kit_visible, + _clear_held_fish, + ) + else: + if _pocket_visual_target == PocketVisualTarget.HELD_FISH: + _cancel_pocket_visual() + _clear_held_fish() return + var previous_show_pose: bool = _has_held_show_item() + var item_changed: bool = ( + not _held_fish_visible + or _held_fish_sprite.texture != fish.display_texture + ) _held_fish_visible = true - _held_fish_sprite.texture = fish.display_texture - _held_fish_display.scale = ( + _pending_held_fish_texture = fish.display_texture + _pending_held_fish_scale = ( Vector3.ONE * maxf(display_scale, 0.01) * catch_presentation_base_scale ) - _held_fish_display.visible = true + if not previous_show_pose: + _held_fish_display.visible = false + _fishing_rod.visible = false + if item_changed: + _begin_pocket_visual( + PocketVisualTarget.HELD_FISH, + previous_show_pose, + true, + Callable(), + false, + _apply_pending_held_fish, + ) + else: + _apply_pending_held_fish() + _update_character_animation() + + +func _has_held_show_item() -> bool: + return _held_fish_visible or _held_art_kit_visible + + +func _clear_held_fish() -> void: + var was_visible: bool = _held_fish_visible + _held_fish_visible = false + _pending_held_fish_texture = null + _pending_held_fish_scale = Vector3.ONE + _held_fish_display.visible = false + _held_fish_display.scale = Vector3.ONE + _held_fish_sprite.texture = null + if was_visible: + _apply_active_rod_visibility() _update_character_animation() +func _apply_pending_held_fish() -> void: + if not _held_fish_visible or _pending_held_fish_texture == null: + return + _held_fish_sprite.texture = _pending_held_fish_texture + _held_fish_display.scale = _pending_held_fish_scale + _held_fish_display.visible = true + + +func _clear_active_art_kit() -> void: + var was_visible: bool = _held_art_kit_visible + _held_art_kit_visible = false + _held_art_kit_sprite.texture = null + _held_art_kit_sprite.visible = false + if was_visible: + _apply_active_rod_visibility() + if _held_fish_visible: + _apply_pending_held_fish() + _update_character_animation() + + +func _begin_pocket_visual( + target: PocketVisualTarget, + starts_in_show_pose: bool, + ends_in_show_pose: bool, + finished_callback: Callable, + callback_runs_on_interrupt: bool = true, + midpoint_callback: Callable = Callable(), +) -> void: + var animation_name: StringName + if starts_in_show_pose: + animation_name = ( + CHARACTER_POCKET_SHOW_SHOW_ANIMATION + if ends_in_show_pose + else CHARACTER_POCKET_SHOW_IDLE_ANIMATION + ) + else: + animation_name = ( + CHARACTER_POCKET_IDLE_SHOW_ANIMATION + if ends_in_show_pose + else CHARACTER_POCKET_IDLE_IDLE_ANIMATION + ) + if _pocket_visual_active: + if ( + _pocket_visual_target == target + and _pocket_visual_animation == animation_name + ): + return + if _pocket_visual_callback_runs_on_interrupt: + _complete_pocket_visual() + else: + _cancel_pocket_visual() + if ( + _sitting + or animation_name.is_empty() + or _character_animation_player == null + or not _character_animation_player.has_animation(animation_name) + ): + if midpoint_callback.is_valid(): + midpoint_callback.call() + if finished_callback.is_valid(): + finished_callback.call() + _update_character_animation() + return + _pocket_visual_active = true + _pocket_visual_target = target + _pocket_visual_animation = animation_name + _pocket_visual_finished_callback = finished_callback + _pocket_visual_callback_runs_on_interrupt = callback_runs_on_interrupt + _pocket_visual_midpoint_callback = midpoint_callback + _pocket_visual_midpoint_called = false + _pocket_visual_generation += 1 + var midpoint_generation: int = _pocket_visual_generation + _update_character_animation() + _schedule_pocket_visual_midpoint(midpoint_generation, animation_name) + + +func _schedule_pocket_visual_midpoint( + generation: int, + animation_name: StringName, +) -> void: + if not _pocket_visual_midpoint_callback.is_valid(): + return + var animation: Animation = _character_animation_player.get_animation( + animation_name + ) + var midpoint_seconds: float = maxf(animation.length * 0.5, 0.0) + if midpoint_seconds > 0.0: + await get_tree().create_timer(midpoint_seconds, false).timeout + if ( + generation != _pocket_visual_generation + or not _pocket_visual_active + or _pocket_visual_animation != animation_name + ): + return + _apply_pocket_visual_midpoint() + + +func _apply_pocket_visual_midpoint() -> void: + if _pocket_visual_midpoint_called: + return + _pocket_visual_midpoint_called = true + var midpoint_callback: Callable = _pocket_visual_midpoint_callback + _pocket_visual_midpoint_callback = Callable() + if midpoint_callback.is_valid(): + midpoint_callback.call() + + +func _complete_pocket_visual() -> void: + if not _pocket_visual_active: + return + var finished_callback: Callable = _pocket_visual_finished_callback + _pocket_visual_active = false + _pocket_visual_target = PocketVisualTarget.NONE + _pocket_visual_animation = &"" + _pocket_visual_finished_callback = Callable() + _pocket_visual_callback_runs_on_interrupt = true + _pocket_visual_midpoint_callback = Callable() + _pocket_visual_midpoint_called = false + _pocket_visual_generation += 1 + if finished_callback.is_valid(): + finished_callback.call() + _update_character_animation() + + +func _cancel_pocket_visual() -> void: + _pocket_visual_active = false + _pocket_visual_target = PocketVisualTarget.NONE + _pocket_visual_animation = &"" + _pocket_visual_finished_callback = Callable() + _pocket_visual_callback_runs_on_interrupt = true + _pocket_visual_midpoint_callback = Callable() + _pocket_visual_midpoint_called = false + _pocket_visual_generation += 1 + + func get_cast_origin_position() -> Vector3: return _cast_origin.global_position @@ -1366,8 +1755,9 @@ func get_cast_origin_position() -> Vector3: func begin_catch_showcase(fish_catch: FishCatchType) -> void: if fish_catch == null or not fish_catch.is_valid(): return + if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE: + _cancel_pocket_visual() _showcase_animation_active = true - _update_character_animation() _kill_showcase_camera_tween() _kill_showcase_restore_tween() _capture_showcase_camera_snapshot() @@ -1387,6 +1777,7 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void: * catch_presentation_base_scale ) _catch_display.visible = _catch_sprite.texture != null + _update_character_animation() func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void: @@ -1394,7 +1785,6 @@ func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void: return end_catch_showcase(Callable(), true) _showcase_animation_active = true - _update_character_animation() _showcase_rod_visibility = _fishing_rod.visible _showcase_rod_state_stored = true _fishing_rod.visible = false @@ -1405,12 +1795,28 @@ func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void: * catch_presentation_base_scale ) _catch_display.visible = _catch_sprite.texture != null + _update_character_animation() func end_catch_showcase( restored_callback: Callable = Callable(), immediate: bool = false, + pocket_completed: bool = false, ) -> void: + if immediate and _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE: + _cancel_pocket_visual() + elif ( + not pocket_completed + and _showcase_animation_active + and _catch_display.visible + ): + _begin_pocket_visual( + PocketVisualTarget.CATCH_SHOWCASE, + true, + false, + end_catch_showcase.bind(restored_callback, false, true), + ) + return _showcase_animation_active = false _update_character_animation() _kill_showcase_turn_tween() diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd index 3b1ab54..23f02ef 100644 --- a/tests/fish_showcase_multiplayer_validation.gd +++ b/tests/fish_showcase_multiplayer_validation.gd @@ -53,7 +53,7 @@ func _run_host() -> void: ) as PlayerSpawnService var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) assert(remote_avatar != null) - var remote_display := remote_avatar.get_node("%HeldFishDisplay") as Node3D + var remote_display := remote_avatar.get("_held_fish_display") as Node3D var visible_deadline: int = Time.get_ticks_msec() + 10000 while Time.get_ticks_msec() < visible_deadline and not remote_display.visible: await process_frame @@ -95,13 +95,23 @@ func _run_client() -> void: ) as PlayerSpawnService var host_avatar: Player = spawn_service.get_avatar(1) assert(host_avatar != null) - var host_display := host_avatar.get_node("%HeldFishDisplay") as Node3D + var host_display := host_avatar.get("_held_fish_display") as Node3D var snapshot_deadline: int = Time.get_ticks_msec() + 10000 while Time.get_ticks_msec() < snapshot_deadline and not host_display.visible: await process_frame assert(host_display.visible) var player := main.get("_player") as Player + var animation_player := player.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer + for animation_name: StringName in [ + &"pocket_idle_idle", + &"pocket_idle_show", + &"pocket_show_show", + &"pocket_show_idle", + ]: + assert(animation_player.has_animation(animation_name)) var service := main.get_node( "%NetworkFishShowcaseService" ) as NetworkFishShowcaseService @@ -109,11 +119,16 @@ func _run_client() -> void: assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.select_slot(1)) assert(service.toggle_selected_fish()) - assert((player.get_node("%HeldFishDisplay") as Node3D).visible) + assert((player.get("_held_fish_display") as Node3D).visible) await create_timer(2.0).timeout assert(service.toggle_selected_fish()) - assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) - await create_timer(1.0).timeout + var held_display := player.get("_held_fish_display") as Node3D + assert(held_display.visible) + assert(animation_player.current_animation == &"pocket_show_idle") + var pocket_deadline: int = Time.get_ticks_msec() + 5000 + while Time.get_ticks_msec() < pocket_deadline and held_display.visible: + await process_frame + assert(not held_display.visible) print("Fish showcase multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index fe9db15..524a426 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -54,6 +54,30 @@ func _run_host() -> void: var remote_visuals := remote_avatar.get_node("Visuals") as Node3D remote_visuals.rotation.y = PI * 0.5 session.publish_authoritative_teleport(remote_peer_id) + var aiming_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < aiming_deadline + and int(remote_avatar.get("_fishing_visual_phase")) + != Player.FishingVisualPhase.CASTING + ): + await process_frame + assert( + int(remote_avatar.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.CASTING + ) + var remote_position_error := ( + remote_avatar.global_position - Vector3(-0.5, 3.95, 2.1) + ) + remote_position_error.y = 0.0 + assert( + remote_position_error.length() <= 0.25, + "remote casting position drifted: %s" % remote_avatar.global_position + ) + assert( + remote_avatar.get_facing_direction().dot(Vector3.LEFT) > 0.99, + "remote casting facing drifted: %s" + % remote_avatar.get_facing_direction() + ) var saw_remote_attempt: bool = false var remote_presentation: RemoteFishingPresentation @@ -65,8 +89,8 @@ func _run_host() -> void: if peer_id == session.get_local_peer_id(): continue var attempt: NetworkFishingAttempt = attempts[peer_id] - assert(is_equal_approx(attempt.target.y, 2.51)) - assert(attempt.bobber_position.y <= 2.51 + 0.001) + assert(attempt.target.is_finite()) + assert(attempt.bobber_position.distance_to(attempt.target) <= 0.001) var presentations: Dictionary = service.get("_remote_presentations") remote_presentation = presentations.get(peer_id) assert(remote_presentation != null) @@ -78,6 +102,31 @@ func _run_host() -> void: if saw_remote_attempt: break assert(saw_remote_attempt) + assert( + int(remote_avatar.get("_fishing_visual_phase")) + in [ + Player.FishingVisualPhase.RELEASE, + Player.FishingVisualPhase.FISHING, + ] + ) + var sitting_deadline: int = Time.get_ticks_msec() + 3000 + while Time.get_ticks_msec() < sitting_deadline and not remote_avatar.is_sitting(): + await process_frame + assert(remote_avatar.is_sitting()) + var fishing_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < fishing_deadline + and int(remote_avatar.get("_fishing_visual_phase")) + != Player.FishingVisualPhase.FISHING + ): + await process_frame + assert( + int(remote_avatar.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.FISHING + ) + var remote_animation_player := remote_avatar.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer var return_deadline: int = Time.get_ticks_msec() + 5000 while ( Time.get_ticks_msec() < return_deadline @@ -85,8 +134,11 @@ func _run_host() -> void: ): await process_frame assert(not service.has_peer_attempt(remote_peer_id)) - assert(is_instance_valid(remote_presentation)) - assert(remote_presentation.get("_return_tween") != null) + assert( + int(remote_avatar.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.RETRACT + ) + assert(remote_animation_player.current_animation == &"retract_sit") var completion_deadline: int = Time.get_ticks_msec() + 12000 while ( @@ -119,8 +171,17 @@ func _run_client() -> void: var service := main.get_node( "%NetworkFishingService" ) as NetworkFishingService + var cast_rejections: Array[String] = [] + service.local_cast_rejected.connect( + func(message: String) -> void: + cast_rejections.append(message) + ) var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType var player := main.get("_player") as Player + var character_animation_player := player.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer + assert(character_animation_player.has_animation(&"retract_sit")) var placement_deadline: int = Time.get_ticks_msec() + 5000 while ( Time.get_ticks_msec() < placement_deadline @@ -131,14 +192,59 @@ func _run_client() -> void: assert(player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) <= 0.25) assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) + player.set_casting_visual() + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.CASTING + ) + for _frame: int in 30: + await physics_frame + player.set_fishing_visual(false) + player.call("_set_sitting", true) + assert(player.is_sitting()) + for _frame: int in 15: + await physics_frame fishing_spot.call("_begin_aiming", player) assert(player.is_movement_enabled()) - fishing_spot.set("_cast_charge", 0.32) - fishing_spot.call("_update_cast_charge", 0.0) - var target: Vector3 = fishing_spot.get("_cast_target") - assert(fishing_spot.is_target_fishable(target)) - assert(is_equal_approx(target.y, 2.51)) - fishing_spot.call("_confirm_cast") + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.CASTING + ) + var cast_charge: float = 0.0 + var target: Vector3 = Vector3.ZERO + var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position") + for charge_step: int in range(1, 21): + var candidate_charge := float(charge_step) / 20.0 + fishing_spot.set("_cast_charge", candidate_charge) + fishing_spot.call("_update_cast_charge", 0.0) + var candidate_target: Vector3 = fishing_spot.get("_cast_target") + var candidate_surface := fishing_spot.get( + "_aim_surface_sample" + ) as FishingSurfaceSample + if ( + candidate_surface.is_fishable() + and fishing_spot.is_cast_path_clear(cast_origin, candidate_target) + ): + cast_charge = candidate_charge + target = candidate_target + break + assert(cast_charge > 0.0, "no clear fishable test target was found") + var evidence: Dictionary = fishing_spot.call("_build_network_evidence") + fishing_spot.set("state", FishingSpotType.FishingState.CASTING) + player.set_movement_enabled(false) + player.set_release_visual() + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.RELEASE + ) + assert( + not service.request_local_cast( + cast_origin, + target, + cast_charge, + evidence, + ).is_empty() + ) var accepted_deadline: int = Time.get_ticks_msec() + 6000 while ( @@ -146,8 +252,32 @@ func _run_client() -> void: and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE ): await process_frame - assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) + assert( + fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE, + ( + "cast acceptance timed out: state=%s local_attempt=%s target=%s " + + "rejections=%s" + ) % [ + fishing_spot.state, + service.has_local_attempt(), + target, + cast_rejections, + ] + ) assert(service.has_local_attempt()) + var fishing_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < fishing_deadline + and int(player.get("_fishing_visual_phase")) + != Player.FishingVisualPhase.FISHING + ): + await process_frame + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.FISHING + ) + for _frame: int in 30: + await physics_frame fishing_spot.set("_withdrawal_input_held", true) fishing_spot.set("_network_primary_input_held", true) service.submit_local_input(true, true) @@ -159,10 +289,18 @@ func _run_client() -> void: ): await process_frame assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING) + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.RETRACT + ) + assert(character_animation_player.current_animation == &"retract_sit") var bobber := fishing_spot.get_node( "FishingPresentation/Bobber" ) as MeshInstance3D assert(bobber.visible) + while not player.is_retract_visual_complete(): + assert(fishing_spot.state == FishingSpotType.FishingState.RETURNING) + await process_frame var ready_deadline: int = Time.get_ticks_msec() + 3000 while ( @@ -173,6 +311,9 @@ func _run_client() -> void: assert(fishing_spot.state == FishingSpotType.FishingState.READY) assert(player.is_movement_enabled()) assert(not bobber.visible) + var host_observation_deadline: int = Time.get_ticks_msec() + 2000 + while Time.get_ticks_msec() < host_observation_deadline: + await process_frame print("Fishing multiplayer client validation: PASS") session.disconnect_session("") main.queue_free()