Integrate per-save identity and interaction UI

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:50 -04:00
parent ac82a28f3b
commit 4fda6e97f6
58 changed files with 3471 additions and 461 deletions

View file

@ -146,9 +146,6 @@ const TITLE_MUSIC_PATH: String = (
const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav"
const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0)
const SHOP_PATTERN_SCALE: float = 1.75
const DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND: int = 10
const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
const HOME_ENTRY_INPUT_OWNER: StringName = &"home_entry_transition"
@ -158,6 +155,8 @@ const HOME_ENTRY_WALK_DISTANCE: float = 1.05
const HOME_ENTRY_WALK_DURATION: float = 0.55
const HOME_EXIT_WALK_DISTANCE: float = 1.25
const HOME_EXIT_WALK_DURATION: float = 0.62
const SHOP_NPC_HANDOFF_HYSTERESIS_METERS: float = 0.1
const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation"
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@ -263,14 +262,16 @@ const HOME_EXIT_WALK_DURATION: float = 0.62
@onready var _world_gatherables_root: Node3D = $WorldGatherables
@onready var _home_world: HomeWorldServiceType = %HomeWorldService
@onready var _title_background: ColorRect = %TitleBackground
@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
@onready var _shop_backdrop: ColorRect = %ShopBackdrop
var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _rv_upgrade_shop_interaction: RVUpgradeInteractionType
var _active_shop_npc_interaction: FishingShopInteractionType
var _engaged_shop_npc_interaction: FishingShopInteractionType
var _shop_conversation_shop_opened: bool = false
var _shop_conversation_camera_restoring: bool = false
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
@ -279,8 +280,6 @@ var _dusk_music_played_for_natural_day: bool = false
var _quit_in_progress: bool = false
var _join_requested_from_title: bool = false
var _join_requested_from_pause: bool = false
var _player_menu_backdrop_tween: Tween
var _shop_backdrop_tween: Tween
var _pending_join_endpoint: String = ""
var _social_prompt_queue: Array[Dictionary] = []
var _active_social_prompt: Dictionary = {}
@ -307,6 +306,7 @@ var _home_exit_start_position: Vector3 = Vector3.ZERO
var _home_exit_walk_tween: Tween
var _home_entry_transition_active: bool = false
var _home_entry_walk_tween: Tween
var _observed_cancel_input_frame: int = -1
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
@ -341,8 +341,6 @@ func _ready() -> void:
_player,
_test_world.get_saltwater_shoreline_mesh(),
)
get_window().size_changed.connect(_resize_native_overlays)
_resize_native_overlays()
if not _settings_manager.settings_changed.is_connected(
_apply_runtime_settings
):
@ -370,44 +368,6 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void:
"animation_enabled",
not light_profile,
)
var menu_pattern_material := (
_player_menu_backdrop.material as ShaderMaterial
)
if menu_pattern_material != null:
menu_pattern_material.set_shader_parameter(
"animation_enabled",
not light_profile,
)
menu_pattern_material.set_shader_parameter(
"display_scale",
PLAYER_MENU_PATTERN_SCALE,
)
menu_pattern_material.set_shader_parameter(
"scroll_velocity_pixels",
(
Vector2.ZERO
if light_profile
else PLAYER_MENU_PATTERN_SCROLL_VELOCITY
),
)
var shop_pattern_material := _shop_backdrop.material as ShaderMaterial
if shop_pattern_material != null:
shop_pattern_material.set_shader_parameter(
"animation_enabled",
not light_profile,
)
shop_pattern_material.set_shader_parameter(
"display_scale",
SHOP_PATTERN_SCALE,
)
shop_pattern_material.set_shader_parameter(
"scroll_velocity_pixels",
(
Vector2.ZERO
if light_profile
else PLAYER_MENU_PATTERN_SCROLL_VELOCITY
),
)
func _configure_audio_performance_profile(light_profile: bool) -> void:
@ -437,8 +397,6 @@ func _is_dedicated_server_runtime() -> bool:
func _disable_dedicated_presentation_tree() -> void:
for path: NodePath in [
NodePath("TitleBackgroundLayer"),
NodePath("PlayerMenuBackdrop"),
NodePath("ShopBackdrop"),
NodePath("WorldPixelationPostprocess"),
NodePath("UIPresentation"),
NodePath("PixelationResetOverlay"),
@ -779,6 +737,7 @@ func _initialize_application(dedicated: bool) -> void:
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_active_shop_npc_interaction = null
_storage_interaction = _test_world.get_player_storage()
if not dedicated:
if not _home_state.changed.is_connected(_on_home_state_changed):
@ -818,6 +777,8 @@ func _initialize_application(dedicated: bool) -> void:
_player_jobs,
_player,
_home_state,
_network_profile if not dedicated else null,
_appearance_store if not dedicated else null,
)
_home_world.setup(
_test_world,
@ -1045,6 +1006,9 @@ func _initialize_application(dedicated: bool) -> void:
_fishing_spot,
_game_ui.get_home_decor_toolbar(),
)
_home_decor_controller.exit_requested.connect(
_on_home_toolbar_exit_requested
)
_game_ui.get_player_storage().decorate_requested.connect(
_home_decor_controller.begin_decorating
)
@ -1117,12 +1081,19 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.passive_pointer_ui_changed.connect(
_ui_pixelation.set_passive_pointer_ui_enabled
)
_game_ui.player_menu_backdrop_visibility_changed.connect(
_set_player_menu_backdrop_visible
)
_game_ui.shop_backdrop_visibility_changed.connect(
_set_shop_backdrop_visible
_game_ui.get_node("%PlayerMenu").connect(
&"menu_visibility_changed",
_on_player_menu_conversation_visibility_changed
)
for shop_menu: Node in [
_game_ui.get_fishing_shop(),
_game_ui.get_decor_shop(),
_game_ui.get_rv_upgrade_shop(),
]:
shop_menu.connect(
&"menu_visibility_changed",
_on_shop_conversation_menu_visibility_changed
)
_pixelation_reset.return_to_settings_requested.connect(
_game_ui.focus_open_settings_back_button
)
@ -1167,6 +1138,9 @@ func _initialize_application(dedicated: bool) -> void:
pause_menu.menu_visibility_changed.connect(
_game_ui.set_system_menu_open
)
pause_menu.menu_visibility_changed.connect(
_on_pause_menu_conversation_visibility_changed
)
_player.hotbar.selected_assignment_changed.connect(
_on_active_hotbar_item_changed
)
@ -1441,8 +1415,15 @@ func _on_data_root_changed() -> void:
_save_manager.set_autosave_enabled(false)
_save_manager.cancel_pending_autosave()
_configure_portable_stores()
if not _network_profile_service.reload_persisted_profile():
var profile_reloaded: bool = (
_network_profile_service.reload_persisted_profile()
)
if not profile_reloaded:
push_warning("The selected data folder's player profile could not be loaded.")
else:
_save_manager.capture_legacy_character_fallback()
if not _save_manager.load_character_profile():
push_warning("The active save's character profile could not be loaded.")
_network_session.set_local_appearance_snapshot(
_appearance_store.get_snapshot()
)
@ -1547,6 +1528,25 @@ func _input(event: InputEvent) -> void:
if _home_exit_transition_active:
get_viewport().set_input_as_handled()
return
# While dialogue is active the conversation owns the complete input stream.
# The second interaction press advances into the shop; cancel safely leaves
# the conversation without opening another menu behind it.
if (
is_instance_valid(_engaged_shop_npc_interaction)
and not _shop_conversation_shop_opened
):
if (
event.is_action_pressed("interact")
and not (event is InputEventKey and event.echo)
):
_open_engaged_shop()
elif (
event.is_action_pressed("ui_cancel")
or event.is_action_pressed("open_system_menu")
):
_end_shop_npc_conversation(false)
get_viewport().set_input_as_handled()
return
# Do not let controller aliases close or manipulate the world/menu behind
# the modal on-screen keyboard. The keyboard consumes the event itself.
if _game_ui.is_controller_text_entry_open():
@ -1564,6 +1564,8 @@ func _input(event: InputEvent) -> void:
or (event is InputEventKey and event.echo)
):
return
if event.is_action_pressed("ui_cancel"):
_observed_cancel_input_frame = Engine.get_process_frames()
var pause_open_requested: bool = _is_pause_open_request(event)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
if title_screen.visible or not _gameplay_started:
@ -1712,31 +1714,16 @@ func _unhandled_input(event: InputEvent) -> void:
_refresh_home_door_prompt({})
get_viewport().set_input_as_handled()
return
# All shop prompts and interactions resolve through the same nearest-NPC
# selection. This prevents overlapping trigger margins from making a fixed
# shop priority open (or speak for) someone the player is not facing.
_refresh_shop_npc_speech_state()
var active_shop_interaction := _get_active_shop_npc_interaction()
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
active_shop_interaction != null
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_rv_upgrade_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_decor_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_fishing_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
_begin_shop_npc_conversation(active_shop_interaction)
get_viewport().set_input_as_handled()
@ -1750,25 +1737,33 @@ func _process(delta: float) -> void:
)
_print_dedicated_metrics()
return
# Native Godot tooltip popups can take ownership of Escape before this
# scene's _input callback sees it. Poll the action once as a fallback so a
# visible tooltip never forces a second Escape press to close its menu.
if (
Input.is_action_just_pressed("ui_cancel")
and _observed_cancel_input_frame != Engine.get_process_frames()
):
_consume_tooltip_captured_escape()
_maybe_begin_home_exit_transition()
var show_rv_upgrade_prompt := _can_show_rv_upgrade_shop_prompt()
var show_decor_shop_prompt := _can_show_decor_shop_prompt()
var show_shop_prompt := (
show_rv_upgrade_prompt
or show_decor_shop_prompt
or _can_show_shop_prompt()
# Shop close animations/signals may finish after the input frame that asked
# for them. Keep the cinematic held until the UI is actually gone.
if (
_shop_conversation_shop_opened
and is_instance_valid(_engaged_shop_npc_interaction)
and not _is_any_shop_menu_visible()
):
_end_shop_npc_conversation(false)
# Re-evaluate while moving, not only when an Area3D emits enter/exit. This
# gives overlapping edge cases an immediate nearest-character handoff.
_refresh_shop_npc_speech_state()
var active_shop_interaction := _get_active_shop_npc_interaction()
var show_shop_prompt := _can_show_active_shop_prompt(
active_shop_interaction
)
var shop_prompt_anchor := (
_rv_upgrade_shop_interaction.get_prompt_anchor_position()
if show_rv_upgrade_prompt
else (
_decor_shop_interaction.get_prompt_anchor_position()
if show_decor_shop_prompt
else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
)
)
active_shop_interaction.get_prompt_anchor_position()
if active_shop_interaction != null else Vector3.ZERO
)
_game_ui.set_shop_prompt_visible(
show_shop_prompt,
@ -1781,6 +1776,28 @@ func _process(delta: float) -> void:
)
func _consume_tooltip_captured_escape() -> bool:
if (
not _gameplay_started
or _game_ui.get_title_screen().visible
or _home_exit_transition_active
or _game_ui.is_controller_text_entry_open()
or _game_ui.is_input_mapping_capturing()
):
return false
if _game_ui.get_player_storage().consume_escape():
return true
if _game_ui.get_decor_shop().consume_escape():
return true
if _game_ui.get_rv_upgrade_shop().consume_escape():
return true
if _game_ui.get_fishing_shop().consume_escape():
return true
if _game_ui.get_pause_menu().handle_escape():
return true
return _game_ui.consume_player_menu_escape()
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
if settings == null:
return
@ -1866,11 +1883,22 @@ func _reset_pixelation() -> void:
func _set_gameplay_active(active: bool) -> void:
var was_gameplay_started: bool = _gameplay_started
if not active and was_gameplay_started and _save_manager.is_dirty():
# Session teardown can mutate transient multiplayer state immediately
# before this method runs. Preserve any real progression first, then
# discard teardown-only dirty notifications once gameplay is inactive.
_save_manager.save_if_dirty()
_gameplay_started = active
# Session loss, a world switch, or a return to title can interrupt the home
# handoff before its fade callback runs. Do not carry its input suppressors
# or black screen into the next gameplay session.
if not active:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
_shoreline_ambience.set_active(active)
@ -1901,8 +1929,7 @@ func _set_gameplay_active(active: bool) -> void:
_refresh_active_hotbar_item()
else:
_player_jobs.end_progression_session()
_set_player_menu_backdrop_visible(false)
_set_shop_backdrop_visible(false)
_save_manager.cancel_pending_autosave()
func _on_natural_time_advanced(advanced_hours: float) -> void:
@ -1953,85 +1980,6 @@ static func _natural_interval_crosses_hour(
)
func _set_player_menu_backdrop_visible(requested_visible: bool) -> void:
if _player_menu_backdrop_tween != null:
_player_menu_backdrop_tween.kill()
_player_menu_backdrop_tween = null
var should_show: bool = requested_visible and _gameplay_started
if should_show:
var was_visible: bool = _player_menu_backdrop.visible
_player_menu_backdrop.visible = true
if not was_visible:
_player_menu_backdrop.modulate.a = 0.0
_player_menu_backdrop_tween = create_tween()
_player_menu_backdrop_tween.tween_property(
_player_menu_backdrop,
"modulate:a",
1.0,
UIMotion.PLAYER_MENU_ENTER_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
return
if not _player_menu_backdrop.visible:
return
_player_menu_backdrop_tween = create_tween()
_player_menu_backdrop_tween.tween_property(
_player_menu_backdrop,
"modulate:a",
0.0,
UIMotion.PLAYER_MENU_EXIT_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_player_menu_backdrop_tween.finished.connect(
func() -> void:
_player_menu_backdrop.visible = false
_player_menu_backdrop.modulate.a = 1.0
_player_menu_backdrop_tween = null
)
func _set_shop_backdrop_visible(requested_visible: bool) -> void:
if _shop_backdrop_tween != null:
_shop_backdrop_tween.kill()
_shop_backdrop_tween = null
var should_show: bool = requested_visible and _gameplay_started
if should_show:
var was_visible: bool = _shop_backdrop.visible
_shop_backdrop.visible = true
if not was_visible:
_shop_backdrop.modulate.a = 0.0
_shop_backdrop_tween = create_tween()
_shop_backdrop_tween.tween_property(
_shop_backdrop,
"modulate:a",
1.0,
UIMotion.PLAYER_MENU_ENTER_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
return
if not _shop_backdrop.visible:
return
_shop_backdrop_tween = create_tween()
_shop_backdrop_tween.tween_property(
_shop_backdrop,
"modulate:a",
0.0,
UIMotion.PLAYER_MENU_EXIT_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_shop_backdrop_tween.finished.connect(
func() -> void:
_shop_backdrop.visible = false
_shop_backdrop.modulate.a = 1.0
_shop_backdrop_tween = null
)
func _resize_native_overlays() -> void:
if not is_node_ready():
return
_player_menu_backdrop.position = Vector2.ZERO
_player_menu_backdrop.size = Vector2(get_window().size)
_shop_backdrop.position = Vector2.ZERO
_shop_backdrop.size = Vector2(get_window().size)
func _on_new_game_requested(
world_layout: StringName,
world_seed: int,
@ -2039,6 +1987,15 @@ func _on_new_game_requested(
if _gameplay_started or _quit_in_progress:
return
_start_new_game_music()
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
return
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
@ -2054,16 +2011,6 @@ func _on_new_game_requested(
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
return
_enter_gameplay()
@ -2152,6 +2099,18 @@ func _on_new_slot_requested(
_on_slot_play_requested(slot_id)
return
_start_new_game_music()
# Reset progression before the private session publishes its first home
# manifest. Doing this after hosting allowed the previous slot's RV tier to
# be announced and spawned into an otherwise brand-new save.
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression."
)
return
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
@ -2167,16 +2126,6 @@ func _on_new_slot_requested(
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression."
)
return
_save_slots.mark_played(slot_id)
_enter_gameplay()
@ -2243,10 +2192,17 @@ func _on_title_join_game_requested(requested_route: Variant) -> void:
"That connection address is invalid."
)
return
var active_slot: Dictionary = _save_slots.ensure_active_slot()
if not bool(active_slot.get("ok", false)):
var online_slot: Dictionary = _save_slots.ensure_online_slot()
if not bool(online_slot.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(active_slot.get("message", "Local progression is unavailable."))
str(online_slot.get(
"message", "Online progression is unavailable."
))
)
return
if not _save_manager.load_character_profile():
_game_ui.get_title_screen().report_network_error(
"The selected online character could not be loaded."
)
return
if _network_session.state != NetworkSessionType.State.INACTIVE:
@ -2356,7 +2312,9 @@ func _show_next_social_prompt() -> void:
_active_social_prompt = _social_prompt_queue.pop_front()
var kind := str(_active_social_prompt.get("kind", ""))
var display_name := str(
_active_social_prompt.get("display_name", "Player")
_active_social_prompt.get(
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
)
var shown: bool = false
if kind == "friend_request":
@ -2468,7 +2426,11 @@ func _on_network_join_authenticated() -> void:
var progression_ready: bool = (
_save_manager.load_player_data()
if inspection.can_continue()
else _save_manager.initialize_new_game()
else _save_manager.initialize_new_game(
PlayerSaveManager.DEFAULT_WORLD_SEED,
WorldLayout.GENERATED,
false,
)
)
if not progression_ready:
_network_session.disconnect_session(
@ -2662,6 +2624,12 @@ func _apply_world(
func _refresh_active_world_bindings() -> void:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
if is_instance_valid(_shop_interaction):
if _shop_interaction.local_player_range_changed.is_connected(
_on_shop_range_changed
@ -2693,6 +2661,7 @@ func _refresh_active_world_bindings() -> void:
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_active_shop_npc_interaction = null
_storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction)
@ -2747,6 +2716,12 @@ func _refresh_active_world_bindings() -> void:
func _on_water_recovery_starting() -> void:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
_local_recovery_attempt_id = (
"recovery:%s"
% Crypto.new().generate_random_bytes(16).hex_encode()
@ -2957,6 +2932,15 @@ func _finish_quit() -> void:
func _exit_tree() -> void:
if (
is_instance_valid(_player)
and (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
)
):
_end_shop_npc_conversation(true)
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
if _dedicated_runtime and _network_session != null:
@ -2976,10 +2960,15 @@ func _exit_tree() -> void:
func _on_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
# Speech ownership is resolved once per frame after all Area3D enter/exit
# signals have settled, avoiding a one-frame clear during zone handoffs.
if not in_range:
_game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_storage_range_changed(in_range: bool) -> void:
@ -2993,17 +2982,23 @@ func _on_storage_range_changed(in_range: bool) -> void:
func _on_decor_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_decor_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _decor_shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_rv_upgrade_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_rv_upgrade_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _rv_upgrade_shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_home_state_changed() -> void:
@ -3015,6 +3010,11 @@ func _on_local_home_space_changed(
owner_fingerprint: String,
) -> void:
var inside_rv: bool = String(space_id).begins_with("rv:")
_world_time_visuals.set_local_home_interior_active(inside_rv)
_rain_ambience.set_suppressed(inside_rv)
_shoreline_ambience.set_suppressed(inside_rv)
_surface_drawings_root.visible = not inside_rv
_world_gatherables_root.visible = not inside_rv
_refresh_active_hotbar_item()
_fishing_spot.set_fishing_environment_enabled(not inside_rv)
_game_ui.set_rv_interior_camera_mode(
@ -3094,7 +3094,12 @@ func _maybe_begin_home_exit_transition() -> void:
_begin_home_exit_transition(owner_fingerprint)
func _begin_home_exit_transition(owner_fingerprint: String) -> void:
func _begin_home_exit_transition(
owner_fingerprint: String,
skip_walk: bool = false,
) -> void:
if owner_fingerprint.is_empty() or _home_exit_transition_active:
return
_home_exit_transition_active = true
_home_exit_waiting_for_network = false
_home_exit_start_position = _player.global_position
@ -3107,8 +3112,11 @@ func _begin_home_exit_transition(owner_fingerprint: String) -> void:
_game_ui.get_player_storage().close_storage()
_game_ui.set_rv_storage_button_visible(false)
_game_ui.set_gameplay_input_locked(true)
var exit_direction: Vector3 = _home_world.get_interior_exit_direction(
owner_fingerprint
if skip_walk:
_on_home_exit_walk_finished()
return
var exit_direction: Vector3 = _home_world.get_interior_exit_direction_near(
owner_fingerprint, _player.global_position
)
_player.face_world_direction(exit_direction)
_player.begin_animation_action(PlayerType.CHARACTER_WALKING_ANIMATION)
@ -3127,6 +3135,19 @@ func _begin_home_exit_transition(owner_fingerprint: String) -> void:
)
func _on_home_toolbar_exit_requested() -> void:
if _home_exit_transition_active:
return
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
if owner_fingerprint.is_empty():
return
if _home_entry_transition_active:
_cancel_home_entry_transition()
_begin_home_exit_transition(owner_fingerprint, true)
func _on_home_exit_walk_finished() -> void:
_home_exit_walk_tween = null
if not _home_exit_transition_active:
@ -3209,35 +3230,33 @@ func _on_rv_storage_requested() -> void:
func _can_show_shop_prompt() -> bool:
return (
_gameplay_started
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
_get_active_shop_npc_interaction() == _shop_interaction
and _can_show_active_shop_prompt(_shop_interaction)
)
func _can_show_decor_shop_prompt() -> bool:
return (
_gameplay_started
and _decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
_get_active_shop_npc_interaction() == _decor_shop_interaction
and _can_show_active_shop_prompt(_decor_shop_interaction)
)
func _can_show_rv_upgrade_shop_prompt() -> bool:
return (
_get_active_shop_npc_interaction() == _rv_upgrade_shop_interaction
and _can_show_active_shop_prompt(_rv_upgrade_shop_interaction)
)
func _can_show_active_shop_prompt(
interaction: FishingShopInteractionType,
) -> bool:
return (
_gameplay_started
and _rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
and not _shop_conversation_camera_restoring
and interaction != null
and interaction.is_local_player_in_range()
and not _game_ui.get_rv_upgrade_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
@ -3247,10 +3266,8 @@ func _can_show_rv_upgrade_shop_prompt() -> bool:
func _can_show_any_shop_prompt() -> bool:
return (
_can_show_shop_prompt()
or _can_show_decor_shop_prompt()
or _can_show_rv_upgrade_shop_prompt()
return _can_show_active_shop_prompt(
_get_active_shop_npc_interaction()
)
@ -3272,34 +3289,181 @@ func _is_any_shop_npc_in_range() -> bool:
func _refresh_shop_npc_speech_state() -> void:
if (
_engaged_shop_npc_interaction != null
and (
not is_instance_valid(_engaged_shop_npc_interaction)
or not _engaged_shop_npc_interaction.is_local_player_in_range()
)
):
if _shop_conversation_shop_opened and _is_any_shop_menu_visible():
_game_ui.set_shop_npc_player_in_range(false)
return
_end_shop_npc_conversation(false)
var interaction := _get_active_shop_npc_interaction()
var dialogue_text: String = ""
if interaction != null:
dialogue_text = interaction.get_dialogue_text(_home_state.get_tier())
_game_ui.set_shop_npc_player_in_range(
interaction != null,
interaction != null and not _shop_conversation_shop_opened,
dialogue_text,
interaction.get_instance_id() if interaction != null else 0,
interaction == _engaged_shop_npc_interaction
and not _shop_conversation_shop_opened,
)
func _get_active_shop_npc_interaction() -> FishingShopInteractionType:
# Match the interaction and prompt priority used above: RV, decor, fishing.
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
is_instance_valid(_engaged_shop_npc_interaction)
and _engaged_shop_npc_interaction.is_local_player_in_range()
):
return _rv_upgrade_shop_interaction
_active_shop_npc_interaction = _engaged_shop_npc_interaction
return _active_shop_npc_interaction
var candidates: Array[FishingShopInteractionType] = []
if _shop_interaction != null:
candidates.append(_shop_interaction)
if _decor_shop_interaction != null:
candidates.append(_decor_shop_interaction)
if _rv_upgrade_shop_interaction != null:
candidates.append(_rv_upgrade_shop_interaction)
var nearest: FishingShopInteractionType
var nearest_distance_squared: float = INF
for interaction: FishingShopInteractionType in candidates:
if not interaction.is_local_player_in_range():
continue
var distance_squared := interaction.get_local_player_distance_squared()
if distance_squared < nearest_distance_squared:
nearest = interaction
nearest_distance_squared = distance_squared
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
nearest != null
and nearest != _active_shop_npc_interaction
and is_instance_valid(_active_shop_npc_interaction)
and _active_shop_npc_interaction.is_local_player_in_range()
):
return _decor_shop_interaction
var active_distance_squared := (
_active_shop_npc_interaction.get_local_player_distance_squared()
)
# Require a small, visible distance advantage before ownership switches.
# This keeps a player standing on the zone seam from chattering between
# two NPCs while still handing off well before the old compact zone ends.
if (
sqrt(nearest_distance_squared)
+ SHOP_NPC_HANDOFF_HYSTERESIS_METERS
>= sqrt(active_distance_squared)
):
nearest = _active_shop_npc_interaction
_active_shop_npc_interaction = nearest
return _active_shop_npc_interaction
func _begin_shop_npc_conversation(
interaction: FishingShopInteractionType,
) -> bool:
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
_shop_conversation_camera_restoring
or is_instance_valid(_engaged_shop_npc_interaction)
or interaction == null
or not interaction.is_local_player_in_range()
or not _fishing_spot.can_open_fishing_shop()
):
return _shop_interaction
return null
return false
_engaged_shop_npc_interaction = interaction
_active_shop_npc_interaction = interaction
_shop_conversation_shop_opened = false
interaction.set_engaged(true)
_player.begin_shop_conversation_camera(
interaction.get_shopkeeper_position()
)
_player.set_local_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, true
)
_player.set_camera_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, true
)
_refresh_shop_npc_speech_state()
return true
func _open_engaged_shop() -> bool:
var interaction := _engaged_shop_npc_interaction
if (
interaction == null
or not is_instance_valid(interaction)
or not interaction.is_local_player_in_range()
or _shop_conversation_shop_opened
):
return false
var opened: bool = false
if interaction == _rv_upgrade_shop_interaction:
opened = _game_ui.get_rv_upgrade_shop().open_shop()
elif interaction == _decor_shop_interaction:
opened = _game_ui.get_decor_shop().open_shop()
elif interaction == _shop_interaction:
opened = _game_ui.get_fishing_shop().open_shop()
if not opened:
return false
_shop_conversation_shop_opened = true
_game_ui.set_shop_prompt_visible(false)
_refresh_shop_npc_speech_state()
return true
func _end_shop_npc_conversation(immediate_camera: bool = false) -> void:
if _shop_conversation_camera_restoring and not immediate_camera:
return
var interaction := _engaged_shop_npc_interaction
if is_instance_valid(interaction):
interaction.set_engaged(false)
_engaged_shop_npc_interaction = null
_shop_conversation_shop_opened = false
_game_ui.set_shop_npc_player_in_range(false)
var camera_was_active := _player.is_shop_conversation_camera_active()
_shop_conversation_camera_restoring = camera_was_active and not immediate_camera
_player.restore_shop_conversation_camera(
immediate_camera,
_finish_shop_npc_conversation_restore,
)
func _finish_shop_npc_conversation_restore() -> void:
_player.set_local_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, false
)
_player.set_camera_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, false
)
_shop_conversation_camera_restoring = false
if _gameplay_started and is_instance_valid(_game_ui):
_refresh_shop_npc_speech_state()
func _is_any_shop_menu_visible() -> bool:
return (
_game_ui.get_fishing_shop().visible
or _game_ui.get_decor_shop().visible
or _game_ui.get_rv_upgrade_shop().visible
)
func _on_shop_conversation_menu_visibility_changed(is_open: bool) -> void:
if (
not is_open
and _shop_conversation_shop_opened
and not _is_any_shop_menu_visible()
):
_end_shop_npc_conversation(false)
func _on_pause_menu_conversation_visibility_changed(is_open: bool) -> void:
if is_open and is_instance_valid(_engaged_shop_npc_interaction):
_end_shop_npc_conversation(false)
func _on_player_menu_conversation_visibility_changed(is_open: bool) -> void:
if is_open and is_instance_valid(_engaged_shop_npc_interaction):
_end_shop_npc_conversation(false)
func _can_show_storage_prompt() -> bool:

File diff suppressed because one or more lines are too long