Integrate per-save identity and interaction UI
This commit is contained in:
parent
ac82a28f3b
commit
4fda6e97f6
58 changed files with 3471 additions and 461 deletions
|
|
@ -14,4 +14,5 @@ surface_water_relation = 2
|
|||
population = 4
|
||||
surface_population_per_100_square_meters = 0.4
|
||||
maximum_surface_population = 16
|
||||
sprite_pixel_size = 0.0005
|
||||
sprite_pixel_size = 0.01
|
||||
sprite_surface_offset = 0.11
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ scare_radius = 2.8
|
|||
capture_radius = 0.7
|
||||
interaction_range = 2.6
|
||||
charge_duration = 2.0
|
||||
sprite_pixel_size = 0.0005
|
||||
sprite_pixel_size = 0.01
|
||||
sprite_surface_offset = 0.11
|
||||
sprite_tilt_degrees = -45.0
|
||||
capture_respawn_min_seconds = 480.0
|
||||
capture_respawn_max_seconds = 720.0
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ required_tool_id = &"crab_net"
|
|||
surface_materials = Array[StringName]([&"sand"])
|
||||
minimum_surface_y = -0.5
|
||||
population = 4
|
||||
sprite_pixel_size = 0.0005
|
||||
sprite_pixel_size = 0.01
|
||||
sprite_surface_offset = 0.11
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ required_tool_id = &"crab_net"
|
|||
surface_materials = Array[StringName]([&"sand"])
|
||||
minimum_surface_y = 0.08
|
||||
population = 4
|
||||
sprite_pixel_size = 0.0005
|
||||
sprite_pixel_size = 0.01
|
||||
sprite_surface_offset = 0.11
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ var surface_population_per_100_square_meters := 0.0
|
|||
@export_range(0.1, 10.0, 0.05) var interaction_range: float = 2.6
|
||||
@export_range(0.1, 10.0, 0.05) var charge_duration: float = 2.0
|
||||
@export_range(0.0001, 0.01, 0.00005) var sprite_pixel_size: float = 0.001
|
||||
@export_range(-2.0, 2.0, 0.01) var sprite_surface_offset: float = 0.11
|
||||
@export_range(-90.0, 90.0, 1.0) var sprite_tilt_degrees: float = -45.0
|
||||
@export_category("Respawn Budget")
|
||||
@export_range(0.0, 3600.0, 1.0) var capture_respawn_min_seconds: float = 480.0
|
||||
|
|
@ -117,6 +118,7 @@ func is_valid() -> bool:
|
|||
and interaction_range > 0.0
|
||||
and charge_duration > 0.0
|
||||
and sprite_pixel_size > 0.0
|
||||
and is_finite(sprite_surface_offset)
|
||||
and capture_respawn_max_seconds >= capture_respawn_min_seconds
|
||||
and scare_respawn_max_seconds >= scare_respawn_min_seconds
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ class_name WorldGatherable
|
|||
extends Node3D
|
||||
|
||||
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
|
||||
const REFERENCE_SPRITE_PIXEL_SIZE: float = 0.001
|
||||
const REFERENCE_SPRITE_HEIGHT: float = 0.22
|
||||
const WATER_SPURT_INTERVAL_SECONDS: float = 1.15
|
||||
const WATER_SPURT_COLOR := Color(0.48, 0.82, 0.84, 1.0)
|
||||
const WATER_SPURT_HOLE_COLOR := Color(0.23, 0.27, 0.24, 1.0)
|
||||
|
|
@ -47,11 +45,7 @@ func configure(
|
|||
_sprite.position.y = (
|
||||
0.0
|
||||
if not data.spawn_anchor_set_id.is_empty()
|
||||
else (
|
||||
REFERENCE_SPRITE_HEIGHT
|
||||
* data.sprite_pixel_size
|
||||
/ REFERENCE_SPRITE_PIXEL_SIZE
|
||||
)
|
||||
else data.sprite_surface_offset
|
||||
)
|
||||
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
|
||||
|
||||
|
|
@ -118,7 +112,6 @@ func _ensure_visual() -> void:
|
|||
return
|
||||
_sprite = Sprite3D.new()
|
||||
_sprite.name = "GatherableSprite"
|
||||
_sprite.position.y = REFERENCE_SPRITE_HEIGHT
|
||||
_sprite.shaded = false
|
||||
_sprite.double_sided = true
|
||||
_sprite.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
|
|
|
|||
648
main/main.gd
648
main/main.gd
|
|
@ -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
|
|
@ -278,7 +278,10 @@ func get_friend_presence() -> Array[Dictionary]:
|
|||
result.append({
|
||||
"fingerprint": fingerprint,
|
||||
"display_name": str(
|
||||
friend.get("last_known_display_name", "Player")
|
||||
friend.get(
|
||||
"last_known_display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)
|
||||
),
|
||||
"online": bool(live.get("online", false)),
|
||||
"room": (
|
||||
|
|
@ -1444,7 +1447,10 @@ func _on_invitation_poll_completed(
|
|||
continue
|
||||
friend_invite_received.emit(
|
||||
fingerprint,
|
||||
str(friend.get("last_known_display_name", "Player")),
|
||||
str(friend.get(
|
||||
"last_known_display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)),
|
||||
room.duplicate(true),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ static var _invisible_edge_pattern: RegEx = RegEx.create_from_string(
|
|||
static var _visible_content_pattern: RegEx = RegEx.create_from_string(
|
||||
"[^\\s\\p{Z}\\p{Cf}\\x{2800}]"
|
||||
)
|
||||
static var _roleplay_separator_pattern: RegEx = RegEx.create_from_string(
|
||||
"^[\\s\\p{Z}\\p{Cf}\\x{2800}]"
|
||||
)
|
||||
|
||||
enum Kind { PLAYER, SYSTEM }
|
||||
|
||||
|
|
@ -40,12 +43,40 @@ static func sanitize_body(value: Variant) -> String:
|
|||
return result
|
||||
|
||||
|
||||
static func is_roleplay_body(value: Variant) -> bool:
|
||||
var body: String = sanitize_body(value)
|
||||
if body.length() < 3 or body.left(3).to_lower() != "/me":
|
||||
return false
|
||||
return (
|
||||
body.length() == 3
|
||||
or _roleplay_separator_pattern.search(body.substr(3, 1)) != null
|
||||
)
|
||||
|
||||
|
||||
static func roleplay_action(value: Variant) -> String:
|
||||
var body: String = sanitize_body(value)
|
||||
if not is_roleplay_body(body):
|
||||
return ""
|
||||
return sanitize_body(body.substr(3))
|
||||
|
||||
|
||||
static func valid_user_body(value: Variant) -> bool:
|
||||
var body: String = sanitize_body(value)
|
||||
return (
|
||||
not body.is_empty()
|
||||
and (
|
||||
not is_roleplay_body(body)
|
||||
or not roleplay_action(body).is_empty()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func validate_request(data: Variant) -> bool:
|
||||
return (
|
||||
typeof(data) == TYPE_DICTIONARY
|
||||
and _valid_id(data.get("request_id"))
|
||||
and _valid_id(data.get("session_id"))
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
and valid_user_body(data.get("body"))
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("sender_fingerprint")
|
||||
)
|
||||
|
|
@ -67,7 +98,16 @@ static func validate_message(data: Variant) -> bool:
|
|||
and typeof(data.get("sender_peer_id")) == TYPE_INT
|
||||
and typeof(data.get("sender_display_name")) == TYPE_STRING
|
||||
and str(data["sender_display_name"]).length() <= 24
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
and (
|
||||
(
|
||||
int(data["kind"]) == Kind.SYSTEM
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
)
|
||||
or (
|
||||
int(data["kind"]) == Kind.PLAYER
|
||||
and valid_user_body(data.get("body"))
|
||||
)
|
||||
)
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("sender_fingerprint")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ func send_local_message(
|
|||
send_rejected.emit("Chat is unavailable.")
|
||||
return false
|
||||
var clean := NetworkChatProtocol.sanitize_body(body)
|
||||
if clean.is_empty():
|
||||
if not NetworkChatProtocol.valid_user_body(clean):
|
||||
if NetworkChatProtocol.is_roleplay_body(clean):
|
||||
send_rejected.emit("Usage: /me [action].")
|
||||
return false
|
||||
send_rejected.emit("Message is empty or too long.")
|
||||
return false
|
||||
var request := {
|
||||
|
|
@ -412,6 +415,7 @@ func _apply_message(
|
|||
return
|
||||
var kind := int(data["kind"])
|
||||
var valid_signature := false
|
||||
var canonical_sender_name: String = ""
|
||||
if kind == NetworkChatProtocol.Kind.SYSTEM:
|
||||
valid_signature = _session.verify_host_action(
|
||||
"chat_system",
|
||||
|
|
@ -424,6 +428,7 @@ func _apply_message(
|
|||
if record == null:
|
||||
_queue_pending_peer_message(sender_id, data, emit_live_signals)
|
||||
return
|
||||
canonical_sender_name = record.display_name.left(24)
|
||||
valid_signature = (
|
||||
record.identity_fingerprint == str(data["sender_fingerprint"])
|
||||
and _session.verify_peer_action(
|
||||
|
|
@ -437,6 +442,10 @@ func _apply_message(
|
|||
return
|
||||
_seen_messages[data["message_id"]] = true
|
||||
var stored_message := data.duplicate(true)
|
||||
if kind == NetworkChatProtocol.Kind.PLAYER:
|
||||
# The authenticated peer registry, not sender-controlled message data,
|
||||
# owns the name shown beside chat and roleplay actions.
|
||||
stored_message["sender_display_name"] = canonical_sender_name
|
||||
# Suppression is immutable for this received copy. Relationship changes
|
||||
# later must never resurrect text that was hidden on arrival.
|
||||
stored_message["locally_suppressed"] = (
|
||||
|
|
@ -548,7 +557,9 @@ func _on_peer_removed(peer_id: int) -> void:
|
|||
_pending_peer_messages.erase(peer_id)
|
||||
if not _session.is_host():
|
||||
return
|
||||
var display_name: String = _peer_names.get(peer_id, "Player")
|
||||
var display_name: String = _peer_names.get(
|
||||
peer_id, NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
)
|
||||
_peer_names.erase(peer_id)
|
||||
_broadcast(_make_message(
|
||||
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % display_name
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ func is_local_recipient(letter: Dictionary) -> bool:
|
|||
|
||||
func get_local_display_name() -> String:
|
||||
var record := _session.get_peer_record(_session.get_local_peer_id())
|
||||
return record.display_name if record != null else "Player"
|
||||
return (
|
||||
record.display_name
|
||||
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
)
|
||||
|
||||
|
||||
func get_recipient_choices() -> Array[Dictionary]:
|
||||
|
|
|
|||
|
|
@ -477,7 +477,10 @@ func request_ban(peer_id: int, fingerprint: String) -> void:
|
|||
if not _valid_operator_sender(sender_id):
|
||||
return
|
||||
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
|
||||
var display_name: String = record.display_name if record != null else "Player"
|
||||
var display_name: String = (
|
||||
record.display_name
|
||||
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
)
|
||||
var ok: bool = _ban_on_host(
|
||||
peer_id, fingerprint, display_name, -1, false
|
||||
)
|
||||
|
|
@ -544,7 +547,10 @@ func receive_ban_snapshot(records: Array) -> void:
|
|||
var record: Dictionary = value
|
||||
var fingerprint: String = str(record.get("target_fingerprint", ""))
|
||||
var display_name: String = str(
|
||||
record.get("last_known_display_name", "Player")
|
||||
record.get(
|
||||
"last_known_display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)
|
||||
).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
|
||||
if (
|
||||
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
class_name NetworkProfilePreferences
|
||||
extends Node
|
||||
|
||||
signal profile_changed
|
||||
|
||||
const FORMAT_VERSION: int = 1
|
||||
const DEFAULT_DISPLAY_NAME: String = "stray"
|
||||
const DEFAULT_TITLE: String = "newbie"
|
||||
const VoiceProfilesType = preload(
|
||||
"res://player/animalese_voice_profiles.gd"
|
||||
)
|
||||
var profile_id: String = ""
|
||||
var display_name: String = "Player"
|
||||
var display_name: String = DEFAULT_DISPLAY_NAME
|
||||
var title: String = DEFAULT_TITLE
|
||||
var voice_id: String = VoiceProfilesType.DEFAULT_ID
|
||||
var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
|
||||
var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
|
||||
|
|
@ -56,7 +61,8 @@ func load_or_create() -> bool:
|
|||
int(Time.get_unix_time_from_system()),
|
||||
Time.get_ticks_usec(),
|
||||
]
|
||||
display_name = "Player"
|
||||
display_name = DEFAULT_DISPLAY_NAME
|
||||
title = DEFAULT_TITLE
|
||||
voice_id = VoiceProfilesType.DEFAULT_ID
|
||||
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
|
||||
call_id = VoiceProfilesType.DEFAULT_CALL_ID
|
||||
|
|
@ -65,6 +71,113 @@ func load_or_create() -> bool:
|
|||
return _save_atomic()
|
||||
|
||||
|
||||
func to_save_data() -> Dictionary:
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"display_name": display_name,
|
||||
"title": title,
|
||||
"voice_id": voice_id,
|
||||
"speech_speed_id": speech_speed_id,
|
||||
"call_id": call_id,
|
||||
"sample_set_id": sample_set_id,
|
||||
"created_at_unix": created_at_unix,
|
||||
}
|
||||
|
||||
|
||||
static func validate_save_data(value: Variant) -> bool:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var data: Dictionary = value
|
||||
if (
|
||||
typeof(data.get("profile_id")) != TYPE_STRING
|
||||
or typeof(data.get("display_name")) != TYPE_STRING
|
||||
or (
|
||||
data.has("title")
|
||||
and typeof(data.get("title")) != TYPE_STRING
|
||||
)
|
||||
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
|
||||
):
|
||||
return false
|
||||
var saved_profile_id := str(data.get("profile_id", ""))
|
||||
var saved_name := str(data.get("display_name", ""))
|
||||
var saved_title := str(data.get("title", DEFAULT_TITLE))
|
||||
return (
|
||||
not saved_profile_id.is_empty()
|
||||
and saved_profile_id.length() <= NetworkProtocol.MAX_PROFILE_ID_LENGTH
|
||||
and is_valid_display_name(saved_name)
|
||||
and is_valid_title(saved_title)
|
||||
and VoiceProfilesType.is_valid(str(data.get(
|
||||
"voice_id",
|
||||
VoiceProfilesType.DEFAULT_ID,
|
||||
)))
|
||||
and VoiceProfilesType.is_valid_speed(str(data.get(
|
||||
"speech_speed_id",
|
||||
VoiceProfilesType.DEFAULT_SPEED_ID,
|
||||
)))
|
||||
and VoiceProfilesType.is_valid_call(str(data.get(
|
||||
"call_id",
|
||||
VoiceProfilesType.DEFAULT_CALL_ID,
|
||||
)))
|
||||
and VoiceProfilesType.is_valid_sample_set(str(data.get(
|
||||
"sample_set_id",
|
||||
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
)))
|
||||
and int(data.get("created_at_unix", -1)) >= 0
|
||||
)
|
||||
|
||||
|
||||
func restore_from_save_data(
|
||||
value: Variant,
|
||||
persist_to_profile_file: bool = true,
|
||||
) -> bool:
|
||||
if not validate_save_data(value):
|
||||
return false
|
||||
var data: Dictionary = value
|
||||
var previous: Dictionary = to_save_data()
|
||||
profile_id = str(data["profile_id"])
|
||||
display_name = str(data["display_name"]).strip_edges()
|
||||
title = str(data.get("title", DEFAULT_TITLE)).strip_edges()
|
||||
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
|
||||
speech_speed_id = str(data.get(
|
||||
"speech_speed_id",
|
||||
VoiceProfilesType.DEFAULT_SPEED_ID,
|
||||
))
|
||||
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
|
||||
sample_set_id = str(data.get(
|
||||
"sample_set_id",
|
||||
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
))
|
||||
created_at_unix = int(data["created_at_unix"])
|
||||
if persist_to_profile_file and not _save_atomic():
|
||||
_assign_save_data(previous)
|
||||
return false
|
||||
if to_save_data() != previous:
|
||||
profile_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func reset_to_defaults(persist_to_profile_file: bool = true) -> bool:
|
||||
var previous: Dictionary = to_save_data()
|
||||
profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
||||
if profile_id.is_empty():
|
||||
profile_id = "%d-%d" % [
|
||||
int(Time.get_unix_time_from_system()),
|
||||
Time.get_ticks_usec(),
|
||||
]
|
||||
display_name = DEFAULT_DISPLAY_NAME
|
||||
title = DEFAULT_TITLE
|
||||
voice_id = VoiceProfilesType.DEFAULT_ID
|
||||
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
|
||||
call_id = VoiceProfilesType.DEFAULT_CALL_ID
|
||||
sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
|
||||
created_at_unix = int(Time.get_unix_time_from_system())
|
||||
if persist_to_profile_file and not _save_atomic():
|
||||
_assign_save_data(previous)
|
||||
return false
|
||||
profile_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func set_display_name(value: String) -> bool:
|
||||
return set_profile_identity(
|
||||
value,
|
||||
|
|
@ -72,6 +185,7 @@ func set_display_name(value: String) -> bool:
|
|||
speech_speed_id,
|
||||
call_id,
|
||||
sample_set_id,
|
||||
title,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -81,10 +195,15 @@ func set_profile_identity(
|
|||
selected_speech_speed_id: String,
|
||||
selected_call_id: String,
|
||||
selected_sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
selected_title: String = "",
|
||||
) -> bool:
|
||||
var clean_name: String = value.strip_edges()
|
||||
var clean_title: String = (
|
||||
title if selected_title.is_empty() else selected_title.strip_edges()
|
||||
)
|
||||
if (
|
||||
not is_valid_display_name(clean_name)
|
||||
or not is_valid_title(clean_title)
|
||||
or not VoiceProfilesType.is_valid(selected_voice_id)
|
||||
or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
|
||||
or not VoiceProfilesType.is_valid_call(selected_call_id)
|
||||
|
|
@ -92,18 +211,30 @@ func set_profile_identity(
|
|||
):
|
||||
return false
|
||||
var previous_name: String = display_name
|
||||
var previous_title: String = title
|
||||
var previous_voice_id: String = voice_id
|
||||
var previous_speech_speed_id: String = speech_speed_id
|
||||
var previous_call_id: String = call_id
|
||||
var previous_sample_set_id: String = sample_set_id
|
||||
display_name = clean_name
|
||||
title = clean_title
|
||||
voice_id = selected_voice_id
|
||||
speech_speed_id = selected_speech_speed_id
|
||||
call_id = selected_call_id
|
||||
sample_set_id = selected_sample_set_id
|
||||
if _save_atomic():
|
||||
if (
|
||||
display_name != previous_name
|
||||
or voice_id != previous_voice_id
|
||||
or title != previous_title
|
||||
or speech_speed_id != previous_speech_speed_id
|
||||
or call_id != previous_call_id
|
||||
or sample_set_id != previous_sample_set_id
|
||||
):
|
||||
profile_changed.emit()
|
||||
return true
|
||||
display_name = previous_name
|
||||
title = previous_title
|
||||
voice_id = previous_voice_id
|
||||
speech_speed_id = previous_speech_speed_id
|
||||
call_id = previous_call_id
|
||||
|
|
@ -111,6 +242,23 @@ func set_profile_identity(
|
|||
return false
|
||||
|
||||
|
||||
func _assign_save_data(data: Dictionary) -> void:
|
||||
profile_id = str(data.get("profile_id", ""))
|
||||
display_name = str(data.get("display_name", DEFAULT_DISPLAY_NAME))
|
||||
title = str(data.get("title", DEFAULT_TITLE))
|
||||
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
|
||||
speech_speed_id = str(data.get(
|
||||
"speech_speed_id",
|
||||
VoiceProfilesType.DEFAULT_SPEED_ID,
|
||||
))
|
||||
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
|
||||
sample_set_id = str(data.get(
|
||||
"sample_set_id",
|
||||
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
))
|
||||
created_at_unix = int(data.get("created_at_unix", 0))
|
||||
|
||||
|
||||
static func is_valid_display_name(value: String) -> bool:
|
||||
var clean_name := value.strip_edges()
|
||||
if clean_name.is_empty() or clean_name.length() > 24:
|
||||
|
|
@ -122,6 +270,17 @@ static func is_valid_display_name(value: String) -> bool:
|
|||
return true
|
||||
|
||||
|
||||
static func is_valid_title(value: String) -> bool:
|
||||
var clean_title := value.strip_edges()
|
||||
if clean_title.is_empty() or clean_title.length() > NetworkProtocol.MAX_PLAYER_TITLE_LENGTH:
|
||||
return false
|
||||
for index: int in clean_title.length():
|
||||
var codepoint: int = clean_title.unicode_at(index)
|
||||
if codepoint < 32 or codepoint == 127:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _load_existing() -> bool:
|
||||
var file := FileAccess.open(_profile_path, FileAccess.READ)
|
||||
if file == null:
|
||||
|
|
@ -142,20 +301,27 @@ func _load_existing() -> bool:
|
|||
data.get("format_version") != FORMAT_VERSION
|
||||
or typeof(data.get("profile_id")) != TYPE_STRING
|
||||
or typeof(data.get("display_name")) != TYPE_STRING
|
||||
or (
|
||||
data.has("title")
|
||||
and typeof(data.get("title")) != TYPE_STRING
|
||||
)
|
||||
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
|
||||
):
|
||||
return false
|
||||
var loaded_id: String = data["profile_id"]
|
||||
var loaded_name: String = data["display_name"]
|
||||
var loaded_title := str(data.get("title", DEFAULT_TITLE))
|
||||
if (
|
||||
loaded_id.is_empty()
|
||||
or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH
|
||||
or loaded_name.is_empty()
|
||||
or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
|
||||
or not is_valid_title(loaded_title)
|
||||
):
|
||||
return false
|
||||
profile_id = loaded_id
|
||||
display_name = loaded_name
|
||||
title = loaded_title.strip_edges()
|
||||
voice_id = VoiceProfilesType.sanitized_id(
|
||||
str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
|
||||
)
|
||||
|
|
@ -180,6 +346,7 @@ func _save_atomic() -> bool:
|
|||
"format_version": FORMAT_VERSION,
|
||||
"profile_id": profile_id,
|
||||
"display_name": display_name,
|
||||
"title": title,
|
||||
"voice_id": voice_id,
|
||||
"speech_speed_id": speech_speed_id,
|
||||
"call_id": call_id,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ static func valid_check_request(data: Variant) -> bool:
|
|||
static func valid_apply_request(data: Variant) -> bool:
|
||||
return (
|
||||
valid_check_request(data)
|
||||
and typeof(data.get("player_title")) == TYPE_STRING
|
||||
and NetworkProfilePreferences.is_valid_title(data["player_title"])
|
||||
and typeof(data.get("appearance")) == TYPE_DICTIONARY
|
||||
and valid_snapshot(data["appearance"])
|
||||
and typeof(data.get("use_anyway")) == TYPE_BOOL
|
||||
|
|
@ -63,6 +65,7 @@ static func signature_fields(data: Dictionary) -> Array:
|
|||
str(data.get("request_id", "")),
|
||||
str(data.get("sender_fingerprint", "")),
|
||||
str(data.get("display_name", "")),
|
||||
str(data.get("player_title", "")),
|
||||
]
|
||||
result.append_array(
|
||||
CharacterCustomizationCatalog.appearance_signature_values(appearance)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ signal profile_snapshot_changed(
|
|||
display_name: String,
|
||||
appearance: Dictionary,
|
||||
)
|
||||
signal profile_title_changed(peer_id: int, title: String)
|
||||
|
||||
var _session: NetworkSession
|
||||
var _preferences: NetworkProfilePreferences
|
||||
|
|
@ -26,6 +27,7 @@ var _pending_voice_ids: Dictionary[String, String] = {}
|
|||
var _pending_speech_speed_ids: Dictionary[String, String] = {}
|
||||
var _pending_call_ids: Dictionary[String, String] = {}
|
||||
var _pending_sample_set_ids: Dictionary[String, String] = {}
|
||||
var _pending_titles: Dictionary[String, String] = {}
|
||||
var _host_pending_apply: Dictionary[String, Dictionary] = {}
|
||||
var _latest_check_id: String = ""
|
||||
var _latest_check_name: String = ""
|
||||
|
|
@ -42,6 +44,16 @@ func setup(
|
|||
_appearance_store = appearance_store
|
||||
_spawn_service = spawn_service
|
||||
_appearance_store.load_preferences()
|
||||
if not _preferences.profile_changed.is_connected(
|
||||
_on_local_profile_store_changed
|
||||
):
|
||||
_preferences.profile_changed.connect(_on_local_profile_store_changed)
|
||||
if not _appearance_store.appearance_changed.is_connected(
|
||||
_on_local_appearance_store_changed
|
||||
):
|
||||
_appearance_store.appearance_changed.connect(
|
||||
_on_local_appearance_store_changed
|
||||
)
|
||||
_session.peer_authenticated.connect(_on_peer_authenticated)
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
|
||||
|
|
@ -51,6 +63,20 @@ func setup(
|
|||
_apply_local_voice_to_avatar()
|
||||
|
||||
|
||||
func _on_local_profile_store_changed() -> void:
|
||||
_apply_local_voice_to_avatar()
|
||||
|
||||
|
||||
func _on_local_appearance_store_changed() -> void:
|
||||
var snapshot: Dictionary = _appearance_store.get_snapshot()
|
||||
var local_peer_id: int = (
|
||||
_session.get_local_peer_id() if _session != null else 1
|
||||
)
|
||||
_apply_to_avatar(local_peer_id, snapshot)
|
||||
if _session != null:
|
||||
_session.set_local_appearance_snapshot(snapshot)
|
||||
|
||||
|
||||
func reload_persisted_profile() -> bool:
|
||||
if (
|
||||
not _preferences.load_or_create()
|
||||
|
|
@ -66,6 +92,10 @@ func get_persisted_name() -> String:
|
|||
return _preferences.display_name
|
||||
|
||||
|
||||
func get_persisted_title() -> String:
|
||||
return _preferences.title
|
||||
|
||||
|
||||
func get_persisted_appearance() -> Dictionary:
|
||||
return _appearance_store.get_snapshot()
|
||||
|
||||
|
|
@ -107,6 +137,7 @@ func preview_appearance(appearance: Dictionary) -> bool:
|
|||
local_peer_id,
|
||||
_preferences.display_name,
|
||||
snapshot,
|
||||
_preferences.title,
|
||||
)
|
||||
var authorization := _make_appearance_preview_authorization(snapshot)
|
||||
var local_record := _session.get_peer_record(local_peer_id)
|
||||
|
|
@ -179,10 +210,16 @@ func apply_profile(
|
|||
speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID,
|
||||
call_id: String = VoiceProfilesType.DEFAULT_CALL_ID,
|
||||
sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
player_title: String = "",
|
||||
) -> bool:
|
||||
var clean_name := display_name.strip_edges()
|
||||
var clean_title := (
|
||||
_preferences.title
|
||||
if player_title.is_empty() else player_title.strip_edges()
|
||||
)
|
||||
if (
|
||||
not NetworkProfilePreferences.is_valid_display_name(clean_name)
|
||||
or not NetworkProfilePreferences.is_valid_title(clean_title)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
or not VoiceProfilesType.is_valid(voice_id)
|
||||
or not VoiceProfilesType.is_valid_speed(speech_speed_id)
|
||||
|
|
@ -196,6 +233,7 @@ func apply_profile(
|
|||
"request_id": request_id,
|
||||
"session_id": _session.get_session_id() if _session != null else "",
|
||||
"display_name": clean_name,
|
||||
"player_title": clean_title,
|
||||
"appearance": appearance.duplicate(true),
|
||||
"use_anyway": use_anyway,
|
||||
"sender_fingerprint": _session.get_local_identity_fingerprint(),
|
||||
|
|
@ -208,6 +246,7 @@ func apply_profile(
|
|||
_pending_speech_speed_ids[request_id] = speech_speed_id
|
||||
_pending_call_ids[request_id] = call_id
|
||||
_pending_sample_set_ids[request_id] = sample_set_id
|
||||
_pending_titles[request_id] = clean_title
|
||||
if _session == null or not _session.is_gameplay_session_active():
|
||||
_apply_local_result(request_id, true, "", false, PackedStringArray())
|
||||
elif _session.is_host():
|
||||
|
|
@ -303,6 +342,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
|
|||
sender_id,
|
||||
record.display_name,
|
||||
appearance,
|
||||
record.title,
|
||||
)
|
||||
_apply_to_avatar(sender_id, appearance)
|
||||
_broadcast_appearance_preview_to_supported_peers(sender_id, appearance)
|
||||
|
|
@ -310,6 +350,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
|
|||
|
||||
func _process_apply_request(peer_id: int, data: Dictionary) -> void:
|
||||
var display_name: String = str(data["display_name"]).strip_edges()
|
||||
var player_title: String = str(data["player_title"]).strip_edges()
|
||||
var conflict: bool = _name_conflicts(peer_id, display_name)
|
||||
var accepted: bool = not conflict or bool(data["use_anyway"])
|
||||
var suggestions: PackedStringArray = (
|
||||
|
|
@ -325,7 +366,8 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
|
|||
if accepted:
|
||||
_host_pending_apply[str(data["request_id"])] = {
|
||||
"peer_id": peer_id,
|
||||
"display_name": display_name,
|
||||
"display_name": display_name,
|
||||
"player_title": player_title,
|
||||
"appearance": Dictionary(data["appearance"]).duplicate(true),
|
||||
"authorization": {
|
||||
"request_id": data["request_id"],
|
||||
|
|
@ -356,8 +398,11 @@ func confirm_profile_saved(request_id: String) -> void:
|
|||
return
|
||||
_host_pending_apply.erase(request_id)
|
||||
var display_name: String = str(pending["display_name"])
|
||||
var player_title: String = str(pending["player_title"])
|
||||
var appearance: Dictionary = Dictionary(pending["appearance"])
|
||||
_session.apply_canonical_profile(sender_id, display_name, appearance)
|
||||
_session.apply_canonical_profile(
|
||||
sender_id, display_name, appearance, player_title
|
||||
)
|
||||
var record: PeerRegistry.PeerRecord = _session.get_peer_record(sender_id)
|
||||
if record != null:
|
||||
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
|
||||
|
|
@ -365,6 +410,7 @@ func confirm_profile_saved(request_id: String) -> void:
|
|||
broadcast_profile_snapshot.rpc(
|
||||
sender_id,
|
||||
display_name,
|
||||
player_title,
|
||||
appearance,
|
||||
pending.get("authorization", {}),
|
||||
)
|
||||
|
|
@ -405,10 +451,12 @@ func _apply_local_result(
|
|||
_pending_speech_speed_ids.erase(request_id)
|
||||
_pending_call_ids.erase(request_id)
|
||||
_pending_sample_set_ids.erase(request_id)
|
||||
_pending_titles.erase(request_id)
|
||||
conflict_result.emit(request_id, conflict, suggestions)
|
||||
apply_finished.emit(false, message)
|
||||
return
|
||||
var previous_name := _preferences.display_name
|
||||
var previous_title := _preferences.title
|
||||
var previous_voice_id := _preferences.voice_id
|
||||
var previous_speech_speed_id := _preferences.speech_speed_id
|
||||
var previous_call_id := _preferences.call_id
|
||||
|
|
@ -429,18 +477,24 @@ func _apply_local_result(
|
|||
request_id,
|
||||
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
|
||||
)
|
||||
var requested_title: String = _pending_titles.get(
|
||||
request_id,
|
||||
NetworkProfilePreferences.DEFAULT_TITLE,
|
||||
)
|
||||
if not _preferences.set_profile_identity(
|
||||
str(request["display_name"]),
|
||||
requested_voice_id,
|
||||
requested_speech_speed_id,
|
||||
requested_call_id,
|
||||
requested_sample_set_id,
|
||||
requested_title,
|
||||
):
|
||||
_pending_apply.erase(request_id)
|
||||
_pending_voice_ids.erase(request_id)
|
||||
_pending_speech_speed_ids.erase(request_id)
|
||||
_pending_call_ids.erase(request_id)
|
||||
_pending_sample_set_ids.erase(request_id)
|
||||
_pending_titles.erase(request_id)
|
||||
apply_finished.emit(false, "Profile could not be saved.")
|
||||
return
|
||||
if not _appearance_store.save_snapshot(request["appearance"]):
|
||||
|
|
@ -450,12 +504,14 @@ func _apply_local_result(
|
|||
previous_speech_speed_id,
|
||||
previous_call_id,
|
||||
previous_sample_set_id,
|
||||
previous_title,
|
||||
)
|
||||
_pending_apply.erase(request_id)
|
||||
_pending_voice_ids.erase(request_id)
|
||||
_pending_speech_speed_ids.erase(request_id)
|
||||
_pending_call_ids.erase(request_id)
|
||||
_pending_sample_set_ids.erase(request_id)
|
||||
_pending_titles.erase(request_id)
|
||||
apply_finished.emit(false, "Profile could not be saved.")
|
||||
return
|
||||
_pending_apply.erase(request_id)
|
||||
|
|
@ -463,6 +519,7 @@ func _apply_local_result(
|
|||
_pending_speech_speed_ids.erase(request_id)
|
||||
_pending_call_ids.erase(request_id)
|
||||
_pending_sample_set_ids.erase(request_id)
|
||||
_pending_titles.erase(request_id)
|
||||
if _session != null:
|
||||
_session.set_local_appearance_snapshot(_appearance_store.get_snapshot())
|
||||
if _session != null and _session.is_gameplay_session_active():
|
||||
|
|
@ -471,6 +528,7 @@ func _apply_local_result(
|
|||
_session.get_local_peer_id(),
|
||||
_preferences.display_name,
|
||||
_appearance_store.get_snapshot(),
|
||||
_preferences.title,
|
||||
)
|
||||
var record := _session.get_peer_record(
|
||||
_session.get_local_peer_id()
|
||||
|
|
@ -480,6 +538,7 @@ func _apply_local_result(
|
|||
broadcast_profile_snapshot.rpc(
|
||||
_session.get_local_peer_id(),
|
||||
_preferences.display_name,
|
||||
_preferences.title,
|
||||
_appearance_store.get_snapshot(),
|
||||
request,
|
||||
)
|
||||
|
|
@ -499,11 +558,13 @@ func _apply_local_result(
|
|||
func broadcast_profile_snapshot(
|
||||
peer_id: int,
|
||||
display_name: String,
|
||||
player_title: String,
|
||||
appearance: Dictionary,
|
||||
authorization: Dictionary = {},
|
||||
) -> void:
|
||||
if (
|
||||
not NetworkProfilePreferences.is_valid_display_name(display_name)
|
||||
or not NetworkProfilePreferences.is_valid_title(player_title)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
):
|
||||
return
|
||||
|
|
@ -514,12 +575,16 @@ func broadcast_profile_snapshot(
|
|||
display_name,
|
||||
appearance,
|
||||
authorization,
|
||||
player_title,
|
||||
)
|
||||
):
|
||||
return
|
||||
_session.apply_canonical_profile(peer_id, display_name, appearance)
|
||||
_session.apply_canonical_profile(
|
||||
peer_id, display_name, appearance, player_title
|
||||
)
|
||||
_apply_to_avatar(peer_id, appearance)
|
||||
profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
|
||||
profile_title_changed.emit(peer_id, player_title)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
|
||||
|
|
@ -533,7 +598,9 @@ func broadcast_appearance_preview(
|
|||
if record == null:
|
||||
return
|
||||
var snapshot := CharacterCustomizationCatalog.sanitized_snapshot(appearance)
|
||||
_session.apply_canonical_profile(peer_id, record.display_name, snapshot)
|
||||
_session.apply_canonical_profile(
|
||||
peer_id, record.display_name, snapshot, record.title
|
||||
)
|
||||
_apply_to_avatar(peer_id, snapshot)
|
||||
profile_snapshot_changed.emit(
|
||||
peer_id,
|
||||
|
|
@ -575,6 +642,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
|||
peer_id,
|
||||
existing_id,
|
||||
record.display_name,
|
||||
record.title,
|
||||
record.appearance_snapshot,
|
||||
record.profile_authorization,
|
||||
)
|
||||
|
|
@ -638,6 +706,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
|
|||
_pending_speech_speed_ids.clear()
|
||||
_pending_call_ids.clear()
|
||||
_pending_sample_set_ids.clear()
|
||||
_pending_titles.clear()
|
||||
_host_pending_apply.clear()
|
||||
_latest_check_id = ""
|
||||
_latest_check_name = ""
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ extends RefCounted
|
|||
|
||||
const WorldLayoutType = preload("res://world/world_layout.gd")
|
||||
|
||||
const PROTOCOL_VERSION: int = 12
|
||||
const PROTOCOL_VERSION: int = 13
|
||||
const GAME_BUILD: String = "prealpha"
|
||||
const MAX_GAME_VERSION_LENGTH: int = 64
|
||||
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
||||
const MAX_PLAYER_TITLE_LENGTH: int = 24
|
||||
const MAX_PROFILE_ID_LENGTH: int = 96
|
||||
const MAX_NONCE_LENGTH: int = 96
|
||||
const MAX_PUBLIC_KEY_LENGTH: int = 8192
|
||||
|
|
@ -183,6 +184,7 @@ static func make_client_hello(
|
|||
cosmetic_snapshot: Dictionary = {},
|
||||
identity_fingerprint: String = "",
|
||||
identity_signature: PackedByteArray = PackedByteArray(),
|
||||
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
|
|
@ -190,6 +192,7 @@ static func make_client_hello(
|
|||
"game_build": GAME_BUILD,
|
||||
"local_profile_id": profile_id,
|
||||
"display_name": display_name,
|
||||
"player_title": player_title,
|
||||
"client_nonce": client_nonce,
|
||||
"capability_flags": PackedStringArray([
|
||||
FISH_QUALITY_CAPABILITY,
|
||||
|
|
@ -224,6 +227,7 @@ static func validate_client_hello(data: Variant) -> String:
|
|||
"game_build",
|
||||
"local_profile_id",
|
||||
"display_name",
|
||||
"player_title",
|
||||
"client_nonce",
|
||||
"capability_flags",
|
||||
"cosmetic_snapshot",
|
||||
|
|
@ -246,6 +250,11 @@ static func validate_client_hello(data: Variant) -> String:
|
|||
return "Profile ID is invalid."
|
||||
if typeof(payload["display_name"]) != TYPE_STRING:
|
||||
return "Display name is invalid."
|
||||
if (
|
||||
typeof(payload["player_title"]) != TYPE_STRING
|
||||
or not NetworkProfilePreferences.is_valid_title(payload["player_title"])
|
||||
):
|
||||
return "Player title is invalid."
|
||||
if typeof(payload["client_nonce"]) != TYPE_STRING:
|
||||
return "Client nonce is invalid."
|
||||
if typeof(payload["capability_flags"]) not in [
|
||||
|
|
@ -295,6 +304,7 @@ static func client_profile_fields(data: Dictionary) -> Array:
|
|||
str(data.get("identity_fingerprint", "")),
|
||||
str(data.get("local_profile_id", "")),
|
||||
str(data.get("display_name", "")),
|
||||
str(data.get("player_title", "")),
|
||||
]
|
||||
result.append_array(
|
||||
CharacterCustomizationCatalog.appearance_signature_values(appearance)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ signal host_openness_changed(is_open: bool)
|
|||
signal session_display_name_changed(display_name: String)
|
||||
signal peer_count_changed(player_count: int, max_players: int)
|
||||
signal peer_display_name_changed(peer_id: int, display_name: String)
|
||||
signal peer_title_changed(peer_id: int, title: String)
|
||||
signal peer_profile_changed(
|
||||
peer_id: int,
|
||||
display_name: String,
|
||||
|
|
@ -354,6 +355,7 @@ func _register_player_host() -> void:
|
|||
NetworkProtocol.HOME_CAPABILITY,
|
||||
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
|
||||
]),
|
||||
_profile.title,
|
||||
)
|
||||
_registry.update_appearance(1, _local_appearance_snapshot)
|
||||
var host_profile_hello := NetworkProtocol.make_client_hello(
|
||||
|
|
@ -362,6 +364,8 @@ func _register_player_host() -> void:
|
|||
NetworkIdentityCrypto.secure_id(32),
|
||||
_local_appearance_snapshot,
|
||||
_player_identity.fingerprint,
|
||||
PackedByteArray(),
|
||||
_profile.title,
|
||||
)
|
||||
host_profile_hello["identity_signature"] = _player_identity.sign(
|
||||
"handshake_client_profile",
|
||||
|
|
@ -931,13 +935,16 @@ func apply_canonical_profile(
|
|||
peer_id: int,
|
||||
display_name: String,
|
||||
appearance: Dictionary,
|
||||
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
|
||||
) -> bool:
|
||||
if (
|
||||
not NetworkProfilePreferences.is_valid_display_name(display_name)
|
||||
or not NetworkProfilePreferences.is_valid_title(player_title)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
):
|
||||
return false
|
||||
var name_changed := _registry.update_display_name(peer_id, display_name)
|
||||
var title_changed: bool = _registry.update_title(peer_id, player_title)
|
||||
var appearance_changed := _registry.update_appearance(peer_id, appearance)
|
||||
if name_changed:
|
||||
peer_display_name_changed.emit(peer_id, display_name)
|
||||
|
|
@ -945,9 +952,11 @@ func apply_canonical_profile(
|
|||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
if avatar != null:
|
||||
avatar.apply_appearance_snapshot(appearance)
|
||||
if title_changed:
|
||||
peer_title_changed.emit(peer_id, player_title)
|
||||
if name_changed or appearance_changed:
|
||||
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
|
||||
return name_changed or appearance_changed
|
||||
return name_changed or title_changed or appearance_changed
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
|
|
@ -1376,6 +1385,8 @@ func request_client_profile() -> void:
|
|||
_client_nonce,
|
||||
_local_appearance_snapshot,
|
||||
_player_identity.fingerprint,
|
||||
PackedByteArray(),
|
||||
_profile.title,
|
||||
)
|
||||
hello["identity_signature"] = _player_identity.sign(
|
||||
"handshake_client_profile",
|
||||
|
|
@ -1459,6 +1470,7 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
return
|
||||
var profile_id: String = data["local_profile_id"]
|
||||
var display_name: String = data["display_name"]
|
||||
var player_title: String = data["player_title"]
|
||||
if not NetworkProfilePreferences.is_valid_display_name(display_name):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
|
|
@ -1473,6 +1485,7 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
identity["fingerprint"],
|
||||
identity["public_key"],
|
||||
client_capabilities,
|
||||
player_title,
|
||||
):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
|
|
@ -1515,6 +1528,7 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
display_name,
|
||||
submitted_appearance.duplicate(true),
|
||||
)
|
||||
peer_title_changed.emit(sender_id, player_title)
|
||||
receive_server_hello.rpc_id(
|
||||
sender_id,
|
||||
NetworkProtocol.make_server_hello(
|
||||
|
|
@ -1691,6 +1705,7 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
NetworkProtocol.HOME_CAPABILITY,
|
||||
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
|
||||
]),
|
||||
_profile.title,
|
||||
)
|
||||
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
|
||||
var local_record := _registry.get_peer(local_peer_id)
|
||||
|
|
@ -1777,6 +1792,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
|
|||
typeof(entry.get("peer_id")) != TYPE_INT
|
||||
or typeof(entry.get("profile_id")) != TYPE_STRING
|
||||
or typeof(entry.get("display_name")) != TYPE_STRING
|
||||
or typeof(entry.get("player_title")) != TYPE_STRING
|
||||
or not NetworkProfilePreferences.is_valid_title(
|
||||
str(entry["player_title"])
|
||||
)
|
||||
or typeof(entry.get("position")) != TYPE_ARRAY
|
||||
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
|
||||
or typeof(entry.get("sitting")) != TYPE_BOOL
|
||||
|
|
@ -1811,6 +1830,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
|
|||
str(entry.get("identity_fingerprint", "")),
|
||||
str(entry.get("identity_public_key", "")),
|
||||
_sanitized_capabilities(entry.get("capability_flags", [])),
|
||||
str(entry["player_title"]),
|
||||
):
|
||||
return
|
||||
peer_was_added = true
|
||||
|
|
@ -1894,6 +1914,10 @@ func _make_spawn_entry(
|
|||
"peer_id": peer_id,
|
||||
"profile_id": record.profile_id if record != null else "",
|
||||
"display_name": display_name,
|
||||
"player_title": (
|
||||
record.title
|
||||
if record != null else NetworkProfilePreferences.DEFAULT_TITLE
|
||||
),
|
||||
"position": [
|
||||
transform.origin.x,
|
||||
transform.origin.y,
|
||||
|
|
@ -1978,6 +2002,7 @@ func verify_profile_authorization(
|
|||
display_name: String,
|
||||
appearance: Dictionary,
|
||||
authorization: Dictionary,
|
||||
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
|
||||
) -> bool:
|
||||
var record := _registry.get_peer(peer_id)
|
||||
if record == null:
|
||||
|
|
@ -1986,6 +2011,7 @@ func verify_profile_authorization(
|
|||
peer_id,
|
||||
record.profile_id,
|
||||
display_name,
|
||||
player_title,
|
||||
appearance,
|
||||
record.identity_fingerprint,
|
||||
record.identity_public_key,
|
||||
|
|
@ -2000,6 +2026,7 @@ func _verify_spawn_identity(entry: Dictionary) -> bool:
|
|||
int(entry.get("peer_id", 0)),
|
||||
str(entry.get("profile_id", "")),
|
||||
str(entry.get("display_name", "")),
|
||||
str(entry.get("player_title", NetworkProfilePreferences.DEFAULT_TITLE)),
|
||||
Dictionary(entry.get("appearance", {})),
|
||||
str(entry.get("identity_fingerprint", "")),
|
||||
str(entry.get("identity_public_key", "")),
|
||||
|
|
@ -2011,6 +2038,7 @@ func _verify_profile_authorization_fields(
|
|||
peer_id: int,
|
||||
profile_id: String,
|
||||
display_name: String,
|
||||
player_title: String,
|
||||
appearance: Dictionary,
|
||||
fingerprint: String,
|
||||
identity_public_key: String,
|
||||
|
|
@ -2021,6 +2049,8 @@ func _verify_profile_authorization_fields(
|
|||
)
|
||||
if NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint:
|
||||
return false
|
||||
if not NetworkProfilePreferences.is_valid_title(player_title):
|
||||
return false
|
||||
if authorization.is_empty():
|
||||
return peer_id == 1
|
||||
if authorization.get("domain") == "handshake_client_profile":
|
||||
|
|
@ -2031,6 +2061,7 @@ func _verify_profile_authorization_fields(
|
|||
appearance,
|
||||
fingerprint,
|
||||
authorization.get("signature", PackedByteArray()),
|
||||
player_title,
|
||||
)
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(public_pem),
|
||||
|
|
@ -2054,6 +2085,7 @@ func _verify_profile_authorization_fields(
|
|||
return false
|
||||
var signed := authorization.duplicate(true)
|
||||
signed["display_name"] = display_name
|
||||
signed["player_title"] = player_title
|
||||
signed["appearance"] = appearance
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(public_pem),
|
||||
|
|
@ -2182,7 +2214,6 @@ func _maybe_send_local_animation_action() -> void:
|
|||
str(action["id"]),
|
||||
int(action["sequence"]),
|
||||
bool(action.get("paused", false)),
|
||||
avatar.get_network_sitting_intent(),
|
||||
]
|
||||
if signature == _last_local_animation_action_signature:
|
||||
return
|
||||
|
|
@ -2237,9 +2268,9 @@ func submit_movement_animation_action(encoded: Array) -> void:
|
|||
avatar.apply_authoritative_network_animation_action(
|
||||
animation_state["action"]
|
||||
)
|
||||
avatar.apply_authoritative_network_sitting_state(
|
||||
bool(animation_state["sitting"])
|
||||
)
|
||||
# Sitting is authoritative only on sequenced movement input. The legacy bit
|
||||
# remains in this packet's wire shape for protocol compatibility, but acting
|
||||
# on it here would race jump input across two independent ENet channels.
|
||||
|
||||
|
||||
static func _encode_movement_input(data: Dictionary) -> Array:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ class PeerRecord:
|
|||
var peer_id: int = 0
|
||||
var profile_id: String = ""
|
||||
var display_name: String = ""
|
||||
var title: String = NetworkProfilePreferences.DEFAULT_TITLE
|
||||
var protocol_version: int = 0
|
||||
var joined_at_unix: int = 0
|
||||
var appearance_snapshot: Dictionary = (
|
||||
|
|
@ -30,10 +31,12 @@ func add_peer(
|
|||
identity_fingerprint: String = "",
|
||||
identity_public_key: String = "",
|
||||
capability_flags: PackedStringArray = PackedStringArray(),
|
||||
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
|
||||
) -> bool:
|
||||
if (
|
||||
peer_id <= 0
|
||||
or profile_id.is_empty()
|
||||
or not NetworkProfilePreferences.is_valid_title(player_title)
|
||||
or (
|
||||
not identity_fingerprint.is_empty()
|
||||
and has_fingerprint(identity_fingerprint)
|
||||
|
|
@ -44,6 +47,7 @@ func add_peer(
|
|||
record.peer_id = peer_id
|
||||
record.profile_id = profile_id
|
||||
record.display_name = display_name
|
||||
record.title = player_title
|
||||
record.protocol_version = protocol_version
|
||||
record.joined_at_unix = int(Time.get_unix_time_from_system())
|
||||
record.identity_fingerprint = identity_fingerprint
|
||||
|
|
@ -74,6 +78,14 @@ func update_display_name(peer_id: int, display_name: String) -> bool:
|
|||
return true
|
||||
|
||||
|
||||
func update_title(peer_id: int, player_title: String) -> bool:
|
||||
var record: PeerRecord = _records.get(peer_id)
|
||||
if record == null or not NetworkProfilePreferences.is_valid_title(player_title):
|
||||
return false
|
||||
record.title = player_title.strip_edges()
|
||||
return true
|
||||
|
||||
|
||||
func update_appearance(peer_id: int, snapshot: Dictionary) -> bool:
|
||||
var record: PeerRecord = _records.get(peer_id)
|
||||
if record == null or not CharacterCustomizationCatalog.validate_snapshot(snapshot):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR"
|
|||
const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_DATA_DIR"
|
||||
const LEGACY_ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
|
||||
const LEGACY_CREATE_ENVIRONMENT_VARIABLE := "NETFISHING_CREATE_DATA_DIR"
|
||||
const ISOLATED_VALIDATION_ENVIRONMENT := (
|
||||
"straywild_ISOLATED_VALIDATION_STORAGE"
|
||||
)
|
||||
const ISOLATED_VALIDATION_DIRECTORY := "isolated-validation-data"
|
||||
const APPLICATION_ID := "straywild"
|
||||
const LEGACY_APPLICATION_ID := "netfishing"
|
||||
const APP_DATA_PORTABLE_PATH := "user://portable-data"
|
||||
|
|
@ -57,6 +61,9 @@ enum Mode {
|
|||
COMMAND_LINE_OVERRIDE,
|
||||
}
|
||||
|
||||
static var _process_validation_root := ""
|
||||
static var _process_validation_device_id := ""
|
||||
|
||||
var mode := Mode.UNRESOLVED
|
||||
var root_path := ""
|
||||
var root_id := ""
|
||||
|
|
@ -67,6 +74,11 @@ var override_active := false
|
|||
|
||||
|
||||
func resolve() -> bool:
|
||||
# Validation scripts may be launched directly instead of through the
|
||||
# repository runner. Keep the opt-in flag fail-safe: it must never follow a
|
||||
# real user's bootstrap pointer merely because XDG isolation was omitted.
|
||||
if _isolated_validation_requested():
|
||||
return _resolve_isolated_validation_root()
|
||||
prepare_legacy_user_data()
|
||||
_load_bootstrap_identity()
|
||||
var command_line: String = _command_line_override()
|
||||
|
|
@ -116,6 +128,44 @@ func resolve() -> bool:
|
|||
return false
|
||||
|
||||
|
||||
func _resolve_isolated_validation_root() -> bool:
|
||||
# Behave like app-data storage after choosing the isolated root so migration
|
||||
# tests can still exercise live root switching within their sandbox.
|
||||
override_active = false
|
||||
mode = Mode.APP_DATA
|
||||
if _process_validation_device_id.is_empty():
|
||||
_process_validation_device_id = (
|
||||
Crypto.new().generate_random_bytes(16).hex_encode()
|
||||
)
|
||||
device_id = _process_validation_device_id
|
||||
if _process_validation_root.is_empty():
|
||||
var parent: String
|
||||
if OS.has_environment("XDG_DATA_HOME"):
|
||||
parent = ProjectSettings.globalize_path("user://")
|
||||
else:
|
||||
parent = OS.get_temp_dir().path_join(
|
||||
"straywild-validation-%d-%d"
|
||||
% [OS.get_process_id(), Time.get_ticks_usec()]
|
||||
)
|
||||
_process_validation_root = parent.path_join(
|
||||
ISOLATED_VALIDATION_DIRECTORY
|
||||
)
|
||||
if not has_data_manifest(_process_validation_root):
|
||||
var created: Dictionary = create_unbound_root(
|
||||
_process_validation_root
|
||||
)
|
||||
if not bool(created.get("ok", false)):
|
||||
return false
|
||||
return _activate_existing(_process_validation_root, "", false)
|
||||
|
||||
|
||||
static func _isolated_validation_requested() -> bool:
|
||||
return (
|
||||
DisplayServer.get_name() == "headless"
|
||||
and OS.get_environment(ISOLATED_VALIDATION_ENVIRONMENT) == "1"
|
||||
)
|
||||
|
||||
|
||||
func prepare_legacy_user_data() -> void:
|
||||
var current_root: String = _normalize(
|
||||
ProjectSettings.globalize_path("user://")
|
||||
|
|
|
|||
236
player/player.gd
236
player/player.gd
|
|
@ -388,6 +388,17 @@ class ShowcaseCameraSnapshot:
|
|||
var camera_dragging: bool
|
||||
|
||||
|
||||
class ConversationCameraSnapshot:
|
||||
extends RefCounted
|
||||
|
||||
var yaw_transform: Transform3D
|
||||
var yaw_rotation: Vector3
|
||||
var pitch_rotation: Vector3
|
||||
var spring_length: float
|
||||
var target_zoom: float
|
||||
var camera_dragging: bool
|
||||
|
||||
|
||||
@export_category("Movement")
|
||||
@export var walk_speed: float = 2.25
|
||||
@export var sprint_speed: float = 4.5
|
||||
|
|
@ -446,6 +457,11 @@ var _rv_previous_camera_environment: Environment
|
|||
@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55
|
||||
@export_range(1.0, 40.0, 0.5) var free_camera_speed: float = 10.0
|
||||
@export_range(1.0, 10.0, 0.5) var free_camera_sprint_multiplier: float = 3.0
|
||||
@export_category("Conversation Camera")
|
||||
@export_range(0.05, 1.0, 0.01) var conversation_camera_transition_seconds: float = 0.42
|
||||
@export_range(-45.0, 20.0, 1.0) var conversation_camera_pitch_degrees: float = -9.0
|
||||
@export_range(1.5, 8.0, 0.1, "suffix:m") var conversation_camera_distance: float = 3.65
|
||||
@export_range(0.5, 2.5, 0.05, "suffix:m") var conversation_camera_target_height: float = 1.3
|
||||
|
||||
@onready var _visuals: Node3D = %Visuals
|
||||
@onready var _character_rig: Node3D = get_node_or_null(
|
||||
|
|
@ -521,6 +537,9 @@ var _showcase_restore_tween: Tween
|
|||
var _showcase_camera_tween: Tween
|
||||
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
|
||||
var _showcase_restore_generation: int = 0
|
||||
var _conversation_camera_tween: Tween
|
||||
var _conversation_camera_snapshot: ConversationCameraSnapshot
|
||||
var _conversation_camera_generation: int = 0
|
||||
var _network_peer_id: int = 0
|
||||
var _network_authoritative_simulation: bool = false
|
||||
var _network_interpolation_enabled: bool = false
|
||||
|
|
@ -1070,7 +1089,18 @@ func _simulate_movement_physics(delta: float) -> void:
|
|||
if jump_requested:
|
||||
_set_sitting(false, local_control_enabled)
|
||||
else:
|
||||
velocity = Vector3.ZERO
|
||||
# Keep the body participating in physics while seated. In particular,
|
||||
# move_and_slide() refreshes Godot's floor state, so a stale grounded
|
||||
# flag from prediction or a delayed packet cannot trap this early-return
|
||||
# path forever after the capsule has moved into the air.
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
if not is_on_floor():
|
||||
velocity.y -= _gravity * fall_gravity_multiplier * delta
|
||||
move_and_slide()
|
||||
if not is_on_floor():
|
||||
_sit_after_landing = true
|
||||
_set_sitting(false)
|
||||
_network_jump_pending = false
|
||||
return
|
||||
if _water_recovery_active:
|
||||
|
|
@ -1726,6 +1756,10 @@ func toggle_sitting() -> void:
|
|||
return
|
||||
if not _can_begin_sitting():
|
||||
_sit_after_landing = true
|
||||
# Queue and repeat the intent until an owner snapshot acknowledges it.
|
||||
# Previously this branch returned before marking a local sitting intent,
|
||||
# so a delayed authoritative audit could race the deferred sit.
|
||||
_set_sitting(false, local_control_enabled)
|
||||
return
|
||||
_set_sitting(should_sit, local_control_enabled)
|
||||
|
||||
|
|
@ -1750,7 +1784,11 @@ func _set_sitting(
|
|||
return
|
||||
_sitting = should_sit
|
||||
if _sitting:
|
||||
velocity = Vector3.ZERO
|
||||
# Sitting stops horizontal locomotion, but vertical motion remains owned by
|
||||
# physics. Clearing Y here allowed a delayed seated state to repeatedly
|
||||
# cancel gravity in a high-latency session.
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
_character_animation_name = &""
|
||||
_update_character_animation()
|
||||
|
||||
|
|
@ -1803,6 +1841,24 @@ func apply_authoritative_network_sitting_state(should_sit: bool) -> void:
|
|||
_set_sitting(should_sit)
|
||||
|
||||
|
||||
func _apply_local_prediction_sitting_state(should_sit: bool) -> void:
|
||||
# Owner audits can arrive several physics frames behind the locally predicted
|
||||
# jump. Treat an authoritative seated bit as an intent until this capsule is
|
||||
# actually grounded; applying it directly zeros vertical velocity and lets a
|
||||
# stream of high-latency snapshots hold the local player in mid-air.
|
||||
if _local_network_jump_intent_pending:
|
||||
_sit_after_landing = false
|
||||
_set_sitting(false)
|
||||
return
|
||||
if should_sit and not _can_begin_sitting():
|
||||
_sit_after_landing = true
|
||||
_set_sitting(false)
|
||||
return
|
||||
if should_sit:
|
||||
_sit_after_landing = false
|
||||
_set_sitting(should_sit)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# Camera dragging must observe the complete mouse gesture before GUI controls
|
||||
# get a chance to consume one part of it. Do not claim the event here: the
|
||||
|
|
@ -2048,6 +2104,162 @@ func get_active_gameplay_camera() -> Camera3D:
|
|||
return _camera
|
||||
|
||||
|
||||
func begin_shop_conversation_camera(npc_world_position: Vector3) -> bool:
|
||||
if (
|
||||
not local_control_enabled
|
||||
or _camera_yaw == null
|
||||
or _camera_pitch == null
|
||||
or _spring_arm == null
|
||||
):
|
||||
return false
|
||||
if _free_camera_active:
|
||||
_set_free_camera_active(false)
|
||||
_kill_conversation_camera_tween()
|
||||
if _conversation_camera_snapshot == null:
|
||||
_conversation_camera_snapshot = ConversationCameraSnapshot.new()
|
||||
_conversation_camera_snapshot.yaw_transform = _camera_yaw.transform
|
||||
_conversation_camera_snapshot.yaw_rotation = _camera_yaw.rotation
|
||||
_conversation_camera_snapshot.pitch_rotation = _camera_pitch.rotation
|
||||
_conversation_camera_snapshot.spring_length = _spring_arm.spring_length
|
||||
_conversation_camera_snapshot.target_zoom = _target_zoom
|
||||
_conversation_camera_snapshot.camera_dragging = _camera_dragging
|
||||
_set_camera_dragging(false)
|
||||
|
||||
var pair_direction := npc_world_position - global_position
|
||||
pair_direction.y = 0.0
|
||||
if pair_direction.length_squared() <= 0.000001:
|
||||
pair_direction = Vector3.RIGHT
|
||||
else:
|
||||
pair_direction = pair_direction.normalized()
|
||||
# Frame the midpoint, with the camera's right axis running from the player
|
||||
# toward the NPC. This naturally places the player left and shopkeeper right.
|
||||
var target_world_yaw := atan2(-pair_direction.z, pair_direction.x)
|
||||
var target_local_yaw := target_world_yaw - global_rotation.y
|
||||
var shortest_target_yaw := (
|
||||
_camera_yaw.rotation.y
|
||||
+ wrapf(target_local_yaw - _camera_yaw.rotation.y, -PI, PI)
|
||||
)
|
||||
var pair_midpoint := (global_position + npc_world_position) * 0.5
|
||||
pair_midpoint.y = global_position.y + conversation_camera_target_height
|
||||
var target_position := to_local(pair_midpoint)
|
||||
var duration := maxf(conversation_camera_transition_seconds, 0.05)
|
||||
_conversation_camera_tween = create_tween()
|
||||
_conversation_camera_tween.set_parallel(true)
|
||||
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
|
||||
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_yaw, "rotation:y", shortest_target_yaw, duration
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_yaw, "position", target_position, duration
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_pitch,
|
||||
"rotation:x",
|
||||
deg_to_rad(conversation_camera_pitch_degrees),
|
||||
duration,
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_spring_arm,
|
||||
"spring_length",
|
||||
maxf(conversation_camera_distance, 0.1),
|
||||
duration,
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
func restore_shop_conversation_camera(
|
||||
immediate: bool = false,
|
||||
restored_callback: Callable = Callable(),
|
||||
) -> void:
|
||||
_kill_conversation_camera_tween()
|
||||
if _conversation_camera_snapshot == null:
|
||||
if restored_callback.is_valid():
|
||||
restored_callback.call()
|
||||
return
|
||||
if immediate:
|
||||
_complete_conversation_camera_restore(restored_callback)
|
||||
return
|
||||
var yaw_target := _conversation_camera_snapshot.yaw_rotation
|
||||
yaw_target.y = (
|
||||
_camera_yaw.rotation.y
|
||||
+ wrapf(yaw_target.y - _camera_yaw.rotation.y, -PI, PI)
|
||||
)
|
||||
var generation := _conversation_camera_generation
|
||||
var duration := maxf(conversation_camera_transition_seconds, 0.05)
|
||||
_conversation_camera_tween = create_tween()
|
||||
_conversation_camera_tween.set_parallel(true)
|
||||
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
|
||||
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_yaw, "rotation", yaw_target, duration
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_yaw,
|
||||
"position",
|
||||
_conversation_camera_snapshot.yaw_transform.origin,
|
||||
duration,
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_camera_pitch,
|
||||
"rotation",
|
||||
_conversation_camera_snapshot.pitch_rotation,
|
||||
duration,
|
||||
)
|
||||
_conversation_camera_tween.tween_property(
|
||||
_spring_arm,
|
||||
"spring_length",
|
||||
_conversation_camera_snapshot.spring_length,
|
||||
duration,
|
||||
)
|
||||
_conversation_camera_tween.finished.connect(
|
||||
_on_conversation_camera_restore_finished.bind(
|
||||
generation, restored_callback
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func is_shop_conversation_camera_active() -> bool:
|
||||
return _conversation_camera_snapshot != null
|
||||
|
||||
|
||||
func _kill_conversation_camera_tween() -> void:
|
||||
if (
|
||||
_conversation_camera_tween != null
|
||||
and _conversation_camera_tween.is_valid()
|
||||
):
|
||||
_conversation_camera_tween.kill()
|
||||
_conversation_camera_tween = null
|
||||
_conversation_camera_generation += 1
|
||||
|
||||
|
||||
func _on_conversation_camera_restore_finished(
|
||||
generation: int,
|
||||
restored_callback: Callable,
|
||||
) -> void:
|
||||
if generation != _conversation_camera_generation:
|
||||
return
|
||||
_conversation_camera_tween = null
|
||||
_complete_conversation_camera_restore(restored_callback)
|
||||
|
||||
|
||||
func _complete_conversation_camera_restore(
|
||||
restored_callback: Callable,
|
||||
) -> void:
|
||||
if _conversation_camera_snapshot != null:
|
||||
_camera_yaw.transform = _conversation_camera_snapshot.yaw_transform
|
||||
_camera_pitch.rotation = _conversation_camera_snapshot.pitch_rotation
|
||||
_spring_arm.spring_length = _conversation_camera_snapshot.spring_length
|
||||
_target_zoom = _conversation_camera_snapshot.target_zoom
|
||||
_set_camera_dragging(
|
||||
_conversation_camera_snapshot.camera_dragging
|
||||
and Input.is_action_pressed("camera_drag")
|
||||
)
|
||||
_conversation_camera_snapshot = null
|
||||
if restored_callback.is_valid():
|
||||
restored_callback.call()
|
||||
|
||||
|
||||
func _set_target_zoom(value: float) -> void:
|
||||
_target_zoom = clampf(
|
||||
value,
|
||||
|
|
@ -2526,7 +2738,7 @@ func apply_local_prediction_correction(
|
|||
):
|
||||
_clear_local_network_jump_intent()
|
||||
if not _sitting_intent_pending:
|
||||
_set_sitting(bool(parsed["sitting"]))
|
||||
_apply_local_prediction_sitting_state(bool(parsed["sitting"]))
|
||||
var authoritative_position: Vector3 = parsed["position"]
|
||||
var transit_seconds: float = resolve_local_prediction_transit_seconds(
|
||||
acknowledged_input,
|
||||
|
|
@ -3322,9 +3534,23 @@ func get_body_center_position() -> Vector3:
|
|||
|
||||
|
||||
func get_chat_anchor_position() -> Vector3:
|
||||
return get_nameplate_anchor_position()
|
||||
|
||||
|
||||
func get_nameplate_anchor_position() -> Vector3:
|
||||
# Character head meshes keep stable authored bounds while skeletal animation
|
||||
# plays. Restricting the anchor to them prevents rods, nets, held catches,
|
||||
# and showcase props from making overhead UI bounce during an action.
|
||||
var highest_y := -INF
|
||||
for node: Node in _visuals.find_children("*", "MeshInstance3D", true, false):
|
||||
var mesh_instance := node as MeshInstance3D
|
||||
var skeleton := get_node_or_null(
|
||||
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
|
||||
) as Skeleton3D
|
||||
for head_name: String in ["head_round", "head_pointy"]:
|
||||
var mesh_instance := (
|
||||
skeleton.get_node_or_null(head_name) as MeshInstance3D
|
||||
if skeleton != null
|
||||
else null
|
||||
)
|
||||
if mesh_instance == null or not mesh_instance.is_visible_in_tree():
|
||||
continue
|
||||
var bounds := mesh_instance.get_aabb()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
class_name PlayerAppearanceStore
|
||||
extends Node
|
||||
|
||||
signal appearance_changed
|
||||
|
||||
const FORMAT_VERSION: int = 1
|
||||
var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
|
||||
var _loaded: bool = false
|
||||
|
|
@ -70,11 +72,42 @@ func save_snapshot(value: Dictionary) -> bool:
|
|||
_snapshot = value.duplicate(true)
|
||||
if _save_atomic():
|
||||
_loaded = true
|
||||
if _snapshot != previous:
|
||||
appearance_changed.emit()
|
||||
return true
|
||||
_snapshot = previous
|
||||
return false
|
||||
|
||||
|
||||
func restore_from_save_data(
|
||||
value: Variant,
|
||||
persist_to_profile_file: bool = true,
|
||||
) -> bool:
|
||||
if (
|
||||
typeof(value) != TYPE_DICTIONARY
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(value)
|
||||
):
|
||||
return false
|
||||
var sanitized: Dictionary = (
|
||||
CharacterCustomizationCatalog.sanitized_snapshot(value)
|
||||
)
|
||||
if persist_to_profile_file:
|
||||
return save_snapshot(sanitized)
|
||||
var previous: Dictionary = _snapshot.duplicate(true)
|
||||
_snapshot = sanitized
|
||||
_loaded = true
|
||||
if _snapshot != previous:
|
||||
appearance_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func reset_to_defaults(persist_to_profile_file: bool = true) -> bool:
|
||||
return restore_from_save_data(
|
||||
CharacterCustomizationCatalog.default_snapshot(),
|
||||
persist_to_profile_file,
|
||||
)
|
||||
|
||||
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ const WorldLayoutType = preload("res://world/world_layout.gd")
|
|||
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
|
||||
const PlayerType = preload("res://player/player.gd")
|
||||
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
|
||||
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
|
||||
const NetworkProfilePreferencesType = preload(
|
||||
"res://network/network_profile_preferences.gd"
|
||||
)
|
||||
const PlayerAppearanceStoreType = preload(
|
||||
"res://progression/player_appearance_store.gd"
|
||||
)
|
||||
|
||||
const SAVE_VERSION: int = 11
|
||||
const LEGACY_SAVE_FILENAME := "player_save.json"
|
||||
|
|
@ -78,6 +85,9 @@ class LoadSnapshot:
|
|||
)
|
||||
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
|
||||
var home_data: Dictionary = PlayerHomeStateType.default_save_data()
|
||||
var has_character_data: bool = false
|
||||
var character_profile_data: Dictionary = {}
|
||||
var appearance_data: Dictionary = {}
|
||||
|
||||
|
||||
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
|
||||
|
|
@ -99,6 +109,9 @@ var _world_weather: WorldWeatherServiceType
|
|||
var _jobs: PlayerJobServiceType
|
||||
var _player: PlayerType
|
||||
var _home_state: PlayerHomeStateType
|
||||
var _network_profile: NetworkProfilePreferencesType
|
||||
var _appearance_store: PlayerAppearanceStoreType
|
||||
var _legacy_character_data: Dictionary = {}
|
||||
var _autosave_timer: Timer
|
||||
var _is_configured: bool = false
|
||||
var _is_restoring: bool = false
|
||||
|
|
@ -179,6 +192,8 @@ func setup(
|
|||
jobs: PlayerJobServiceType,
|
||||
player: PlayerType,
|
||||
home_state: PlayerHomeStateType,
|
||||
network_profile: NetworkProfilePreferencesType = null,
|
||||
appearance_store: PlayerAppearanceStoreType = null,
|
||||
) -> void:
|
||||
_inventory = inventory
|
||||
_collection_log = collection_log
|
||||
|
|
@ -197,6 +212,9 @@ func setup(
|
|||
_jobs = jobs
|
||||
_player = player
|
||||
_home_state = home_state
|
||||
_network_profile = network_profile
|
||||
_appearance_store = appearance_store
|
||||
capture_legacy_character_fallback()
|
||||
_is_configured = (
|
||||
_inventory != null
|
||||
and _collection_log != null
|
||||
|
|
@ -263,6 +281,132 @@ func setup(
|
|||
_player.active_lure_changed.connect(_on_active_tackle_changed)
|
||||
if not _home_state.changed.is_connected(_mark_dirty):
|
||||
_home_state.changed.connect(_mark_dirty)
|
||||
if (
|
||||
_network_profile != null
|
||||
and not _network_profile.profile_changed.is_connected(_mark_dirty)
|
||||
):
|
||||
_network_profile.profile_changed.connect(_mark_dirty)
|
||||
if (
|
||||
_appearance_store != null
|
||||
and not _appearance_store.appearance_changed.is_connected(_mark_dirty)
|
||||
):
|
||||
_appearance_store.appearance_changed.connect(_mark_dirty)
|
||||
|
||||
|
||||
func capture_legacy_character_fallback() -> void:
|
||||
_legacy_character_data = _current_character_data()
|
||||
|
||||
|
||||
func load_character_profile() -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
_recover_interrupted_write()
|
||||
var read_path: String = _read_path()
|
||||
var previous_restoring: bool = _is_restoring
|
||||
_is_restoring = true
|
||||
if read_path.is_empty():
|
||||
var reset_ok: bool = _reset_character_profile()
|
||||
_is_restoring = previous_restoring
|
||||
return reset_ok
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
read_path,
|
||||
read_path == _legacy_save_path(),
|
||||
)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
var version: int = _read_integer(save_data.get("save_version"), -1)
|
||||
if version < 1 or version > SAVE_VERSION:
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
if version != SAVE_VERSION:
|
||||
save_data = _migrate_save(save_data, version)
|
||||
if save_data.is_empty():
|
||||
_is_restoring = previous_restoring
|
||||
return false
|
||||
var character: Dictionary = _character_data_from_save(save_data)
|
||||
var restored: bool = (
|
||||
not character.is_empty()
|
||||
and _restore_character_data(character)
|
||||
)
|
||||
_is_restoring = previous_restoring
|
||||
return restored
|
||||
|
||||
|
||||
func _current_character_data() -> Dictionary:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return {}
|
||||
var profile: Dictionary = _network_profile.to_save_data()
|
||||
var appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if (
|
||||
not NetworkProfilePreferencesType.validate_save_data(profile)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"profile": profile,
|
||||
"appearance": appearance,
|
||||
}
|
||||
|
||||
|
||||
func _character_data_from_save(save_data: Dictionary) -> Dictionary:
|
||||
if not save_data.has("character"):
|
||||
return _legacy_character_data.duplicate(true)
|
||||
if typeof(save_data.get("character")) != TYPE_DICTIONARY:
|
||||
return {}
|
||||
var character: Dictionary = save_data["character"]
|
||||
if (
|
||||
not NetworkProfilePreferencesType.validate_save_data(
|
||||
character.get("profile")
|
||||
)
|
||||
or typeof(character.get("appearance")) != TYPE_DICTIONARY
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(
|
||||
character.get("appearance")
|
||||
)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"profile": (character["profile"] as Dictionary).duplicate(true),
|
||||
"appearance": CharacterCustomizationCatalog.sanitized_snapshot(
|
||||
character["appearance"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
func _restore_character_data(character: Dictionary) -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
# The selected progression save is authoritative here. Applying it must not
|
||||
# rewrite the compatibility profile mirrors: their guarded writes may be
|
||||
# stale after a Syncthing edit, and that must not make a valid slot unloadable.
|
||||
var previous_profile: Dictionary = _network_profile.to_save_data()
|
||||
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if not _network_profile.restore_from_save_data(
|
||||
character.get("profile"), false
|
||||
):
|
||||
return false
|
||||
if _appearance_store.restore_from_save_data(
|
||||
character.get("appearance"), false
|
||||
):
|
||||
return true
|
||||
_network_profile.restore_from_save_data(previous_profile, false)
|
||||
_appearance_store.restore_from_save_data(previous_appearance, false)
|
||||
return false
|
||||
|
||||
|
||||
func _reset_character_profile() -> bool:
|
||||
if _network_profile == null or _appearance_store == null:
|
||||
return true
|
||||
var previous_profile: Dictionary = _network_profile.to_save_data()
|
||||
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
|
||||
if not _network_profile.reset_to_defaults(false):
|
||||
return false
|
||||
if _appearance_store.reset_to_defaults(false):
|
||||
return true
|
||||
_network_profile.restore_from_save_data(previous_profile, false)
|
||||
_appearance_store.restore_from_save_data(previous_appearance, false)
|
||||
return false
|
||||
|
||||
|
||||
func load_player_data() -> bool:
|
||||
|
|
@ -315,6 +459,12 @@ func load_player_data() -> bool:
|
|||
return false
|
||||
|
||||
_is_restoring = true
|
||||
var character_restored: bool = true
|
||||
if snapshot.has_character_data:
|
||||
character_restored = _restore_character_data({
|
||||
"profile": snapshot.character_profile_data,
|
||||
"appearance": snapshot.appearance_data,
|
||||
})
|
||||
var inventory_restored: bool = _inventory.replace_all_catches(
|
||||
snapshot.catches,
|
||||
snapshot.next_catch_sequence
|
||||
|
|
@ -390,13 +540,14 @@ func load_player_data() -> bool:
|
|||
or not world_weather_restored
|
||||
or not jobs_restored
|
||||
or not home_restored
|
||||
or not character_restored
|
||||
):
|
||||
push_error(
|
||||
(
|
||||
"Validated player save could not be restored: "
|
||||
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
|
||||
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
|
||||
+ "time=%s weather=%s jobs=%s home=%s"
|
||||
+ "time=%s weather=%s jobs=%s home=%s character=%s"
|
||||
)
|
||||
% [
|
||||
inventory_restored,
|
||||
|
|
@ -414,6 +565,7 @@ func load_player_data() -> bool:
|
|||
world_weather_restored,
|
||||
jobs_restored,
|
||||
home_restored,
|
||||
character_restored,
|
||||
]
|
||||
)
|
||||
return false
|
||||
|
|
@ -669,6 +821,7 @@ func copy_progression_to_path(
|
|||
func initialize_new_game(
|
||||
world_seed: int = DEFAULT_WORLD_SEED,
|
||||
world_layout: StringName = WorldLayoutType.GENERATED,
|
||||
reset_character_profile: bool = true,
|
||||
) -> bool:
|
||||
if not _is_configured:
|
||||
return false
|
||||
|
|
@ -679,6 +832,12 @@ func initialize_new_game(
|
|||
):
|
||||
return false
|
||||
_automatic_saving_blocked = false
|
||||
if reset_character_profile:
|
||||
_is_restoring = true
|
||||
var character_reset: bool = _reset_character_profile()
|
||||
_is_restoring = false
|
||||
if not character_reset:
|
||||
return false
|
||||
_restore_defaults()
|
||||
_world_layout = world_layout
|
||||
_world_seed = world_seed
|
||||
|
|
@ -872,7 +1031,7 @@ func _build_save_dictionary() -> Dictionary:
|
|||
):
|
||||
return {}
|
||||
|
||||
return {
|
||||
var save_data: Dictionary = {
|
||||
"save_version": SAVE_VERSION,
|
||||
"wallet": {
|
||||
"balance": _wallet.get_balance(),
|
||||
|
|
@ -916,6 +1075,12 @@ func _build_save_dictionary() -> Dictionary:
|
|||
"jobs": _jobs.to_save_data(),
|
||||
"home": _home_state.to_save_data(),
|
||||
}
|
||||
var character: Dictionary = _current_character_data()
|
||||
if _network_profile != null and _appearance_store != null:
|
||||
if character.is_empty():
|
||||
return {}
|
||||
save_data["character"] = character
|
||||
return save_data
|
||||
|
||||
|
||||
func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
||||
|
|
@ -998,6 +1163,17 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
return null
|
||||
|
||||
var snapshot := LoadSnapshot.new()
|
||||
var character: Dictionary = _character_data_from_save(save_data)
|
||||
if save_data.has("character") and character.is_empty():
|
||||
return null
|
||||
if not character.is_empty():
|
||||
snapshot.has_character_data = true
|
||||
snapshot.character_profile_data = (
|
||||
character["profile"] as Dictionary
|
||||
).duplicate(true)
|
||||
snapshot.appearance_data = (
|
||||
character["appearance"] as Dictionary
|
||||
).duplicate(true)
|
||||
if world_data.has("layout"):
|
||||
if not WorldLayoutType.is_valid(world_data["layout"]):
|
||||
return null
|
||||
|
|
@ -1208,7 +1384,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
)
|
||||
continue
|
||||
var maximum: int = (
|
||||
item_data.max_stack if item_data.stackable else 1
|
||||
mini(
|
||||
item_data.max_stack,
|
||||
DecorCatalogType.MAX_OWNED_PER_PRODUCT,
|
||||
)
|
||||
if DecorCatalogType.has_product(item_id)
|
||||
else item_data.max_stack if item_data.stackable else 1
|
||||
)
|
||||
if quantity > maximum:
|
||||
push_warning(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ var _manifest_path := ""
|
|||
var _slots_directory := ""
|
||||
var _slots: Array[Dictionary] = []
|
||||
var _active_slot_id := ""
|
||||
var _online_slot_id := ""
|
||||
var _expected_hash := ""
|
||||
var _error_message := ""
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ func configure(
|
|||
_slots_directory = data_root.root_path.path_join("player/saves")
|
||||
_slots.clear()
|
||||
_active_slot_id = ""
|
||||
_online_slot_id = ""
|
||||
_expected_hash = ""
|
||||
_error_message = ""
|
||||
if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK:
|
||||
|
|
@ -43,13 +45,17 @@ func configure(
|
|||
return false
|
||||
var slot_count_before_discovery: int = _slots.size()
|
||||
var active_before_discovery: String = _active_slot_id
|
||||
var online_before_discovery: String = _online_slot_id
|
||||
_discover_untracked_saves()
|
||||
if _active_slot_id.is_empty() and not _slots.is_empty():
|
||||
_active_slot_id = str(_slots.front().get("slot_id", ""))
|
||||
if _online_slot_id.is_empty() and not _slots.is_empty():
|
||||
_online_slot_id = _active_slot_id
|
||||
if (
|
||||
not loaded
|
||||
or _slots.size() != slot_count_before_discovery
|
||||
or _active_slot_id != active_before_discovery
|
||||
or _online_slot_id != online_before_discovery
|
||||
):
|
||||
if not _save_manifest():
|
||||
return false
|
||||
|
|
@ -86,6 +92,24 @@ func get_active_slot_id() -> String:
|
|||
return _active_slot_id
|
||||
|
||||
|
||||
func get_online_slot_id() -> String:
|
||||
return _online_slot_id
|
||||
|
||||
|
||||
func mark_slot_for_online(slot_id: String) -> bool:
|
||||
if _find_slot(slot_id).is_empty():
|
||||
return false
|
||||
if _online_slot_id == slot_id:
|
||||
return true
|
||||
var previous: String = _online_slot_id
|
||||
_online_slot_id = slot_id
|
||||
if not _save_manifest():
|
||||
_online_slot_id = previous
|
||||
return false
|
||||
slots_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func has_slots() -> bool:
|
||||
return not _slots.is_empty()
|
||||
|
||||
|
|
@ -102,6 +126,26 @@ func ensure_active_slot() -> Dictionary:
|
|||
return {"ok": true, "slot_id": slot_id}
|
||||
|
||||
|
||||
func ensure_online_slot() -> Dictionary:
|
||||
var slot_id: String = _online_slot_id
|
||||
if slot_id.is_empty() or _find_slot(slot_id).is_empty():
|
||||
var active: Dictionary = ensure_active_slot()
|
||||
if not bool(active.get("ok", false)):
|
||||
return active
|
||||
slot_id = str(active.get("slot_id", ""))
|
||||
if not mark_slot_for_online(slot_id):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "the online progression could not be selected.",
|
||||
}
|
||||
if _active_slot_id != slot_id and not activate_slot(slot_id):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "the online progression could not be opened.",
|
||||
}
|
||||
return {"ok": true, "slot_id": slot_id}
|
||||
|
||||
|
||||
func create_empty_slot(display_name: String) -> Dictionary:
|
||||
if _slots.size() >= MAX_SLOTS:
|
||||
return {"ok": false, "message": "the maximum number of save slots has been reached."}
|
||||
|
|
@ -117,8 +161,12 @@ func create_empty_slot(display_name: String) -> Dictionary:
|
|||
"last_played_at_unix": 0,
|
||||
"legacy": false,
|
||||
})
|
||||
var previous_online: String = _online_slot_id
|
||||
if _online_slot_id.is_empty():
|
||||
_online_slot_id = slot_id
|
||||
if not _save_manifest():
|
||||
_slots.pop_back()
|
||||
_online_slot_id = previous_online
|
||||
return {"ok": false, "message": "the save-slot catalog could not be updated."}
|
||||
slots_changed.emit()
|
||||
return {"ok": true, "slot_id": slot_id}
|
||||
|
|
@ -267,6 +315,7 @@ func delete_slot(slot_id: String) -> bool:
|
|||
for previous_entry: Dictionary in _slots:
|
||||
previous_slots.append(previous_entry.duplicate(true))
|
||||
var previous_active: String = _active_slot_id
|
||||
var previous_online: String = _online_slot_id
|
||||
var entry: Dictionary = _slots[index]
|
||||
var source_path: String = _existing_slot_path(entry)
|
||||
var preserved_path := ""
|
||||
|
|
@ -281,9 +330,18 @@ func delete_slot(slot_id: String) -> bool:
|
|||
if not _slots.is_empty()
|
||||
else ""
|
||||
)
|
||||
if _online_slot_id == slot_id:
|
||||
_online_slot_id = (
|
||||
_active_slot_id
|
||||
if not _active_slot_id.is_empty()
|
||||
else str(_slots.front().get("slot_id", ""))
|
||||
if not _slots.is_empty()
|
||||
else ""
|
||||
)
|
||||
if not _select_configured_storage() or not _save_manifest():
|
||||
_slots = previous_slots
|
||||
_active_slot_id = previous_active
|
||||
_online_slot_id = previous_online
|
||||
_select_configured_storage()
|
||||
if not preserved_path.is_empty():
|
||||
_rename_file(preserved_path, source_path)
|
||||
|
|
@ -315,6 +373,7 @@ func _build_slot_summary(entry: Dictionary) -> Dictionary:
|
|||
path.ends_with(".json"),
|
||||
)
|
||||
summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id
|
||||
summary["online"] = str(entry.get("slot_id", "")) == _online_slot_id
|
||||
summary["has_save"] = inspection.can_continue()
|
||||
summary["save_status"] = inspection.status
|
||||
summary["status_message"] = inspection.message
|
||||
|
|
@ -360,6 +419,9 @@ func _load_manifest() -> bool:
|
|||
_active_slot_id = str(data.get("active_slot_id", ""))
|
||||
if not _active_slot_id.is_empty() and not seen_ids.has(_active_slot_id):
|
||||
_active_slot_id = ""
|
||||
_online_slot_id = str(data.get("online_slot_id", ""))
|
||||
if not _online_slot_id.is_empty() and not seen_ids.has(_online_slot_id):
|
||||
_online_slot_id = ""
|
||||
_expected_hash = PortableFileGuard.hash_file(_manifest_path)
|
||||
return true
|
||||
|
||||
|
|
@ -368,6 +430,7 @@ func _save_manifest() -> bool:
|
|||
var data: Dictionary = {
|
||||
"format_version": FORMAT_VERSION,
|
||||
"active_slot_id": _active_slot_id,
|
||||
"online_slot_id": _online_slot_id,
|
||||
"slots": _slots,
|
||||
}
|
||||
var result: Dictionary = PortableFileGuard.write_guarded(
|
||||
|
|
@ -576,6 +639,7 @@ func _preserve_invalid_manifest() -> bool:
|
|||
_expected_hash = ""
|
||||
_slots.clear()
|
||||
_active_slot_id = ""
|
||||
_online_slot_id = ""
|
||||
return true
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/on_screen_keyboard_validation.gd"
|
||||
"tests/player_gathering_animation_validation.gd"
|
||||
"tests/player_experience_validation.gd"
|
||||
"tests/profile_title_validation.gd"
|
||||
"tests/shoreline_ambience_validation.gd"
|
||||
"tests/surface_drawing_validation.gd"
|
||||
"tests/surface_drawing_performance_validation.gd"
|
||||
|
|
@ -54,6 +55,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/terrain_chunk_generator_validation.gd"
|
||||
"tests/texture_sampling_validation.gd"
|
||||
"tests/tree_gathering_prototype_validation.gd"
|
||||
"tests/tooltip_escape_validation.gd"
|
||||
"tests/unified_inventory_validation.gd"
|
||||
"tests/world_time_validation.gd"
|
||||
"tests/world_layout_validation.gd"
|
||||
|
|
@ -80,6 +82,7 @@ readonly -a HOST_TESTS=(
|
|||
"tests/inventory_storage_persistence_validation.gd"
|
||||
"tests/job_system_validation.gd"
|
||||
"tests/progression_archive_validation.gd"
|
||||
"tests/save_reload_conflict_validation.gd"
|
||||
"tests/surface_drawing_runtime_validation.gd"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -409,7 +409,17 @@ func _run() -> void:
|
|||
# not the inactive normal gameplay camera.
|
||||
var normal_camera := player.get_gameplay_camera()
|
||||
var normal_camera_transform: Transform3D = normal_camera.global_transform
|
||||
var chat_anchor: Vector3 = player.get_chat_anchor_position()
|
||||
var chat_anchor: Vector3 = player.get_nameplate_anchor_position()
|
||||
assert(player.get_chat_anchor_position().is_equal_approx(chat_anchor))
|
||||
var held_gear_probe := MeshInstance3D.new()
|
||||
var held_gear_mesh := BoxMesh.new()
|
||||
held_gear_mesh.size = Vector3(0.5, 4.0, 0.5)
|
||||
held_gear_probe.mesh = held_gear_mesh
|
||||
held_gear_probe.position = Vector3(0.0, 6.0, 0.0)
|
||||
player.get_node("%Visuals").add_child(held_gear_probe)
|
||||
assert(player.get_nameplate_anchor_position().is_equal_approx(chat_anchor))
|
||||
assert(player.get_chat_anchor_position().is_equal_approx(chat_anchor))
|
||||
held_gear_probe.free()
|
||||
normal_camera.look_at(
|
||||
normal_camera.global_position
|
||||
- (chat_anchor - normal_camera.global_position),
|
||||
|
|
@ -417,6 +427,120 @@ func _run() -> void:
|
|||
)
|
||||
assert(normal_camera.is_position_behind(chat_anchor))
|
||||
assert(not free_camera.is_position_behind(chat_anchor))
|
||||
free_camera.look_at(chat_anchor, Vector3.UP)
|
||||
chat_ui.call("_update_nameplates")
|
||||
var nameplate_layer := chat_ui.get("_nameplate_layer") as Control
|
||||
var nameplates: Dictionary = chat_ui.get("_nameplates")
|
||||
var local_nameplate: Dictionary = nameplates.get(
|
||||
session.get_local_peer_id(), {}
|
||||
)
|
||||
var nameplate_control := local_nameplate.get("control") as Control
|
||||
var nameplate_name := local_nameplate.get("name") as Label
|
||||
var nameplate_title := local_nameplate.get("title") as Label
|
||||
var local_record := session.get_peer_record(session.get_local_peer_id())
|
||||
assert(
|
||||
nameplate_control != null and nameplate_control.visible,
|
||||
(
|
||||
"local nameplate must be visible (available=%s world=%s layer=%s "
|
||||
+ "shared=%s remote_visible=%s behind=%s occluded=%s screen=%s viewport=%s)"
|
||||
)
|
||||
% [
|
||||
bool(chat_ui.get("_available")),
|
||||
chat_ui.is_world_speech_visible(),
|
||||
nameplate_layer.visible,
|
||||
bool(chat_ui.call(
|
||||
"_speaker_shares_local_space", session.get_local_peer_id()
|
||||
)),
|
||||
player.is_remote_presentation_visible(),
|
||||
free_camera.is_position_behind(chat_anchor),
|
||||
bool(chat_ui.call(
|
||||
"_is_speech_world_occluded", free_camera, player, chat_anchor
|
||||
)),
|
||||
free_camera.unproject_position(chat_anchor)
|
||||
/ float(chat_ui.get("_output_scale")),
|
||||
chat_ui.get_viewport_rect().size,
|
||||
],
|
||||
)
|
||||
assert(nameplate_name != null and nameplate_title != null)
|
||||
assert(nameplate_name.text == local_record.display_name)
|
||||
assert(nameplate_title.text == local_record.title)
|
||||
assert(
|
||||
nameplate_name.get_theme_font_size("font_size")
|
||||
== ChatUI.NAMEPLATE_NAME_FONT_SIZE
|
||||
)
|
||||
assert(
|
||||
nameplate_title.get_theme_font_size("font_size")
|
||||
== ChatUI.NAMEPLATE_TITLE_FONT_SIZE
|
||||
)
|
||||
assert(
|
||||
nameplate_name.get_theme_font_size("font_size")
|
||||
> nameplate_title.get_theme_font_size("font_size")
|
||||
)
|
||||
assert(ChatUI.NAMEPLATE_NAME_FONT_SIZE == 20)
|
||||
assert(ChatUI.NAMEPLATE_TITLE_TOP < ChatUI.NAMEPLATE_NAME_HEIGHT)
|
||||
assert(ChatUI.NAMEPLATE_HEIGHT < 42.0)
|
||||
assert(nameplate_title.position.y == ChatUI.NAMEPLATE_TITLE_TOP)
|
||||
for flat_label: Label in [nameplate_name, nameplate_title]:
|
||||
assert(flat_label.get_theme_color("font_color") == Color.WHITE)
|
||||
assert(flat_label.get_theme_constant("outline_size") == 0)
|
||||
assert(flat_label.get_theme_color("font_outline_color").a == 0.0)
|
||||
assert(flat_label.get_theme_color("font_shadow_color").a == 0.0)
|
||||
|
||||
# Persistent remote labels obey both the authoritative home-space boundary
|
||||
# and the avatar presentation mask. They return only when the avatar shares
|
||||
# the local space and its world presentation is visible.
|
||||
var spawn_service := main.get_node(
|
||||
"%PlayerSpawnService"
|
||||
) as PlayerSpawnService
|
||||
var registry := session.get("_registry") as PeerRegistry
|
||||
const NAMEPLATE_TEST_PEER_ID: int = 9191
|
||||
assert(registry.add_peer(
|
||||
NAMEPLATE_TEST_PEER_ID,
|
||||
"nameplate-test-profile",
|
||||
"remote stray",
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
"",
|
||||
"",
|
||||
PackedStringArray(),
|
||||
"trailblazer",
|
||||
))
|
||||
var remote_transform: Transform3D = player.global_transform
|
||||
remote_transform.origin += Vector3(0.8, 0.0, 0.0)
|
||||
var remote_avatar := spawn_service.spawn_remote_player(
|
||||
NAMEPLATE_TEST_PEER_ID,
|
||||
remote_transform,
|
||||
)
|
||||
assert(remote_avatar != null)
|
||||
assert(session.set_peer_space(NAMEPLATE_TEST_PEER_ID, &"rv:privacy-test"))
|
||||
remote_avatar.set_remote_presentation_visible(true)
|
||||
chat_ui.call("_update_nameplates")
|
||||
var remote_nameplate: Dictionary = nameplates.get(
|
||||
NAMEPLATE_TEST_PEER_ID, {}
|
||||
)
|
||||
var remote_plate := remote_nameplate.get("control") as Control
|
||||
assert(remote_plate != null and not remote_plate.visible)
|
||||
assert(session.set_peer_space(
|
||||
NAMEPLATE_TEST_PEER_ID,
|
||||
session.get_peer_space(session.get_local_peer_id()),
|
||||
))
|
||||
remote_avatar.set_remote_presentation_visible(false)
|
||||
chat_ui.call("_update_nameplates")
|
||||
assert(not remote_plate.visible)
|
||||
remote_avatar.set_remote_presentation_visible(true)
|
||||
free_camera.look_at(
|
||||
remote_avatar.get_nameplate_anchor_position(), Vector3.UP
|
||||
)
|
||||
chat_ui.call("_update_nameplates")
|
||||
assert(remote_plate.visible)
|
||||
assert((remote_nameplate.get("name") as Label).text == "remote stray")
|
||||
assert((remote_nameplate.get("title") as Label).text == "trailblazer")
|
||||
spawn_service.remove_peer(NAMEPLATE_TEST_PEER_ID)
|
||||
registry.remove_peer(NAMEPLATE_TEST_PEER_ID)
|
||||
chat_ui.call("_update_nameplates")
|
||||
assert(not nameplates.has(NAMEPLATE_TEST_PEER_ID))
|
||||
free_camera.look_at(chat_anchor, Vector3.UP)
|
||||
chat_ui.call("_update_nameplates")
|
||||
assert(nameplate_control.visible)
|
||||
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
|
||||
var storage_prompt := game_ui.get_node("%StoragePrompt") as Control
|
||||
game_ui.set_shop_prompt_visible(true, chat_anchor)
|
||||
|
|
@ -432,11 +556,19 @@ func _run() -> void:
|
|||
assert(not first_speech_mouth.is_empty())
|
||||
assert(not second_speech_mouth.is_empty())
|
||||
assert(first_speech_mouth != second_speech_mouth)
|
||||
free_camera.look_at(chat_anchor, Vector3.UP)
|
||||
chat_ui.call("_update_nameplates")
|
||||
chat_ui.call("_update_speech")
|
||||
var speech: Dictionary = chat_ui.get("_speech")
|
||||
var local_speech: Dictionary = speech.get(session.get_local_peer_id(), {})
|
||||
var speech_bubble := local_speech.get("bubble") as PanelContainer
|
||||
assert(speech_bubble != null and speech_bubble.visible)
|
||||
assert(
|
||||
speech_bubble.position.y
|
||||
+ speech_bubble.size.y
|
||||
+ ChatUI.SPEECH_POINTER_HEIGHT
|
||||
<= nameplate_control.position.y
|
||||
)
|
||||
normal_camera.global_transform = normal_camera_transform
|
||||
await create_timer(0.6).timeout
|
||||
assert(not bool(player.get("_speech_mouth_active")))
|
||||
|
|
@ -448,6 +580,7 @@ func _run() -> void:
|
|||
await process_frame
|
||||
assert(game_ui.is_gameplay_hud_hidden())
|
||||
assert(chat_ui.is_hud_hidden())
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(not (
|
||||
game_ui.get_node("%GameplayTransientHUD") as Control
|
||||
).visible)
|
||||
|
|
@ -479,6 +612,8 @@ func _run() -> void:
|
|||
chat_ui.open_chat()
|
||||
await process_frame
|
||||
assert(chat_ui.is_open() and chat_panel.visible)
|
||||
assert(not chat_ui.is_hud_hidden())
|
||||
assert(not nameplate_layer.visible)
|
||||
chat_ui.refocus_gameplay()
|
||||
assert(chat_ui.is_hud_hidden())
|
||||
var cancel_button := InputEventJoypadButton.new()
|
||||
|
|
@ -503,6 +638,7 @@ func _run() -> void:
|
|||
await process_frame
|
||||
assert(not game_ui.is_gameplay_hud_hidden())
|
||||
assert(not chat_ui.is_hud_hidden())
|
||||
assert(nameplate_layer.visible)
|
||||
assert(not service.can_activate())
|
||||
assert(not service.is_active() and not toolbar.visible)
|
||||
assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ const MainScene: PackedScene = preload("res://main/main.tscn")
|
|||
const TEST_PORT: int = 18152
|
||||
const PREJOIN_MESSAGE: String = "private prejoin message"
|
||||
const LIVE_MESSAGE: String = "live message after join"
|
||||
const HOST_ROLEPLAY: String = "/me grabs [b]his fishing rod[/b]"
|
||||
const CLIENT_ROLEPLAY: String = "/me waves [color=red]hello[/color]"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -39,11 +41,34 @@ func _run_host() -> void:
|
|||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
assert(not chat_service.send_local_message("/me"))
|
||||
assert(chat_service.send_local_message(PREJOIN_MESSAGE))
|
||||
var remote_peer_id: int = await _wait_for_remote_peer(session)
|
||||
assert(remote_peer_id > 1)
|
||||
await create_timer(1.0).timeout
|
||||
assert(chat_service.send_local_message(LIVE_MESSAGE))
|
||||
assert(chat_service.send_local_message(HOST_ROLEPLAY))
|
||||
var client_roleplay_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < client_roleplay_deadline
|
||||
and not _history_contains(chat_service, CLIENT_ROLEPLAY)
|
||||
):
|
||||
await process_frame
|
||||
assert(_history_contains(chat_service, CLIENT_ROLEPLAY))
|
||||
var client_roleplay: Dictionary = _history_message(
|
||||
chat_service, CLIENT_ROLEPLAY
|
||||
)
|
||||
assert(int(client_roleplay.get("sender_peer_id", 0)) == remote_peer_id)
|
||||
assert(
|
||||
str(client_roleplay.get("sender_display_name", ""))
|
||||
== session.get_peer_record(remote_peer_id).display_name
|
||||
)
|
||||
_assert_roleplay_rendered(
|
||||
main,
|
||||
str(client_roleplay["sender_display_name"]),
|
||||
"waves [color=red]hello[/color]",
|
||||
)
|
||||
_assert_no_world_speech(main, remote_peer_id)
|
||||
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < disconnect_deadline
|
||||
|
|
@ -75,11 +100,39 @@ func _run_client() -> void:
|
|||
var live_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < live_deadline
|
||||
and not _history_contains(chat_service, LIVE_MESSAGE)
|
||||
and (
|
||||
not _history_contains(chat_service, LIVE_MESSAGE)
|
||||
or not _history_contains(chat_service, HOST_ROLEPLAY)
|
||||
)
|
||||
):
|
||||
await process_frame
|
||||
assert(_history_contains(chat_service, LIVE_MESSAGE))
|
||||
assert(_history_contains(chat_service, HOST_ROLEPLAY))
|
||||
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
|
||||
var host_roleplay: Dictionary = _history_message(
|
||||
chat_service, HOST_ROLEPLAY
|
||||
)
|
||||
var host_peer_id: int = int(host_roleplay.get("sender_peer_id", 0))
|
||||
assert(host_peer_id > 0)
|
||||
assert(
|
||||
str(host_roleplay.get("sender_display_name", ""))
|
||||
== session.get_peer_record(host_peer_id).display_name
|
||||
)
|
||||
_assert_roleplay_rendered(
|
||||
main,
|
||||
str(host_roleplay["sender_display_name"]),
|
||||
"grabs [b]his fishing rod[/b]",
|
||||
)
|
||||
_assert_world_speech_body(main, host_peer_id, LIVE_MESSAGE)
|
||||
assert(chat_service.send_local_message(CLIENT_ROLEPLAY))
|
||||
var confirmation_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < confirmation_deadline
|
||||
and not _history_contains(chat_service, CLIENT_ROLEPLAY)
|
||||
):
|
||||
await process_frame
|
||||
assert(_history_contains(chat_service, CLIENT_ROLEPLAY))
|
||||
_assert_no_world_speech(main, session.get_local_peer_id())
|
||||
print("Chat privacy multiplayer client validation: PASS")
|
||||
await _session_cleanup(main, session)
|
||||
|
||||
|
|
@ -91,6 +144,59 @@ func _history_contains(service: NetworkChatService, body: String) -> bool:
|
|||
)
|
||||
|
||||
|
||||
func _history_message(
|
||||
service: NetworkChatService,
|
||||
body: String,
|
||||
) -> Dictionary:
|
||||
for message: Dictionary in service.get_history():
|
||||
if str(message.get("body", "")) == body:
|
||||
return message
|
||||
return {}
|
||||
|
||||
|
||||
func _assert_roleplay_rendered(
|
||||
main: Node,
|
||||
display_name: String,
|
||||
action: String,
|
||||
) -> void:
|
||||
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
|
||||
var history := chat_ui.get("_history") as RichTextLabel
|
||||
assert(history != null)
|
||||
var expected: String = "%s %s" % [display_name, action]
|
||||
assert(history.get_parsed_text().contains(expected))
|
||||
assert(not history.get_parsed_text().contains(
|
||||
"%s: /me" % display_name
|
||||
))
|
||||
assert(not history.bbcode_enabled)
|
||||
var italic_font := history.get_theme_font("italics_font") as FontVariation
|
||||
assert(italic_font != null)
|
||||
assert(not is_zero_approx(italic_font.variation_transform.y.x))
|
||||
|
||||
|
||||
func _assert_no_world_speech(main: Node, peer_id: int) -> void:
|
||||
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
|
||||
var speech: Dictionary = chat_ui.get("_speech")
|
||||
assert(not speech.has(peer_id))
|
||||
|
||||
|
||||
func _assert_world_speech_body(
|
||||
main: Node,
|
||||
peer_id: int,
|
||||
expected_body: String,
|
||||
) -> void:
|
||||
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
|
||||
var speech: Dictionary = chat_ui.get("_speech")
|
||||
assert(speech.has(peer_id))
|
||||
var state: Dictionary = speech[peer_id]
|
||||
var bubble := state.get("bubble") as PanelContainer
|
||||
assert(bubble != null)
|
||||
for child: Node in bubble.get_children():
|
||||
if child is Label:
|
||||
assert((child as Label).text == expected_body)
|
||||
return
|
||||
assert(false, "Speech bubble label is missing.")
|
||||
|
||||
|
||||
func _create_initialized_main() -> Node:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
|
|
|
|||
|
|
@ -187,6 +187,19 @@ func _validate_primary_menu_navigation() -> void:
|
|||
)
|
||||
for title_control: Control in title_controls:
|
||||
_assert_directionally_reachable(title_control, title_controls)
|
||||
var version_row := title.get_node("%VersionRow") as Control
|
||||
title.call("_on_continue_pressed")
|
||||
await process_frame
|
||||
_expect(
|
||||
not version_row.visible,
|
||||
"The title version remains visible behind the Save Slots panel.",
|
||||
)
|
||||
title.call("_close_save_slots")
|
||||
await process_frame
|
||||
_expect(
|
||||
version_row.visible,
|
||||
"The title version was not restored after closing Save Slots.",
|
||||
)
|
||||
title.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
|
@ -221,7 +234,17 @@ func _validate_save_slots_navigation() -> void:
|
|||
await create_timer(0.25).timeout
|
||||
var saves_tab := page.get_node("%SavesTab") as Button
|
||||
var new_tab := page.get_node("%NewSlotTab") as Button
|
||||
var main_panel := page.get_node("%MainPanel") as PanelContainer
|
||||
var content_panel := page.get_node("%ContentPanel") as PanelContainer
|
||||
var back_button := page.get_node("%BackButton") as Button
|
||||
_expect(
|
||||
main_panel.size.is_equal_approx(Vector2(880.0, 660.0)),
|
||||
"Save Slots changed the fixed 880 by 660 outer panel size.",
|
||||
)
|
||||
_expect(
|
||||
main_panel.get_global_rect().encloses(back_button.get_global_rect()),
|
||||
"The Save Slots Back button extends beyond the outer panel.",
|
||||
)
|
||||
var tab_overlap: float = (
|
||||
saves_tab.get_global_rect().end.y
|
||||
- content_panel.get_global_rect().position.y
|
||||
|
|
@ -258,6 +281,40 @@ func _validate_save_slots_navigation() -> void:
|
|||
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
|
||||
var slot_summary := page.get_node("%SlotSummary") as RichTextLabel
|
||||
slot_summary.text = str(page.call("_format_slot_summary", {
|
||||
"has_save": true,
|
||||
"player_level": 100,
|
||||
"catch_count": 9999,
|
||||
"discovered_species_count": 999,
|
||||
"wallet_balance": 999999,
|
||||
"world_layout": &"generated",
|
||||
"world_seed": 2147483647,
|
||||
"last_played_at_unix": 1788282000,
|
||||
}))
|
||||
for _frame: int in 2:
|
||||
await process_frame
|
||||
var content_rect: Rect2 = content_panel.get_global_rect()
|
||||
for control: Control in [
|
||||
import_button,
|
||||
page.get_node("%SelectedSlotName") as Control,
|
||||
slot_summary,
|
||||
page.get_node("%OnlineSlotButton") as Control,
|
||||
play_slot,
|
||||
rename_slot,
|
||||
duplicate_slot,
|
||||
export_slot,
|
||||
delete_slot,
|
||||
]:
|
||||
_expect(
|
||||
content_rect.encloses(control.get_global_rect()),
|
||||
"Save-slot control %s extends beyond the content panel."
|
||||
% control.name,
|
||||
)
|
||||
_expect(
|
||||
content_rect.end.y <= back_button.get_global_rect().position.y,
|
||||
"Save-slot content overlaps the Back 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.",
|
||||
|
|
@ -960,6 +1017,18 @@ func _validate_profile_confirmation_focus() -> void:
|
|||
page.call("set_interactive", true)
|
||||
page.call("reset_controller_zone")
|
||||
var name_edit := page.get("_name_edit") as LineEdit
|
||||
var customize := page.get("_customize_button") as Button
|
||||
for _frame: int in 2:
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == customize,
|
||||
"Opening Profile activated the player-name field instead of its "
|
||||
+ "non-text customization action.",
|
||||
)
|
||||
_expect(
|
||||
not name_edit.has_focus(),
|
||||
"Opening Profile activated the player-name field without user input.",
|
||||
)
|
||||
name_edit.grab_focus()
|
||||
page.call("_on_name_changed", "V")
|
||||
_expect(
|
||||
|
|
|
|||
|
|
@ -49,12 +49,16 @@ func _run() -> void:
|
|||
|
||||
var player := main.get("_player") as Player
|
||||
var interaction := main.get("_shop_interaction") as FishingShopInteraction
|
||||
var decor_interaction := main.get(
|
||||
"_decor_shop_interaction"
|
||||
) as DecorShopInteraction
|
||||
var game_ui := main.get("_game_ui") as GameUI
|
||||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
_expect(player != null, "local player is unavailable")
|
||||
_expect(interaction != null, "shop interaction is unavailable")
|
||||
_expect(decor_interaction != null, "decor interaction is unavailable")
|
||||
_expect(game_ui != null, "game UI is unavailable")
|
||||
_expect(chat_service != null, "network chat service is unavailable")
|
||||
if (
|
||||
|
|
@ -70,6 +74,136 @@ func _run() -> void:
|
|||
interaction.is_local_player_in_range(),
|
||||
"local player did not enter the shop interaction range",
|
||||
)
|
||||
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
|
||||
var shop_message := game_ui.get_node("%ShopPromptMessage") as Label
|
||||
_expect(
|
||||
not bool(game_ui.get("_shop_npc_dialogue_active"))
|
||||
and not shop_message.visible,
|
||||
"shopkeeper spoke before the player initiated conversation",
|
||||
)
|
||||
_expect(
|
||||
shop_prompt.size == Vector2(30.0, 30.0),
|
||||
"nearby shop prompt was not reduced to the compact interaction glyph",
|
||||
)
|
||||
var fixed_area_transform := interaction.global_transform
|
||||
var shopkeeper := interaction.get_node("../Shopkeeper") as WorldCharacterDisplay
|
||||
var shopkeeper_start := shopkeeper.position
|
||||
var camera_yaw := player.get_node("CameraYaw") as Node3D
|
||||
var camera_pitch := player.get_node("CameraYaw/CameraPitch") as Node3D
|
||||
var spring_arm := player.get_node(
|
||||
"CameraYaw/CameraPitch/SpringArm3D"
|
||||
) as SpringArm3D
|
||||
var initial_camera_yaw_transform := camera_yaw.transform
|
||||
var initial_camera_pitch := camera_pitch.rotation
|
||||
var initial_spring_length := spring_arm.spring_length
|
||||
# Route planning treats the player as a live obstacle. Move the validation
|
||||
# avatar clear before forcing an ambient patrol leg.
|
||||
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
|
||||
interaction.call("_refresh_local_player_proximity")
|
||||
interaction.call("_choose_roaming_destination")
|
||||
var roaming_destination: Vector3 = interaction.get(
|
||||
"_roaming_destination"
|
||||
)
|
||||
var safe_route: Dictionary = interaction.call(
|
||||
"_resolve_safe_route_destination",
|
||||
roaming_destination,
|
||||
)
|
||||
_expect(
|
||||
not safe_route.is_empty(),
|
||||
"shopkeeper accepted a destination that failed its terrain route probe",
|
||||
)
|
||||
var initial_route_offset := roaming_destination - shopkeeper.position
|
||||
shopkeeper.rotation.y = (
|
||||
atan2(initial_route_offset.x, initial_route_offset.z) + PI
|
||||
)
|
||||
interaction.call("_process", 0.05)
|
||||
_expect(
|
||||
shopkeeper.position.is_equal_approx(shopkeeper_start)
|
||||
and not shopkeeper.is_locomotion_walking(),
|
||||
"shopkeeper translated before finishing its turn-in-place",
|
||||
)
|
||||
# A patrol leg deliberately starts by turning in place. Advance in small
|
||||
# slices until the body is aligned and translation begins.
|
||||
for _turn_frame: int in 20:
|
||||
interaction.call("_process", 0.05)
|
||||
if shopkeeper.position.distance_to(shopkeeper_start) > 0.001:
|
||||
break
|
||||
_expect(
|
||||
interaction.global_transform.is_equal_approx(fixed_area_transform),
|
||||
"ambient shopkeeper roaming moved the authoritative interaction area",
|
||||
)
|
||||
_expect(
|
||||
shopkeeper.position.distance_to(shopkeeper_start) > 0.001
|
||||
and shopkeeper.is_locomotion_walking(),
|
||||
"shopkeeper did not walk toward an ambient roaming destination",
|
||||
)
|
||||
var roaming_direction := (
|
||||
shopkeeper.position - shopkeeper_start
|
||||
).normalized()
|
||||
var roaming_origin: Vector3 = interaction.get("_roaming_origin")
|
||||
_expect(
|
||||
roaming_destination.distance_to(shopkeeper_start)
|
||||
>= interaction.minimum_route_distance
|
||||
and roaming_destination.distance_to(shopkeeper_start)
|
||||
<= interaction.maximum_route_distance + 0.001
|
||||
and roaming_destination.distance_to(roaming_origin)
|
||||
<= interaction.roaming_radius + 0.001
|
||||
and shopkeeper.basis.z.normalized().dot(roaming_direction) > 0.95,
|
||||
(
|
||||
"shopkeeper patrol leg was unsafe, too short, or did not face "
|
||||
+ "its travel direction"
|
||||
),
|
||||
)
|
||||
|
||||
# Ambient visuals may roam well beyond their original compact trigger, but
|
||||
# the trigger itself must remain fixed for multiplayer authority. Local UI
|
||||
# eligibility follows the moving character, while the host validates a
|
||||
# conservative authored-origin envelope that covers the full patrol.
|
||||
var captured_roaming_origin: Vector3 = interaction.get(
|
||||
"_roaming_origin"
|
||||
)
|
||||
interaction.roaming_enabled = false
|
||||
shopkeeper.position = captured_roaming_origin + Vector3(2.6, 0.0, 0.0)
|
||||
interaction.setup_local_player(player)
|
||||
_expect(
|
||||
(interaction.get("_roaming_origin") as Vector3).is_equal_approx(
|
||||
captured_roaming_origin
|
||||
),
|
||||
"rebinding the player recaptured a wandered position as the patrol origin",
|
||||
)
|
||||
player.global_position = shopkeeper.global_position + Vector3(1.4, 0.0, 0.0)
|
||||
interaction.call("_refresh_local_player_proximity")
|
||||
_expect(
|
||||
interaction.is_local_player_in_range()
|
||||
and interaction.is_avatar_in_range(player),
|
||||
"moving-shopkeeper reach was not covered locally and authoritatively",
|
||||
)
|
||||
player.global_position = interaction.call("_get_roaming_origin_global")
|
||||
interaction.call("_refresh_local_player_proximity")
|
||||
_expect(
|
||||
not interaction.is_local_player_in_range()
|
||||
and interaction.is_avatar_in_range(player),
|
||||
"local proximity still followed the fixed authored Area3D",
|
||||
)
|
||||
var authority_radius := interaction.get_authority_interaction_radius()
|
||||
player.global_position = (
|
||||
interaction.call("_get_roaming_origin_global")
|
||||
+ Vector3(authority_radius + 0.1, 0.0, 0.0)
|
||||
)
|
||||
_expect(
|
||||
not interaction.is_avatar_in_range(player),
|
||||
"shop authority accepted a player beyond the fixed patrol envelope",
|
||||
)
|
||||
shopkeeper.position = shopkeeper_start
|
||||
interaction.roaming_enabled = true
|
||||
player.global_position = (
|
||||
shopkeeper.global_position + Vector3(0.0, 0.0, -1.0)
|
||||
)
|
||||
interaction.call("_refresh_local_player_proximity")
|
||||
_expect(
|
||||
interaction.global_transform.is_equal_approx(fixed_area_transform),
|
||||
"proximity validation moved the authoritative interaction area",
|
||||
)
|
||||
var character_calls: Array[String] = []
|
||||
var capture_call := func(
|
||||
_peer_id: int,
|
||||
|
|
@ -94,12 +228,57 @@ func _run() -> void:
|
|||
main.call("_unhandled_input", press)
|
||||
await process_frame
|
||||
_expect(
|
||||
game_ui.get_fishing_shop().visible,
|
||||
"Y did not open the shop while the player was in range",
|
||||
not game_ui.get_fishing_shop().visible
|
||||
and main.get("_engaged_shop_npc_interaction") == interaction
|
||||
and interaction.is_engaged(),
|
||||
"first Y press did not enter shopkeeper dialogue without opening",
|
||||
)
|
||||
_expect(
|
||||
bool(game_ui.get("_shop_npc_dialogue_active"))
|
||||
and shop_message.visible
|
||||
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
|
||||
"engaged shopkeeper dialogue was not presented",
|
||||
)
|
||||
_expect(
|
||||
not bool(player.call("_is_movement_input_enabled"))
|
||||
and not bool(player.call("_is_camera_input_enabled"))
|
||||
and player.is_shop_conversation_camera_active(),
|
||||
"shopkeeper dialogue did not lock movement and hold its camera frame",
|
||||
)
|
||||
_expect(
|
||||
character_calls.is_empty(),
|
||||
"Y played a character call instead of opening the shop",
|
||||
"Y played a character call instead of starting shopkeeper dialogue",
|
||||
)
|
||||
await create_timer(0.5).timeout
|
||||
var player_to_shopkeeper := (
|
||||
shopkeeper.global_position - player.global_position
|
||||
)
|
||||
player_to_shopkeeper.y = 0.0
|
||||
player_to_shopkeeper = player_to_shopkeeper.normalized()
|
||||
_expect(
|
||||
camera_yaw.global_basis.x.normalized().dot(player_to_shopkeeper)
|
||||
> 0.9
|
||||
and spring_arm.spring_length < initial_spring_length,
|
||||
"conversation camera did not frame player-left and NPC-right",
|
||||
)
|
||||
_expect(
|
||||
shopkeeper.global_basis.z.normalized().dot(
|
||||
(player.global_position - shopkeeper.global_position).normalized()
|
||||
) > 0.9
|
||||
and not shopkeeper.is_locomotion_walking(),
|
||||
"engaged shopkeeper did not stop and face the player",
|
||||
)
|
||||
main.call("_input", press)
|
||||
await process_frame
|
||||
_expect(
|
||||
game_ui.get_fishing_shop().visible
|
||||
and bool(main.get("_shop_conversation_shop_opened")),
|
||||
"second Y press did not dismiss dialogue and open the shop",
|
||||
)
|
||||
_expect(
|
||||
main.get("_engaged_shop_npc_interaction") == interaction
|
||||
and not bool(player.call("_is_camera_input_enabled")),
|
||||
"shop opening released the conversation frame before close",
|
||||
)
|
||||
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
|
||||
for _frame: int in 6:
|
||||
|
|
@ -108,6 +287,12 @@ func _run() -> void:
|
|||
if not game_ui.get_fishing_shop().visible:
|
||||
break
|
||||
await process_frame
|
||||
for _frame: int in 40:
|
||||
if not player.is_shop_conversation_camera_active():
|
||||
break
|
||||
await process_frame
|
||||
if player.is_shop_conversation_camera_active():
|
||||
await create_timer(0.55).timeout
|
||||
_expect(
|
||||
not interaction.is_local_player_in_range(),
|
||||
"local player did not leave the shop interaction range",
|
||||
|
|
@ -116,12 +301,219 @@ func _run() -> void:
|
|||
not game_ui.get_fishing_shop().visible,
|
||||
"shop did not close after leaving its interaction range",
|
||||
)
|
||||
_expect(
|
||||
main.get("_engaged_shop_npc_interaction") == null
|
||||
and bool(player.call("_is_movement_input_enabled"))
|
||||
and bool(player.call("_is_camera_input_enabled")),
|
||||
(
|
||||
"shop close did not restore conversation controls and camera "
|
||||
+ "(engaged=%s movement=%s camera=%s restoring=%s)"
|
||||
) % [
|
||||
main.get("_engaged_shop_npc_interaction"),
|
||||
player.call("_is_movement_input_enabled"),
|
||||
player.call("_is_camera_input_enabled"),
|
||||
main.get("_shop_conversation_camera_restoring"),
|
||||
],
|
||||
)
|
||||
_expect(
|
||||
camera_yaw.transform.is_equal_approx(
|
||||
initial_camera_yaw_transform
|
||||
)
|
||||
and camera_pitch.rotation.is_equal_approx(initial_camera_pitch)
|
||||
and is_equal_approx(spring_arm.spring_length, initial_spring_length),
|
||||
"conversation camera did not restore its exact pre-dialogue frame",
|
||||
)
|
||||
game_ui.call("_input", press)
|
||||
await process_frame
|
||||
_expect(
|
||||
character_calls.size() == 1,
|
||||
"Y did not play a character call away from an interaction",
|
||||
)
|
||||
|
||||
# Deliberately overlap two shop margins to validate the transition case.
|
||||
# Whichever character the player is actually closest to must own both the
|
||||
# dialogue/prompt and the interaction button, regardless of shop type.
|
||||
if decor_interaction != null:
|
||||
var fishing_root := interaction.get_parent() as Node3D
|
||||
var decor_root := decor_interaction.get_parent() as Node3D
|
||||
var original_decor_transform := decor_root.global_transform
|
||||
interaction.roaming_enabled = false
|
||||
decor_interaction.roaming_enabled = false
|
||||
(interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
|
||||
(decor_interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
|
||||
decor_root.global_position = (
|
||||
fishing_root.global_position + Vector3(1.1, 0.0, 0.0)
|
||||
)
|
||||
player.global_position = decor_root.global_position
|
||||
for _frame: int in 6:
|
||||
await physics_frame
|
||||
_expect(
|
||||
interaction.is_local_player_in_range()
|
||||
and decor_interaction.is_local_player_in_range(),
|
||||
"overlapping shop transition margins were not established",
|
||||
)
|
||||
_expect(
|
||||
main.call("_get_active_shop_npc_interaction")
|
||||
== decor_interaction,
|
||||
"nearest decor NPC did not own the overlapping interaction",
|
||||
)
|
||||
_expect(
|
||||
int(game_ui.get("_shop_npc_source_id"))
|
||||
== decor_interaction.get_instance_id()
|
||||
and shop_message.text
|
||||
== DecorShopInteraction.DECOR_DIALOGUE_TEXT,
|
||||
"decor dialogue did not follow the active NPC handoff",
|
||||
)
|
||||
player.global_position = (
|
||||
fishing_root.global_position + Vector3(0.57, 0.0, 0.0)
|
||||
)
|
||||
for _frame: int in 3:
|
||||
await physics_frame
|
||||
_expect(
|
||||
main.call("_get_active_shop_npc_interaction")
|
||||
== decor_interaction,
|
||||
"shop handoff hysteresis did not stabilize a near-tie",
|
||||
)
|
||||
player.global_position = (
|
||||
fishing_root.global_position + Vector3(0.45, 0.0, 0.0)
|
||||
)
|
||||
for _frame: int in 3:
|
||||
await physics_frame
|
||||
_expect(
|
||||
main.call("_get_active_shop_npc_interaction")
|
||||
== interaction,
|
||||
"shop ownership did not switch after a clear distance lead",
|
||||
)
|
||||
_expect(
|
||||
int(game_ui.get("_shop_npc_source_id"))
|
||||
== interaction.get_instance_id()
|
||||
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
|
||||
"fishing dialogue did not replace the prior NPC cleanly",
|
||||
)
|
||||
player.global_position = decor_root.global_position
|
||||
for _frame: int in 3:
|
||||
await physics_frame
|
||||
game_ui.call("_input", press)
|
||||
main.call("_unhandled_input", press)
|
||||
await process_frame
|
||||
_expect(
|
||||
not game_ui.get_decor_shop().visible
|
||||
and main.get("_engaged_shop_npc_interaction")
|
||||
== decor_interaction,
|
||||
"first Y did not engage the nearest decor NPC in an overlap",
|
||||
)
|
||||
main.call("_input", press)
|
||||
await process_frame
|
||||
_expect(
|
||||
game_ui.get_decor_shop().visible
|
||||
and main.get("_engaged_shop_npc_interaction")
|
||||
== decor_interaction,
|
||||
"second Y did not open the engaged decor shop",
|
||||
)
|
||||
_expect(
|
||||
character_calls.size() == 1,
|
||||
"Y emitted a character call instead of opening the decor shop",
|
||||
)
|
||||
game_ui.get_decor_shop().close_shop()
|
||||
for _frame: int in 40:
|
||||
if not player.is_shop_conversation_camera_active():
|
||||
break
|
||||
await process_frame
|
||||
if player.is_shop_conversation_camera_active():
|
||||
await create_timer(0.55).timeout
|
||||
player.global_position = fishing_root.global_position
|
||||
for _frame: int in 6:
|
||||
await physics_frame
|
||||
_expect(
|
||||
main.call("_get_active_shop_npc_interaction")
|
||||
== interaction,
|
||||
"shop ownership did not hand back to the nearer fishing NPC",
|
||||
)
|
||||
decor_root.global_transform = original_decor_transform
|
||||
|
||||
# Idle visuals can still begin a frame overlapped (for example after two
|
||||
# independently planned routes converge). They must separate without
|
||||
# moving either authoritative Area3D, deadlocking on their reservations,
|
||||
# or waiting for one NPC to already be in the walking state.
|
||||
var fishing_visual := interaction.get_node(
|
||||
"../Shopkeeper"
|
||||
) as WorldCharacterDisplay
|
||||
var decor_visual := decor_interaction.get_node(
|
||||
"../Shopkeeper"
|
||||
) as WorldCharacterDisplay
|
||||
var fishing_visual_transform := fishing_visual.global_transform
|
||||
var decor_visual_transform := decor_visual.global_transform
|
||||
var fishing_area_transform := interaction.global_transform
|
||||
var decor_area_transform := decor_interaction.global_transform
|
||||
player.global_position = fishing_visual.global_position + Vector3(20.0, 0.0, 0.0)
|
||||
interaction.roaming_enabled = true
|
||||
decor_interaction.roaming_enabled = true
|
||||
interaction.call("_schedule_idle")
|
||||
decor_interaction.call("_schedule_idle")
|
||||
var crossing_center := fishing_visual.global_position
|
||||
decor_visual.global_position = (
|
||||
crossing_center + Vector3(1.0, 0.0, -1.0)
|
||||
)
|
||||
decor_interaction.set(
|
||||
"_roaming_destination",
|
||||
decor_visual.get_parent_node_3d().to_local(
|
||||
crossing_center + Vector3(1.0, 0.0, 1.0)
|
||||
),
|
||||
)
|
||||
decor_interaction.set("_roaming_walking", true)
|
||||
_expect(
|
||||
bool(interaction.call(
|
||||
"_route_conflicts_with_reserved_corridors",
|
||||
crossing_center,
|
||||
crossing_center + Vector3(2.0, 0.0, 0.0),
|
||||
)),
|
||||
"crossing shopkeeper route corridors were not reserved",
|
||||
)
|
||||
decor_interaction.call("_schedule_idle")
|
||||
var fishing_priority: String = interaction.call(
|
||||
"_traffic_priority_key"
|
||||
)
|
||||
var decor_priority: String = decor_interaction.call(
|
||||
"_traffic_priority_key"
|
||||
)
|
||||
var blocked_outward_direction := (
|
||||
Vector3.RIGHT
|
||||
if fishing_priority < decor_priority
|
||||
else Vector3.LEFT
|
||||
)
|
||||
fishing_visual.global_position = (
|
||||
interaction.call("_get_roaming_origin_global")
|
||||
+ blocked_outward_direction * interaction.roaming_radius
|
||||
)
|
||||
decor_visual.global_position = fishing_visual.global_position
|
||||
var overlapped_position := fishing_visual.global_position
|
||||
for _separation_frame: int in 80:
|
||||
interaction.call("_process", 0.05)
|
||||
decor_interaction.call("_process", 0.05)
|
||||
var separation := fishing_visual.global_position - decor_visual.global_position
|
||||
separation.y = 0.0
|
||||
if separation.length() >= interaction.other_shopkeeper_clearance:
|
||||
break
|
||||
var resolved_separation := (
|
||||
fishing_visual.global_position - decor_visual.global_position
|
||||
)
|
||||
resolved_separation.y = 0.0
|
||||
_expect(
|
||||
resolved_separation.length()
|
||||
>= interaction.other_shopkeeper_clearance - 0.02
|
||||
and fishing_visual.global_position.distance_to(overlapped_position)
|
||||
> 0.1,
|
||||
"idle-overlapped shopkeepers did not separate and resume safely",
|
||||
)
|
||||
_expect(
|
||||
interaction.global_transform.is_equal_approx(fishing_area_transform)
|
||||
and decor_interaction.global_transform.is_equal_approx(
|
||||
decor_area_transform
|
||||
),
|
||||
"live shopkeeper separation moved an authoritative Area3D",
|
||||
)
|
||||
fishing_visual.global_transform = fishing_visual_transform
|
||||
decor_visual.global_transform = decor_visual_transform
|
||||
chat_service.character_call_received.disconnect(capture_call)
|
||||
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
|
|
|
|||
|
|
@ -622,7 +622,6 @@ func _test_fishing_shop_sale_ui(
|
|||
var game_ui := main.get_node("%GameUI") as GameUI
|
||||
var shop := game_ui.get_node("%FishingShop") as FishingShop
|
||||
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
|
||||
var shop_backdrop := main.get_node("%ShopBackdrop") as ColorRect
|
||||
var ui_viewport := main.get_node(
|
||||
"UIPresentation/UIViewport"
|
||||
) as SubViewport
|
||||
|
|
@ -630,7 +629,6 @@ func _test_fishing_shop_sale_ui(
|
|||
interaction != null
|
||||
and shop != null
|
||||
and player_menu != null
|
||||
and shop_backdrop != null
|
||||
and ui_viewport != null
|
||||
)
|
||||
var item_catalog := main.get("item_catalog") as ItemCatalog
|
||||
|
|
@ -701,8 +699,7 @@ func _test_fishing_shop_sale_ui(
|
|||
assert(shop.open_shop())
|
||||
for _frame: int in 2:
|
||||
await process_frame
|
||||
assert(shop_backdrop.visible)
|
||||
assert(shop_backdrop.material is ShaderMaterial)
|
||||
assert(main.get_node_or_null("%ShopBackdrop") == null)
|
||||
assert(shop.has_node("InputBlocker") and not shop.has_node("Dimmer"))
|
||||
var shop_panel := shop.get_node("%ShopPanel") as PanelContainer
|
||||
assert(shop_panel != null and shop_panel.size == Vector2(980.0, 600.0))
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ func _run() -> void:
|
|||
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
|
||||
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
||||
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
|
||||
assert(protocol_version == 12)
|
||||
assert(protocol_version == 13)
|
||||
assert(enet_channel_count == 18)
|
||||
assert(
|
||||
fish_quality_capability == "fish_quality_v1"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func _run() -> void:
|
|||
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
|
||||
)
|
||||
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
||||
assert(protocol_version == 12)
|
||||
assert(protocol_version == 13)
|
||||
assert(enet_channel_count == 18)
|
||||
assert(movement_animation_channel == 11)
|
||||
assert(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ func _run_host() -> void:
|
|||
"%PlayerSpawnService"
|
||||
) as PlayerSpawnService
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
var home_state := main.get_node("%PlayerHomeState") as PlayerHomeState
|
||||
assert(bool(main.call(
|
||||
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
|
||||
)))
|
||||
|
|
@ -59,6 +60,60 @@ func _run_host() -> void:
|
|||
await physics_frame
|
||||
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
|
||||
assert(_faces_away_from_home(host_avatar, home_world, host_fingerprint))
|
||||
var upgrade_result_id := "home-test-upgrade"
|
||||
network_home.authorize_home_upgrade(
|
||||
session.get_local_peer_id(),
|
||||
PlayerHomeState.STARTER_RV_TIER,
|
||||
upgrade_result_id,
|
||||
)
|
||||
assert(home_state.upgrade_to_next_tier())
|
||||
var rv_deadline: int = Time.get_ticks_msec() + 5000
|
||||
while (
|
||||
Time.get_ticks_msec() < rv_deadline
|
||||
and home_world.get_interior_exit_positions(host_fingerprint).size() < 2
|
||||
):
|
||||
await process_frame
|
||||
var rv_exits: Array[Vector3] = home_world.get_interior_exit_positions(
|
||||
host_fingerprint
|
||||
)
|
||||
assert(rv_exits.size() == 2)
|
||||
network_home.finalize_home_upgrade(
|
||||
session.get_local_peer_id(), upgrade_result_id, true
|
||||
)
|
||||
assert(rv_exits[0].x < rv_exits[1].x)
|
||||
for exit_position: Vector3 in rv_exits:
|
||||
host_avatar.global_position = (
|
||||
exit_position
|
||||
+ home_world.get_interior_exit_direction_near(
|
||||
host_fingerprint, exit_position
|
||||
) * 0.05
|
||||
)
|
||||
assert(home_world.has_avatar_crossed_interior_exit_threshold(
|
||||
host_avatar, host_fingerprint
|
||||
))
|
||||
host_avatar.global_position = rv_exits[0] + Vector3(0.0, 0.0, 1.1)
|
||||
assert(not home_world.has_avatar_crossed_interior_exit_threshold(
|
||||
host_avatar, host_fingerprint
|
||||
))
|
||||
var presentations: Dictionary = home_world.get(
|
||||
"_presentations_by_fingerprint"
|
||||
)
|
||||
var host_presentation: Dictionary = presentations.get(host_fingerprint, {})
|
||||
var host_interior := host_presentation.get("interior") as Node3D
|
||||
assert(host_interior != null)
|
||||
var wall_collision := host_interior.find_child(
|
||||
"WallWestCollision", true, false
|
||||
) as StaticBody3D
|
||||
assert(wall_collision != null)
|
||||
assert(bool(wall_collision.get_meta(
|
||||
HomeWorldService.INTERIOR_SHELL_COLLISION_META, false
|
||||
)))
|
||||
var wall_shape := wall_collision.get_child(0) as CollisionShape3D
|
||||
assert(wall_shape != null)
|
||||
assert((wall_shape.shape as ConcavePolygonShape3D).backface_collision)
|
||||
host_avatar.global_position = host_spawn.origin
|
||||
assert(network_home.request_enter(host_fingerprint))
|
||||
await physics_frame
|
||||
|
||||
var remote_peer_id: int = await _wait_for_remote_peer(session)
|
||||
assert(remote_peer_id > 1)
|
||||
|
|
@ -66,6 +121,53 @@ func _run_host() -> void:
|
|||
var remote_fingerprint: String = remote_record.identity_fingerprint
|
||||
assert(await _wait_for_home(network_home, remote_fingerprint))
|
||||
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
|
||||
assert(not remote_avatar.is_remote_presentation_visible())
|
||||
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
|
||||
chat_ui.call(
|
||||
"_show_speech_bubble", remote_peer_id, "outside", remote_fingerprint
|
||||
)
|
||||
var speech_states: Dictionary = chat_ui.get("_speech")
|
||||
assert(not speech_states.has(remote_peer_id))
|
||||
var toolbar := main.get_node("%GameUI").get_node(
|
||||
"%HomeDecorToolbar"
|
||||
) as HomeDecorToolbar
|
||||
assert(toolbar.visible)
|
||||
var exit_button := toolbar.find_child(
|
||||
"ExitRVButton", true, false
|
||||
) as Button
|
||||
assert(exit_button != null and exit_button.text == "Exit RV")
|
||||
var storage := main.get_node("%GameUI").get_node(
|
||||
"%PlayerStorage"
|
||||
) as PlayerStorage
|
||||
assert(storage.open_storage_from_home(true))
|
||||
var close_storage_event := InputEventAction.new()
|
||||
close_storage_event.action = &"interact"
|
||||
close_storage_event.pressed = true
|
||||
storage._unhandled_input(close_storage_event)
|
||||
assert(not storage.visible)
|
||||
exit_button.pressed.emit()
|
||||
var exit_deadline: int = Time.get_ticks_msec() + 5000
|
||||
while (
|
||||
Time.get_ticks_msec() < exit_deadline
|
||||
and not network_home.get_local_home_owner_fingerprint().is_empty()
|
||||
):
|
||||
await process_frame
|
||||
assert(network_home.get_local_home_owner_fingerprint().is_empty())
|
||||
var visibility_deadline: int = Time.get_ticks_msec() + 1000
|
||||
while (
|
||||
Time.get_ticks_msec() < visibility_deadline
|
||||
and is_instance_valid(remote_avatar)
|
||||
and not remote_avatar.is_remote_presentation_visible()
|
||||
):
|
||||
await process_frame
|
||||
assert(is_instance_valid(remote_avatar))
|
||||
assert(remote_avatar.is_remote_presentation_visible())
|
||||
chat_ui.call(
|
||||
"_show_speech_bubble", remote_peer_id, "inside", remote_fingerprint
|
||||
)
|
||||
speech_states = chat_ui.get("_speech")
|
||||
assert(speech_states.has(remote_peer_id))
|
||||
chat_ui.call("_on_peer_removed", remote_peer_id)
|
||||
var remote_spawn: Transform3D = home_world.get_exterior_spawn_transform(
|
||||
remote_fingerprint
|
||||
)
|
||||
|
|
@ -124,6 +226,10 @@ func _run_client() -> void:
|
|||
player.global_position, exterior_spawn.origin
|
||||
))
|
||||
assert(_faces_away_from_home(player, home_world, fingerprint))
|
||||
# Keep the peer present long enough for the host to rebuild the upgraded RV,
|
||||
# exercise late-spawn visibility, and use the toolbar transition back to the
|
||||
# shared world. A two-second grace period raced the host on slower machines.
|
||||
await create_timer(10.0).timeout
|
||||
print("Home spawn multiplayer client validation: PASS")
|
||||
await _cleanup(main, session)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,21 +105,7 @@ func _validate_main_profile(main: Node) -> void:
|
|||
assert(not bool(title_material.get_shader_parameter(
|
||||
"animation_enabled"
|
||||
)))
|
||||
for backdrop_name: StringName in [
|
||||
&"PlayerMenuBackdrop",
|
||||
&"ShopBackdrop",
|
||||
]:
|
||||
var backdrop := main.get_node("%%%s" % backdrop_name) as ColorRect
|
||||
var backdrop_material := backdrop.material as ShaderMaterial
|
||||
assert(backdrop_material != null)
|
||||
assert(not bool(backdrop_material.get_shader_parameter(
|
||||
"animation_enabled"
|
||||
)))
|
||||
assert(
|
||||
backdrop_material.get_shader_parameter(
|
||||
"scroll_velocity_pixels"
|
||||
) == Vector2.ZERO
|
||||
)
|
||||
_validate_no_full_screen_menu_backdrops(main)
|
||||
var game_ui := main.get_node("%GameUI") as GameUI
|
||||
var title_screen := game_ui.get_title_screen()
|
||||
assert(title_screen != null)
|
||||
|
|
@ -166,21 +152,7 @@ func _validate_normal_profile(main: Node) -> void:
|
|||
var title_material := title_background.material as ShaderMaterial
|
||||
assert(title_material != null)
|
||||
assert(bool(title_material.get_shader_parameter("animation_enabled")))
|
||||
for backdrop_name: StringName in [
|
||||
&"PlayerMenuBackdrop",
|
||||
&"ShopBackdrop",
|
||||
]:
|
||||
var backdrop := main.get_node("%%%s" % backdrop_name) as ColorRect
|
||||
var backdrop_material := backdrop.material as ShaderMaterial
|
||||
assert(backdrop_material != null)
|
||||
assert(bool(backdrop_material.get_shader_parameter(
|
||||
"animation_enabled"
|
||||
)))
|
||||
assert(
|
||||
backdrop_material.get_shader_parameter(
|
||||
"scroll_velocity_pixels"
|
||||
) != Vector2.ZERO
|
||||
)
|
||||
_validate_no_full_screen_menu_backdrops(main)
|
||||
var visuals: WorldTimeVisualController = main.get_node(
|
||||
"%WorldTimeVisualController"
|
||||
)
|
||||
|
|
@ -216,6 +188,11 @@ func _validate_new_game_music_transition(main: Node) -> void:
|
|||
assert(title_music.playing)
|
||||
|
||||
|
||||
func _validate_no_full_screen_menu_backdrops(main: Node) -> void:
|
||||
assert(main.get_node_or_null("%PlayerMenuBackdrop") == null)
|
||||
assert(main.get_node_or_null("%ShopBackdrop") == null)
|
||||
|
||||
|
||||
func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
|
||||
var fresh_water_root := main.get_node(
|
||||
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ func _run() -> void:
|
|||
var game_ui := main.get_node("%GameUI") as GameUI
|
||||
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
|
||||
var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage
|
||||
var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect
|
||||
var hotbar := game_ui.get_node("%Hotbar") as Control
|
||||
var fishing_spot := main.get("_fishing_spot") as FishingSpot
|
||||
assert(logbook.get("_fishing_spot") == fishing_spot)
|
||||
|
|
@ -49,7 +48,7 @@ func _run() -> void:
|
|||
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
|
||||
assert(player_menu.visible)
|
||||
assert(logbook.visible)
|
||||
assert(backdrop.visible)
|
||||
assert(main.get_node_or_null("%PlayerMenuBackdrop") == null)
|
||||
assert(not bool(game_ui.get("_player_menu_hotbar_visible")))
|
||||
assert(not hotbar.visible)
|
||||
|
||||
|
|
|
|||
|
|
@ -195,11 +195,56 @@ func _validate_compact_animation_encoding() -> void:
|
|||
func _validate_airborne_sitting(avatar: Player) -> void:
|
||||
avatar.reset_network_movement_state()
|
||||
avatar.set_local_control(true)
|
||||
avatar.set("_sit_after_landing", true)
|
||||
avatar.global_position = Vector3(0.0, 2.0, 0.0)
|
||||
avatar.velocity = Vector3(0.0, -1.0, 0.0)
|
||||
avatar.toggle_sitting()
|
||||
assert(avatar.get_network_sitting_intent())
|
||||
assert(not avatar.get_network_sitting_state())
|
||||
assert(bool(avatar.get("_sitting_intent_pending")))
|
||||
assert(not bool(avatar.make_network_snapshot(2)["sitting"]))
|
||||
assert(bool(avatar.capture_network_input(1)["sitting"]))
|
||||
|
||||
# Reproduce the high-RTT owner audit race: the host has reached the ground
|
||||
# and reports the deferred sit while this client's predicted capsule is still
|
||||
# airborne. Repeated seated snapshots must not zero its falling velocity.
|
||||
var delayed_sit_snapshot: Dictionary = _network_snapshot(
|
||||
avatar.global_position,
|
||||
Vector3.ZERO,
|
||||
1,
|
||||
)
|
||||
delayed_sit_snapshot["sitting"] = true
|
||||
avatar.apply_local_prediction_correction(
|
||||
delayed_sit_snapshot,
|
||||
1,
|
||||
1.0 / 30.0,
|
||||
0.3,
|
||||
0.1,
|
||||
)
|
||||
assert(not avatar.is_sitting())
|
||||
assert(bool(avatar.get("_sit_after_landing")))
|
||||
assert(avatar.velocity.y < 0.0)
|
||||
var airborne_height: float = avatar.global_position.y
|
||||
avatar.call("_simulate_movement_physics", 0.1)
|
||||
assert(avatar.global_position.y < airborne_height)
|
||||
assert(avatar.velocity.y < -1.0)
|
||||
for _delayed_audit: int in 3:
|
||||
delayed_sit_snapshot["position"] = [
|
||||
avatar.global_position.x,
|
||||
avatar.global_position.y,
|
||||
avatar.global_position.z,
|
||||
]
|
||||
var falling_velocity: float = avatar.velocity.y
|
||||
avatar.apply_local_prediction_correction(
|
||||
delayed_sit_snapshot,
|
||||
1,
|
||||
1.0 / 30.0,
|
||||
0.3,
|
||||
0.1,
|
||||
)
|
||||
assert(not avatar.is_sitting())
|
||||
assert(is_equal_approx(avatar.velocity.y, falling_velocity))
|
||||
avatar.call("_simulate_movement_physics", 0.1)
|
||||
assert(avatar.global_position.y < airborne_height)
|
||||
avatar.call("_queue_local_network_jump_intent")
|
||||
var jumping_input: Dictionary = avatar.capture_network_input(2)
|
||||
assert(bool(jumping_input["jump"]))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ func _initialize() -> void:
|
|||
func _validate_protocol() -> void:
|
||||
_validate_type_and_empty_handling()
|
||||
_validate_message_cleanup()
|
||||
_validate_roleplay_commands()
|
||||
_validate_protocol_limits()
|
||||
print("Network chat protocol validation: PASS")
|
||||
quit()
|
||||
|
|
@ -34,6 +35,28 @@ func _validate_message_cleanup() -> void:
|
|||
)
|
||||
|
||||
|
||||
func _validate_roleplay_commands() -> void:
|
||||
assert(NetworkChatProtocol.is_roleplay_body("/me waves"))
|
||||
assert(NetworkChatProtocol.is_roleplay_body("/ME waves"))
|
||||
assert(NetworkChatProtocol.is_roleplay_body("/me"))
|
||||
assert(not NetworkChatProtocol.is_roleplay_body("/meow"))
|
||||
assert(NetworkChatProtocol.roleplay_action("/me waves") == "waves")
|
||||
assert(NetworkChatProtocol.roleplay_action(
|
||||
"/me [color=red]waves[/color]"
|
||||
) == "[color=red]waves[/color]")
|
||||
assert(NetworkChatProtocol.valid_user_body("/me waves"))
|
||||
assert(not NetworkChatProtocol.valid_user_body("/me"))
|
||||
assert(not NetworkChatProtocol.valid_user_body("/me "))
|
||||
assert(NetworkChatProtocol.valid_user_body("/meow"))
|
||||
var signed_fields: Array = NetworkChatProtocol.signature_fields({
|
||||
"session_id": "session",
|
||||
"request_id": "request",
|
||||
"sender_fingerprint": "fingerprint",
|
||||
"body": "/me waves",
|
||||
})
|
||||
assert(signed_fields.back() == "/me waves")
|
||||
|
||||
|
||||
func _validate_protocol_limits() -> void:
|
||||
var maximum_body: String = "a".repeat(
|
||||
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ func _run_host() -> void:
|
|||
var profile := main.get_node(
|
||||
"%NetworkProfilePreferences"
|
||||
) as NetworkProfilePreferences
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
assert(save_manager.initialize_new_game())
|
||||
assert(profile.set_profile_identity(
|
||||
profile.display_name,
|
||||
"deep",
|
||||
|
|
@ -55,7 +57,6 @@ func _run_host() -> void:
|
|||
profile.call_id,
|
||||
"kim",
|
||||
))
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
assert(bool(main.call(
|
||||
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
|
||||
)))
|
||||
|
|
@ -67,7 +68,6 @@ func _run_host() -> void:
|
|||
assert(local_avatar != null)
|
||||
assert(local_avatar.get_animalese_voice_id() == "deep")
|
||||
assert(local_avatar.get_animalese_sample_set_id() == "kim")
|
||||
assert(save_manager.initialize_new_game())
|
||||
main.call("_enter_gameplay")
|
||||
for _frame: int in 4:
|
||||
await physics_frame
|
||||
|
|
@ -99,6 +99,7 @@ func _run_host() -> void:
|
|||
await _wait_for_remote_appearance(session, remote_peer_id, changed)
|
||||
await _wait_for_remote_appearance(session, remote_peer_id, original)
|
||||
await _wait_for_remote_appearance(session, remote_peer_id, changed)
|
||||
await _wait_for_remote_title(session, remote_peer_id, "trail guide")
|
||||
var avatar := main.get_node("%PlayerSpawnService").get_avatar(
|
||||
remote_peer_id
|
||||
) as Player
|
||||
|
|
@ -187,6 +188,7 @@ func _run_client() -> void:
|
|||
service.get_persisted_speech_speed_id(),
|
||||
service.get_persisted_call_id(),
|
||||
"kim",
|
||||
"trail guide",
|
||||
))
|
||||
var apply_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while Time.get_ticks_msec() < apply_deadline and apply_results.is_empty():
|
||||
|
|
@ -196,6 +198,11 @@ func _run_client() -> void:
|
|||
assert(service.get_persisted_appearance() == changed)
|
||||
assert(service.get_persisted_voice_id() == "deep")
|
||||
assert(service.get_persisted_sample_set_id() == "kim")
|
||||
assert(service.get_persisted_title() == "trail guide")
|
||||
assert(
|
||||
session.get_peer_record(session.get_local_peer_id()).title
|
||||
== "trail guide"
|
||||
)
|
||||
assert(Dictionary(session.get("_local_appearance_snapshot")) == changed)
|
||||
var player := main.get_node("%PlayerSpawnService").get_local_player() as Player
|
||||
assert(player.get_animalese_voice_id() == "deep")
|
||||
|
|
@ -278,6 +285,20 @@ func _wait_for_remote_appearance(
|
|||
assert(false, "Timed out waiting for a remote appearance update.")
|
||||
|
||||
|
||||
func _wait_for_remote_title(
|
||||
session: NetworkSession,
|
||||
peer_id: int,
|
||||
expected: String,
|
||||
) -> void:
|
||||
var deadline: int = Time.get_ticks_msec() + 8000
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
var record := session.get_peer_record(peer_id)
|
||||
if record != null and record.title == expected:
|
||||
return
|
||||
assert(false, "Timed out waiting for a remote player title update.")
|
||||
|
||||
|
||||
func _create_initialized_main() -> Node:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
|
|
|
|||
151
tests/profile_title_validation.gd
Normal file
151
tests/profile_title_validation.gd
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
extends SceneTree
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_validate_profile_defaults_and_legacy_data()
|
||||
_validate_network_payloads()
|
||||
_validate_peer_registry_title()
|
||||
await _validate_profile_layout()
|
||||
print("Profile title validation: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _validate_profile_defaults_and_legacy_data() -> void:
|
||||
var preferences := NetworkProfilePreferences.new()
|
||||
assert(preferences.display_name == "stray")
|
||||
assert(preferences.title == "newbie")
|
||||
assert(NetworkProfilePreferences.is_valid_title("trail guide"))
|
||||
assert(not NetworkProfilePreferences.is_valid_title(""))
|
||||
assert(not NetworkProfilePreferences.is_valid_title("x".repeat(
|
||||
NetworkProtocol.MAX_PLAYER_TITLE_LENGTH + 1
|
||||
)))
|
||||
preferences.profile_id = "profile-id"
|
||||
preferences.created_at_unix = 1
|
||||
var save_data := preferences.to_save_data()
|
||||
assert(str(save_data.get("title", "")) == "newbie")
|
||||
assert(NetworkProfilePreferences.validate_save_data(save_data))
|
||||
# Title was added without invalidating existing per-save character records.
|
||||
var legacy_save := save_data.duplicate(true)
|
||||
legacy_save.erase("title")
|
||||
assert(NetworkProfilePreferences.validate_save_data(legacy_save))
|
||||
legacy_save["title"] = 7
|
||||
assert(not NetworkProfilePreferences.validate_save_data(legacy_save))
|
||||
preferences.free()
|
||||
|
||||
|
||||
func _validate_network_payloads() -> void:
|
||||
var appearance := CharacterCustomizationCatalog.default_snapshot()
|
||||
var fingerprint := "a".repeat(64)
|
||||
var hello := NetworkProtocol.make_client_hello(
|
||||
"profile-id",
|
||||
"stray",
|
||||
"client-nonce",
|
||||
appearance,
|
||||
fingerprint,
|
||||
PackedByteArray(),
|
||||
"trail guide",
|
||||
)
|
||||
assert(NetworkProtocol.validate_client_hello(hello).is_empty())
|
||||
assert(str(hello.get("player_title", "")) == "trail guide")
|
||||
var invalid_hello := hello.duplicate(true)
|
||||
invalid_hello["player_title"] = ""
|
||||
assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty())
|
||||
|
||||
var check_request := {
|
||||
"request_id": "check",
|
||||
"session_id": "session",
|
||||
"display_name": "stray",
|
||||
}
|
||||
assert(NetworkProfileProtocol.valid_check_request(check_request))
|
||||
var apply_request := check_request.duplicate(true)
|
||||
apply_request.merge({
|
||||
"player_title": "trail guide",
|
||||
"appearance": appearance,
|
||||
"use_anyway": false,
|
||||
"sender_fingerprint": fingerprint,
|
||||
"sender_signature": PackedByteArray(),
|
||||
})
|
||||
assert(NetworkProfileProtocol.valid_apply_request(apply_request))
|
||||
var signed_fields := NetworkProfileProtocol.signature_fields(apply_request)
|
||||
assert(signed_fields.has("trail guide"))
|
||||
apply_request.erase("player_title")
|
||||
assert(not NetworkProfileProtocol.valid_apply_request(apply_request))
|
||||
|
||||
|
||||
func _validate_peer_registry_title() -> void:
|
||||
var registry := PeerRegistry.new()
|
||||
assert(registry.add_peer(
|
||||
2,
|
||||
"profile-id",
|
||||
"stray",
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
"",
|
||||
"",
|
||||
PackedStringArray(),
|
||||
"trail guide",
|
||||
))
|
||||
assert(registry.get_peer(2).title == "trail guide")
|
||||
assert(registry.update_title(2, "river scout"))
|
||||
assert(registry.get_peer(2).title == "river scout")
|
||||
assert(not registry.update_title(2, ""))
|
||||
assert(not registry.add_peer(
|
||||
3,
|
||||
"invalid-profile",
|
||||
"stray",
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
"",
|
||||
"",
|
||||
PackedStringArray(),
|
||||
"",
|
||||
))
|
||||
|
||||
|
||||
func _validate_profile_layout() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var page := ProfilePage.new()
|
||||
page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
await process_frame
|
||||
var name_label := page.get("_name_label") as Label
|
||||
var name_edit := page.get("_name_edit") as LineEdit
|
||||
var title_edit := page.get("_title_edit") as LineEdit
|
||||
var identity := page.find_child("IdentityFingerprint", true, false) as Label
|
||||
var identity_stack := page.find_child(
|
||||
"ProfileIdentityStack", true, false
|
||||
) as VBoxContainer
|
||||
var experience_row := page.find_child(
|
||||
"ProfileExperienceRow", true, false
|
||||
) as HBoxContainer
|
||||
assert(name_label != null and name_edit != null and title_edit != null)
|
||||
assert(name_label.text == "name")
|
||||
assert(identity != null and identity_stack != null and experience_row != null)
|
||||
assert(name_edit.placeholder_text == "stray")
|
||||
assert(title_edit.placeholder_text == "newbie")
|
||||
assert(title_edit.global_position.y > name_edit.global_position.y)
|
||||
assert(is_equal_approx(title_edit.position.x, name_edit.position.x))
|
||||
assert(identity.get_parent() == identity_stack)
|
||||
assert(experience_row.get_parent() == identity_stack)
|
||||
assert(identity.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
assert(identity_stack.global_position.x > name_edit.global_position.x)
|
||||
assert(name_label.tooltip_text.contains(
|
||||
"Shown to other players in multiplayer."
|
||||
))
|
||||
page.call("_set_name_status", "Name is available in this game.")
|
||||
assert(name_label.tooltip_text.contains(
|
||||
"Name is available in this game."
|
||||
))
|
||||
for visible_label: Label in page.find_children("*", "Label", true, false):
|
||||
if visible_label.visible:
|
||||
assert(visible_label.text != "Shown to other players in multiplayer.")
|
||||
assert(visible_label.text != "Name is available in this game.")
|
||||
var account_controls: Array = page.call("_account_controller_controls")
|
||||
assert(name_edit in account_controls)
|
||||
assert(title_edit in account_controls)
|
||||
page.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
1
tests/profile_title_validation.gd.uid
Normal file
1
tests/profile_title_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cat806v782sr7
|
||||
|
|
@ -23,14 +23,35 @@ func _run() -> void:
|
|||
var player := main.get("_player") as Player
|
||||
var data_root := main.get("_data_root") as PlayerDataRoot
|
||||
var home_state := main.get("_home_state") as PlayerHomeState
|
||||
var network_profile := main.get_node(
|
||||
"%NetworkProfilePreferences"
|
||||
) as NetworkProfilePreferences
|
||||
var appearance_store := main.get_node(
|
||||
"%PlayerAppearanceStore"
|
||||
) as PlayerAppearanceStore
|
||||
assert(
|
||||
save_manager != null
|
||||
and player != null
|
||||
and data_root != null
|
||||
and home_state != null
|
||||
and network_profile != null
|
||||
and appearance_store != null
|
||||
)
|
||||
assert(save_manager.initialize_new_game(864209))
|
||||
save_manager.set_autosave_enabled(true)
|
||||
var first_profile_id: String = network_profile.profile_id
|
||||
var first_appearance: Dictionary = appearance_store.get_snapshot()
|
||||
first_appearance["scale"] = 0.8
|
||||
assert(CharacterCustomizationCatalog.validate_snapshot(first_appearance))
|
||||
assert(network_profile.set_profile_identity(
|
||||
"first save character",
|
||||
"deep",
|
||||
network_profile.speech_speed_id,
|
||||
network_profile.call_id,
|
||||
network_profile.sample_set_id,
|
||||
"trailblazer",
|
||||
))
|
||||
assert(appearance_store.save_snapshot(first_appearance))
|
||||
assert(player.wallet.restore_balance(4321))
|
||||
assert(player.bag.add_item(&"coffee"))
|
||||
assert(home_state.add_owned_decor(&"basic_cube"))
|
||||
|
|
@ -52,6 +73,39 @@ func _run() -> void:
|
|||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
assert(int((decoded["data"] as Dictionary)["save_version"]) == 11)
|
||||
var saved_character: Dictionary = (
|
||||
(decoded["data"] as Dictionary).get("character", {})
|
||||
)
|
||||
assert(NetworkProfilePreferences.validate_save_data(
|
||||
(saved_character.get("profile", {}) as Dictionary)
|
||||
))
|
||||
assert(str((saved_character["profile"] as Dictionary)["profile_id"])
|
||||
== first_profile_id)
|
||||
assert(str((saved_character["profile"] as Dictionary)["display_name"])
|
||||
== "first save character")
|
||||
assert(str((saved_character["profile"] as Dictionary)["title"])
|
||||
== "trailblazer")
|
||||
assert(
|
||||
(saved_character["appearance"] as Dictionary)
|
||||
== first_appearance
|
||||
)
|
||||
# Starting another save must feel like a new character, then loading this
|
||||
# archive must restore only this slot's name, voice, and appearance.
|
||||
assert(save_manager.initialize_new_game(97531))
|
||||
assert(network_profile.profile_id != first_profile_id)
|
||||
assert(network_profile.display_name == "stray")
|
||||
assert(network_profile.title == "newbie")
|
||||
assert(network_profile.voice_id != "deep")
|
||||
assert(
|
||||
appearance_store.get_snapshot()
|
||||
== CharacterCustomizationCatalog.default_snapshot()
|
||||
)
|
||||
assert(save_manager.load_player_data())
|
||||
assert(network_profile.profile_id == first_profile_id)
|
||||
assert(network_profile.display_name == "first save character")
|
||||
assert(network_profile.title == "trailblazer")
|
||||
assert(network_profile.voice_id == "deep")
|
||||
assert(appearance_store.get_snapshot() == first_appearance)
|
||||
assert(not PlayerHomeState.sanitize_save_data(
|
||||
(decoded["data"] as Dictionary).get("home", {})
|
||||
).is_empty())
|
||||
|
|
@ -122,6 +176,8 @@ func _run() -> void:
|
|||
var adopted_slot_id: String = str(adopted_slots.front().get("slot_id", ""))
|
||||
assert(bool(adopted_slots.front().get("legacy", false)))
|
||||
assert(bool(adopted_slots.front().get("has_save", false)))
|
||||
assert(save_slots.get_online_slot_id() == adopted_slot_id)
|
||||
assert(bool(adopted_slots.front().get("online", false)))
|
||||
|
||||
var duplicated: Dictionary = save_slots.duplicate_slot(
|
||||
adopted_slot_id,
|
||||
|
|
@ -135,6 +191,9 @@ func _run() -> void:
|
|||
assert(int(duplicate_summary.get("wallet_balance", -1)) == 4321)
|
||||
assert(int(duplicate_summary.get("world_seed", -1)) == 97531)
|
||||
assert(save_slots.activate_slot(duplicated_slot_id))
|
||||
assert(save_slots.mark_slot_for_online(duplicated_slot_id))
|
||||
assert(save_slots.get_online_slot_id() == duplicated_slot_id)
|
||||
assert(bool(save_slots.get_slot(duplicated_slot_id).get("online", false)))
|
||||
assert(save_manager.load_player_data())
|
||||
assert(player.wallet.get_balance() == 4321)
|
||||
assert(save_manager.get_world_seed() == 97531)
|
||||
|
|
@ -157,6 +216,7 @@ func _run() -> void:
|
|||
assert(save_slots.delete_slot(duplicated_slot_id))
|
||||
assert(save_slots.get_slot(duplicated_slot_id).is_empty())
|
||||
assert(save_slots.list_slots().size() == 2)
|
||||
assert(save_slots.get_online_slot_id() == save_slots.get_active_slot_id())
|
||||
|
||||
var settings_panels: Array[Node] = main.find_children(
|
||||
"*", "SettingsPanel", true, false
|
||||
|
|
|
|||
166
tests/save_reload_conflict_validation.gd
Normal file
166
tests/save_reload_conflict_validation.gd
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
const WAIT_MILLISECONDS: int = 20_000
|
||||
|
||||
var _conflicts: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
|
||||
var data_root := main.get("_data_root") as PlayerDataRoot
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog
|
||||
var player := main.get("_player") as Player
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var network_profile := main.get_node(
|
||||
"%NetworkProfilePreferences"
|
||||
) as NetworkProfilePreferences
|
||||
var appearance_store := main.get_node(
|
||||
"%PlayerAppearanceStore"
|
||||
) as PlayerAppearanceStore
|
||||
assert(
|
||||
data_root != null
|
||||
and save_manager != null
|
||||
and save_slots != null
|
||||
and player != null
|
||||
and session != null
|
||||
and network_profile != null
|
||||
and appearance_store != null
|
||||
)
|
||||
var conflict_dialog_callback := Callable(main, "_on_portable_conflict")
|
||||
if data_root.conflict_detected.is_connected(conflict_dialog_callback):
|
||||
data_root.conflict_detected.disconnect(conflict_dialog_callback)
|
||||
data_root.conflict_detected.connect(_on_conflict_detected)
|
||||
if OS.get_environment(
|
||||
PlayerDataRoot.ISOLATED_VALIDATION_ENVIRONMENT
|
||||
) == "1":
|
||||
assert(
|
||||
PlayerDataRoot.ISOLATED_VALIDATION_DIRECTORY
|
||||
in data_root.root_path
|
||||
)
|
||||
|
||||
main.call(
|
||||
"_on_new_slot_requested",
|
||||
"reload regression",
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
424242,
|
||||
"",
|
||||
)
|
||||
assert(await _wait_for_gameplay(main, true))
|
||||
var slot_id: String = save_slots.get_active_slot_id()
|
||||
assert(not slot_id.is_empty())
|
||||
assert(player.wallet.restore_balance(321))
|
||||
assert(network_profile.set_profile_identity(
|
||||
"reload stray",
|
||||
"deep",
|
||||
network_profile.speech_speed_id,
|
||||
network_profile.call_id,
|
||||
network_profile.sample_set_id,
|
||||
"returner",
|
||||
))
|
||||
var custom_appearance: Dictionary = appearance_store.get_snapshot()
|
||||
custom_appearance["scale"] = 0.8
|
||||
assert(appearance_store.save_snapshot(custom_appearance))
|
||||
main.call("_on_return_to_title_requested")
|
||||
assert(await _wait_for_gameplay(main, false))
|
||||
assert(not save_manager.is_dirty())
|
||||
|
||||
# Establish the exact reported lifecycle once: load a local slot, return to
|
||||
# title, then immediately select that same slot again in this process.
|
||||
main.call("_on_slot_play_requested", slot_id)
|
||||
assert(await _wait_for_gameplay(main, true))
|
||||
main.call("_on_return_to_title_requested")
|
||||
assert(await _wait_for_gameplay(main, false))
|
||||
assert(not save_manager.is_dirty())
|
||||
|
||||
# A selected save owns its character state. An out-of-band edit to the
|
||||
# compatibility profile mirror must not make that valid save unloadable or
|
||||
# be overwritten merely by loading it. A later explicit profile edit must
|
||||
# still detect the external mutation.
|
||||
var profile_path: String = data_root.path_for(&"network_profile")
|
||||
var external_profile_bytes := PortableFileGuard.read_bytes(profile_path)
|
||||
assert(not external_profile_bytes.is_empty())
|
||||
external_profile_bytes.append(0x0a)
|
||||
assert(_write_external_bytes(profile_path, external_profile_bytes))
|
||||
_conflicts.clear()
|
||||
main.call("_on_slot_play_requested", slot_id)
|
||||
assert(await _wait_for_gameplay(main, true))
|
||||
assert(
|
||||
_conflicts.is_empty(),
|
||||
"same-process same-slot reload reported false conflicts: %s"
|
||||
% [_conflicts],
|
||||
)
|
||||
assert(player.wallet.get_balance() == 321)
|
||||
assert(network_profile.display_name == "reload stray")
|
||||
assert(network_profile.title == "returner")
|
||||
assert(appearance_store.get_snapshot() == custom_appearance)
|
||||
assert(
|
||||
PortableFileGuard.read_bytes(profile_path)
|
||||
== external_profile_bytes
|
||||
)
|
||||
_conflicts.clear()
|
||||
assert(not network_profile.set_display_name("explicit overwrite"))
|
||||
assert(_conflicts.size() == 1)
|
||||
assert(FileAccess.file_exists(str(_conflicts.front().get("path", ""))))
|
||||
|
||||
# A real out-of-band edit must still be caught. Mutating the canonical bytes
|
||||
# after load simulates a Syncthing/device replacement while this process is
|
||||
# holding an older expected hash.
|
||||
var save_path: String = str(save_manager.get("_save_path"))
|
||||
var external_bytes := PortableFileGuard.read_bytes(save_path)
|
||||
assert(not external_bytes.is_empty())
|
||||
external_bytes.append(0x7f)
|
||||
assert(_write_external_bytes(save_path, external_bytes))
|
||||
assert(player.wallet.restore_balance(654))
|
||||
_conflicts.clear()
|
||||
assert(not save_manager.save_now())
|
||||
assert(_conflicts.size() == 1)
|
||||
assert(FileAccess.file_exists(str(_conflicts.front().get("path", ""))))
|
||||
|
||||
save_manager.set_autosave_enabled(false)
|
||||
save_manager.cancel_pending_autosave()
|
||||
session.disconnect_session("Save reload conflict validation complete.")
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
print("Save reload conflict validation: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _wait_for_gameplay(main: Node, expected: bool) -> bool:
|
||||
var deadline: int = Time.get_ticks_msec() + WAIT_MILLISECONDS
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
if bool(main.get("_gameplay_started")) == expected:
|
||||
return true
|
||||
await process_frame
|
||||
return false
|
||||
|
||||
|
||||
func _on_conflict_detected(message: String, path: String) -> void:
|
||||
_conflicts.append({"message": message, "path": path})
|
||||
|
||||
|
||||
func _write_external_bytes(path: String, bytes: PackedByteArray) -> bool:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_buffer(bytes)
|
||||
file.flush()
|
||||
var succeeded: bool = file.get_error() == OK
|
||||
file.close()
|
||||
return succeeded
|
||||
1
tests/save_reload_conflict_validation.gd.uid
Normal file
1
tests/save_reload_conflict_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cmd4elmipmywa
|
||||
|
|
@ -123,6 +123,32 @@ func _run_client() -> void:
|
|||
)
|
||||
await _wait_for_join(main, session, SECOND_HOST_PORT)
|
||||
assert(bool(main.get("_gameplay_started")))
|
||||
var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
var local_slot_id: String = save_slots.get_active_slot_id()
|
||||
assert(not local_slot_id.is_empty())
|
||||
main.call("_on_return_to_title_requested")
|
||||
var title_deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000
|
||||
while (
|
||||
Time.get_ticks_msec() < title_deadline
|
||||
and session.state != NetworkSession.State.INACTIVE
|
||||
):
|
||||
await process_frame
|
||||
assert(session.state == NetworkSession.State.INACTIVE)
|
||||
assert(not bool(main.get("_gameplay_started")))
|
||||
assert(not save_manager.is_dirty())
|
||||
main.call("_on_slot_play_requested", local_slot_id)
|
||||
var local_deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000
|
||||
while (
|
||||
Time.get_ticks_msec() < local_deadline
|
||||
and (
|
||||
not session.is_host()
|
||||
or not bool(main.get("_gameplay_started"))
|
||||
)
|
||||
):
|
||||
await process_frame
|
||||
assert(session.is_host())
|
||||
assert(bool(main.get("_gameplay_started")))
|
||||
print("Session switch multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
|
|
|
|||
43
tests/tooltip_escape_validation.gd
Normal file
43
tests/tooltip_escape_validation.gd
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
|
||||
var game_ui: GameUI = main.get("_game_ui") as GameUI
|
||||
var player_menu: PlayerMenu = game_ui.get_node("%PlayerMenu") as PlayerMenu
|
||||
assert(game_ui != null and player_menu != null)
|
||||
game_ui.get_title_screen().hide()
|
||||
main.set("_gameplay_started", true)
|
||||
player_menu.show()
|
||||
player_menu.set("_transitioning", false)
|
||||
|
||||
# Input.action_press updates global action state without delivering an
|
||||
# InputEvent to Main._input. That reproduces the path used when a native
|
||||
# tooltip window consumes Escape before the gameplay viewport receives it.
|
||||
Input.action_press("ui_cancel")
|
||||
await process_frame
|
||||
Input.action_release("ui_cancel")
|
||||
for _frame: int in 40:
|
||||
await process_frame
|
||||
assert(not player_menu.visible)
|
||||
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
print("Tooltip Escape validation: PASS")
|
||||
quit(0)
|
||||
1
tests/tooltip_escape_validation.gd.uid
Normal file
1
tests/tooltip_escape_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cwc8lnci5ccph
|
||||
|
|
@ -39,6 +39,15 @@ func _run() -> void:
|
|||
var canonical_stage := game_ui.get_node(
|
||||
"UIRoot/CanonicalStage"
|
||||
) as Control
|
||||
var gameplay_screen_effects := game_ui.get_node(
|
||||
"UIRoot/GameplayScreenEffects"
|
||||
) as Control
|
||||
var fishing_danger_vignette := game_ui.get_node(
|
||||
"UIRoot/GameplayScreenEffects/FishingDangerVignette"
|
||||
) as ColorRect
|
||||
var gameplay_transient_hud := game_ui.get_node(
|
||||
"UIRoot/CanonicalStage/GameplayTransientHUD"
|
||||
) as Control
|
||||
var chat_ui := game_ui.get_node("UIRoot/ChatUI") as ChatUI
|
||||
var player_menu := game_ui.get_node(
|
||||
"UIRoot/CanonicalStage/PlayerMenu"
|
||||
|
|
@ -49,9 +58,15 @@ func _run() -> void:
|
|||
var hotbar := game_ui.get_node(
|
||||
"UIRoot/CanonicalStage/Hotbar"
|
||||
) as HotbarUI
|
||||
var home_decor_toolbar := game_ui.get_node(
|
||||
"UIRoot/CanonicalStage/HomeDecorToolbar"
|
||||
) as HomeDecorToolbar
|
||||
assert(game_ui.get_node_or_null(
|
||||
"UIRoot/CanonicalStage/GameplayTransientHUD/EffectStatus"
|
||||
) == null)
|
||||
assert(game_ui.get_node_or_null(
|
||||
"UIRoot/CanonicalStage/GameplayTransientHUD/FishingDangerVignette"
|
||||
) == null)
|
||||
var screen_fade := game_ui.get_node("UIRoot/ScreenFade") as Control
|
||||
var title_screen := game_ui.get_node("UIRoot/TitleScreen") as TitleScreen
|
||||
var title_content_stage := title_screen.get_node(
|
||||
|
|
@ -71,15 +86,63 @@ func _run() -> void:
|
|||
root.add_child(presenter)
|
||||
await process_frame
|
||||
chat_ui.set_available(true)
|
||||
var nameplate_layer := chat_ui.get("_nameplate_layer") as Control
|
||||
var speech_layer := chat_ui.get("_speech_layer") as Control
|
||||
assert(nameplate_layer != null and nameplate_layer.visible)
|
||||
assert(speech_layer != null and speech_layer.visible)
|
||||
home_decor_toolbar.set_active(true)
|
||||
assert(home_decor_toolbar.visible)
|
||||
game_ui.call("_on_player_menu_visibility_changed", true)
|
||||
assert(not chat_ui.is_world_speech_visible())
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(not speech_layer.visible)
|
||||
assert(not home_decor_toolbar.visible)
|
||||
game_ui.call("_on_player_menu_visibility_changed", false)
|
||||
assert(chat_ui.is_world_speech_visible())
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(speech_layer.visible)
|
||||
chat_ui.set_available(true)
|
||||
assert(nameplate_layer.visible)
|
||||
chat_ui.set_available(false)
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(speech_layer.visible)
|
||||
chat_ui.set_available(true)
|
||||
assert(nameplate_layer.visible)
|
||||
chat_ui.set_hud_hidden(true)
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(speech_layer.visible)
|
||||
chat_ui.set_hud_hidden(false)
|
||||
assert(nameplate_layer.visible)
|
||||
chat_ui.set_world_nameplates_hud_hidden(true)
|
||||
assert(not chat_ui.is_hud_hidden())
|
||||
assert(not nameplate_layer.visible)
|
||||
assert(speech_layer.visible)
|
||||
chat_ui.set_world_nameplates_hud_hidden(false)
|
||||
assert(nameplate_layer.visible)
|
||||
assert(home_decor_toolbar.visible)
|
||||
game_ui.set("_storage_open", true)
|
||||
game_ui.call("_emit_interactive_pointer_ui_changed")
|
||||
assert(not home_decor_toolbar.visible)
|
||||
game_ui.call("_on_player_menu_visibility_changed", true)
|
||||
game_ui.set("_storage_open", false)
|
||||
game_ui.call("_emit_interactive_pointer_ui_changed")
|
||||
assert(not home_decor_toolbar.visible)
|
||||
game_ui.call("_on_player_menu_visibility_changed", false)
|
||||
assert(home_decor_toolbar.visible)
|
||||
home_decor_toolbar.set_active(false)
|
||||
game_ui.set("_gameplay_ui_enabled", true)
|
||||
game_ui.set("_gameplay_hud_hidden", false)
|
||||
game_ui.call("_refresh_gameplay_hud_visibility")
|
||||
assert(gameplay_screen_effects.visible)
|
||||
assert(gameplay_transient_hud.visible)
|
||||
game_ui.set("_system_menu_open", true)
|
||||
game_ui.call("_refresh_gameplay_hud_visibility")
|
||||
assert(not gameplay_screen_effects.visible)
|
||||
assert(not gameplay_transient_hud.visible)
|
||||
game_ui.set("_system_menu_open", false)
|
||||
game_ui.set("_gameplay_ui_enabled", false)
|
||||
game_ui.call("_refresh_gameplay_hud_visibility")
|
||||
chat_ui.set_available(true)
|
||||
chat_ui.call(
|
||||
"_set_presentation_state",
|
||||
ChatUI.PresentationState.EXPANDED,
|
||||
|
|
@ -121,7 +184,16 @@ func _run() -> void:
|
|||
assert(canonical_stage.position.is_equal_approx(
|
||||
UIReferencePresentationType.get_stage_position(display_size)
|
||||
))
|
||||
assert(gameplay_screen_effects.position.is_equal_approx(Vector2.ZERO))
|
||||
assert(gameplay_screen_effects.size.is_equal_approx(
|
||||
expected_visible_size
|
||||
))
|
||||
assert(fishing_danger_vignette.position.is_equal_approx(Vector2.ZERO))
|
||||
assert(fishing_danger_vignette.size.is_equal_approx(
|
||||
expected_visible_size
|
||||
))
|
||||
assert(chat_ui.size.is_equal_approx(expected_visible_size))
|
||||
assert(nameplate_layer.size.is_equal_approx(expected_visible_size))
|
||||
assert(screen_fade.size.is_equal_approx(expected_visible_size))
|
||||
assert(player_menu.position.is_equal_approx(Vector2.ZERO))
|
||||
assert(player_menu.size.is_equal_approx(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ const GatheringControllerType = preload(
|
|||
)
|
||||
const WorldGatherableType = preload("res://gathering/world_gatherable.gd")
|
||||
|
||||
const CRAB_PIXEL_SIZE: float = 0.0005
|
||||
const CRAB_PIXEL_SIZE: float = 0.01
|
||||
const CRAB_SURFACE_OFFSET: float = 0.11
|
||||
const CRAB_SPRITE_EXTENT: float = 0.64
|
||||
const REDUCED_CATCH_RING_RADIUS: float = 0.175
|
||||
const NET_STRIKE_MARKER_DISTANCE: float = 0.85
|
||||
const CalendarSeasonType = preload("res://world/calendar_season.gd")
|
||||
|
|
@ -30,7 +32,7 @@ func _run() -> void:
|
|||
var snapshot_entities_per_envelope: int = (
|
||||
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
|
||||
)
|
||||
assert(protocol_version == 12)
|
||||
assert(protocol_version == 13)
|
||||
assert(
|
||||
world_spawn_capability == protocol_world_spawn_capability
|
||||
)
|
||||
|
|
@ -56,6 +58,10 @@ func _validate_catalog_statuses() -> void:
|
|||
CRAB_PIXEL_SIZE,
|
||||
)
|
||||
)
|
||||
assert(is_equal_approx(
|
||||
brown.sprite_surface_offset,
|
||||
CRAB_SURFACE_OFFSET,
|
||||
))
|
||||
_validate_quality_behavior(brown)
|
||||
assert(is_equal_approx(brown.capture_respawn_min_seconds, 480.0))
|
||||
assert(is_equal_approx(brown.capture_respawn_max_seconds, 720.0))
|
||||
|
|
@ -121,6 +127,11 @@ func _validate_catalog_statuses() -> void:
|
|||
0.4,
|
||||
))
|
||||
assert(blue.maximum_surface_population == 16)
|
||||
assert(is_equal_approx(blue.sprite_pixel_size, CRAB_PIXEL_SIZE))
|
||||
assert(is_equal_approx(
|
||||
blue.sprite_surface_offset,
|
||||
CRAB_SURFACE_OFFSET,
|
||||
))
|
||||
assert(blue.target_population_for_surface_area(100.0) == 4)
|
||||
assert(blue.target_population_for_surface_area(2250.0) == 9)
|
||||
assert(FishCatalog.get_fish_by_id(&"crab_blue") == blue.catch_data)
|
||||
|
|
@ -138,6 +149,10 @@ func _validate_catalog_statuses() -> void:
|
|||
CRAB_PIXEL_SIZE,
|
||||
)
|
||||
)
|
||||
assert(is_equal_approx(
|
||||
entry.sprite_surface_offset,
|
||||
CRAB_SURFACE_OFFSET,
|
||||
))
|
||||
assert(entry.catch_data.collection_method == FishData.CollectionMethod.NET)
|
||||
assert(
|
||||
entry.catch_data.logbook_section
|
||||
|
|
@ -182,13 +197,22 @@ func _validate_catalog_statuses() -> void:
|
|||
|
||||
func _validate_billboard_presentation() -> void:
|
||||
var gatherable := WorldGatherableType.new()
|
||||
gatherable.call("_ensure_visual")
|
||||
root.add_child(gatherable)
|
||||
var brown: GatherableData = Gatherables.get_entry(&"crab_brown")
|
||||
gatherable.configure("crab-size-validation", brown, Vector3.ZERO, 0.0)
|
||||
var sprite := gatherable.get_node("GatherableSprite") as Sprite3D
|
||||
assert(sprite != null)
|
||||
assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED)
|
||||
assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST)
|
||||
assert(not sprite.shaded)
|
||||
gatherable.free()
|
||||
assert(is_equal_approx(sprite.pixel_size, CRAB_PIXEL_SIZE))
|
||||
assert(is_equal_approx(sprite.position.y, CRAB_SURFACE_OFFSET))
|
||||
assert(is_equal_approx(
|
||||
float(sprite.texture.get_width()) * sprite.pixel_size,
|
||||
CRAB_SPRITE_EXTENT,
|
||||
))
|
||||
gatherable.queue_free()
|
||||
await process_frame
|
||||
|
||||
var unconfigured_hotspot := WorldGatherableType.new()
|
||||
unconfigured_hotspot.call("_ensure_water_spurt_visual")
|
||||
|
|
|
|||
324
ui/chat_ui.gd
324
ui/chat_ui.gd
|
|
@ -25,9 +25,21 @@ const SPEECH_BUBBLE_WIDTH: float = 320.0
|
|||
const SPEECH_POINTER_HALF_WIDTH: float = 12.0
|
||||
const SPEECH_POINTER_HEIGHT: float = 14.0
|
||||
const SPEECH_POINTER_OVERLAP: float = 3.0
|
||||
const NAMEPLATE_WIDTH: float = 300.0
|
||||
const NAMEPLATE_NAME_HEIGHT: float = 24.0
|
||||
const NAMEPLATE_TITLE_TOP: float = 21.0
|
||||
const NAMEPLATE_TITLE_HEIGHT: float = 18.0
|
||||
const NAMEPLATE_HEIGHT: float = (
|
||||
NAMEPLATE_TITLE_TOP + NAMEPLATE_TITLE_HEIGHT
|
||||
)
|
||||
const NAMEPLATE_NAME_FONT_SIZE: int = 20
|
||||
const NAMEPLATE_TITLE_FONT_SIZE: int = 14
|
||||
const NAMEPLATE_HEAD_GAP: float = 3.0
|
||||
const NAMEPLATE_SPEECH_GAP: float = 5.0
|
||||
const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0
|
||||
const ANIMALESE_SILENT_DISTANCE: float = 24.0
|
||||
const ANIMALESE_SILENT_VOLUME_DB: float = -80.0
|
||||
const ROLEPLAY_FONT_SLANT: float = -0.18
|
||||
const MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000
|
||||
const MOBILE_COMPACT_WIDTH: float = 620.0
|
||||
const MOBILE_EXPANDED_WIDTH: float = 820.0
|
||||
|
|
@ -107,6 +119,7 @@ var _player: Player
|
|||
var _fishing_spot: FishingSpot
|
||||
var _settings: PlayerSettingsManager
|
||||
var _history: RichTextLabel
|
||||
var _roleplay_font: FontVariation
|
||||
var _entry: LineEdit
|
||||
var _status: Label
|
||||
var _panel: PanelContainer
|
||||
|
|
@ -114,6 +127,7 @@ var _collapse_button: Button
|
|||
var _height_button: Button
|
||||
var _unread_indicator: Label
|
||||
var _hint: Label
|
||||
var _nameplate_layer: Control
|
||||
var _speech_layer: Control
|
||||
var _animalese_voice: AnimaleseVoiceType
|
||||
var _clock_panel: PanelContainer
|
||||
|
|
@ -125,6 +139,7 @@ var _calendar_balance: CurrencyAmount
|
|||
var _weather_icon: WeatherIconType
|
||||
var _status_effect_column: VBoxContainer
|
||||
var _status_effect_icons: Dictionary[StringName, TextureRect] = {}
|
||||
var _nameplates: Dictionary[int, Dictionary] = {}
|
||||
var _speech: Dictionary[int, Dictionary] = {}
|
||||
var _draft_save_timer: Timer
|
||||
var _opacity_tween: Tween
|
||||
|
|
@ -132,6 +147,7 @@ var _height_tween: Tween
|
|||
var _opened: bool = false
|
||||
var _available: bool = false
|
||||
var _hud_hidden: bool = false
|
||||
var _nameplates_hud_hidden: bool = false
|
||||
var _world_speech_visible: bool = true
|
||||
var _presentation_state := PresentationState.COMPACT
|
||||
var _visible_state_before_collapse := PresentationState.COMPACT
|
||||
|
|
@ -247,7 +263,12 @@ func setup(
|
|||
_service.local_message_confirmed.connect(_on_local_message_confirmed)
|
||||
_service.history_replaced.connect(_on_history)
|
||||
_service.send_rejected.connect(_on_rejected)
|
||||
_session.peer_removed.connect(_on_peer_removed)
|
||||
_session.peer_authenticated.connect(_on_peer_authenticated)
|
||||
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
|
||||
_session.peer_title_changed.connect(_on_peer_title_changed)
|
||||
_session.peer_removed.connect(_on_session_peer_removed)
|
||||
_spawn.avatar_spawned.connect(_on_avatar_spawned)
|
||||
_spawn.avatar_removed.connect(_on_avatar_removed)
|
||||
_entry.text = _settings.current_settings.chat_draft
|
||||
_entry.caret_column = _entry.text.length()
|
||||
_set_presentation_state(
|
||||
|
|
@ -258,6 +279,7 @@ func setup(
|
|||
false,
|
||||
)
|
||||
_refresh_history()
|
||||
_reconcile_nameplates()
|
||||
|
||||
|
||||
func _rebuild_status_effect_icons() -> void:
|
||||
|
|
@ -370,6 +392,7 @@ func open_command_chat() -> void:
|
|||
|
||||
func set_available(value: bool) -> void:
|
||||
_available = value
|
||||
_refresh_nameplate_layer_visibility()
|
||||
if not value:
|
||||
_clear_suspended_chat_state()
|
||||
_send_pending = false
|
||||
|
|
@ -601,6 +624,7 @@ func _process(_delta: float) -> void:
|
|||
_refresh_visibility()
|
||||
_update_status_effect_icons()
|
||||
_update_panel_opacity()
|
||||
_update_nameplates()
|
||||
_update_speech()
|
||||
|
||||
|
||||
|
|
@ -612,6 +636,11 @@ func _exit_tree() -> void:
|
|||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_roleplay_font = FontVariation.new()
|
||||
_roleplay_font.base_font = UtilityPageStyle.TuffyFont
|
||||
var roleplay_transform := Transform2D.IDENTITY
|
||||
roleplay_transform.y.x = ROLEPLAY_FONT_SLANT
|
||||
_roleplay_font.variation_transform = roleplay_transform
|
||||
_clock_panel = PanelContainer.new()
|
||||
_clock_panel.name = "WorldClockPanel"
|
||||
_clock_panel.size = CLOCK_SIZE
|
||||
|
|
@ -720,7 +749,9 @@ func _build_ui() -> void:
|
|||
_history.scroll_following = true
|
||||
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
||||
_history.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_history.bbcode_enabled = false
|
||||
_history.add_theme_font_override("normal_font", UtilityPageStyle.TuffyFont)
|
||||
_history.add_theme_font_override("italics_font", _roleplay_font)
|
||||
_history.add_theme_font_size_override("normal_font_size", HISTORY_FONT_SIZE)
|
||||
_history.add_theme_color_override("default_color", UtilityPageStyle.LIGHT_TEXT)
|
||||
_history.add_theme_stylebox_override("normal", _borderless_style(Color.TRANSPARENT))
|
||||
|
|
@ -846,7 +877,16 @@ func _build_ui() -> void:
|
|||
_hint.add_theme_constant_override("shadow_offset_x", 1)
|
||||
_hint.add_theme_constant_override("shadow_offset_y", 2)
|
||||
add_child(_hint)
|
||||
_nameplate_layer = Control.new()
|
||||
_nameplate_layer.name = "PlayerNameplateLayer"
|
||||
_nameplate_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_nameplate_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameplate_layer.visible = (
|
||||
_available and _world_speech_visible and not _nameplates_hud_hidden
|
||||
)
|
||||
add_child(_nameplate_layer)
|
||||
_speech_layer = Control.new()
|
||||
_speech_layer.name = "PlayerSpeechLayer"
|
||||
_speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_speech_layer)
|
||||
|
|
@ -1193,10 +1233,15 @@ func _on_message(message: Dictionary) -> void:
|
|||
_update_panel_opacity(true)
|
||||
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
|
||||
return
|
||||
var body: String = str(message["body"])
|
||||
# Roleplay actions belong only in chat history. They deliberately do not
|
||||
# create world speech, animalese, or a talking animation.
|
||||
if NetworkChatProtocol.is_roleplay_body(body):
|
||||
return
|
||||
var peer_id: int = message["sender_peer_id"]
|
||||
_show_speech_bubble(
|
||||
peer_id,
|
||||
str(message["body"]),
|
||||
body,
|
||||
str(message.get("sender_fingerprint", "")),
|
||||
VoiceProfilesType.sanitized_id(str(message.get(
|
||||
"voice_id", VoiceProfilesType.DEFAULT_ID
|
||||
|
|
@ -1221,6 +1266,8 @@ func _show_speech_bubble(
|
|||
requested_sample_set_id: String = "",
|
||||
) -> void:
|
||||
_on_peer_removed(peer_id)
|
||||
if not _speaker_shares_local_space(peer_id):
|
||||
return
|
||||
var bubble := PanelContainer.new()
|
||||
bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
bubble.custom_minimum_size = Vector2(SPEECH_BUBBLE_WIDTH, 0.0)
|
||||
|
|
@ -1342,16 +1389,27 @@ func _on_rejected(message: String) -> void:
|
|||
func _refresh_history() -> void:
|
||||
if _service == null:
|
||||
return
|
||||
var lines: Array[String] = []
|
||||
var messages := _service.get_history()
|
||||
for message: Dictionary in messages:
|
||||
_history.clear()
|
||||
for message_index: int in messages.size():
|
||||
var message: Dictionary = messages[message_index]
|
||||
if message_index > 0:
|
||||
_history.add_text("\n")
|
||||
if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM:
|
||||
lines.append("• %s" % str(message["body"]))
|
||||
else:
|
||||
lines.append("%s: %s" % [
|
||||
str(message["sender_display_name"]), str(message["body"]),
|
||||
_history.add_text("• %s" % str(message["body"]))
|
||||
continue
|
||||
var body: String = str(message["body"])
|
||||
if NetworkChatProtocol.is_roleplay_body(body):
|
||||
_history.push_italics()
|
||||
_history.add_text("%s %s" % [
|
||||
str(message["sender_display_name"]),
|
||||
NetworkChatProtocol.roleplay_action(body),
|
||||
])
|
||||
_history.text = "\n".join(lines)
|
||||
_history.pop()
|
||||
continue
|
||||
_history.add_text("%s: %s" % [
|
||||
str(message["sender_display_name"]), body,
|
||||
])
|
||||
_scroll_history_to_bottom.call_deferred()
|
||||
|
||||
|
||||
|
|
@ -1465,6 +1523,8 @@ func is_mobile_mode() -> bool:
|
|||
|
||||
func set_hud_hidden(is_hidden: bool) -> void:
|
||||
_hud_hidden = is_hidden
|
||||
_nameplates_hud_hidden = is_hidden
|
||||
_refresh_nameplate_layer_visibility()
|
||||
_refresh_visibility()
|
||||
|
||||
|
||||
|
|
@ -1472,8 +1532,14 @@ func is_hud_hidden() -> bool:
|
|||
return _hud_hidden
|
||||
|
||||
|
||||
func set_world_nameplates_hud_hidden(is_hidden: bool) -> void:
|
||||
_nameplates_hud_hidden = is_hidden
|
||||
_refresh_nameplate_layer_visibility()
|
||||
|
||||
|
||||
func set_world_speech_visible(should_be_visible: bool) -> void:
|
||||
_world_speech_visible = should_be_visible
|
||||
_refresh_nameplate_layer_visibility()
|
||||
if _speech_layer != null:
|
||||
_speech_layer.visible = should_be_visible
|
||||
|
||||
|
|
@ -1482,6 +1548,14 @@ func is_world_speech_visible() -> bool:
|
|||
return _world_speech_visible
|
||||
|
||||
|
||||
func _refresh_nameplate_layer_visibility() -> void:
|
||||
if _nameplate_layer == null:
|
||||
return
|
||||
_nameplate_layer.visible = (
|
||||
_available and _world_speech_visible and not _nameplates_hud_hidden
|
||||
)
|
||||
|
||||
|
||||
func _refresh_handle_labels(state: PresentationState) -> void:
|
||||
var collapsed := state == PresentationState.COLLAPSED
|
||||
var height_state := _visible_state_before_collapse if collapsed else state
|
||||
|
|
@ -1538,11 +1612,217 @@ func _update_panel_opacity(immediate_recheck: bool = false) -> void:
|
|||
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func _update_nameplates() -> void:
|
||||
_reconcile_nameplates()
|
||||
if _spawn == null or _player == null:
|
||||
_hide_all_nameplates()
|
||||
return
|
||||
var camera := _player.get_active_gameplay_camera()
|
||||
if camera == null:
|
||||
_hide_all_nameplates()
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
for peer_id: int in _nameplates.keys():
|
||||
var state: Dictionary = _nameplates[peer_id]
|
||||
var plate := state.get("control") as Control
|
||||
var avatar := _spawn.get_avatar(peer_id)
|
||||
if plate == null:
|
||||
continue
|
||||
if (
|
||||
avatar == null
|
||||
or not _available
|
||||
or _nameplates_hud_hidden
|
||||
or not _world_speech_visible
|
||||
or not _speaker_shares_local_space(peer_id)
|
||||
or not _avatar_presentation_visible(peer_id, avatar)
|
||||
):
|
||||
plate.hide()
|
||||
continue
|
||||
var world_position := avatar.get_nameplate_anchor_position()
|
||||
if (
|
||||
camera.is_position_behind(world_position)
|
||||
or _is_speech_world_occluded(camera, avatar, world_position)
|
||||
):
|
||||
plate.hide()
|
||||
continue
|
||||
var screen_position := (
|
||||
camera.unproject_position(world_position) / _output_scale
|
||||
)
|
||||
if (
|
||||
screen_position.x < -NAMEPLATE_WIDTH * 0.5
|
||||
or screen_position.x > viewport_size.x + NAMEPLATE_WIDTH * 0.5
|
||||
or screen_position.y < -NAMEPLATE_HEIGHT
|
||||
or screen_position.y > viewport_size.y + NAMEPLATE_HEIGHT
|
||||
):
|
||||
plate.hide()
|
||||
continue
|
||||
plate.position = Vector2(
|
||||
screen_position.x - NAMEPLATE_WIDTH * 0.5,
|
||||
screen_position.y - NAMEPLATE_HEIGHT - NAMEPLATE_HEAD_GAP,
|
||||
)
|
||||
plate.show()
|
||||
|
||||
|
||||
func _reconcile_nameplates() -> void:
|
||||
if _spawn == null or _session == null or _nameplate_layer == null:
|
||||
return
|
||||
var live_peer_ids: Dictionary[int, bool] = {}
|
||||
for peer_id: int in _spawn.get_peer_ids():
|
||||
if (
|
||||
_spawn.get_avatar(peer_id) == null
|
||||
or _session.get_peer_record(peer_id) == null
|
||||
):
|
||||
continue
|
||||
live_peer_ids[peer_id] = true
|
||||
_ensure_nameplate(peer_id)
|
||||
for peer_id: int in _nameplates.keys():
|
||||
if not live_peer_ids.has(peer_id):
|
||||
_remove_nameplate(peer_id)
|
||||
|
||||
|
||||
func _ensure_nameplate(peer_id: int) -> void:
|
||||
if _nameplates.has(peer_id):
|
||||
_refresh_nameplate_text(peer_id)
|
||||
return
|
||||
var plate := Control.new()
|
||||
plate.name = "PlayerNameplate_%d" % peer_id
|
||||
plate.size = Vector2(NAMEPLATE_WIDTH, NAMEPLATE_HEIGHT)
|
||||
plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
plate.hide()
|
||||
var name_label := _create_nameplate_label(
|
||||
"PlayerName",
|
||||
NAMEPLATE_NAME_FONT_SIZE,
|
||||
0.0,
|
||||
NAMEPLATE_NAME_HEIGHT,
|
||||
)
|
||||
var title_label := _create_nameplate_label(
|
||||
"PlayerTitle",
|
||||
NAMEPLATE_TITLE_FONT_SIZE,
|
||||
NAMEPLATE_TITLE_TOP,
|
||||
NAMEPLATE_TITLE_HEIGHT,
|
||||
)
|
||||
plate.add_child(name_label)
|
||||
plate.add_child(title_label)
|
||||
_nameplate_layer.add_child(plate)
|
||||
_nameplates[peer_id] = {
|
||||
"control": plate,
|
||||
"name": name_label,
|
||||
"title": title_label,
|
||||
}
|
||||
_refresh_nameplate_text(peer_id)
|
||||
|
||||
|
||||
func _create_nameplate_label(
|
||||
node_name: String,
|
||||
font_size: int,
|
||||
vertical_position: float,
|
||||
height: float,
|
||||
) -> Label:
|
||||
var label := Label.new()
|
||||
label.name = node_name
|
||||
label.position = Vector2(0.0, vertical_position)
|
||||
label.size = Vector2(NAMEPLATE_WIDTH, height)
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.clip_text = true
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", Color.WHITE)
|
||||
label.add_theme_color_override("font_outline_color", Color.TRANSPARENT)
|
||||
label.add_theme_color_override("font_shadow_color", Color.TRANSPARENT)
|
||||
label.add_theme_constant_override("outline_size", 0)
|
||||
label.add_theme_constant_override("shadow_offset_x", 0)
|
||||
label.add_theme_constant_override("shadow_offset_y", 0)
|
||||
return label
|
||||
|
||||
|
||||
func _refresh_nameplate_text(peer_id: int) -> void:
|
||||
if _session == null or not _nameplates.has(peer_id):
|
||||
return
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if record == null:
|
||||
return
|
||||
var state: Dictionary = _nameplates[peer_id]
|
||||
var name_label := state.get("name") as Label
|
||||
var title_label := state.get("title") as Label
|
||||
if name_label != null:
|
||||
name_label.text = (
|
||||
record.display_name
|
||||
if not record.display_name.is_empty()
|
||||
else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
)
|
||||
if title_label != null:
|
||||
title_label.text = (
|
||||
record.title
|
||||
if not record.title.is_empty()
|
||||
else NetworkProfilePreferences.DEFAULT_TITLE
|
||||
)
|
||||
|
||||
|
||||
func _remove_nameplate(peer_id: int) -> void:
|
||||
var state: Dictionary = _nameplates.get(peer_id, {})
|
||||
var plate := state.get("control") as Control
|
||||
if plate != null:
|
||||
plate.queue_free()
|
||||
_nameplates.erase(peer_id)
|
||||
|
||||
|
||||
func _hide_all_nameplates() -> void:
|
||||
for state: Dictionary in _nameplates.values():
|
||||
var plate := state.get("control") as Control
|
||||
if plate != null:
|
||||
plate.hide()
|
||||
|
||||
|
||||
func _avatar_presentation_visible(peer_id: int, avatar: Player) -> bool:
|
||||
if _session == null or peer_id == _session.get_local_peer_id():
|
||||
return true
|
||||
return avatar.is_remote_presentation_visible()
|
||||
|
||||
|
||||
func _nameplate_speech_clearance(peer_id: int) -> float:
|
||||
var state: Dictionary = _nameplates.get(peer_id, {})
|
||||
var plate := state.get("control") as Control
|
||||
if plate == null or not plate.is_visible_in_tree():
|
||||
return 0.0
|
||||
return NAMEPLATE_HEIGHT + NAMEPLATE_HEAD_GAP + NAMEPLATE_SPEECH_GAP
|
||||
|
||||
|
||||
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
||||
_ensure_nameplate(peer_id)
|
||||
|
||||
|
||||
func _on_peer_display_name_changed(peer_id: int, _display_name: String) -> void:
|
||||
_ensure_nameplate(peer_id)
|
||||
|
||||
|
||||
func _on_peer_title_changed(peer_id: int, _title: String) -> void:
|
||||
_ensure_nameplate(peer_id)
|
||||
|
||||
|
||||
func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
|
||||
_ensure_nameplate(peer_id)
|
||||
|
||||
|
||||
func _on_avatar_removed(peer_id: int) -> void:
|
||||
_remove_nameplate(peer_id)
|
||||
|
||||
|
||||
func _on_session_peer_removed(peer_id: int) -> void:
|
||||
_on_peer_removed(peer_id)
|
||||
_remove_nameplate(peer_id)
|
||||
|
||||
|
||||
func _update_speech() -> void:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
for peer_id: int in _speech.keys():
|
||||
var state: Dictionary = _speech[peer_id]
|
||||
var bubble := state.get("bubble") as PanelContainer
|
||||
if not _speaker_shares_local_space(peer_id):
|
||||
_on_peer_removed(peer_id)
|
||||
continue
|
||||
if bubble == null or now >= float(state.get("expires", 0.0)):
|
||||
if bubble != null:
|
||||
bubble.queue_free()
|
||||
|
|
@ -1553,10 +1833,14 @@ func _update_speech() -> void:
|
|||
continue
|
||||
var avatar := _spawn.get_avatar(peer_id)
|
||||
var camera := _player.get_active_gameplay_camera()
|
||||
if avatar == null or camera == null:
|
||||
if (
|
||||
avatar == null
|
||||
or camera == null
|
||||
or not _avatar_presentation_visible(peer_id, avatar)
|
||||
):
|
||||
bubble.hide()
|
||||
continue
|
||||
var world_position := avatar.get_chat_anchor_position()
|
||||
var world_position := avatar.get_nameplate_anchor_position()
|
||||
if camera.is_position_behind(world_position):
|
||||
bubble.hide()
|
||||
continue
|
||||
|
|
@ -1566,6 +1850,7 @@ func _update_speech() -> void:
|
|||
var screen_position := (
|
||||
camera.unproject_position(world_position) / _output_scale
|
||||
)
|
||||
screen_position.y -= _nameplate_speech_clearance(peer_id)
|
||||
var viewport_size := get_viewport_rect().size
|
||||
if (
|
||||
screen_position.x < -80.0
|
||||
|
|
@ -1631,10 +1916,27 @@ func _is_speech_world_occluded(
|
|||
if collider is Player:
|
||||
excluded.append(collider.get_rid())
|
||||
continue
|
||||
if (
|
||||
collider != null
|
||||
and bool(collider.get_meta(
|
||||
HomeWorldService.INTERIOR_SHELL_COLLISION_META, false
|
||||
))
|
||||
):
|
||||
excluded.append(collider.get_rid())
|
||||
continue
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _speaker_shares_local_space(peer_id: int) -> bool:
|
||||
if _session == null:
|
||||
return true
|
||||
var local_peer_id: int = _session.get_local_peer_id()
|
||||
if local_peer_id <= 0:
|
||||
return true
|
||||
return _session.peers_share_space(local_peer_id, peer_id)
|
||||
|
||||
|
||||
func _on_draft_changed(_value: String) -> void:
|
||||
if _settings == null:
|
||||
return
|
||||
|
|
|
|||
163
ui/game_ui.gd
163
ui/game_ui.gd
|
|
@ -88,8 +88,6 @@ signal pixelation_settings_visibility_changed(is_visible: bool)
|
|||
signal crisp_reset_focus_requested
|
||||
signal interactive_pointer_ui_changed(is_open: bool)
|
||||
signal passive_pointer_ui_changed(is_enabled: bool)
|
||||
signal player_menu_backdrop_visibility_changed(is_visible: bool)
|
||||
signal shop_backdrop_visibility_changed(is_visible: bool)
|
||||
signal virtual_pointer_mode_changed(is_active: bool)
|
||||
signal social_prompt_accepted
|
||||
signal social_prompt_declined
|
||||
|
|
@ -119,6 +117,7 @@ const FISHING_PANEL_SHOWCASE_BOTTOM_OFFSET: float = -104.0
|
|||
@onready var _status_label: Label = %StatusLabel
|
||||
@onready var _bite_prompt_button: Button = %BitePromptButton
|
||||
@onready var _gameplay_transient_hud: Control = %GameplayTransientHUD
|
||||
@onready var _gameplay_screen_effects: Control = %GameplayScreenEffects
|
||||
@onready var _active_bait_indicator: Control = %ActiveBaitIndicator
|
||||
@onready var _active_bait_button: Button = %ActiveBaitButton
|
||||
@onready var _active_bait_quantity_badge: Panel = %ActiveBaitQuantityBadge
|
||||
|
|
@ -148,7 +147,8 @@ const VoiceProfilesType = preload(
|
|||
const SHOP_ANIMALESE_VOICE_ID: String = "natural"
|
||||
const SHOP_ANIMALESE_BASE_PITCH: float = 1.08
|
||||
const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0
|
||||
const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
|
||||
const SHOP_PROMPT_DIALOGUE_SIZE := Vector2(300.0, 72.0)
|
||||
const SHOP_PROMPT_COMPACT_SIZE := Vector2(30.0, 30.0)
|
||||
const FISHING_DANGER_START_GAP: float = 0.15
|
||||
const FISHING_DANGER_FULL_GAP: float = 0.025
|
||||
|
||||
|
|
@ -175,9 +175,11 @@ const FISHING_DANGER_FULL_GAP: float = 0.025
|
|||
@onready var _shop_prompt_key: Label = %ShopPromptKey
|
||||
@onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer
|
||||
var _shop_animalese_voice: AnimaleseVoiceType
|
||||
var _shop_speech_tween: Tween
|
||||
var _shop_npc_player_in_range: bool = false
|
||||
var _shop_npc_dialogue_active: bool = false
|
||||
var _shop_npc_spoken_for_current_visit: bool = false
|
||||
var _shop_npc_next_speech_msec: int = 0
|
||||
var _shop_npc_source_id: int = 0
|
||||
@onready var _chat_ui: ChatUIType = %ChatUI
|
||||
@onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu
|
||||
@onready var _quick_radial_menu: QuickRadialMenuType = %QuickRadialMenu
|
||||
|
|
@ -559,11 +561,8 @@ func setup(
|
|||
_on_storage_visibility_changed
|
||||
)
|
||||
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
|
||||
_fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
|
||||
_decor_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
|
||||
_decor_shop.menu_exit_started.connect(_on_shop_exit_started)
|
||||
_rv_upgrade_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
|
||||
_rv_upgrade_shop.menu_exit_started.connect(_on_shop_exit_started)
|
||||
_fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested)
|
||||
_fishing_shop.shop_cooler_return_requested.connect(
|
||||
_on_shop_cooler_return_requested
|
||||
|
|
@ -700,6 +699,7 @@ func _input(event: InputEvent) -> void:
|
|||
can_open and not _quick_radial_menu.is_open(),
|
||||
):
|
||||
var emote_is_open: bool = _emote_radial_menu.is_open()
|
||||
_refresh_home_decor_toolbar_obscuring()
|
||||
if emote_is_open != emote_was_open and _player != null:
|
||||
_player.set_camera_input_suppressed(
|
||||
EMOTE_RADIAL_CAMERA_OWNER,
|
||||
|
|
@ -713,6 +713,7 @@ func _input(event: InputEvent) -> void:
|
|||
can_open and not _emote_radial_menu.is_open(),
|
||||
):
|
||||
var quick_is_open: bool = _quick_radial_menu.is_open()
|
||||
_refresh_home_decor_toolbar_obscuring()
|
||||
if quick_is_open != quick_was_open and _player != null:
|
||||
_player.set_camera_input_suppressed(
|
||||
QUICK_RADIAL_CAMERA_OWNER,
|
||||
|
|
@ -755,6 +756,7 @@ func _close_radial_menus() -> void:
|
|||
func _close_emote_radial_menu() -> void:
|
||||
if _emote_radial_menu != null and _emote_radial_menu.is_open():
|
||||
_emote_radial_menu.close_menu()
|
||||
_refresh_home_decor_toolbar_obscuring()
|
||||
if _player != null and is_instance_valid(_player):
|
||||
_player.set_camera_input_suppressed(
|
||||
EMOTE_RADIAL_CAMERA_OWNER,
|
||||
|
|
@ -765,6 +767,7 @@ func _close_emote_radial_menu() -> void:
|
|||
func _close_quick_radial_menu() -> void:
|
||||
if _quick_radial_menu != null and _quick_radial_menu.is_open():
|
||||
_quick_radial_menu.close_menu()
|
||||
_refresh_home_decor_toolbar_obscuring()
|
||||
if _player != null and is_instance_valid(_player):
|
||||
_player.set_camera_input_suppressed(
|
||||
QUICK_RADIAL_CAMERA_OWNER,
|
||||
|
|
@ -798,6 +801,14 @@ func _character_call_yields_to_world_interaction(
|
|||
_shop_interaction != null
|
||||
and _shop_interaction.is_local_player_in_range()
|
||||
)
|
||||
or (
|
||||
_decor_shop_interaction != null
|
||||
and _decor_shop_interaction.is_local_player_in_range()
|
||||
)
|
||||
or (
|
||||
_rv_upgrade_shop_interaction != null
|
||||
and _rv_upgrade_shop_interaction.is_local_player_in_range()
|
||||
)
|
||||
or (
|
||||
_storage_interaction != null
|
||||
and _storage_interaction.is_local_player_in_range()
|
||||
|
|
@ -806,6 +817,8 @@ func _character_call_yields_to_world_interaction(
|
|||
and _fishing_spot != null
|
||||
and _fishing_spot.can_open_fishing_shop()
|
||||
and not _fishing_shop.visible
|
||||
and not _decor_shop.visible
|
||||
and not _rv_upgrade_shop.visible
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1544,12 +1557,19 @@ func _refresh_interaction_prompt_bindings() -> void:
|
|||
|
||||
|
||||
func _resize_interaction_prompts() -> void:
|
||||
var badge_width: float = maxf(
|
||||
24.0,
|
||||
ceilf(_shop_prompt_key.get_combined_minimum_size().x) + 12.0,
|
||||
)
|
||||
_shop_prompt_key_badge.position.x = _shop_prompt.size.x - badge_width + 6.0
|
||||
_shop_prompt_key_badge.size.x = badge_width
|
||||
if not _shop_npc_dialogue_active:
|
||||
_shop_prompt_key_badge.position = Vector2.ZERO
|
||||
_shop_prompt_key_badge.size = SHOP_PROMPT_COMPACT_SIZE
|
||||
else:
|
||||
var badge_width: float = maxf(
|
||||
24.0,
|
||||
ceilf(_shop_prompt_key.get_combined_minimum_size().x) + 12.0,
|
||||
)
|
||||
_shop_prompt_key_badge.position = Vector2(
|
||||
_shop_prompt.size.x - badge_width + 6.0,
|
||||
-8.0,
|
||||
)
|
||||
_shop_prompt_key_badge.size = Vector2(badge_width, 24.0)
|
||||
var storage_width: float = maxf(
|
||||
220.0,
|
||||
ceilf(_storage_prompt_message.get_combined_minimum_size().x) + 24.0,
|
||||
|
|
@ -1925,6 +1945,7 @@ func _refresh_gameplay_hud_visibility() -> void:
|
|||
and not _storage_open
|
||||
)
|
||||
_gameplay_transient_hud.visible = show_world_hud
|
||||
_gameplay_screen_effects.visible = show_world_hud
|
||||
_experience_presentation.visible = (
|
||||
_gameplay_ui_enabled and not _gameplay_hud_hidden
|
||||
)
|
||||
|
|
@ -2110,19 +2131,39 @@ func set_shop_prompt_visible(
|
|||
func set_shop_npc_player_in_range(
|
||||
in_range: bool,
|
||||
dialogue_text: String = "",
|
||||
source_id: int = 0,
|
||||
dialogue_active: bool = false,
|
||||
) -> void:
|
||||
var source_changed: bool = source_id != _shop_npc_source_id
|
||||
var next_dialogue_active: bool = in_range and dialogue_active
|
||||
var dialogue_stage_changed: bool = (
|
||||
next_dialogue_active != _shop_npc_dialogue_active
|
||||
)
|
||||
var dialogue_changed: bool = (
|
||||
not dialogue_text.is_empty()
|
||||
and _shop_prompt_message.text != dialogue_text
|
||||
)
|
||||
if source_changed or dialogue_changed or dialogue_stage_changed:
|
||||
_cancel_shop_npc_speech()
|
||||
_shop_npc_source_id = source_id
|
||||
if dialogue_changed:
|
||||
_shop_prompt_message.text = dialogue_text
|
||||
_shop_prompt_message.visible_characters = -1
|
||||
_shop_npc_spoken_for_current_visit = false
|
||||
if _shop_npc_player_in_range == in_range and not dialogue_changed:
|
||||
if source_changed or dialogue_stage_changed:
|
||||
_shop_npc_spoken_for_current_visit = false
|
||||
if (
|
||||
_shop_npc_player_in_range == in_range
|
||||
and not dialogue_changed
|
||||
and not source_changed
|
||||
and not dialogue_stage_changed
|
||||
):
|
||||
return
|
||||
_shop_npc_player_in_range = in_range
|
||||
if not in_range:
|
||||
_shop_npc_dialogue_active = next_dialogue_active
|
||||
_apply_shop_prompt_mode()
|
||||
if not in_range or not _shop_npc_dialogue_active:
|
||||
_cancel_shop_npc_speech()
|
||||
_shop_npc_spoken_for_current_visit = false
|
||||
return
|
||||
_try_start_shop_npc_speech()
|
||||
|
|
@ -2131,31 +2172,33 @@ func set_shop_npc_player_in_range(
|
|||
func _try_start_shop_npc_speech() -> void:
|
||||
if (
|
||||
not _shop_npc_player_in_range
|
||||
or not _shop_npc_dialogue_active
|
||||
or _shop_npc_spoken_for_current_visit
|
||||
or not _gameplay_ui_enabled
|
||||
):
|
||||
return
|
||||
var now_msec: int = Time.get_ticks_msec()
|
||||
if now_msec < _shop_npc_next_speech_msec:
|
||||
return
|
||||
_shop_npc_spoken_for_current_visit = true
|
||||
_shop_npc_next_speech_msec = (
|
||||
now_msec + SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS
|
||||
)
|
||||
_ensure_shop_animalese_voice()
|
||||
TypewriterRevealType.start(
|
||||
_shop_prompt_message,
|
||||
SHOP_SPEECH_CHARACTERS_PER_SECOND,
|
||||
)
|
||||
_shop_animalese_voice.speak_text(
|
||||
_shop_speech_tween = _shop_animalese_voice.speak_text(
|
||||
_shop_prompt_message,
|
||||
_shop_prompt_message.text,
|
||||
"shopkeeper",
|
||||
"shopkeeper:%d" % _shop_npc_source_id,
|
||||
SHOP_ANIMALESE_VOICE_ID,
|
||||
SHOP_SPEECH_CHARACTERS_PER_SECOND,
|
||||
)
|
||||
|
||||
|
||||
func _cancel_shop_npc_speech() -> void:
|
||||
TypewriterRevealType.cancel(_shop_prompt_message)
|
||||
if _shop_speech_tween != null and _shop_speech_tween.is_valid():
|
||||
_shop_speech_tween.kill()
|
||||
_shop_speech_tween = null
|
||||
|
||||
|
||||
func _ensure_shop_animalese_voice() -> void:
|
||||
if _shop_animalese_voice != null:
|
||||
return
|
||||
|
|
@ -2191,6 +2234,29 @@ func _apply_shop_prompt_style() -> void:
|
|||
UtilityPageStyle.OCEAN_PANEL_MID,
|
||||
0.96,
|
||||
)
|
||||
_apply_shop_prompt_mode()
|
||||
|
||||
|
||||
func _apply_shop_prompt_mode() -> void:
|
||||
if not is_node_ready():
|
||||
return
|
||||
var dialogue_mode: bool = _shop_npc_dialogue_active
|
||||
var requested_size := (
|
||||
SHOP_PROMPT_DIALOGUE_SIZE
|
||||
if dialogue_mode
|
||||
else SHOP_PROMPT_COMPACT_SIZE
|
||||
)
|
||||
_shop_prompt.custom_minimum_size = requested_size
|
||||
_shop_prompt.size = requested_size
|
||||
_shop_prompt_bubble.visible = dialogue_mode
|
||||
_shop_prompt_message.visible = dialogue_mode
|
||||
_shop_prompt_pointer.visible = dialogue_mode
|
||||
var badge_style := (
|
||||
_shop_prompt_key_badge.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
)
|
||||
if badge_style != null:
|
||||
badge_style.set_corner_radius_all(12 if dialogue_mode else 15)
|
||||
_resize_interaction_prompts()
|
||||
|
||||
|
||||
func _position_shop_prompt(world_anchor: Vector3) -> void:
|
||||
|
|
@ -2219,7 +2285,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
|
|||
):
|
||||
_shop_prompt.hide()
|
||||
return
|
||||
var pointer_height := 10.0
|
||||
var pointer_height := 10.0 if _shop_npc_dialogue_active else 6.0
|
||||
var desired := screen_position - Vector2(
|
||||
_shop_prompt.size.x * 0.5,
|
||||
_shop_prompt.size.y + pointer_height,
|
||||
|
|
@ -2236,14 +2302,15 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
|
|||
ui_viewport_size.y - _shop_prompt.size.y - pointer_height - 8.0,
|
||||
),
|
||||
)
|
||||
_shop_prompt_pointer.position = Vector2(
|
||||
clampf(
|
||||
screen_position.x - _shop_prompt.position.x,
|
||||
20.0,
|
||||
_shop_prompt.size.x - 20.0,
|
||||
),
|
||||
_shop_prompt.size.y - 1.0,
|
||||
)
|
||||
if _shop_npc_dialogue_active:
|
||||
_shop_prompt_pointer.position = Vector2(
|
||||
clampf(
|
||||
screen_position.x - _shop_prompt.position.x,
|
||||
20.0,
|
||||
_shop_prompt.size.x - 20.0,
|
||||
),
|
||||
_shop_prompt.size.y - 1.0,
|
||||
)
|
||||
|
||||
|
||||
func get_screen_fade() -> ScreenFade:
|
||||
|
|
@ -2831,7 +2898,6 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void:
|
|||
true,
|
||||
UIMotion.PLAYER_MENU_ENTER_DURATION,
|
||||
)
|
||||
player_menu_backdrop_visibility_changed.emit(true)
|
||||
else:
|
||||
_player_menu_hotbar_visible = false
|
||||
_hotbar_ui.set_presentation_visible(false, false)
|
||||
|
|
@ -2862,13 +2928,6 @@ func _on_player_menu_exit_started() -> void:
|
|||
true,
|
||||
UIMotion.PLAYER_MENU_EXIT_DURATION,
|
||||
)
|
||||
player_menu_backdrop_visibility_changed.emit(false)
|
||||
|
||||
|
||||
func _on_shop_exit_started() -> void:
|
||||
if not _shop_open:
|
||||
return
|
||||
shop_backdrop_visibility_changed.emit(false)
|
||||
|
||||
|
||||
func _on_shop_visibility_changed(is_open: bool) -> void:
|
||||
|
|
@ -2876,8 +2935,6 @@ func _on_shop_visibility_changed(is_open: bool) -> void:
|
|||
if is_open:
|
||||
_close_radial_menus()
|
||||
_refresh_surface_drawing_activation()
|
||||
if is_open:
|
||||
shop_backdrop_visibility_changed.emit(true)
|
||||
if not is_open and _player_menu.is_shop_cooler_mounted():
|
||||
_player_menu.unmount_shop_cooler()
|
||||
_refresh_gameplay_hud_visibility()
|
||||
|
|
@ -3049,6 +3106,7 @@ func _refresh_hotbar_visibility() -> void:
|
|||
func _emit_interactive_pointer_ui_changed() -> void:
|
||||
_refresh_active_bait_indicator_visibility()
|
||||
_refresh_rv_storage_button_visibility()
|
||||
_refresh_home_decor_toolbar_obscuring()
|
||||
interactive_pointer_ui_changed.emit(
|
||||
_system_menu_open
|
||||
or _player_menu_open
|
||||
|
|
@ -3058,6 +3116,26 @@ func _emit_interactive_pointer_ui_changed() -> void:
|
|||
)
|
||||
|
||||
|
||||
func _refresh_home_decor_toolbar_obscuring() -> void:
|
||||
if _home_decor_toolbar == null:
|
||||
return
|
||||
_home_decor_toolbar.set_menu_obscured(
|
||||
_system_menu_open
|
||||
or _player_menu_open
|
||||
or _shop_open
|
||||
or _storage_open
|
||||
or _chat_input_open
|
||||
or (
|
||||
_emote_radial_menu != null
|
||||
and _emote_radial_menu.is_open()
|
||||
)
|
||||
or (
|
||||
_quick_radial_menu != null
|
||||
and _quick_radial_menu.is_open()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _on_chat_text_entry_ownership_changed(active: bool) -> void:
|
||||
_chat_input_open = active
|
||||
if active:
|
||||
|
|
@ -3080,4 +3158,5 @@ func _refresh_chat_availability() -> void:
|
|||
)
|
||||
_chat_ui.set_available(chat_available)
|
||||
_chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open)
|
||||
_chat_ui.set_world_nameplates_hud_hidden(_gameplay_hud_hidden)
|
||||
passive_pointer_ui_changed.emit(chat_available)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ mouse_filter = 2
|
|||
[node name="SurfaceDrawingToolbar" parent="UIRoot" instance=ExtResource("11_drawing_toolbar")]
|
||||
unique_name_in_owner = true
|
||||
|
||||
[node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"]
|
||||
[node name="GameplayScreenEffects" type="Control" parent="UIRoot"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
|
|
@ -91,7 +91,7 @@ grow_horizontal = 2
|
|||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="FishingDangerVignette" type="ColorRect" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
|
||||
[node name="FishingDangerVignette" type="ColorRect" parent="UIRoot/GameplayScreenEffects"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
z_index = 50
|
||||
|
|
@ -105,6 +105,16 @@ mouse_filter = 2
|
|||
material = SubResource("ShaderMaterial_fishing_vignette")
|
||||
color = Color(1, 1, 1, 1)
|
||||
|
||||
[node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ActiveBaitIndicator" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
|
||||
unique_name_in_owner = true
|
||||
z_index = 54
|
||||
|
|
|
|||
|
|
@ -801,7 +801,10 @@ func _refresh_entries() -> void:
|
|||
else "online"
|
||||
)
|
||||
_server_list.add_item("%s — %s" % [
|
||||
str(friend.get("display_name", "Player")), state,
|
||||
str(friend.get(
|
||||
"display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)), state,
|
||||
])
|
||||
return
|
||||
if _saved_servers == null or _mode == Mode.DIRECT:
|
||||
|
|
@ -1451,7 +1454,9 @@ func _format_friend_details(friend: Dictionary) -> String:
|
|||
return "Select a friend."
|
||||
var room := _selected_friend_room()
|
||||
var lines: Array[String] = [
|
||||
"Friend: %s" % str(friend.get("display_name", "Player")),
|
||||
"Friend: %s" % str(friend.get(
|
||||
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
)),
|
||||
"Status: %s" % (
|
||||
"online" if bool(friend.get("online", false)) else "offline"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -689,7 +689,10 @@ func _build_relationship_rows() -> void:
|
|||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
var fingerprint := str(record["fingerprint"])
|
||||
label.text = "%s %s" % [
|
||||
str(record.get("last_known_display_name", "Player")),
|
||||
str(record.get(
|
||||
"last_known_display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)),
|
||||
"Blocked" if bool(record.get("blocked", false)) else "Muted",
|
||||
]
|
||||
_configure_identity_tooltip(
|
||||
|
|
@ -733,7 +736,9 @@ func _build_friend_rows() -> void:
|
|||
for friend: Dictionary in friends:
|
||||
var row := _make_row()
|
||||
var fingerprint := str(friend.get("fingerprint", ""))
|
||||
var display_name := str(friend.get("display_name", "Player"))
|
||||
var display_name := str(friend.get(
|
||||
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
))
|
||||
var room: Dictionary = (
|
||||
friend.get("room", {})
|
||||
if typeof(friend.get("room", {})) == TYPE_DICTIONARY
|
||||
|
|
@ -835,7 +840,10 @@ func _build_ban_rows() -> void:
|
|||
label.clip_text = true
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
label.text = "%s banned %s" % [
|
||||
str(record.get("last_known_display_name", "Player")),
|
||||
str(record.get(
|
||||
"last_known_display_name",
|
||||
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
|
||||
)),
|
||||
Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))),
|
||||
]
|
||||
_configure_identity_tooltip(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const VoiceProfilesType = preload(
|
|||
"res://player/animalese_voice_profiles.gd"
|
||||
)
|
||||
const VOICE_CATEGORY_ID: String = "voice"
|
||||
const NAME_TOOLTIP_BASE: String = "Shown to other players in multiplayer."
|
||||
|
||||
enum ControllerZone {
|
||||
ACCOUNT,
|
||||
|
|
@ -45,8 +46,10 @@ enum ControllerZone {
|
|||
var _service: NetworkProfileService
|
||||
var _experience: PlayerExperience
|
||||
var _draft_name: String = ""
|
||||
var _draft_title: String = NetworkProfilePreferences.DEFAULT_TITLE
|
||||
var _draft_appearance: Dictionary = {}
|
||||
var _persisted_name: String = ""
|
||||
var _persisted_title: String = NetworkProfilePreferences.DEFAULT_TITLE
|
||||
var _persisted_appearance: Dictionary = {}
|
||||
var _draft_voice_id: String = VoiceProfilesType.DEFAULT_ID
|
||||
var _persisted_voice_id: String = VoiceProfilesType.DEFAULT_ID
|
||||
|
|
@ -61,6 +64,8 @@ var _dirty: bool = false
|
|||
var _allow_duplicate: bool = false
|
||||
|
||||
var _name_edit: LineEdit
|
||||
var _title_edit: LineEdit
|
||||
var _name_label: Label
|
||||
var _name_status: Label
|
||||
var _suggestions: HBoxContainer
|
||||
var _category_list: VBoxContainer
|
||||
|
|
@ -215,8 +220,9 @@ func consume_escape() -> bool:
|
|||
if _discard_confirmation.visible:
|
||||
_close_discard_confirmation()
|
||||
return true
|
||||
if _name_edit.has_focus():
|
||||
if _name_edit.has_focus() or _title_edit.has_focus():
|
||||
_name_edit.release_focus()
|
||||
_title_edit.release_focus()
|
||||
return true
|
||||
if _dirty:
|
||||
_show_confirmation("discard")
|
||||
|
|
@ -426,6 +432,18 @@ func _focus_controller_zone() -> void:
|
|||
if not _profile_interactive:
|
||||
return
|
||||
var controls: Array[Control] = _active_controller_zone_controls()
|
||||
# The account zone starts with a LineEdit in layout order. Do not activate
|
||||
# text entry merely because the Profile page opened or controller focus
|
||||
# returned from customization; keep the name field opt-in.
|
||||
if (
|
||||
_controller_zone == ControllerZone.ACCOUNT
|
||||
and _customize_button in controls
|
||||
and _customize_button.focus_mode != Control.FOCUS_NONE
|
||||
and _customize_button.is_visible_in_tree()
|
||||
and not _customize_button.disabled
|
||||
):
|
||||
_customize_button.grab_focus()
|
||||
return
|
||||
for control: Control in controls:
|
||||
var button := control as BaseButton
|
||||
if (
|
||||
|
|
@ -496,6 +514,7 @@ func _account_controller_controls() -> Array[Control]:
|
|||
var controls: Array[Control] = []
|
||||
for control: Control in [
|
||||
_name_edit,
|
||||
_title_edit,
|
||||
_customize_button,
|
||||
_apply_button,
|
||||
_revert_button,
|
||||
|
|
@ -604,22 +623,26 @@ func _build_ui() -> void:
|
|||
layout.add_child(account_row)
|
||||
var account_stack := VBoxContainer.new()
|
||||
account_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
account_stack.add_theme_constant_override("separation", 2)
|
||||
account_stack.add_theme_constant_override("separation", 7)
|
||||
account_row.add_child(account_stack)
|
||||
var name_line := HBoxContainer.new()
|
||||
name_line.name = "PlayerNameRow"
|
||||
name_line.add_theme_constant_override("separation", 10)
|
||||
account_stack.add_child(name_line)
|
||||
var name_label := Label.new()
|
||||
name_label.text = "player name"
|
||||
name_label.custom_minimum_size.x = 98.0
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.add_theme_color_override(
|
||||
_name_label = Label.new()
|
||||
_name_label.name = "PlayerNameLabel"
|
||||
_name_label.text = "name"
|
||||
_name_label.tooltip_text = NAME_TOOLTIP_BASE
|
||||
_name_label.custom_minimum_size.x = 128.0
|
||||
_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_name_label.add_theme_color_override(
|
||||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||||
)
|
||||
name_line.add_child(name_label)
|
||||
name_line.add_child(_name_label)
|
||||
_name_edit = LineEdit.new()
|
||||
_name_edit.name = "PlayerNameEdit"
|
||||
_name_edit.max_length = NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
|
||||
_name_edit.placeholder_text = "Player"
|
||||
_name_edit.placeholder_text = NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
|
||||
_name_edit.custom_minimum_size = Vector2(280, 34)
|
||||
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
|
||||
_name_edit.add_theme_stylebox_override(
|
||||
|
|
@ -629,23 +652,46 @@ func _build_ui() -> void:
|
|||
)
|
||||
_name_edit.text_changed.connect(_on_name_changed)
|
||||
name_line.add_child(_name_edit)
|
||||
var helper := Label.new()
|
||||
helper.text = "Shown to other players in multiplayer."
|
||||
helper.add_theme_font_size_override("font_size", 12)
|
||||
helper.add_theme_color_override(
|
||||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||||
var title_line := HBoxContainer.new()
|
||||
title_line.name = "PlayerTitleRow"
|
||||
title_line.add_theme_constant_override("separation", 10)
|
||||
account_stack.add_child(title_line)
|
||||
var title_label := Label.new()
|
||||
title_label.name = "PlayerTitleLabel"
|
||||
title_label.text = "title"
|
||||
title_label.tooltip_text = "Shown below your name to other players."
|
||||
title_label.custom_minimum_size.x = 128.0
|
||||
title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
title_label.add_theme_color_override(
|
||||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||||
)
|
||||
account_stack.add_child(helper)
|
||||
title_line.add_child(title_label)
|
||||
_title_edit = LineEdit.new()
|
||||
_title_edit.name = "PlayerTitleEdit"
|
||||
_title_edit.max_length = NetworkProtocol.MAX_PLAYER_TITLE_LENGTH
|
||||
_title_edit.placeholder_text = NetworkProfilePreferences.DEFAULT_TITLE
|
||||
_title_edit.custom_minimum_size = Vector2(280, 34)
|
||||
UtilityPageStyle.apply_ocean_line_edit(_title_edit)
|
||||
_title_edit.add_theme_stylebox_override(
|
||||
"normal", UtilityPageStyle.ocean_button_style(
|
||||
UtilityPageStyle.OCEAN_PANEL_MID
|
||||
)
|
||||
)
|
||||
_title_edit.text_changed.connect(_on_title_changed)
|
||||
title_line.add_child(_title_edit)
|
||||
_name_status = Label.new()
|
||||
_name_status.add_theme_font_size_override("font_size", 12)
|
||||
_name_status.add_theme_color_override(
|
||||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||||
)
|
||||
_name_status.name = "PlayerNameStatus"
|
||||
_name_status.visible = false
|
||||
account_stack.add_child(_name_status)
|
||||
_suggestions = HBoxContainer.new()
|
||||
_suggestions.add_theme_constant_override("separation", 6)
|
||||
account_stack.add_child(_suggestions)
|
||||
|
||||
var account_info_stack := VBoxContainer.new()
|
||||
account_info_stack.name = "ProfileIdentityStack"
|
||||
account_info_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
account_info_stack.add_theme_constant_override("separation", 7)
|
||||
account_row.add_child(account_info_stack)
|
||||
var identity_value := Label.new()
|
||||
identity_value.name = "IdentityFingerprint"
|
||||
identity_value.text = "identity • stored on this device"
|
||||
|
|
@ -656,11 +702,14 @@ func _build_ui() -> void:
|
|||
identity_value.add_theme_color_override(
|
||||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||||
)
|
||||
account_stack.add_child(identity_value)
|
||||
identity_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
account_info_stack.add_child(identity_value)
|
||||
|
||||
var experience_row := HBoxContainer.new()
|
||||
experience_row.name = "ProfileExperienceRow"
|
||||
experience_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
experience_row.add_theme_constant_override("separation", 9)
|
||||
account_stack.add_child(experience_row)
|
||||
account_info_stack.add_child(experience_row)
|
||||
_experience_level = Label.new()
|
||||
_experience_level.custom_minimum_size.x = 64.0
|
||||
_experience_level.add_theme_font_size_override("font_size", 14)
|
||||
|
|
@ -695,7 +744,7 @@ func _build_ui() -> void:
|
|||
actions.alignment = BoxContainer.ALIGNMENT_END
|
||||
actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
actions.add_theme_constant_override("separation", 7)
|
||||
account_row.add_child(actions)
|
||||
account_info_stack.add_child(actions)
|
||||
_customize_button = Button.new()
|
||||
_customize_button.text = "customize"
|
||||
_customize_button.custom_minimum_size.x = 96.0
|
||||
|
|
@ -2127,10 +2176,16 @@ func _on_name_changed(value: String) -> void:
|
|||
_refresh_actions()
|
||||
_clear_suggestions()
|
||||
if NetworkProfilePreferences.is_valid_display_name(value):
|
||||
_name_status.text = "Checking this game…"
|
||||
_set_name_status("Checking this game…")
|
||||
_debounce.start()
|
||||
else:
|
||||
_name_status.text = "Use 1–24 plain-text characters."
|
||||
_set_name_status("Use 1–24 plain-text characters.")
|
||||
|
||||
|
||||
func _on_title_changed(value: String) -> void:
|
||||
_draft_title = value
|
||||
_dirty = _draft_differs()
|
||||
_refresh_actions()
|
||||
|
||||
|
||||
func _request_conflict_check() -> void:
|
||||
|
|
@ -2145,9 +2200,9 @@ func _on_conflict_result(
|
|||
) -> void:
|
||||
_clear_suggestions()
|
||||
if not has_conflict:
|
||||
_name_status.text = "Name is available in this game."
|
||||
_set_name_status("Name is available in this game.")
|
||||
return
|
||||
_name_status.text = "That name is already in use in this game."
|
||||
_set_name_status("That name is already in use in this game.")
|
||||
for suggestion: String in suggestion_names:
|
||||
var button := Button.new()
|
||||
button.text = suggestion
|
||||
|
|
@ -2158,7 +2213,7 @@ func _on_conflict_result(
|
|||
anyway.text = "use anyway"
|
||||
anyway.pressed.connect(func() -> void:
|
||||
_allow_duplicate = true
|
||||
_name_status.text = "Duplicate name allowed for this apply."
|
||||
_set_name_status("Duplicate name allowed for this apply.")
|
||||
)
|
||||
UtilityPageStyle.apply_compact_ocean_button(anyway)
|
||||
_suggestions.add_child(anyway)
|
||||
|
|
@ -2209,12 +2264,13 @@ func _apply() -> void:
|
|||
_draft_speech_speed_id,
|
||||
_draft_call_id,
|
||||
_draft_sample_set_id,
|
||||
_draft_title,
|
||||
)
|
||||
|
||||
|
||||
func _on_apply_finished(accepted: bool, message: String) -> void:
|
||||
_apply_button.disabled = false
|
||||
_name_status.text = message
|
||||
_set_name_status(message)
|
||||
if accepted:
|
||||
_load_persisted()
|
||||
|
||||
|
|
@ -2223,6 +2279,7 @@ func _load_persisted() -> void:
|
|||
if _service == null:
|
||||
return
|
||||
_persisted_name = _service.get_persisted_name()
|
||||
_persisted_title = _service.get_persisted_title()
|
||||
_persisted_appearance = _service.get_persisted_appearance()
|
||||
_persisted_voice_id = _service.get_persisted_voice_id()
|
||||
_persisted_speech_speed_id = (
|
||||
|
|
@ -2231,6 +2288,7 @@ func _load_persisted() -> void:
|
|||
_persisted_call_id = _service.get_persisted_call_id()
|
||||
_persisted_sample_set_id = _service.get_persisted_sample_set_id()
|
||||
_draft_name = _persisted_name
|
||||
_draft_title = _persisted_title
|
||||
_draft_appearance = _persisted_appearance.duplicate(true)
|
||||
_draft_voice_id = _persisted_voice_id
|
||||
_draft_speech_speed_id = _persisted_speech_speed_id
|
||||
|
|
@ -2240,12 +2298,13 @@ func _load_persisted() -> void:
|
|||
VoiceProfilesType.speed_for(_persisted_speech_speed_id)
|
||||
)
|
||||
_name_edit.text = _draft_name
|
||||
_title_edit.text = _draft_title
|
||||
_preview.apply_appearance_profile(_draft_appearance)
|
||||
_appearance_preview_timer.stop()
|
||||
_service.preview_appearance(_persisted_appearance)
|
||||
_dirty = false
|
||||
_allow_duplicate = false
|
||||
_name_status.text = ""
|
||||
_set_name_status("")
|
||||
_clear_suggestions()
|
||||
_refresh_options()
|
||||
_refresh_actions()
|
||||
|
|
@ -2288,6 +2347,8 @@ func _confirm_pending_action() -> void:
|
|||
_draft_speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
|
||||
_draft_call_id = VoiceProfilesType.DEFAULT_CALL_ID
|
||||
_draft_sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
|
||||
_draft_title = NetworkProfilePreferences.DEFAULT_TITLE
|
||||
_title_edit.text = _draft_title
|
||||
_preview.apply_appearance_profile(_draft_appearance)
|
||||
_queue_draft_appearance_preview()
|
||||
_dirty = _draft_differs()
|
||||
|
|
@ -2318,6 +2379,7 @@ func _publish_draft_appearance() -> void:
|
|||
func _draft_differs() -> bool:
|
||||
return (
|
||||
_draft_name.strip_edges() != _persisted_name
|
||||
or _draft_title.strip_edges() != _persisted_title
|
||||
or _draft_appearance != _persisted_appearance
|
||||
or _draft_voice_id != _persisted_voice_id
|
||||
or _draft_speech_speed_id != _persisted_speech_speed_id
|
||||
|
|
@ -2330,6 +2392,7 @@ func _refresh_actions() -> void:
|
|||
_apply_button.disabled = (
|
||||
not _dirty
|
||||
or not NetworkProfilePreferences.is_valid_display_name(_draft_name)
|
||||
or not NetworkProfilePreferences.is_valid_title(_draft_title)
|
||||
)
|
||||
_revert_button.disabled = not _dirty
|
||||
|
||||
|
|
@ -2339,3 +2402,12 @@ func _clear_suggestions() -> void:
|
|||
_suggestions.remove_child(child)
|
||||
child.queue_free()
|
||||
_apply_controller_zone_focus()
|
||||
|
||||
|
||||
func _set_name_status(message: String) -> void:
|
||||
_name_status.text = message
|
||||
_name_label.tooltip_text = (
|
||||
NAME_TOOLTIP_BASE
|
||||
if message.is_empty()
|
||||
else "%s\n%s" % [NAME_TOOLTIP_BASE, message]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ enum SeedMode {
|
|||
@onready var _empty_slots_label: Label = %EmptySlotsLabel
|
||||
@onready var _slot_name_edit: LineEdit = %SelectedSlotName
|
||||
@onready var _slot_summary: RichTextLabel = %SlotSummary
|
||||
@onready var _online_slot_button: CheckButton = %OnlineSlotButton
|
||||
@onready var _play_button: Button = %PlaySlotButton
|
||||
@onready var _rename_button: Button = %RenameSlotButton
|
||||
@onready var _duplicate_button: Button = %DuplicateSlotButton
|
||||
|
|
@ -75,6 +76,7 @@ func _ready() -> void:
|
|||
_saves_tab.pressed.connect(_select_page.bind(PAGE_SAVES, true))
|
||||
_new_slot_tab.pressed.connect(_open_fresh_slot_page)
|
||||
_play_button.pressed.connect(_request_play)
|
||||
_online_slot_button.toggled.connect(_on_online_slot_toggled)
|
||||
_rename_button.pressed.connect(_rename_selected_slot)
|
||||
_duplicate_button.pressed.connect(_prepare_duplicate)
|
||||
_export_button.pressed.connect(_choose_export_path)
|
||||
|
|
@ -285,9 +287,14 @@ func _refresh_slots() -> void:
|
|||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.focus_mode = Control.FOCUS_ALL
|
||||
button.toggle_mode = true
|
||||
button.text = str(slot.get("display_name", "save")) + (
|
||||
" · active" if bool(slot.get("active", false)) else ""
|
||||
)
|
||||
var markers: Array[String] = []
|
||||
if bool(slot.get("active", false)):
|
||||
markers.append("loaded")
|
||||
if bool(slot.get("online", false)):
|
||||
markers.append("online")
|
||||
button.text = str(slot.get("display_name", "save"))
|
||||
if not markers.is_empty():
|
||||
button.text += " · " + " · ".join(markers)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
UtilityPageStyle.apply_ocean_button(button)
|
||||
button.pressed.connect(_select_slot.bind(slot_id, false))
|
||||
|
|
@ -321,10 +328,14 @@ func _refresh_selected_slot() -> void:
|
|||
_slot_name_edit.text = ""
|
||||
_slot_name_edit.editable = false
|
||||
_slot_summary.text = "[center]create or import a save slot to begin.[/center]"
|
||||
_online_slot_button.set_pressed_no_signal(false)
|
||||
else:
|
||||
_slot_name_edit.editable = true
|
||||
_slot_name_edit.text = str(slot.get("display_name", "save"))
|
||||
_slot_summary.text = _format_slot_summary(slot)
|
||||
_online_slot_button.set_pressed_no_signal(
|
||||
bool(slot.get("online", false))
|
||||
)
|
||||
_refresh_action_state()
|
||||
|
||||
|
||||
|
|
@ -337,9 +348,9 @@ func _format_slot_summary(slot: Dictionary) -> String:
|
|||
int(slot.get("last_played_at_unix", 0))
|
||||
)
|
||||
return (
|
||||
"[font_size=24]level %d[/font_size]\n\n"
|
||||
"[font_size=24]level %d[/font_size]\n"
|
||||
+ "%d catches · %d discovered\n"
|
||||
+ "%d fishcoins\n\n"
|
||||
+ "%d fishcoins\n"
|
||||
+ "%s · seed %d\n"
|
||||
+ "last played %s"
|
||||
) % [
|
||||
|
|
@ -358,6 +369,7 @@ func _refresh_action_state() -> void:
|
|||
var has_slot: bool = not slot.is_empty()
|
||||
var has_save: bool = has_slot and bool(slot.get("has_save", false))
|
||||
_play_button.disabled = _busy or not has_save
|
||||
_online_slot_button.disabled = _busy or not has_save
|
||||
_rename_button.disabled = _busy or not has_slot
|
||||
_duplicate_button.disabled = _busy or not has_save
|
||||
_export_button.disabled = _busy or not has_save
|
||||
|
|
@ -375,6 +387,21 @@ func _request_play() -> void:
|
|||
play_requested.emit(_selected_slot_id)
|
||||
|
||||
|
||||
func _on_online_slot_toggled(enabled: bool) -> void:
|
||||
if _catalog == null or _selected_slot_id.is_empty():
|
||||
return
|
||||
if not enabled:
|
||||
_online_slot_button.set_pressed_no_signal(
|
||||
_catalog.get_online_slot_id() == _selected_slot_id
|
||||
)
|
||||
return
|
||||
if not _catalog.mark_slot_for_online(_selected_slot_id):
|
||||
_status.text = "the online progression could not be changed."
|
||||
_online_slot_button.set_pressed_no_signal(false)
|
||||
return
|
||||
_status.text = "this progression will be used when joining online games."
|
||||
|
||||
|
||||
func _rename_selected_slot() -> void:
|
||||
if _catalog == null or _rename_button.disabled:
|
||||
return
|
||||
|
|
@ -644,8 +671,9 @@ func _configure_controller_focus() -> void:
|
|||
var bottom: Control = _import_button if index == _slot_buttons.size() - 1 else _slot_buttons[index + 1]
|
||||
_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, _play_button, _slot_name_edit, _rename_button)
|
||||
_set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _online_slot_button)
|
||||
_set_neighbors(_online_slot_button, _import_button, _online_slot_button, _slot_name_edit, _play_button)
|
||||
_set_neighbors(_play_button, _import_button, _play_button, _online_slot_button, _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)
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ layout_mode = 2
|
|||
[node name="DetailsColumn" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 10
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
|
||||
layout_mode = 2
|
||||
|
|
@ -165,6 +165,14 @@ scroll_active = false
|
|||
autowrap_mode = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="OnlineSlotButton" type="CheckButton" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
focus_mode = 2
|
||||
text = "use this save online"
|
||||
tooltip_text = "Use this save when joining an online room"
|
||||
|
||||
[node name="Actions" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
|
|
@ -330,7 +338,7 @@ text = "create and play"
|
|||
|
||||
[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_top = 34
|
||||
theme_override_constants/margin_top = 32
|
||||
|
||||
[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"]
|
||||
layout_mode = 2
|
||||
|
|
|
|||
|
|
@ -628,6 +628,7 @@ func _prepare_awaiting_start_input() -> void:
|
|||
_continue_stats_drawer.hide()
|
||||
_continue_stats_drawer.modulate.a = 0.0
|
||||
_continue_stats_drawer.scale = Vector2(1.0, 0.001)
|
||||
_version_row.show()
|
||||
_main_content.show()
|
||||
_start_prompt_center.show()
|
||||
_start_prompt_center.modulate.a = 1.0
|
||||
|
|
@ -927,6 +928,7 @@ func _on_continue_pressed() -> void:
|
|||
_release_primary_menu_focus()
|
||||
_presentation_center.hide()
|
||||
_start_prompt_center.hide()
|
||||
_version_row.hide()
|
||||
_join_game_page.close_page()
|
||||
_credits_page.close_page()
|
||||
_new_game_setup_page.close_page()
|
||||
|
|
@ -935,6 +937,7 @@ func _on_continue_pressed() -> void:
|
|||
|
||||
func _close_save_slots() -> void:
|
||||
_save_slots_page.close_page()
|
||||
_version_row.show()
|
||||
_presentation_center.show()
|
||||
_button_center.show()
|
||||
_start_prompt_center.hide()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ extends RefCounted
|
|||
const DEFAULT_CHARACTERS_PER_SECOND: float = 28.0
|
||||
const MIN_CHARACTERS_PER_SECOND: float = 12.0
|
||||
const MAX_CHARACTERS_PER_SECOND: float = 60.0
|
||||
const ACTIVE_TWEEN_META: StringName = &"typewriter_reveal_active_tween"
|
||||
|
||||
static var _characters_per_second: float = DEFAULT_CHARACTERS_PER_SECOND
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ static func start(
|
|||
) -> float:
|
||||
if label == null:
|
||||
return 0.0
|
||||
cancel(label, false)
|
||||
var character_count: int = label.get_total_character_count()
|
||||
if character_count <= 0:
|
||||
label.visible_characters = -1
|
||||
|
|
@ -38,10 +40,39 @@ static func start(
|
|||
)
|
||||
var duration := float(character_count) / resolved_characters_per_second
|
||||
var reveal_tween := label.create_tween()
|
||||
label.set_meta(ACTIVE_TWEEN_META, reveal_tween)
|
||||
reveal_tween.tween_property(
|
||||
label,
|
||||
"visible_characters",
|
||||
character_count,
|
||||
duration,
|
||||
).from(0).set_trans(Tween.TRANS_LINEAR)
|
||||
reveal_tween.finished.connect(
|
||||
_on_reveal_finished.bind(label, reveal_tween),
|
||||
CONNECT_ONE_SHOT,
|
||||
)
|
||||
return duration
|
||||
|
||||
|
||||
static func cancel(label: Label, reveal_all: bool = true) -> void:
|
||||
if label == null:
|
||||
return
|
||||
var reveal_tween: Tween
|
||||
if label.has_meta(ACTIVE_TWEEN_META):
|
||||
reveal_tween = label.get_meta(ACTIVE_TWEEN_META) as Tween
|
||||
if reveal_tween != null and reveal_tween.is_valid():
|
||||
reveal_tween.kill()
|
||||
if label.has_meta(ACTIVE_TWEEN_META):
|
||||
label.remove_meta(ACTIVE_TWEEN_META)
|
||||
if reveal_all:
|
||||
label.visible_characters = -1
|
||||
|
||||
|
||||
static func _on_reveal_finished(label: Label, reveal_tween: Tween) -> void:
|
||||
if label == null or not is_instance_valid(label):
|
||||
return
|
||||
if (
|
||||
label.has_meta(ACTIVE_TWEEN_META)
|
||||
and label.get_meta(ACTIVE_TWEEN_META) == reveal_tween
|
||||
):
|
||||
label.remove_meta(ACTIVE_TWEEN_META)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue