feat: expand progression and multiplayer systems

Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -6,9 +6,11 @@ signal fish_quality_discovered(fish_id: StringName, quality: int)
signal collection_changed
const FishQualityType = preload("res://fish/fish_quality.gd")
const MAX_CATCH_COUNT: int = 1000000000
var _discovered: Dictionary[StringName, bool] = {}
var _quality_masks: Dictionary[StringName, int] = {}
var _catch_counts: Dictionary[StringName, int] = {}
func has_discovered(fish_id: StringName) -> bool:
@ -41,6 +43,26 @@ func mark_quality_discovered(fish_id: StringName, quality: int) -> void:
collection_changed.emit()
func record_catch(fish_id: StringName, quality: int) -> void:
if fish_id.is_empty() or not FishQualityType.is_valid(quality):
return
var species_was_discovered: bool = has_discovered(fish_id)
if not species_was_discovered:
_discovered[fish_id] = true
var previous_mask: int = _quality_masks.get(fish_id, 0)
var next_mask: int = previous_mask | FishQualityType.bit_for(quality)
_quality_masks[fish_id] = next_mask
_catch_counts[fish_id] = mini(
int(_catch_counts.get(fish_id, 0)) + 1,
MAX_CATCH_COUNT,
)
if not species_was_discovered:
fish_discovered.emit(fish_id)
if next_mask != previous_mask:
fish_quality_discovered.emit(fish_id, quality)
collection_changed.emit()
func get_discovered_ids() -> Array[StringName]:
var discovered_ids: Array[StringName] = []
for fish_id: StringName in _discovered:
@ -65,6 +87,14 @@ func get_discovered_quality_masks() -> Dictionary[StringName, int]:
return _quality_masks.duplicate()
func get_catch_count(fish_id: StringName) -> int:
return _catch_counts.get(fish_id, 0) if has_discovered(fish_id) else 0
func get_catch_counts() -> Dictionary[StringName, int]:
return _catch_counts.duplicate()
func has_mastered(fish_id: StringName) -> bool:
return get_quality_mask(fish_id) == FishQualityType.ALL_TIERS_MASK
@ -77,6 +107,22 @@ func replace_discovered_ids(fish_ids: Array[StringName]) -> bool:
func replace_discovery_state(
fish_ids: Array[StringName],
quality_masks: Dictionary[StringName, int],
) -> bool:
var preserved_counts: Dictionary[StringName, int] = {}
for fish_id: StringName in fish_ids:
if _catch_counts.has(fish_id):
preserved_counts[fish_id] = _catch_counts[fish_id]
return replace_collection_state(
fish_ids,
quality_masks,
preserved_counts,
)
func replace_collection_state(
fish_ids: Array[StringName],
quality_masks: Dictionary[StringName, int],
catch_counts: Dictionary[StringName, int],
) -> bool:
var replacement: Dictionary[StringName, bool] = {}
for fish_id: StringName in fish_ids:
@ -95,7 +141,19 @@ func replace_discovery_state(
return false
if mask != 0:
replacement_masks[fish_id] = mask
var replacement_counts: Dictionary[StringName, int] = {}
for fish_id: StringName in catch_counts:
var count: int = catch_counts[fish_id]
if (
fish_id.is_empty()
or not replacement.has(fish_id)
or count <= 0
or count > MAX_CATCH_COUNT
):
return false
replacement_counts[fish_id] = count
_discovered = replacement
_quality_masks = replacement_masks
_catch_counts = replacement_counts
collection_changed.emit()
return true

View file

@ -46,6 +46,16 @@ traffic or become a gameplay authority. Its base URL comes from
`network/discovery/base_url`, with `NETFISHING_DISCOVERY_URL` available as a
development/deployment override.
Friendships are local identity relationships shared across save slots. A live,
authenticated gameplay session is required to exchange the directional
capabilities that establish a friendship. Blocking an identity removes that
friendship; unblocking does not recreate it. Discovery may publish opt-in,
short-lived friend presence and deliver invitations only while both games are
online. It stores hashed capability identifiers rather than identity
fingerprints or a complete friend graph, keeps no durable social records, and
provides no offline delivery. Direct joins still use the existing verified
public-room and ENet connection path.
The host is authoritative. Clients submit requests or evidence; the host
derives trusted context from registered peers, authoritative regions, and
server-owned state before mutating inventory, wallet, progression, or shared
@ -68,8 +78,12 @@ release version is not a reason to change the protocol number.
`PlayerDataRoot` selects and validates a portable data root. Stores receive
paths from that owner rather than inventing unrelated locations. Progression is
written by `PlayerSaveManager`; device settings and social/identity stores have
separate formats and lifecycles.
written by `PlayerSaveManager` and indexed as named slots by
`PlayerSaveSlotCatalog`. Existing single-save installations are adopted as the
first slot in place. Device settings, appearance, and social/identity stores
remain shared across slots and have separate formats and lifecycles.
Progression archive import and export belong to the title-screen Play page:
imports create a new slot, while exports copy only the selected progression.
Save migrations are sequential and explicit. Existing catches and ownership
are keyed by stable IDs so authored metadata can evolve without rewriting

View file

@ -33,6 +33,7 @@ Rotate camera Hold right mouse, or use right stick
Cycle active hotbar slot Mouse wheel or D-pad left/right
Select hotbar slot 1 through 9
Zoom camera Shift + mouse wheel
(swap wheel controls in Settings > Controls)
Primary tool / fishing Left mouse or right trigger
Inventory and player pages Tab
Game Menu / back Escape

View file

@ -2,7 +2,7 @@ class_name SurfaceDrawingProtocol
extends RefCounted
const CAPABILITY: StringName = &"surface_drawing_v2"
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
const RELIABLE_CHANNEL: int = NetworkProtocol.DRAWING_RELIABLE_CHANNEL
const GRID_SIZES: Array[int] = [16, 32, 64, 128]
const DEFAULT_GRID_SIZE: int = 16
const MAX_GRID_SIZE: int = 128

View file

@ -46,7 +46,7 @@ class Barrier:
# Fight movement uses one shared baseline. Species and quality difficulty comes
# from barrier placement and health, while reel upgrades affect only the
# player's progress speed.
const CHASE_START_DELAY: float = 1.0
const CHASE_START_DELAY: float = 1.5
const CHASE_START_OFFSET: float = 0.04
const CHASE_SPEED: float = 0.07

View file

@ -68,6 +68,7 @@ signal showcase_changed(
)
signal bite_activated
signal bite_prompt_changed(is_visible: bool)
signal fishing_input_priority_changed(active: bool)
signal ready_for_equipment_refresh
enum FishingState {
@ -182,6 +183,7 @@ var _network_active_barrier_index: int = -1
var _bite_rng: RandomNumberGenerator = RandomNumberGenerator.new()
var _pending_cleanup_message: String = ""
var _bite_confirmation_pending: bool = false
var _fishing_input_priority_active: bool = false
var _bite_confirmation_requested: bool = false
@ -458,6 +460,7 @@ func _secure_showcase_catch_for_recovery() -> void:
func _exit_tree() -> void:
_stop_fight_audio()
_stop_reeling_audio()
_set_fishing_input_priority(false)
_showcase_restore_generation += 1
if (
state == FishingState.SHOWING_CATCH
@ -777,6 +780,8 @@ func confirm_pending_bite() -> void:
func _set_bite_confirmation_pending(is_pending: bool) -> void:
if is_pending:
_set_fishing_input_priority(true)
if _bite_confirmation_pending == is_pending:
if not is_pending:
_bite_confirmation_requested = false
@ -786,6 +791,17 @@ func _set_bite_confirmation_pending(is_pending: bool) -> void:
bite_prompt_changed.emit(is_pending)
func _set_fishing_input_priority(active: bool) -> void:
if _fishing_input_priority_active == active:
return
_fishing_input_priority_active = active
fishing_input_priority_changed.emit(active)
func is_fishing_input_priority_active() -> bool:
return _fishing_input_priority_active
func is_returning() -> bool:
return state == FishingState.RETURNING
@ -1126,6 +1142,7 @@ func _activate_bite(confirmation_override: bool = false) -> void:
):
_cancel_attempt()
return
_set_fishing_input_priority(true)
if (
not confirmation_override
and _active_lure_has_effect(&"deferred_fight")
@ -1346,7 +1363,7 @@ func _store_catch_progression(fish_catch: FishCatchType) -> void:
)
)
_local_inventory.add_catch(fish_catch)
_local_collection_log.mark_quality_discovered(
_local_collection_log.record_catch(
fish_catch.fish_id,
fish_catch.quality,
)
@ -1414,6 +1431,7 @@ func _finalize_attempt_cleanup(
cooldown_message: String,
restore_movement: bool = true,
) -> void:
_set_bite_confirmation_pending(false)
_showcase_restore_generation += 1
if _active_player != null:
_active_player.set_fighting_visual(false)
@ -1458,6 +1476,7 @@ func _finalize_attempt_cleanup(
_state_time_remaining = cooldown_duration
_cooldown_status = cooldown_message
status_changed.emit(_cooldown_status)
_set_fishing_input_priority(false)
func _return_to_ready() -> void:
@ -1466,6 +1485,7 @@ func _return_to_ready() -> void:
_cooldown_status = ""
status_changed.emit("")
ready_for_equipment_refresh.emit()
_set_fishing_input_priority(false)
func _cancel_attempt() -> void:
@ -1775,6 +1795,7 @@ func _on_network_bite_started(_attempt_id: String) -> void:
if state != FishingState.WAITING_FOR_BITE:
return
_stop_reeling_audio()
_set_fishing_input_priority(true)
_set_bite_confirmation_pending(false)
state = FishingState.FIGHTING
if _active_player != null:

View file

@ -18,6 +18,8 @@ var _showcase_tween: Tween
var _return_showcase_catch: FishCatch
var _bobber_idle_elapsed: float = 0.0
var _bobber_base_scale: Vector3 = Vector3.ONE
var _attempt_id: String = ""
var _observer_fighting_active: bool = false
func setup(owning_player: Player) -> void:
@ -43,7 +45,11 @@ func setup(owning_player: Player) -> void:
cleanup()
func show_cast(origin: Vector3, target: Vector3) -> void:
func show_cast(
origin: Vector3,
target: Vector3,
attempt_id: String = "",
) -> void:
if (
_owner == null
or not origin.is_finite()
@ -52,6 +58,8 @@ func show_cast(origin: Vector3, target: Vector3) -> void:
return
_kill_cast_tween()
_active = true
_attempt_id = attempt_id
_observer_fighting_active = false
_bobber_base_scale = Vector3.ONE * _owner.get_character_visual_scale()
_target = origin
_pending_target = target
@ -86,10 +94,48 @@ func update_bobber(world_position: Vector3) -> void:
_redraw_line()
func synchronize_active(
attempt_id: String,
world_position: Vector3,
fighting: bool,
) -> void:
if (
_owner == null
or attempt_id.is_empty()
or not world_position.is_finite()
):
return
if _active and not _attempt_id.is_empty() and _attempt_id != attempt_id:
cleanup()
if _attempt_id.is_empty():
_attempt_id = attempt_id
if not _active:
_active = true
_bobber_base_scale = Vector3.ONE * _owner.get_character_visual_scale()
_target = world_position
_pending_target = world_position
_bobber_idle_elapsed = 0.0
_bobber.global_position = world_position
_bobber.scale = _bobber_base_scale
_bobber.visible = true
_line.visible = true
_owner.set_active_item_is_rod(true)
_owner.set_fishing_visual(true)
_redraw_line()
else:
update_bobber(world_position)
if fighting != _observer_fighting_active:
_observer_fighting_active = fighting
_owner.set_fighting_visual(fighting)
if not fighting:
_owner.set_fishing_visual(true)
func show_bite() -> void:
if not _active:
return
if _owner != null and is_instance_valid(_owner):
_observer_fighting_active = true
_owner.set_fighting_visual(true)
var tween: Tween = create_tween()
tween.tween_property(_bobber, "scale", _bobber_base_scale * 0.7, 0.08)
@ -131,6 +177,8 @@ func cleanup() -> void:
_kill_return_tween()
_kill_showcase_tween()
_active = false
_attempt_id = ""
_observer_fighting_active = false
_pending_target = Vector3.ZERO
_return_showcase_catch = null
_bobber_idle_elapsed = 0.0

View file

@ -13,6 +13,9 @@ const WaterRecoveryControllerType = preload(
const PlayerSaveManagerType = preload(
"res://save/player_save_manager.gd"
)
const PlayerSaveSlotCatalogType = preload(
"res://save/player_save_slot_catalog.gd"
)
const PlayerSettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
@ -131,6 +134,8 @@ 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
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@ -234,6 +239,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@onready var _shop_backdrop: ColorRect = %ShopBackdrop
var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
@ -246,6 +252,10 @@ 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 = {}
var _pending_social_invite_join: bool = false
var _pending_social_invite_gameplay: bool = false
var _server_trust_dialog: ConfirmationDialog
var _pending_trust_changed: bool = false
var _identity_notice_dialog: AcceptDialog
@ -260,6 +270,7 @@ var _performance_profile: RuntimePerformanceProfileType
var _pending_existing_root_path := ""
var _local_recovery_attempt_id: String = ""
var _dedicated_runtime: bool = false
var _dedicated_metrics_accumulator: float = 0.0
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
@ -269,6 +280,9 @@ func _ready() -> void:
_performance_profile = RuntimePerformanceProfileType.from_environment()
_dedicated_runtime = _is_dedicated_server_runtime()
if _dedicated_runtime:
Engine.max_fps = 30
Engine.physics_ticks_per_second = 30
_disable_dedicated_presentation_tree()
call_deferred("_start_dedicated_server")
return
_world_pixelation.set_light_performance_profile(
@ -382,6 +396,74 @@ func _is_dedicated_server_runtime() -> bool:
return "--dedicated-server" in OS.get_cmdline_user_args()
func _disable_dedicated_presentation_tree() -> void:
for path: NodePath in [
NodePath("TitleBackgroundLayer"),
NodePath("PlayerMenuBackdrop"),
NodePath("ShopBackdrop"),
NodePath("WorldPixelationPostprocess"),
NodePath("UIPresentation"),
NodePath("PixelationResetOverlay"),
NodePath("WorldTimeVisualController"),
NodePath("GatheringController"),
NodePath("FishingSpot"),
NodePath("WaterRecovery"),
NodePath("ControllerMappingManager"),
NodePath("KeyboardMouseMappingManager"),
]:
var presentation_node: Node = get_node_or_null(path)
if presentation_node == null:
continue
presentation_node.process_mode = Node.PROCESS_MODE_DISABLED
if presentation_node is CanvasItem:
(presentation_node as CanvasItem).visible = false
elif presentation_node is CanvasLayer:
(presentation_node as CanvasLayer).visible = false
for audio_path: NodePath in [
NodePath("TitleMusic"),
NodePath("NewGameMusic"),
NodePath("DuskMusic"),
NodePath("ShorelineAmbience"),
]:
var audio_node: Node = get_node_or_null(audio_path)
if audio_node == null:
continue
if audio_node.has_method("stop"):
audio_node.call("stop")
audio_node.process_mode = Node.PROCESS_MODE_DISABLED
func _print_dedicated_metrics() -> void:
var metrics: Dictionary = {
"players": _network_session.get_player_count(),
"fps": Engine.get_frames_per_second(),
"frame_process_ms": Performance.get_monitor(
Performance.TIME_PROCESS
) * 1000.0,
"frame_physics_ms": Performance.get_monitor(
Performance.TIME_PHYSICS_PROCESS
) * 1000.0,
"memory_mib": Performance.get_monitor(
Performance.MEMORY_STATIC
) / (1024.0 * 1024.0),
"nodes": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
"physics_active_objects": int(Performance.get_monitor(
Performance.PHYSICS_3D_ACTIVE_OBJECTS
)),
"physics_collision_pairs": int(Performance.get_monitor(
Performance.PHYSICS_3D_COLLISION_PAIRS
)),
"movement": _network_session.get_movement_metrics(),
}
if _network_fishing.has_method("get_network_metrics"):
metrics["fishing"] = _network_fishing.call("get_network_metrics")
if _network_world_spawns.has_method("get_network_metrics"):
metrics["gatherables"] = _network_world_spawns.call(
"get_network_metrics"
)
print("NETfishing server metrics: %s" % JSON.stringify(metrics))
func _start_dedicated_server() -> void:
var config: DedicatedServerConfigType = DedicatedServerConfigType.from_runtime()
if not config.is_valid():
@ -429,6 +511,7 @@ func _start_dedicated_server() -> void:
):
_fail_dedicated_server("The configured world seed could not be generated.")
return
_test_world.set_dedicated_simulation(true)
if not _network_session.set_host_world(
WorldLayoutType.GENERATED,
config.world_seed,
@ -441,10 +524,17 @@ func _start_dedicated_server() -> void:
return
_initialize_application(true)
_player.set_local_control(false)
_player.set_network_simulation_only(true)
_player.visible = false
_player.collision_layer = 0
_player.collision_mask = 0
_player.set_physics_process(false)
if not _network_session.peer_count_changed.is_connected(
_on_dedicated_peer_count_changed
):
_network_session.peer_count_changed.connect(
_on_dedicated_peer_count_changed
)
if not _network_session.start_dedicated_host(
config.port,
config.max_players,
@ -456,6 +546,10 @@ func _start_dedicated_server() -> void:
if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.")
return
_on_dedicated_peer_count_changed(
_network_session.get_player_count(),
config.max_players,
)
_player_jobs.begin_progression_session()
if config.public_listing and not _discovery.set_discoverable(true):
_network_session.disconnect_session("Discovery setup failed.")
@ -478,15 +572,34 @@ func _start_dedicated_server() -> void:
)
func _on_dedicated_peer_count_changed(
player_count: int,
_max_players: int,
) -> void:
if not _dedicated_runtime:
return
Engine.physics_ticks_per_second = (
DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND
if player_count > 0
else DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND
)
func _fail_dedicated_server(message: String) -> void:
push_error("Dedicated server startup failed: %s" % message)
get_tree().quit(1)
func _configure_portable_stores() -> void:
if _dedicated_runtime:
_save_manager.configure_storage(
_data_root.path_for(&"player_save"), _data_root
)
elif not _save_slots.configure(_data_root, _save_manager):
push_error(
"Save-slot catalog setup failed: %s"
% _save_slots.get_error_message()
)
_network_profile.configure_storage(
_data_root.path_for(&"network_profile"), _data_root
)
@ -523,7 +636,8 @@ func _initialize_application(dedicated: bool) -> void:
_player_spawn_service.setup(
_players_root,
_player,
_test_world.get_player_spawn_transform()
_test_world.get_player_spawn_transform(),
dedicated,
)
_network_session.setup(
_network_profile,
@ -536,7 +650,7 @@ func _initialize_application(dedicated: bool) -> void:
_host_bans,
dedicated,
)
_discovery.setup(_network_session)
_discovery.setup(_network_session, _relationships)
if not dedicated:
_world_time_visuals.setup(
_world_time,
@ -699,6 +813,7 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager,
_network_item_use,
_world_time,
not dedicated,
)
_gathering_controller.setup(
_player,
@ -747,7 +862,8 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager,
item_catalog,
fish_catalog,
_network_item_use
_network_item_use,
not dedicated,
)
var sale_buyers: Array[FishBuyerProfileType] = [
pelican_buyer_profile,
@ -850,7 +966,6 @@ func _initialize_application(dedicated: bool) -> void:
)
_game_ui.setup_data_and_identity(
_data_root,
_save_manager,
_identity_backups,
_player_identity,
_host_identity,
@ -860,6 +975,18 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.setup_controller_mapping(_controller_mapping_manager)
_ui_pixelation.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager)
_network_player_list.friend_request_received.connect(
_on_friend_request_received
)
_discovery.friend_invite_received.connect(_on_friend_invite_received)
_discovery.public_join_prepared.connect(
_on_social_public_join_prepared
)
_discovery.public_join_status_changed.connect(
_on_social_public_join_status_changed
)
_game_ui.social_prompt_accepted.connect(_on_social_prompt_accepted)
_game_ui.social_prompt_declined.connect(_on_social_prompt_declined)
_data_root.conflict_detected.connect(_on_portable_conflict)
if (
PortableFileGuard.has_syncthing_conflict(
@ -909,16 +1036,19 @@ func _initialize_application(dedicated: bool) -> void:
_apply_runtime_settings(_settings_manager.current_settings)
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.new_game_requested.connect(_on_new_game_requested)
title_screen.continue_game_requested.connect(_on_continue_game_requested)
title_screen.slot_play_requested.connect(_on_slot_play_requested)
title_screen.new_slot_requested.connect(_on_new_slot_requested)
title_screen.quit_requested.connect(_on_quit_requested)
title_screen.setup(
_save_manager,
_save_slots,
_settings_manager,
_network_session,
_saved_servers,
_server_trust,
_discovery,
_data_root,
_interface_fonts,
)
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.setup(
@ -1454,8 +1584,15 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
if _dedicated_runtime:
_dedicated_metrics_accumulator += delta
if _dedicated_metrics_accumulator >= 60.0:
_dedicated_metrics_accumulator = fmod(
_dedicated_metrics_accumulator,
60.0,
)
_print_dedicated_metrics()
return
var show_shop_prompt := _can_show_shop_prompt()
var shop_prompt_anchor := (
@ -1500,7 +1637,8 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
_player.apply_camera_settings(
settings.mouse_camera_sensitivity,
settings.controller_camera_sensitivity,
settings.invert_camera_y
settings.invert_camera_y,
settings.swap_hotbar_camera_scroll,
)
_fishing_spot.configure_accessibility_auto_click(
settings.auto_click_enabled,
@ -1760,6 +1898,97 @@ func _on_continue_game_requested() -> void:
_enter_gameplay()
func _on_slot_play_requested(slot_id: String) -> void:
if _gameplay_started or _quit_in_progress:
return
if not _save_slots.activate_slot(slot_id):
_game_ui.get_title_screen().report_network_error(
"The selected save slot could not be opened."
)
return
if not _save_manager.load_player_data():
_game_ui.get_title_screen().report_network_error(
"Failed to load that save slot. The original was preserved."
)
return
var world_layout: StringName = _save_manager.get_world_layout()
var world_seed: int = _save_manager.get_world_seed()
if (
not _apply_world(world_layout, world_seed, true)
or not _prepare_host_world(world_layout, world_seed)
):
_game_ui.get_title_screen().report_network_error(
"The saved world could not be loaded. The save was preserved."
)
return
if not _prepare_private_host():
return
_save_slots.mark_played(slot_id)
_enter_gameplay()
func _on_new_slot_requested(
display_name: String,
world_layout: StringName,
world_seed: int,
duplicate_source_slot_id: String,
) -> void:
if _gameplay_started or _quit_in_progress:
return
var created: Dictionary = (
_save_slots.duplicate_slot(
duplicate_source_slot_id,
display_name,
world_layout,
world_seed,
)
if not duplicate_source_slot_id.is_empty()
else _save_slots.create_empty_slot(display_name)
)
if not bool(created.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(created.get("message", "The save slot could not be created."))
)
return
var slot_id: String = str(created.get("slot_id", ""))
if not _save_slots.activate_slot(slot_id):
_game_ui.get_title_screen().report_network_error(
"The new save slot could not be selected."
)
return
if not duplicate_source_slot_id.is_empty():
_on_slot_play_requested(slot_id)
return
_start_new_game_music()
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare that world. Try another seed or the starter island."
)
return
if not _prepare_host_world(world_layout, world_seed):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare that world for hosting."
)
return
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()
func _prepare_private_host() -> bool:
_join_requested_from_title = false
_join_requested_from_pause = false
@ -1816,6 +2045,12 @@ func _on_return_to_title_requested() -> void:
func _on_title_join_game_requested(endpoint: String) -> void:
if _quit_in_progress or _gameplay_started:
return
var active_slot: Dictionary = _save_slots.ensure_active_slot()
if not bool(active_slot.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(active_slot.get("message", "Local progression is unavailable."))
)
return
if _network_session.state != NetworkSessionType.State.INACTIVE:
_network_session.disconnect_session("Preparing direct connection.")
_join_requested_from_title = true
@ -1849,6 +2084,135 @@ func _on_pause_join_game_requested(endpoint: String) -> void:
)
func _on_friend_request_received(
request_id: String,
fingerprint: String,
display_name: String,
) -> void:
_queue_social_prompt({
"kind": "friend_request",
"request_id": request_id,
"fingerprint": fingerprint,
"display_name": display_name,
})
func _on_friend_invite_received(
fingerprint: String,
display_name: String,
room: Dictionary,
) -> void:
if (
not _relationships.is_friend(fingerprint)
or _relationships.is_blocked(fingerprint)
):
return
_queue_social_prompt({
"kind": "friend_invite",
"fingerprint": fingerprint,
"display_name": display_name,
"room": room.duplicate(true),
})
func _queue_social_prompt(details: Dictionary) -> void:
_social_prompt_queue.append(details.duplicate(true))
_show_next_social_prompt()
func _show_next_social_prompt() -> void:
if (
not _active_social_prompt.is_empty()
or _game_ui.is_social_prompt_open()
or _social_prompt_queue.is_empty()
):
return
_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")
)
var shown: bool = false
if kind == "friend_request":
shown = _game_ui.show_social_prompt(
"%s wants to add you as a friend.\n" % display_name
+ "Friendships are saved on this device.",
"accept",
"decline",
)
elif kind == "friend_invite":
var room: Dictionary = _active_social_prompt.get("room", {})
var room_name := str(room.get("room_name", "their room"))
shown = _game_ui.show_social_prompt(
"%s invited you to join %s." % [display_name, room_name],
"join",
"not now",
)
if not shown:
_active_social_prompt.clear()
call_deferred("_show_next_social_prompt")
func _on_social_prompt_accepted() -> void:
var details: Dictionary = _active_social_prompt.duplicate(true)
_active_social_prompt.clear()
match str(details.get("kind", "")):
"friend_request":
_network_player_list.respond_friend_request(
str(details.get("request_id", "")), true
)
"friend_invite":
var room: Dictionary = details.get("room", {})
_pending_social_invite_join = true
_pending_social_invite_gameplay = _gameplay_started
if room.is_empty() or not _discovery.prepare_public_join(room):
_pending_social_invite_join = false
_report_social_join_error(
"This person needs to be online to do this."
)
call_deferred("_show_next_social_prompt")
func _on_social_prompt_declined() -> void:
var details: Dictionary = _active_social_prompt.duplicate(true)
_active_social_prompt.clear()
if str(details.get("kind", "")) == "friend_request":
_network_player_list.respond_friend_request(
str(details.get("request_id", "")), false
)
call_deferred("_show_next_social_prompt")
func _on_social_public_join_prepared(endpoint: String) -> void:
if not _pending_social_invite_join:
return
var from_gameplay := _pending_social_invite_gameplay
_pending_social_invite_join = false
_pending_social_invite_gameplay = false
if from_gameplay:
_on_pause_join_game_requested(endpoint)
else:
_on_title_join_game_requested(endpoint)
func _on_social_public_join_status_changed(
message: String,
is_error: bool,
) -> void:
if not _pending_social_invite_join or not is_error:
return
_pending_social_invite_join = false
_pending_social_invite_gameplay = false
_report_social_join_error(message)
func _report_social_join_error(message: String) -> void:
if _gameplay_started:
_game_ui.get_pause_menu().report_network_error(message)
else:
_game_ui.get_title_screen().report_network_error(message)
func _on_network_join_authenticated() -> void:
var connected_endpoint: ConnectionEndpoint = (
_network_session.get_current_endpoint()
@ -1880,6 +2244,7 @@ func _on_network_join_authenticated() -> void:
)
_join_requested_from_title = false
return
_save_slots.mark_played(_save_slots.get_active_slot_id())
if not _apply_joined_world(server_metadata):
return
_fade_out_title_music()
@ -2049,6 +2414,7 @@ func _apply_world(
_player.velocity = Vector3.ZERO
if _application_initialized:
_refresh_active_world_bindings()
_network_world_spawns.refresh_world_context()
if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context(
spawn_transform,

View file

@ -9,6 +9,15 @@ signal host_state_changed(state: int)
signal public_join_prepared(endpoint: String)
signal public_join_status_changed(message: String, is_error: bool)
signal public_join_state_changed(state: int)
signal friend_presence_updated(friends: Array[Dictionary])
signal presence_sharing_changed(enabled: bool)
signal social_status_changed(message: String, is_error: bool)
signal friend_invite_received(
fingerprint: String,
display_name: String,
room: Dictionary,
)
signal friend_invite_finished(success: bool, message: String)
const BASE_URL_SETTING: String = "network/discovery/base_url"
const BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL"
@ -28,6 +37,7 @@ const TRAVERSAL_PACKET_PREFIX: String = "NETFISHING_TRAVERSAL_V1 "
const UPNP_MAPPING_DURATION_SECONDS: int = 3600
const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0
const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0
const SOCIAL_POLL_INTERVAL_SECONDS: float = 5.0
enum HostState {
UNAVAILABLE,
@ -55,6 +65,7 @@ enum HostRequestKind {
}
var _session: NetworkSession
var _relationships: PlayerRelationshipStore
var _base_url: String = ""
var _room_name: String = DEFAULT_ROOM_NAME
var _room_name_uses_default: bool = true
@ -92,6 +103,18 @@ var _upnp_operation_is_renewal: bool = false
var _upnp_renew_timer: Timer
var _upnp: UPNP
var _upnp_mapped_port: int = 0
var _presence_sharing: bool = false
var _social_timer: Timer
var _presence_request: HTTPRequest
var _presence_query_request: HTTPRequest
var _invitation_poll_request: HTTPRequest
var _invitation_send_request: HTTPRequest
var _friend_presence: Dictionary[String, Dictionary] = {}
var _published_presence_tokens: PackedStringArray = PackedStringArray()
var _pending_presence_online: bool = false
var _pending_presence_tokens: PackedStringArray = PackedStringArray()
var _pending_invite_fingerprint: String = ""
var _joined_public_room_id: String = ""
func _ready() -> void:
@ -147,16 +170,48 @@ func _ready() -> void:
_join_probe_timer.one_shot = false
add_child(_join_probe_timer)
_join_probe_timer.timeout.connect(_send_pending_join_probe)
_presence_request = _make_social_request(
"FriendPresencePublish", _on_presence_request_completed
)
_presence_query_request = _make_social_request(
"FriendPresenceQuery", _on_presence_query_completed
)
_invitation_poll_request = _make_social_request(
"FriendInvitationPoll", _on_invitation_poll_completed
)
_invitation_send_request = _make_social_request(
"FriendInvitationSend", _on_invitation_send_completed
)
_social_timer = Timer.new()
_social_timer.name = "FriendPresencePoll"
_social_timer.wait_time = SOCIAL_POLL_INTERVAL_SECONDS
_social_timer.one_shot = false
add_child(_social_timer)
_social_timer.timeout.connect(_refresh_social_state)
_load_settings()
_base_url = _configured_base_url()
func setup(session: NetworkSession) -> void:
func setup(
session: NetworkSession,
relationships: PlayerRelationshipStore = null,
) -> void:
_session = session
_relationships = relationships
if _room_name_uses_default:
_room_name = _default_room_name(_session.get_local_display_name())
_save_settings()
_session.set_session_display_name(_room_name)
if (
_relationships != null
and not _relationships.relationship_changed.is_connected(
_on_social_relationship_changed
)
):
_relationships.relationship_changed.connect(
_on_social_relationship_changed
)
if not _session.state_changed.is_connected(_on_session_state_changed):
_session.state_changed.connect(_on_session_state_changed)
if not _session.peer_count_changed.is_connected(_on_peer_count_changed):
@ -173,6 +228,9 @@ func setup(session: NetworkSession) -> void:
else:
_set_host_state(HostState.CLOSED)
_set_host_status("Open the game before listing it publicly.", false)
if is_configured() and _relationships != null:
_social_timer.start()
call_deferred("_refresh_social_state")
func is_configured() -> bool:
@ -183,6 +241,112 @@ func get_base_url() -> String:
return _base_url
func is_presence_sharing() -> bool:
return _presence_sharing
func set_presence_sharing(enabled: bool) -> bool:
if enabled and (not is_configured() or _relationships == null):
social_status_changed.emit(
"Friend presence is not configured in this build.", true
)
return false
if _presence_sharing == enabled:
return true
_presence_sharing = enabled
_save_settings()
presence_sharing_changed.emit(enabled)
_refresh_social_state()
return true
func get_friend_presence() -> Array[Dictionary]:
var result: Array[Dictionary] = []
if _relationships == null:
return result
for friend: Dictionary in _relationships.get_friends():
var fingerprint := str(friend.get("fingerprint", ""))
var live: Dictionary = _friend_presence.get(fingerprint, {})
result.append({
"fingerprint": fingerprint,
"display_name": str(
friend.get("last_known_display_name", "Player")
),
"online": bool(live.get("online", false)),
"room": (
live.get("room", {}).duplicate(true)
if typeof(live.get("room", {})) == TYPE_DICTIONARY
else {}
),
})
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if bool(a["online"]) != bool(b["online"]):
return bool(a["online"])
return str(a["display_name"]).naturalnocasecmp_to(
str(b["display_name"])
) < 0
)
return result
func request_friend_presence() -> bool:
if not is_configured() or _relationships == null:
friend_presence_updated.emit(get_friend_presence())
return false
_refresh_social_state()
return true
func send_friend_invite(fingerprint: String) -> bool:
if (
_relationships == null
or not _relationships.is_friend(fingerprint)
or _relationships.is_blocked(fingerprint)
or not is_configured()
):
friend_invite_finished.emit(
false, "This person needs to be online to do this."
)
return false
if (
_session == null
or not _session.is_host()
or not _host_verified
or _lease_room_id.is_empty()
):
friend_invite_finished.emit(
false,
"List your open room in discovery before inviting friends.",
)
return false
if (
not _pending_invite_fingerprint.is_empty()
or _invitation_send_request.get_http_client_status()
!= HTTPClient.STATUS_DISCONNECTED
):
return false
var friend: Dictionary = _relationships.get_friend_record(fingerprint)
var inbox_token := str(friend.get("remote_invite_token", ""))
if inbox_token.is_empty():
friend_invite_finished.emit(false, "Friend invitation is unavailable.")
return false
var error := _post_social_json(
_invitation_send_request,
"/v1/invitations",
{
"inbox_token": inbox_token,
"room_id": _lease_room_id,
},
)
if error != OK:
friend_invite_finished.emit(
false, "Friend invitation could not be sent."
)
return false
_pending_invite_fingerprint = fingerprint
return true
func set_base_url_override(value: String) -> bool:
var normalized: String = value.strip_edges()
while normalized.ends_with("/"):
@ -345,7 +509,7 @@ func prepare_public_join(room: Dictionary) -> bool:
func request_rooms() -> bool:
if not is_configured():
rooms_updated.emit([])
rooms_updated.emit(_empty_rooms())
browse_status_changed.emit(
"Public room discovery is not configured in this build.", true
)
@ -362,7 +526,7 @@ func request_rooms() -> bool:
]
var error: Error = _browse_request.request(url)
if error != OK:
rooms_updated.emit([])
rooms_updated.emit(_empty_rooms())
browse_status_changed.emit("Could not request public rooms.", true)
return false
_browse_request_in_flight = true
@ -813,12 +977,12 @@ func _on_browse_request_completed(
_browse_request_in_flight = false
var response: Dictionary = _parse_response_dictionary(body)
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
rooms_updated.emit([])
rooms_updated.emit(_empty_rooms())
browse_status_changed.emit(_request_failure(response), true)
return
var raw_rooms: Variant = response.get("rooms", [])
if typeof(raw_rooms) != TYPE_ARRAY:
rooms_updated.emit([])
rooms_updated.emit(_empty_rooms())
browse_status_changed.emit("Discovery returned an invalid room list.", true)
return
var rooms: Array[Dictionary] = []
@ -873,6 +1037,309 @@ func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool:
)
func _make_social_request(name: String, callback: Callable) -> HTTPRequest:
var request := HTTPRequest.new()
request.name = name
request.timeout = REQUEST_TIMEOUT_SECONDS
add_child(request)
request.request_completed.connect(callback)
return request
func _social_payload(fields: Dictionary = {}) -> Dictionary:
var payload: Dictionary = fields.duplicate(true)
payload["game_version"] = NetworkProtocol.game_version()
payload["protocol_version"] = NetworkProtocol.PROTOCOL_VERSION
return payload
func _post_social_json(
request: HTTPRequest,
path: String,
fields: Dictionary,
) -> Error:
return request.request(
_base_url + path,
PackedStringArray(["Content-Type: application/json"]),
HTTPClient.METHOD_POST,
JSON.stringify(_social_payload(fields)),
)
func _refresh_social_state() -> void:
if not is_configured() or _relationships == null:
return
_publish_friend_presence()
_query_friend_presence()
_poll_friend_invitations()
func _publish_friend_presence() -> void:
if (
_presence_request.get_http_client_status()
!= HTTPClient.STATUS_DISCONNECTED
):
return
var current_tokens := _friend_social_values(
"local_presence_write_token"
)
var online := _presence_sharing and not current_tokens.is_empty()
var publish_online := online
var tokens := PackedStringArray()
if online:
# Revoke removed or blocked friendships before refreshing the remaining
# capabilities. This makes status disappear on the next request instead of
# waiting for the server's short presence TTL.
tokens = _presence_tokens_not_in(
_published_presence_tokens, current_tokens
)
if not tokens.is_empty():
publish_online = false
else:
tokens = current_tokens
else:
tokens = _published_presence_tokens
if tokens.is_empty():
return
var error := _post_social_json(
_presence_request,
"/v1/presence",
{
"display_name": _session.get_local_display_name(),
"room_id": _current_presence_room_id(),
"online": publish_online,
"write_tokens": Array(tokens),
},
)
if error == OK:
_pending_presence_online = publish_online
_pending_presence_tokens = tokens.duplicate()
else:
social_status_changed.emit("Could not update friend presence.", true)
func _query_friend_presence() -> void:
if (
_presence_query_request.get_http_client_status()
!= HTTPClient.STATUS_DISCONNECTED
):
return
var channels := _friend_social_values("remote_presence_channel")
if channels.is_empty():
var had_presence := not _friend_presence.is_empty()
_friend_presence.clear()
if had_presence:
friend_presence_updated.emit(get_friend_presence())
return
var error := _post_social_json(
_presence_query_request,
"/v1/presence/query",
{"channels": Array(channels)},
)
if error != OK:
social_status_changed.emit("Could not refresh friend status.", true)
func _poll_friend_invitations() -> void:
if (
_invitation_poll_request.get_http_client_status()
!= HTTPClient.STATUS_DISCONNECTED
):
return
var tokens := _friend_social_values("local_invite_token")
if tokens.is_empty():
return
var error := _post_social_json(
_invitation_poll_request,
"/v1/invitations/poll",
{"inbox_tokens": Array(tokens)},
)
if error != OK:
social_status_changed.emit("Could not check friend invitations.", true)
func _friend_social_values(key: String) -> PackedStringArray:
var result := PackedStringArray()
if _relationships == null:
return result
for friend: Dictionary in _relationships.get_friends():
var value := str(friend.get(key, ""))
if not value.is_empty() and value not in result:
result.append(value)
return result
func _current_presence_room_id() -> String:
if _session == null:
return ""
if _session.is_host() and _host_verified:
return _lease_room_id
if _session.is_joined_client():
return _joined_public_room_id
return ""
func _on_presence_request_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
) -> void:
var published_online := _pending_presence_online
var request_tokens := _pending_presence_tokens.duplicate()
_pending_presence_online = false
_pending_presence_tokens = PackedStringArray()
var response := _parse_response_dictionary(body)
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
social_status_changed.emit(_request_failure(response), true)
return
if published_online:
for token: String in request_tokens:
if token not in _published_presence_tokens:
_published_presence_tokens.append(token)
else:
for token: String in request_tokens:
var index := _published_presence_tokens.find(token)
if index >= 0:
_published_presence_tokens.remove_at(index)
if _presence_publish_needs_follow_up():
call_deferred("_publish_friend_presence")
func _presence_publish_needs_follow_up() -> bool:
var desired := (
_friend_social_values("local_presence_write_token")
if _presence_sharing
else PackedStringArray()
)
return (
not _presence_tokens_not_in(
_published_presence_tokens, desired
).is_empty()
or not _presence_tokens_not_in(
desired, _published_presence_tokens
).is_empty()
)
func _presence_tokens_not_in(
first: PackedStringArray,
second: PackedStringArray,
) -> PackedStringArray:
var result := PackedStringArray()
for token: String in first:
if token not in second:
result.append(token)
return result
func _on_presence_query_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
) -> void:
var response := _parse_response_dictionary(body)
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
social_status_changed.emit(_request_failure(response), true)
return
var by_channel: Dictionary[String, String] = {}
for friend: Dictionary in _relationships.get_friends():
by_channel[str(friend.get("remote_presence_channel", ""))] = str(
friend.get("fingerprint", "")
)
var next_presence: Dictionary[String, Dictionary] = {}
var raw_presence: Variant = response.get("presence", [])
if typeof(raw_presence) == TYPE_ARRAY:
for value: Variant in raw_presence:
if typeof(value) != TYPE_DICTIONARY:
continue
var presence: Dictionary = value
var fingerprint := str(
by_channel.get(str(presence.get("channel", "")), "")
)
if fingerprint.is_empty() or _relationships.is_blocked(fingerprint):
continue
var room: Dictionary = {}
var raw_room: Variant = presence.get("room", {})
if typeof(raw_room) == TYPE_DICTIONARY and _valid_public_room(raw_room):
room = (raw_room as Dictionary).duplicate(true)
next_presence[fingerprint] = {
"online": true,
"room": room,
}
if next_presence == _friend_presence:
return
_friend_presence = next_presence
friend_presence_updated.emit(get_friend_presence())
func _on_invitation_poll_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
) -> void:
var response := _parse_response_dictionary(body)
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
return
var by_inbox: Dictionary[String, Dictionary] = {}
for friend: Dictionary in _relationships.get_friends():
var token := str(friend.get("local_invite_token", ""))
if not token.is_empty():
by_inbox[PlayerRelationshipStore.invite_inbox_id(token)] = friend
var invitations: Variant = response.get("invitations", [])
if typeof(invitations) != TYPE_ARRAY:
return
for value: Variant in invitations:
if typeof(value) != TYPE_DICTIONARY:
continue
var invitation: Dictionary = value
var friend: Dictionary = by_inbox.get(
str(invitation.get("inbox_id", "")), {}
)
var raw_room: Variant = invitation.get("room", {})
if friend.is_empty() or typeof(raw_room) != TYPE_DICTIONARY:
continue
var room := raw_room as Dictionary
if not _valid_public_room(room):
continue
var fingerprint := str(friend.get("fingerprint", ""))
if _relationships.is_blocked(fingerprint):
continue
friend_invite_received.emit(
fingerprint,
str(friend.get("last_known_display_name", "Player")),
room.duplicate(true),
)
func _on_invitation_send_completed(
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
) -> void:
_pending_invite_fingerprint = ""
var response := _parse_response_dictionary(body)
var success := (
result == HTTPRequest.RESULT_SUCCESS
and response_code == HTTPClient.RESPONSE_CREATED
)
friend_invite_finished.emit(
success,
"Invitation sent." if success else _request_failure(response),
)
func _on_social_relationship_changed(_fingerprint: String) -> void:
for fingerprint: String in _friend_presence.keys():
if not _relationships.is_friend(fingerprint):
_friend_presence.erase(fingerprint)
friend_presence_updated.emit(get_friend_presence())
call_deferred("_refresh_social_state")
func _parse_response_dictionary(body: PackedByteArray) -> Dictionary:
if body.is_empty():
return {}
@ -924,6 +1391,21 @@ func _discovery_version_mismatch_message(required_version: String) -> String:
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state == NetworkSession.State.JOINED_CLIENT:
if not _pending_join_room_id.is_empty():
_joined_public_room_id = _pending_join_room_id
elif state in [
NetworkSession.State.PRIVATE_HOST,
NetworkSession.State.OPEN_HOST,
NetworkSession.State.SERVER_LOST,
NetworkSession.State.CONNECTION_FAILED,
]:
_joined_public_room_id = ""
elif (
state == NetworkSession.State.INACTIVE
and not _preserve_pending_join_on_inactive
):
_joined_public_room_id = ""
if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
_preserve_pending_join_on_inactive = false
_set_public_join_state(PublicJoinState.CONNECTING)
@ -979,6 +1461,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_set_host_status("Open the game before listing it publicly.", false)
else:
_set_host_state(HostState.CLOSED)
call_deferred("_refresh_social_state")
func _on_host_openness_changed(is_open: bool) -> void:
@ -1039,6 +1522,10 @@ func _set_public_join_state(state: PublicJoinState) -> void:
public_join_state_changed.emit(int(state))
func _empty_rooms() -> Array[Dictionary]:
return []
func _sanitize_room_name(value: String) -> String:
var cleaned: String = value.strip_edges().replace("\n", " ").replace("\r", " ")
cleaned = cleaned.replace("\t", " ")
@ -1074,6 +1561,9 @@ func _load_settings() -> void:
LEGACY_DEDICATED_DEFAULT_ROOM_NAME,
],
))
_presence_sharing = bool(config.get_value(
"social", "share_presence", false
))
func _save_settings() -> void:
@ -1084,6 +1574,7 @@ func _save_settings() -> void:
config.set_value(
"host", "room_name_uses_default", _room_name_uses_default
)
config.set_value("social", "share_presence", _presence_sharing)
var error: Error = config.save(SETTINGS_PATH)
if error != OK:
push_warning("Could not save the local NETfishing room name.")
push_warning("Could not save local NETfishing discovery settings.")

View file

@ -2,7 +2,7 @@ class_name NetworkFishShowcaseProtocol
extends RefCounted
const CAPABILITY: StringName = &"fish_showcase_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOWCASE_RELIABLE_CHANNEL
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_FISH_ID_LENGTH: int = 96
const MAX_WEIGHT_LB: float = 1000.0

View file

@ -7,6 +7,7 @@ const FishCatchType = preload("res://fish/fish_catch.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const LOCAL_STATE_RETRY_SECONDS: float = 0.75
var _session: NetworkSession
var _spawn_service: PlayerSpawnService
@ -15,6 +16,8 @@ var _local_inventory: FishInventoryType
var _local_hotbar: PlayerHotbarType
var _states: Dictionary[int, Dictionary] = {}
var _local_revision: int = 0
var _local_acknowledged_revision: int = -1
var _local_state_retry_elapsed: float = 0.0
var _local_visible: bool = false
var _local_catch_id: StringName
@ -54,6 +57,30 @@ func setup(
)
func _process(delta: float) -> void:
if (
_session == null
or not _session.is_joined_client()
or _local_revision <= 0
or _local_revision <= _local_acknowledged_revision
):
_local_state_retry_elapsed = 0.0
return
_local_state_retry_elapsed += delta
if _local_state_retry_elapsed < LOCAL_STATE_RETRY_SECONDS:
return
var fish_catch: FishCatchType
if _local_visible:
fish_catch = _local_inventory.get_catch_by_id(_local_catch_id)
if fish_catch == null or not fish_catch.is_valid():
_submit_local_state(null, false)
return
# A client can finish a generated-world transition on the same frame that
# its local showcase changes. Republish until the authoritative echo arrives
# so that one early application-level miss cannot leave held fish out of sync.
_submit_local_state(fish_catch, _local_visible)
func toggle_selected_fish() -> bool:
if _local_hotbar == null or _local_inventory == null:
return false
@ -78,6 +105,7 @@ func get_local_showcase_catch_id() -> StringName:
func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void:
_local_revision += 1
_local_state_retry_elapsed = 0.0
var local_peer_id: int = (
_session.get_local_peer_id() if _session != null else 1
)
@ -166,6 +194,11 @@ func receive_showcase_state(data: Dictionary) -> void:
):
return
var peer_id: int = int(data["owner_peer_id"])
if peer_id == _session.get_local_peer_id():
_local_acknowledged_revision = maxi(
_local_acknowledged_revision,
int(data["revision"]),
)
var previous: Dictionary = _states.get(peer_id, {})
if int(data["revision"]) <= int(previous.get("revision", -1)):
return
@ -267,6 +300,8 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_states.clear()
_local_visible = false
_local_catch_id = StringName()
_local_acknowledged_revision = -1
_local_state_retry_elapsed = 0.0
var local_avatar: Player = _spawn_service.get_avatar(
_session.get_local_peer_id()
)

View file

@ -8,6 +8,8 @@ const MAX_REEL_SPEED: float = 10.0
const MAX_BARRIER_DAMAGE: int = 128
const INPUT_CHANNEL: int = 3
const SNAPSHOT_CHANNEL: int = 4
const OBSERVER_SNAPSHOT_CHANNEL: int = 10
const RELIABLE_CHANNEL: int = NetworkProtocol.FISHING_RELIABLE_CHANNEL
const INPUT_RATE: float = 1.0 / 30.0
const SNAPSHOT_RATE: float = 1.0 / 20.0
@ -35,6 +37,7 @@ static func validate_cast_request(data: Variant) -> String:
"rarity_multipliers",
"discovered_fish_ids",
"capacity_available",
"movement_sequence",
]:
if not payload.has(key):
return "Malformed fishing request."
@ -51,11 +54,16 @@ static func validate_cast_request(data: Variant) -> String:
or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY
or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY
or typeof(payload["capacity_available"]) != TYPE_BOOL
or typeof(payload["movement_sequence"]) != TYPE_INT
):
return "Malformed fishing request."
if payload.has("bait_id") and typeof(payload["bait_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
if payload.has("bait_id") and typeof(payload["bait_id"]) not in [
TYPE_STRING, TYPE_STRING_NAME
]:
return "Malformed fishing request."
if payload.has("lure_id") and typeof(payload["lure_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
if payload.has("lure_id") and typeof(payload["lure_id"]) not in [
TYPE_STRING, TYPE_STRING_NAME
]:
return "Malformed fishing request."
var request_id: String = payload["request_id"]
var session_id: String = payload["session_id"]
@ -92,6 +100,8 @@ static func validate_cast_request(data: Variant) -> String:
or str(payload["rod_id"]).is_empty()
or str(payload["rod_id"]).length() > 96
or str(payload.get("lure_id", "")).length() > 96
or int(payload["movement_sequence"]) < 0
or int(payload["movement_sequence"]) > 2147483647
):
return "Fishing request values are outside allowed limits."
for value: Variant in rarity:

View file

@ -21,6 +21,8 @@ const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const CAST_ORIGIN_TOLERANCE: float = 2.5
const CAPACITY_RESPONSE_TIMEOUT: float = 5.0
const MIN_CAST_INTERVAL: float = 0.25
const OBSERVER_SNAPSHOT_INTERVAL: float = 0.1
const OBSERVER_RELEVANCE_DISTANCE: float = 96.0
signal local_cast_accepted(attempt_id: String, target: Vector3)
signal local_cast_rejected(message: String)
@ -51,7 +53,13 @@ var _last_input_time: Dictionary[int, float] = {}
var _remote_presentations: Dictionary[int, RemoteFishingPresentation] = {}
var _pending_local_bait_by_request: Dictionary[String, StringName] = {}
var _snapshot_accumulator: float = 0.0
var _observer_snapshot_accumulator: float = 0.0
var _local_input_sequence: int = 0
var _presentation_enabled: bool = true
var _owner_snapshot_packets_sent: int = 0
var _owner_snapshot_states_sent: int = 0
var _observer_snapshot_packets_sent: int = 0
var _observer_snapshot_states_sent: int = 0
func setup(
@ -67,6 +75,7 @@ func setup(
item_catalog: ItemCatalog,
fish_catalog: FishPoolType,
item_use: NetworkItemUseService,
presentation_enabled: bool = true,
) -> void:
_session = session
_spawn_service = spawn_service
@ -80,6 +89,7 @@ func setup(
_item_catalog = item_catalog
_fish_catalog = fish_catalog
_item_use = item_use
_presentation_enabled = presentation_enabled
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -130,6 +140,7 @@ func request_local_cast(
"capacity_available": bool(evidence.get("capacity_available", false)),
"bait_id": str(bait_id),
"lure_id": str(lure_id),
"movement_sequence": _session.get_latest_movement_input_sequence(),
}
if _session.is_host():
_handle_cast_request(_session.get_local_peer_id(), data)
@ -213,15 +224,27 @@ func _process(delta: float) -> void:
if now >= attempt.capacity_deadline:
_cancel_attempt(peer_id, "Fishing attempt ended.")
_snapshot_accumulator += delta
_observer_snapshot_accumulator += delta
if _snapshot_accumulator >= NetworkFishingProtocol.SNAPSHOT_RATE:
_snapshot_accumulator = fmod(
_snapshot_accumulator,
NetworkFishingProtocol.SNAPSHOT_RATE
)
_broadcast_snapshots()
_broadcast_owner_snapshots()
if _observer_snapshot_accumulator >= OBSERVER_SNAPSHOT_INTERVAL:
_observer_snapshot_accumulator = fmod(
_observer_snapshot_accumulator,
OBSERVER_SNAPSHOT_INTERVAL,
)
_broadcast_observer_snapshots()
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_cast_request(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _session.is_host() or not _session.is_authenticated_peer(sender_id):
@ -268,7 +291,13 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
return
var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(data["origin"])
var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"])
var authoritative_origin: Vector3 = avatar.get_cast_origin_position()
var movement_state: Dictionary = avatar.get_lag_compensated_movement_state(
int(data.get("movement_sequence", 0))
)
var authoritative_origin: Vector3 = movement_state.get(
"cast_origin",
avatar.get_cast_origin_position(),
)
if origin.distance_to(authoritative_origin) > CAST_ORIGIN_TOLERANCE:
_record_and_reject(peer_id, request_id, "Cannot fish here.")
return
@ -279,7 +308,10 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
_fishing_spot.maximum_cast_distance,
float(data["charge"])
)
var facing: Vector3 = avatar.get_facing_direction()
var facing: Vector3 = movement_state.get(
"facing",
avatar.get_facing_direction(),
)
facing.y = 0.0
if (
cast_offset.length() < _fishing_spot.minimum_cast_distance - 0.25
@ -642,7 +674,12 @@ func _handle_fishing_input(peer_id: int, data: Dictionary) -> void:
attempt.controller.handle_primary_pressed()
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_cancel_request(attempt_id: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
var attempt: NetworkFishingAttempt = _attempts.get(sender_id)
@ -687,16 +724,59 @@ func _on_encounter_updated(
})
func _broadcast_snapshots() -> void:
var snapshots: Array[Dictionary] = []
func _broadcast_owner_snapshots() -> void:
for attempt: NetworkFishingAttempt in _attempts.values():
var snapshot: Dictionary = attempt.get_meta("snapshot", {})
if not snapshot.is_empty():
snapshots.append(snapshot)
if snapshots.is_empty():
if snapshot.is_empty():
continue
var owner_peer_id: int = attempt.owner_peer_id
if owner_peer_id == _session.get_local_peer_id():
_apply_snapshots([snapshot])
elif owner_peer_id > 1:
receive_fishing_snapshots.rpc_id(owner_peer_id, [snapshot])
_owner_snapshot_packets_sent += 1
_owner_snapshot_states_sent += 1
func _broadcast_observer_snapshots() -> void:
if _attempts.is_empty():
return
_apply_snapshots(snapshots)
receive_fishing_snapshots.rpc(snapshots)
var observer_ids: Array[int] = _session.get_authenticated_peer_ids()
for observer_peer_id: int in observer_ids:
var observer_avatar: Player = _spawn_service.get_avatar(observer_peer_id)
if observer_avatar == null:
continue
var summaries: Array = []
for attempt: NetworkFishingAttempt in _attempts.values():
if attempt.owner_peer_id == observer_peer_id:
continue
var owner_avatar: Player = _spawn_service.get_avatar(
attempt.owner_peer_id
)
if (
owner_avatar == null
or observer_avatar.global_position.distance_squared_to(
owner_avatar.global_position
) > OBSERVER_RELEVANCE_DISTANCE * OBSERVER_RELEVANCE_DISTANCE
):
continue
summaries.append([
attempt.attempt_id,
attempt.owner_peer_id,
attempt.bobber_position,
int(attempt.phase),
])
if summaries.is_empty():
continue
if observer_peer_id == _session.get_local_peer_id():
_apply_observer_snapshots(summaries)
elif observer_peer_id > 1:
receive_fishing_observer_snapshots.rpc_id(
observer_peer_id,
summaries,
)
_observer_snapshot_packets_sent += 1
_observer_snapshot_states_sent += summaries.size()
@rpc("authority", "call_remote", "unreliable_ordered", 4)
@ -704,6 +784,55 @@ func receive_fishing_snapshots(snapshots: Array) -> void:
_apply_snapshots(snapshots)
@rpc(
"authority",
"call_remote",
"unreliable_ordered",
NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL,
)
func receive_fishing_observer_snapshots(summaries: Array) -> void:
_apply_observer_snapshots(summaries)
func _apply_observer_snapshots(summaries: Array) -> void:
if not _presentation_enabled or summaries.size() > 128:
return
for value: Variant in summaries:
if typeof(value) != TYPE_ARRAY:
continue
var fields: Array = value
if (
fields.size() != 4
or typeof(fields[0]) != TYPE_STRING
or str(fields[0]).is_empty()
or str(fields[0]).length() > NetworkFishingProtocol.MAX_ID_LENGTH
or typeof(fields[1]) != TYPE_INT
or int(fields[1]) <= 0
or typeof(fields[2]) != TYPE_VECTOR3
or typeof(fields[3]) != TYPE_INT
or not (fields[2] as Vector3).is_finite()
or int(fields[3]) not in [
NetworkFishingAttempt.Phase.WAITING_FOR_BITE,
NetworkFishingAttempt.Phase.FIGHTING,
NetworkFishingAttempt.Phase.PENDING_CAPACITY,
]
):
continue
var owner_peer_id: int = int(fields[1])
if owner_peer_id == _session.get_local_peer_id():
continue
var presentation := _get_remote_presentation(owner_peer_id)
if presentation != null:
presentation.synchronize_active(
str(fields[0]),
fields[2],
int(fields[3]) in [
NetworkFishingAttempt.Phase.FIGHTING,
NetworkFishingAttempt.Phase.PENDING_CAPACITY,
],
)
func _apply_snapshots(snapshots: Array) -> void:
var local_peer_id: int = _session.get_local_peer_id()
for value: Variant in snapshots:
@ -810,7 +939,12 @@ func _on_attempt_caught(peer_id: int) -> void:
receive_capacity_probe.rpc_id(peer_id, probe)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_capacity_probe(data: Dictionary) -> void:
_handle_local_capacity_probe(data)
@ -844,7 +978,12 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
)
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_capacity_response(
attempt_id: String,
capacity_nonce: String,
@ -902,7 +1041,12 @@ func _finalize_catch(attempt: NetworkFishingAttempt) -> void:
_dispose_attempt(attempt.owner_peer_id)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_target_outcome(data: Dictionary) -> void:
_apply_target_outcome(data)
@ -934,7 +1078,7 @@ func _apply_target_outcome(data: Dictionary) -> void:
)
)
_local_inventory.add_catch(fish_catch)
_local_collection.mark_quality_discovered(
_local_collection.record_catch(
fish_id,
fish_catch.quality,
)
@ -970,7 +1114,12 @@ func _acknowledge_result(result_id: String, catch_id: StringName) -> void:
acknowledge_fishing_result.rpc_id(1, result_id, str(catch_id))
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func acknowledge_fishing_result(result_id: String, catch_id: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
@ -1027,7 +1176,12 @@ func _broadcast_public_outcome(
receive_public_outcome.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_public_outcome(data: Dictionary) -> void:
_apply_public_outcome(data)
@ -1079,7 +1233,12 @@ func _broadcast_cast_accepted(data: Dictionary) -> void:
receive_cast_accepted.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_cast_accepted(data: Dictionary) -> void:
_apply_cast_accepted(data)
@ -1114,10 +1273,19 @@ func _apply_cast_accepted(data: Dictionary) -> void:
else:
var presentation := _get_remote_presentation(peer_id)
if presentation != null:
presentation.show_cast(origin, target)
presentation.show_cast(
origin,
target,
str(data["attempt_id"]),
)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_bite_pending(data: Dictionary) -> void:
_apply_bite_pending(data)
@ -1132,7 +1300,12 @@ func _apply_bite_pending(data: Dictionary) -> void:
local_bite_pending.emit(str(data["attempt_id"]))
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_bite_started(data: Dictionary) -> void:
_apply_bite_started(data)
@ -1193,7 +1366,12 @@ func _send_cast_rejected(
receive_cast_rejected.rpc_id(peer_id, request_id, message.left(128))
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_cast_rejected(request_id: String, message: String) -> void:
_pending_local_bait_by_request.erase(request_id)
local_cast_rejected.emit(message)
@ -1228,6 +1406,8 @@ func _resend_request_response(peer_id: int, response: Dictionary) -> void:
func _get_remote_presentation(
peer_id: int,
) -> RemoteFishingPresentation:
if not _presentation_enabled:
return null
var existing: RemoteFishingPresentation = _remote_presentations.get(peer_id)
if existing != null and is_instance_valid(existing):
return existing
@ -1330,9 +1510,21 @@ func _clear_all() -> void:
_last_input_time.clear()
_pending_local_bait_by_request.clear()
_snapshot_accumulator = 0.0
_observer_snapshot_accumulator = 0.0
_local_input_sequence = 0
func get_network_metrics() -> Dictionary:
return {
"active_attempts": _attempts.size(),
"remote_presentations": _remote_presentations.size(),
"owner_snapshot_packets_sent": _owner_snapshot_packets_sent,
"owner_snapshot_states_sent": _owner_snapshot_states_sent,
"observer_snapshot_packets_sent": _observer_snapshot_packets_sent,
"observer_snapshot_states_sent": _observer_snapshot_states_sent,
}
func _bound_result_ledger() -> void:
while _result_ledgers.size() > MAX_LEDGER_ENTRIES_PER_PEER:
_result_ledgers.erase(_result_ledgers.keys().front())

View file

@ -47,6 +47,7 @@ func setup(
_session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed)
_session.peer_authenticated.connect(_on_peer_authenticated)
_spawn_service.avatar_spawned.connect(_on_avatar_spawned)
func request_use(item_id: StringName) -> String:
@ -436,6 +437,12 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
receive_equipped_state.rpc_id(peer_id, state)
func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
var state: Dictionary = _equipped_states.get(peer_id, {})
if not state.is_empty():
_apply_equipped(state)
func _on_peer_removed(peer_id: int) -> void:
_requests.erase(peer_id)
_pending_by_peer.erase(peer_id)

View file

@ -3,6 +3,15 @@ extends Node
signal entries_changed
signal moderation_finished(success: bool, message: String)
signal friend_request_received(
request_id: String,
fingerprint: String,
display_name: String,
)
signal friend_action_finished(success: bool, message: String)
const MAX_PENDING_FRIEND_REQUESTS := 16
const FRIEND_REQUEST_ID_LENGTH := 32
var _session: NetworkSession
var _relationships: PlayerRelationshipStore
@ -18,6 +27,9 @@ var _peer_fingerprints: Dictionary[int, String] = {}
var _remote_bans: Array[Dictionary] = []
var _ban_snapshot_requested: bool = false
var _ban_snapshot_loaded: bool = false
var _pending_outgoing_friends: Dictionary[String, Dictionary] = {}
var _pending_incoming_friends: Dictionary[String, Dictionary] = {}
var _host_friend_routes: Dictionary[String, Dictionary] = {}
func setup(
@ -74,6 +86,18 @@ func get_entries() -> Array[PlayerListEntry]:
entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
entry.muted = _relationships.is_muted(record.identity_fingerprint)
entry.blocked = blocked
entry.is_friend = _relationships.is_friend(record.identity_fingerprint)
entry.can_request_friend = (
not entry.is_local_player
and not entry.is_friend
and not entry.blocked
and _session.supports_server_capability(
NetworkProtocol.FRIENDS_CAPABILITY
)
and _session.peer_supports_capability(
peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
)
entry.can_kick = (
_session.can_local_moderate()
and peer_id != local_id
@ -125,6 +149,10 @@ func get_relationships() -> Array[Dictionary]:
return _relationships.get_records()
func get_friends() -> Array[Dictionary]:
return _relationships.get_friends()
func get_bans() -> Array[Dictionary]:
if _session.is_host():
return _bans.get_bans(_session.get_host_identity_fingerprint())
@ -180,6 +208,160 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
return _relationships.set_blocked(fingerprint, display_name, value)
func remove_friend(fingerprint: String, display_name: String) -> bool:
if fingerprint == _session.get_local_identity_fingerprint():
return false
var ok := _relationships.remove_friend(fingerprint, display_name)
friend_action_finished.emit(
ok,
"Friend removed." if ok else "Friend could not be removed.",
)
return ok
func send_friend_request(
peer_id: int,
fingerprint: String,
display_name: String,
) -> bool:
if (
_pending_outgoing_friends.size() >= MAX_PENDING_FRIEND_REQUESTS
or fingerprint == _session.get_local_identity_fingerprint()
or _relationships.is_blocked(fingerprint)
or _relationships.is_friend(fingerprint)
or not _valid_friend_target(peer_id, fingerprint)
):
friend_action_finished.emit(
false, "This person needs to be online to do this."
)
return false
var local_capabilities: Dictionary = (
_relationships.create_friend_capabilities()
)
if local_capabilities.is_empty():
friend_action_finished.emit(false, "Friend request could not be created.")
return false
var request_id := NetworkIdentityCrypto.secure_id(16)
_pending_outgoing_friends[request_id] = {
"target_peer_id": peer_id,
"target_fingerprint": fingerprint,
"target_display_name": display_name,
"local_capabilities": local_capabilities,
}
var public_capabilities := _public_friend_capabilities(local_capabilities)
if _session.is_host():
_route_friend_request(
_session.get_local_peer_id(), peer_id, request_id, public_capabilities
)
else:
request_friendship.rpc_id(
1, peer_id, request_id, public_capabilities
)
friend_action_finished.emit(true, "Friend request sent.")
return true
func respond_friend_request(request_id: String, accepted: bool) -> bool:
var pending: Dictionary = _pending_incoming_friends.get(request_id, {})
if pending.is_empty():
friend_action_finished.emit(
false, "This friend request is no longer available."
)
return false
_pending_incoming_friends.erase(request_id)
var local_capabilities: Dictionary = {}
var public_capabilities: Dictionary = {}
var response_accepted := accepted
if accepted:
local_capabilities = _relationships.create_friend_capabilities()
response_accepted = (
not local_capabilities.is_empty()
and _relationships.add_friend(
str(pending["requester_fingerprint"]),
str(pending["requester_display_name"]),
local_capabilities,
pending["remote_capabilities"],
)
)
if response_accepted:
public_capabilities = _public_friend_capabilities(local_capabilities)
if _session.is_host():
_route_friend_response(
_session.get_local_peer_id(),
request_id,
response_accepted,
public_capabilities,
)
else:
respond_friendship.rpc_id(
1, request_id, response_accepted, public_capabilities
)
friend_action_finished.emit(
response_accepted or not accepted,
"Friend added."
if response_accepted
else "Friend request declined."
if not accepted
else "Friend could not be saved.",
)
return response_accepted or not accepted
@rpc("any_peer", "call_remote", "reliable", 0)
func request_friendship(
target_peer_id: int,
request_id: String,
public_capabilities: Dictionary,
) -> void:
_route_friend_request(
multiplayer.get_remote_sender_id(),
target_peer_id,
request_id,
public_capabilities,
)
@rpc("authority", "call_remote", "reliable", 0)
func receive_friend_request(
request_id: String,
requester_fingerprint: String,
requester_display_name: String,
public_capabilities: Dictionary,
) -> void:
_receive_friend_request_local(
request_id,
requester_fingerprint,
requester_display_name,
public_capabilities,
)
@rpc("any_peer", "call_remote", "reliable", 0)
func respond_friendship(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
) -> void:
_route_friend_response(
multiplayer.get_remote_sender_id(),
request_id,
accepted,
public_capabilities,
)
@rpc("authority", "call_remote", "reliable", 0)
func receive_friend_result(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
_receive_friend_result_local(
request_id, accepted, public_capabilities, message
)
func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
if _session.is_host():
return _kick_on_host(peer_id, fingerprint, revision, true)
@ -454,6 +636,32 @@ func _on_peer_removed(peer_id: int) -> void:
for key: String in _host_block_pairs.keys():
if fingerprint in key.split(":"):
_host_block_pairs.erase(key)
for request_id: String in _host_friend_routes.keys():
var route: Dictionary = _host_friend_routes[request_id]
if int(route.get("target_peer_id", 0)) == peer_id:
_send_friend_result_to_peer(
int(route.get("requester_peer_id", 0)),
request_id,
false,
{},
"This person needs to be online to do this.",
)
if (
int(route.get("target_peer_id", 0)) == peer_id
or int(route.get("requester_peer_id", 0)) == peer_id
):
_host_friend_routes.erase(request_id)
for request_id: String in _pending_outgoing_friends.keys():
var pending: Dictionary = _pending_outgoing_friends[request_id]
if str(pending.get("target_fingerprint", "")) == fingerprint:
_pending_outgoing_friends.erase(request_id)
friend_action_finished.emit(
false, "This person needs to be online to do this."
)
for request_id: String in _pending_incoming_friends.keys():
var pending: Dictionary = _pending_incoming_friends[request_id]
if str(pending.get("requester_fingerprint", "")) == fingerprint:
_pending_incoming_friends.erase(request_id)
_changed()
@ -469,6 +677,9 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_remote_bans.clear()
_ban_snapshot_requested = false
_ban_snapshot_loaded = false
_pending_outgoing_friends.clear()
_pending_incoming_friends.clear()
_host_friend_routes.clear()
elif state == NetworkSession.State.JOINED_CLIENT:
_request_ban_snapshot()
_changed()
@ -506,6 +717,245 @@ func _peer_for_fingerprint(fingerprint: String) -> int:
return 0
func _valid_friend_target(peer_id: int, fingerprint: String) -> bool:
if (
not _session.is_gameplay_session_active()
or not _session.supports_server_capability(
NetworkProtocol.FRIENDS_CAPABILITY
)
or not _session.peer_supports_capability(
peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
):
return false
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
return (
record != null
and record.identity_authenticated
and record.identity_fingerprint == fingerprint
)
func _route_friend_request(
requester_peer_id: int,
target_peer_id: int,
request_id: String,
public_capabilities: Dictionary,
) -> void:
if (
not _session.is_host()
or not _valid_friend_request_id(request_id)
or not _valid_public_friend_capabilities(public_capabilities)
or not _session.is_authenticated_peer(requester_peer_id)
):
return
var requester: PeerRegistry.PeerRecord = (
_session.get_peer_record(requester_peer_id)
)
var target: PeerRegistry.PeerRecord = _session.get_peer_record(target_peer_id)
var can_route := (
requester != null
and target != null
and requester_peer_id != target_peer_id
and _valid_friend_target(
target_peer_id, target.identity_fingerprint
)
and _session.peer_supports_capability(
requester_peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
and not pair_is_blocked(
requester.identity_fingerprint, target.identity_fingerprint
)
and _host_friend_routes.size() < (
MAX_PENDING_FRIEND_REQUESTS * 8
)
)
if not can_route:
_send_friend_result_to_peer(
requester_peer_id,
request_id,
false,
{},
"This person needs to be online to do this.",
)
return
var requester_pending_count := 0
for route: Dictionary in _host_friend_routes.values():
if int(route.get("requester_peer_id", 0)) == requester_peer_id:
requester_pending_count += 1
if requester_pending_count >= MAX_PENDING_FRIEND_REQUESTS:
_send_friend_result_to_peer(
requester_peer_id,
request_id,
false,
{},
"Too many friend requests are pending.",
)
return
_host_friend_routes[request_id] = {
"requester_peer_id": requester_peer_id,
"target_peer_id": target_peer_id,
}
if target_peer_id == _session.get_local_peer_id():
_receive_friend_request_local(
request_id,
requester.identity_fingerprint,
requester.display_name,
public_capabilities,
)
else:
receive_friend_request.rpc_id(
target_peer_id,
request_id,
requester.identity_fingerprint,
requester.display_name,
public_capabilities,
)
func _receive_friend_request_local(
request_id: String,
requester_fingerprint: String,
requester_display_name: String,
public_capabilities: Dictionary,
) -> void:
if (
not _valid_friend_request_id(request_id)
or not _valid_public_friend_capabilities(public_capabilities)
or not NetworkIdentityCrypto.valid_fingerprint(requester_fingerprint)
or not NetworkProfilePreferences.is_valid_display_name(
requester_display_name
)
or _pending_incoming_friends.size()
>= MAX_PENDING_FRIEND_REQUESTS
or _relationships.is_blocked(requester_fingerprint)
or _relationships.is_friend(requester_fingerprint)
):
_decline_friend_request(request_id)
return
_pending_incoming_friends[request_id] = {
"requester_fingerprint": requester_fingerprint,
"requester_display_name": requester_display_name,
"remote_capabilities": public_capabilities.duplicate(true),
}
friend_request_received.emit(
request_id, requester_fingerprint, requester_display_name
)
func _decline_friend_request(request_id: String) -> void:
if _session.is_host():
_route_friend_response(
_session.get_local_peer_id(), request_id, false, {}
)
elif _session.is_joined_client():
respond_friendship.rpc_id(1, request_id, false, {})
func _route_friend_response(
responder_peer_id: int,
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
) -> void:
if not _session.is_host():
return
var route: Dictionary = _host_friend_routes.get(request_id, {})
if (
route.is_empty()
or int(route.get("target_peer_id", 0)) != responder_peer_id
):
return
_host_friend_routes.erase(request_id)
var response_accepted := (
accepted and _valid_public_friend_capabilities(public_capabilities)
)
_send_friend_result_to_peer(
int(route["requester_peer_id"]),
request_id,
response_accepted,
public_capabilities if response_accepted else {},
"Friend request accepted."
if response_accepted
else "Friend request declined."
if not accepted
else "Friend request could not be completed.",
)
func _send_friend_result_to_peer(
peer_id: int,
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
if peer_id == _session.get_local_peer_id():
_receive_friend_result_local(
request_id, accepted, public_capabilities, message
)
elif _session.is_authenticated_peer(peer_id):
receive_friend_result.rpc_id(
peer_id,
request_id,
accepted,
public_capabilities,
message.left(120),
)
func _receive_friend_result_local(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
var pending: Dictionary = _pending_outgoing_friends.get(request_id, {})
if pending.is_empty():
return
_pending_outgoing_friends.erase(request_id)
var success := false
if accepted and _valid_public_friend_capabilities(public_capabilities):
success = _relationships.add_friend(
str(pending["target_fingerprint"]),
str(pending["target_display_name"]),
pending["local_capabilities"],
public_capabilities,
)
message = "Friend added." if success else "Friend could not be saved."
friend_action_finished.emit(success, message.left(120))
func _public_friend_capabilities(capabilities: Dictionary) -> Dictionary:
return {
"presence_channel": str(capabilities.get("presence_channel", "")),
"invite_token": str(capabilities.get("invite_token", "")),
}
func _valid_public_friend_capabilities(capabilities: Dictionary) -> bool:
return (
_valid_friend_hex(str(capabilities.get("presence_channel", "")))
and _valid_friend_hex(str(capabilities.get("invite_token", "")))
)
func _valid_friend_request_id(request_id: String) -> bool:
return _valid_friend_hex(request_id, FRIEND_REQUEST_ID_LENGTH)
func _valid_friend_hex(
value: String,
expected_length: int = PlayerRelationshipStore.SOCIAL_TOKEN_LENGTH,
) -> bool:
if value.length() != expected_length:
return false
for character: String in value:
if character not in "0123456789abcdef":
return false
return true
func _valid_moderation_target(
peer_id: int,
fingerprint: String,

View file

@ -3,7 +3,7 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 9
const PROTOCOL_VERSION: int = 10
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -13,14 +13,24 @@ const MAX_PUBLIC_KEY_LENGTH: int = 8192
const MAX_SIGNATURE_LENGTH: int = 2048
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
# 6 reliable shop transactions, 7 reliable item/equipment/showcase/drawing,
# 8 reliable ordered session chat, 9 reliable private session mail.
# 6 reliable shop transactions, 7 reliable item/equipment,
# 8 reliable ordered session chat, 9 reliable private session mail,
# 10 observer-only fishing summaries, 11 reliable movement animation state,
# 12 reliable fish showcase state, 13 reliable world-spawn events,
# 14 reliable artwork state, 15 world-spawn snapshots, and 16 reliable
# fishing lifecycle. Bulk world/art payloads must not head-of-line block held
# items, fishing, or animation presentation.
const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6
const ITEM_RELIABLE_CHANNEL: int = 7
const CHAT_RELIABLE_CHANNEL: int = 8
const MAIL_RELIABLE_CHANNEL: int = 9
const ENET_CHANNEL_COUNT: int = 10
const MOVEMENT_ANIMATION_CHANNEL: int = 11
const SHOWCASE_RELIABLE_CHANNEL: int = 12
const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13
const DRAWING_RELIABLE_CHANNEL: int = 14
const FISHING_RELIABLE_CHANNEL: int = 16
const ENET_CHANNEL_COUNT: int = 17
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1"
@ -32,6 +42,9 @@ const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1"
const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1"
const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1"
const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1"
const FRIENDS_CAPABILITY: String = "friends_v1"
const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2"
const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
@ -186,6 +199,9 @@ static func make_client_hello(
BACKPACK_SHOP_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
WORLD_LAYOUT_CAPABILITY,
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -323,6 +339,9 @@ static func make_server_hello(
APPEARANCE_PREVIEW_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
WORLD_LAYOUT_CAPABILITY,
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -14,6 +14,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd")
const ItemResalePolicyType = preload("res://economy/item_resale_policy.gd")
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const LOCAL_REQUEST_RETRY_SECONDS: float = 2.0
const PELICAN_BUYER_ID: StringName = &"pelicans"
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
@ -45,6 +46,8 @@ var _acknowledged_results: Dictionary[String, bool] = {}
var _applied_results: Dictionary[String, bool] = {}
var _received_results: Dictionary[String, bool] = {}
var _pending_local_request_id: String = ""
var _pending_local_request: Dictionary = {}
var _local_request_retry_elapsed: float = 0.0
var _pending_local_catch_ids: Array[StringName] = []
var _pending_local_items: Array[Dictionary] = []
var _pending_local_buyer_id: StringName
@ -52,6 +55,25 @@ var _reservations: PlayerAssetReservationService
var _inventory_layout: PlayerInventoryLayout
func _process(delta: float) -> void:
if (
_pending_local_request.is_empty()
or _session == null
or not _session.is_joined_client()
):
_local_request_retry_elapsed = 0.0
return
_local_request_retry_elapsed += delta
if _local_request_retry_elapsed < LOCAL_REQUEST_RETRY_SECONDS:
return
_local_request_retry_elapsed = 0.0
# The request ID is stable and the host ledger is idempotent, so an
# application-level retry cannot apply a sale twice. This covers the narrow
# transition where a generated client has authenticated but is only just
# resuming regular multiplayer polling.
submit_sale_request.rpc_id(1, _pending_local_request)
func setup(
session: NetworkSession,
spawn_service: PlayerSpawnService,
@ -208,6 +230,8 @@ func request_local_mixed_sale(
"items": item_evidence,
}
_pending_local_request_id = request_id
_pending_local_request = request.duplicate(true)
_local_request_retry_elapsed = 0.0
_pending_local_catch_ids = catch_ids.duplicate()
_pending_local_items = item_evidence.duplicate(true)
_pending_local_buyer_id = buyer.id
@ -595,6 +619,8 @@ func _finish_local_sale(
payout: int,
) -> void:
_pending_local_request_id = ""
_pending_local_request.clear()
_local_request_retry_elapsed = 0.0
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()
@ -730,6 +756,8 @@ func _clear_session_state() -> void:
_applied_results.clear()
_received_results.clear()
_pending_local_request_id = ""
_pending_local_request.clear()
_local_request_retry_elapsed = 0.0
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()

View file

@ -7,13 +7,50 @@ const DEFAULT_SESSION_MAX_PLAYERS: int = 8
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const ENET_TIMEOUT_LIMIT: int = 32
const ENET_TIMEOUT_MINIMUM_MS: int = 10000
const ENET_TIMEOUT_MAXIMUM_MS: int = 120000
const INPUT_INTERVAL: float = 1.0 / 30.0
const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2
const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6
const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8
const NEAR_REMOTE_DISTANCE: float = 48.0
const FAR_REMOTE_DISTANCE: float = 96.0
const MOVEMENT_SNAPSHOT_BATCH_SIZE: int = 8
const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 13
const MOVEMENT_INPUT_FIELD_COUNT: int = 8
const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 6
const MOVEMENT_ANIMATION_FIELD_COUNT: int = 7
const MOVEMENT_ANIMATION_ACTION_FIELD_COUNT: int = 5
const MAX_PENDING_MOVEMENT_INPUTS: int = 96
const ANIMATION_REFRESH_INTERVAL: float = 1.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25
const MOVEMENT_FLAG_JUMP: int = 1 << 0
const MOVEMENT_FLAG_SPRINT: int = 1 << 1
const MOVEMENT_FLAG_SNEAK: int = 1 << 2
const MOVEMENT_FLAG_SLOW_WALK: int = 1 << 3
const MOVEMENT_FLAG_SITTING: int = 1 << 4
const MOVEMENT_FLAG_CASTING: int = 1 << 5
const MOVEMENT_ALLOWED_FLAGS: int = (
MOVEMENT_FLAG_JUMP
| MOVEMENT_FLAG_SPRINT
| MOVEMENT_FLAG_SNEAK
| MOVEMENT_FLAG_SLOW_WALK
| MOVEMENT_FLAG_SITTING
| MOVEMENT_FLAG_CASTING
)
const SNAPSHOT_FLAG_GROUNDED: int = 1 << 0
const SNAPSHOT_FLAG_SITTING: int = 1 << 1
const SNAPSHOT_FLAG_CASTING: int = 1 << 2
const SNAPSHOT_ALLOWED_FLAGS: int = (
SNAPSHOT_FLAG_GROUNDED
| SNAPSHOT_FLAG_SITTING
| SNAPSHOT_FLAG_CASTING
)
signal state_changed(state: State)
signal status_message_changed(message: String)
signal connection_error(message: String)
@ -80,7 +117,21 @@ var _client_nonce: String = ""
var _current_route: ConnectionRoute
var _input_sequence: int = 0
var _input_accumulator: float = 0.0
var _idle_input_accumulator: float = 0.0
var _last_input_state_hash: int = 0
var _pending_movement_inputs: Array[Dictionary] = []
var _snapshot_accumulator: float = 0.0
var _movement_snapshot_tick: int = 0
var _animation_refresh_accumulator: float = 0.0
var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {}
var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {}
var _last_local_animation_action_signature: Array = []
var _movement_inputs_sent: int = 0
var _movement_inputs_received: int = 0
var _movement_snapshot_packets_sent: int = 0
var _movement_snapshot_states_sent: int = 0
var _movement_animation_packets_sent: int = 0
var _movement_animation_states_sent: int = 0
var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
@ -278,6 +329,9 @@ func _register_player_host() -> void:
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -602,6 +656,9 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -649,6 +706,18 @@ func get_peer_rtt_ms(peer_id: int) -> int:
return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
func get_movement_metrics() -> Dictionary:
return {
"inputs_sent": _movement_inputs_sent,
"inputs_received": _movement_inputs_received,
"snapshot_packets_sent": _movement_snapshot_packets_sent,
"snapshot_states_sent": _movement_snapshot_states_sent,
"animation_packets_sent": _movement_animation_packets_sent,
"animation_states_sent": _movement_animation_states_sent,
"pending_local_inputs": _pending_movement_inputs.size(),
}
func kick_authenticated_peer(
peer_id: int,
fingerprint: String,
@ -813,6 +882,10 @@ func get_local_peer_id() -> int:
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func get_latest_movement_input_sequence() -> int:
return _input_sequence if state == State.JOINED_CLIENT else 0
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (
@ -833,9 +906,13 @@ func _process(delta: float) -> void:
return
_input_accumulator += delta
_snapshot_accumulator += delta
_animation_refresh_accumulator += delta
if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
var elapsed_input_time: float = _input_accumulator
_input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
_send_local_input()
_idle_input_accumulator += elapsed_input_time
_maybe_send_local_animation_action()
_maybe_send_local_input()
if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL:
_snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL)
_broadcast_movement_snapshots()
@ -873,6 +950,7 @@ func _on_peer_connected(peer_id: int) -> void:
if state != State.OPEN_HOST:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
_configure_enet_peer_timeout(peer_id)
_pending_authentication[peer_id] = (
Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
)
@ -881,6 +959,7 @@ func _on_peer_connected(peer_id: int) -> void:
func _on_connected_to_server() -> void:
if state != State.CONNECTING:
return
_configure_enet_peer_timeout(1)
_set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = NetworkIdentityCrypto.secure_id(32)
_client_identity_attempt = NetworkProtocol.make_identity_hello(
@ -892,6 +971,24 @@ func _on_connected_to_server() -> void:
submit_identity_hello.rpc_id(1, _client_identity_attempt)
func _configure_enet_peer_timeout(peer_id: int) -> void:
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if enet == null:
return
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id)
if packet_peer == null:
return
# A deterministic generated world can temporarily occupy the client's main
# thread during the authenticated join transition. Keep that bounded load
# from looking like a dead connection while retaining normal ENet liveness
# checks once the client resumes polling.
packet_peer.set_timeout(
ENET_TIMEOUT_LIMIT,
ENET_TIMEOUT_MINIMUM_MS,
ENET_TIMEOUT_MAXIMUM_MS,
)
func _on_connection_failed() -> void:
if state not in [
State.CONNECTING,
@ -931,6 +1028,8 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id)
@ -1209,6 +1308,16 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if (
_host_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities
@ -1432,6 +1541,14 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("This server does not support generated worlds.")
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in _server_capabilities:
_teardown_peer()
_fail("This server is missing required network capabilities.")
return
if (
received_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities
@ -1456,6 +1573,9 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
@ -1501,6 +1621,8 @@ func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
@ -1542,6 +1664,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
or typeof(entry.get("display_name")) != TYPE_STRING
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
or not NetworkPlayerAnimationProtocol.validate_state(
entry.get("animation_state")
)
):
return
if not _verify_spawn_identity(entry):
@ -1557,6 +1683,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
)
own_snapshot["position"] = own_position
own_snapshot["visual_yaw"] = float(entry["yaw"])
own_snapshot["sitting"] = bool(entry["sitting"])
own_avatar.apply_network_teleport(own_snapshot)
return
var peer_was_added: bool = false
@ -1603,6 +1730,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
var record := _registry.get_peer(peer_id)
if avatar != null and record != null:
avatar.apply_appearance_snapshot(record.appearance_snapshot)
# Lifecycle and animation updates use separate reliable ENet channels.
# Include the current authoritative state in the spawn record so a peer
# joining during an action presents it immediately, regardless of which
# channel arrives first.
avatar.apply_network_animation_state(entry["animation_state"])
avatar.apply_network_sitting_state(bool(entry["sitting"]))
var pending_animation: Dictionary = (
_pending_animation_state_by_peer.get(peer_id, {})
)
if not pending_animation.is_empty():
avatar.apply_network_animation_state(pending_animation)
_pending_animation_state_by_peer.erase(peer_id)
if peer_was_added and record != null:
peer_authenticated.emit(peer_id, record.display_name)
@ -1627,6 +1766,15 @@ func _make_spawn_entry(
transform: Transform3D,
) -> Dictionary:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
var avatar: Player = _spawn_service.get_avatar(peer_id)
var animation_state: Dictionary = (
avatar.make_network_animation_state()
if avatar != null
else NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
)
return {
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
@ -1637,6 +1785,12 @@ func _make_spawn_entry(
transform.origin.z,
],
"yaw": transform.basis.get_euler().y,
"sitting": (
avatar.get_network_sitting_state()
if avatar != null
else false
),
"animation_state": animation_state,
"appearance": (
record.appearance_snapshot.duplicate(true)
if record != null
@ -1862,14 +2016,69 @@ func _fail_identity(message: String) -> void:
_fail(message)
func _maybe_send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
var state_hash: int = avatar.get_network_input_state_hash()
var state_changed: bool = state_hash != _last_input_state_hash
if (
avatar.has_active_network_input()
or state_changed
or _idle_input_accumulator >= IDLE_INPUT_INTERVAL
):
_send_local_input()
_last_input_state_hash = state_hash
_idle_input_accumulator = 0.0
func _send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
_input_sequence += 1
_input_sequence = (
1
if _input_sequence >= MAX_MOVEMENT_INPUT_SEQUENCE
else _input_sequence + 1
)
var input: Dictionary = avatar.capture_network_input(_input_sequence)
submit_movement_input.rpc_id(1, input)
var encoded: Array = _encode_movement_input(input)
if encoded.is_empty():
return
_pending_movement_inputs.append(input.duplicate(true))
while _pending_movement_inputs.size() > MAX_PENDING_MOVEMENT_INPUTS:
_pending_movement_inputs.pop_front()
_movement_inputs_sent += 1
submit_movement_input.rpc_id(1, encoded)
func _maybe_send_local_animation_action() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
var state: Dictionary = avatar.make_network_animation_state()
var action: Dictionary = state.get("action", {})
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return
var signature: Array = [
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
avatar.get_network_sitting_state(),
]
if signature == _last_local_animation_action_signature:
return
var encoded: Array = _encode_movement_animation_action(
action,
avatar.get_network_sitting_state(),
)
if encoded.is_empty():
return
_last_local_animation_action_signature = signature
submit_movement_animation_action.rpc_id(1, encoded)
func submit_neutral_local_movement() -> void:
@ -1879,17 +2088,112 @@ func submit_neutral_local_movement() -> void:
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_movement_input(data: Dictionary) -> void:
func submit_movement_input(encoded: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
if avatar == null or not _is_valid_movement_input(data):
var data: Dictionary = _decode_movement_input(encoded)
if avatar == null or data.is_empty():
return
avatar.apply_authoritative_network_input(data)
_movement_inputs_received += 1
avatar.apply_authoritative_network_input(
data,
Player.resolve_network_input_stale_timeout_seconds(
get_peer_rtt_ms(sender_id)
),
)
func _is_valid_movement_input(data: Dictionary) -> bool:
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func submit_movement_animation_action(encoded: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
var state: Dictionary = _decode_movement_animation_action(encoded)
if avatar == null or state.is_empty():
return
avatar.apply_authoritative_network_animation_action(state["action"])
avatar.apply_authoritative_network_sitting_state(bool(state["sitting"]))
static func _encode_movement_input(data: Dictionary) -> Array:
if not _is_valid_movement_input(data):
return []
var axis: Array = data["axis"]
var flags: int = 0
if bool(data["jump"]):
flags |= MOVEMENT_FLAG_JUMP
if bool(data["sprint"]):
flags |= MOVEMENT_FLAG_SPRINT
if bool(data["sneak"]):
flags |= MOVEMENT_FLAG_SNEAK
if bool(data["slow_walk"]):
flags |= MOVEMENT_FLAG_SLOW_WALK
if bool(data["sitting"]):
flags |= MOVEMENT_FLAG_SITTING
if bool(data["casting"]):
flags |= MOVEMENT_FLAG_CASTING
var action: Dictionary = data["animation_action"]
return [
int(data["sequence"]),
Vector2(float(axis[0]), float(axis[1])),
float(data["camera_yaw"]),
flags,
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_input(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_INPUT_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) != TYPE_VECTOR2
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_INT
or typeof(fields[4]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[5]) != TYPE_INT
or typeof(fields[6]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[7]) != TYPE_BOOL
):
return {}
var axis: Vector2 = fields[1]
var flags: int = int(fields[3])
if flags < 0 or flags & ~MOVEMENT_ALLOWED_FLAGS != 0:
return {}
var decoded: Dictionary = {
"sequence": int(fields[0]),
"axis": [axis.x, axis.y],
"camera_yaw": float(fields[2]),
"jump": bool(flags & MOVEMENT_FLAG_JUMP),
"sprint": bool(flags & MOVEMENT_FLAG_SPRINT),
"sneak": bool(flags & MOVEMENT_FLAG_SNEAK),
"slow_walk": bool(flags & MOVEMENT_FLAG_SLOW_WALK),
"sitting": bool(flags & MOVEMENT_FLAG_SITTING),
"casting": bool(flags & MOVEMENT_FLAG_CASTING),
"animation_action": NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[4])),
int(fields[5]),
float(fields[6]),
bool(fields[7]),
),
}
return decoded if _is_valid_movement_input(decoded) else {}
static func _is_valid_movement_input(data: Dictionary) -> bool:
if (
typeof(data.get("sequence")) != TYPE_INT
or typeof(data.get("axis")) != TYPE_ARRAY
@ -1929,49 +2233,142 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
func _broadcast_movement_snapshots() -> void:
_movement_snapshot_tick += 1
var peer_ids: Array[int] = _registry.get_peer_ids()
for recipient_id: int in peer_ids:
# Peer 1 is the local listen-server player. Dedicated servers do not
# register a peer 1, so every remaining record is a remote recipient.
if recipient_id == 1:
continue
var recipient_avatar: Player = _spawn_service.get_avatar(recipient_id)
if recipient_avatar == null:
continue
var snapshots: Array = []
for peer_id: int in _registry.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
for subject_id: int in peer_ids:
var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
if subject_avatar == null:
continue
if (
subject_id != recipient_id
and not _should_send_remote_snapshot(
recipient_avatar.global_position,
subject_avatar.global_position,
)
):
continue
var encoded: Array = _encode_movement_snapshot(
avatar.make_network_snapshot(peer_id)
subject_avatar.make_network_snapshot(subject_id)
)
if not encoded.is_empty():
snapshots.append(encoded)
# The compact v5 representation keeps a normal eight-player update near
# 1 KiB instead of the roughly 3.7 KiB dictionary representation. Rooms
# configured above the normal cap are divided into the same safe size.
_send_movement_snapshot_batches(recipient_id, snapshots)
_broadcast_movement_animation_updates(peer_ids)
func _should_send_remote_snapshot(
recipient_position: Vector3,
subject_position: Vector3,
) -> bool:
var distance_squared: float = recipient_position.distance_squared_to(
subject_position
)
var divisor: int = DISTANT_REMOTE_SNAPSHOT_DIVISOR
if distance_squared <= NEAR_REMOTE_DISTANCE * NEAR_REMOTE_DISTANCE:
divisor = NEAR_REMOTE_SNAPSHOT_DIVISOR
elif distance_squared <= FAR_REMOTE_DISTANCE * FAR_REMOTE_DISTANCE:
divisor = FAR_REMOTE_SNAPSHOT_DIVISOR
return _movement_snapshot_tick % divisor == 0
func _send_movement_snapshot_batches(
recipient_id: int,
snapshots: Array,
) -> void:
for start_index: int in range(
0,
snapshots.size(),
MOVEMENT_SNAPSHOT_BATCH_SIZE,
):
receive_movement_snapshots.rpc(
snapshots.slice(
var batch: Array = snapshots.slice(
start_index,
mini(
start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE,
snapshots.size(),
),
)
receive_movement_snapshots.rpc_id(recipient_id, batch)
_movement_snapshot_packets_sent += 1
_movement_snapshot_states_sent += batch.size()
func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
var refresh_all: bool = (
_animation_refresh_accumulator >= ANIMATION_REFRESH_INTERVAL
)
if refresh_all:
_animation_refresh_accumulator = fmod(
_animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL,
)
var updates: Array = []
for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null:
continue
var state: Dictionary = avatar.make_network_animation_state()
var previous: Dictionary = _last_animation_state_by_peer.get(
subject_id, {}
)
if (
not refresh_all
and _movement_animation_signature(state)
== _movement_animation_signature(previous)
):
continue
_last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty():
updates.append(encoded)
if updates.is_empty():
return
for recipient_id: int in peer_ids:
if recipient_id == 1:
continue
receive_movement_animations.rpc_id(recipient_id, updates)
_movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size()
static func _movement_animation_signature(state: Dictionary) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
]
static func _encode_movement_snapshot(snapshot: Dictionary) -> Array:
var position: Variant = snapshot.get("position")
var snapshot_velocity: Variant = snapshot.get("velocity")
var animation_value: Variant = snapshot.get("animation_state")
if (
typeof(position) != TYPE_ARRAY
or position.size() != 3
or typeof(snapshot_velocity) != TYPE_ARRAY
or snapshot_velocity.size() != 3
or not NetworkPlayerAnimationProtocol.validate_state(animation_value)
):
return []
var animation: Dictionary = animation_value
var action: Dictionary = animation["action"]
var flags: int = 0
if bool(snapshot.get("grounded", false)):
flags |= SNAPSHOT_FLAG_GROUNDED
if bool(snapshot.get("sitting", false)):
flags |= SNAPSHOT_FLAG_SITTING
if bool(snapshot.get("casting", false)):
flags |= SNAPSHOT_FLAG_CASTING
return [
int(snapshot.get("peer_id", 0)),
int(snapshot.get("acknowledged_input", 0)),
@ -1982,14 +2379,7 @@ static func _encode_movement_snapshot(snapshot: Dictionary) -> Array:
float(snapshot_velocity[2]),
),
float(snapshot.get("visual_yaw", 0.0)),
str(animation["locomotion_id"]),
bool(animation["grounded"]),
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
bool(snapshot.get("sitting", false)),
bool(snapshot.get("casting", false)),
flags,
]
@ -2004,18 +2394,22 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary:
or typeof(fields[2]) != TYPE_VECTOR3
or typeof(fields[3]) != TYPE_VECTOR3
or typeof(fields[4]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[5]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[6]) != TYPE_BOOL
or typeof(fields[7]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[8]) != TYPE_INT
or typeof(fields[9]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[10]) != TYPE_BOOL
or typeof(fields[11]) != TYPE_BOOL
or typeof(fields[12]) != TYPE_BOOL
or typeof(fields[5]) != TYPE_INT
):
return {}
var position: Vector3 = fields[2]
var snapshot_velocity: Vector3 = fields[3]
var flags: int = int(fields[5])
if (
int(fields[0]) <= 0
or int(fields[1]) < 0
or not position.is_finite()
or not snapshot_velocity.is_finite()
or not is_finite(float(fields[4]))
or flags < 0
or flags & ~SNAPSHOT_ALLOWED_FLAGS != 0
):
return {}
return {
"peer_id": int(fields[0]),
"acknowledged_input": int(fields[1]),
@ -2026,19 +2420,100 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary:
snapshot_velocity.z,
],
"visual_yaw": float(fields[4]),
"animation_state": NetworkPlayerAnimationProtocol.make_state(
StringName(str(fields[5])),
bool(fields[6]),
StringName(str(fields[7])),
int(fields[8]),
float(fields[9]),
bool(fields[10]),
),
"sitting": bool(fields[11]),
"casting": bool(fields[12]),
"grounded": bool(flags & SNAPSHOT_FLAG_GROUNDED),
"sitting": bool(flags & SNAPSHOT_FLAG_SITTING),
"casting": bool(flags & SNAPSHOT_FLAG_CASTING),
}
static func _encode_movement_animation(
peer_id: int,
state: Dictionary,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
peer_id,
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_animation(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[2]) != TYPE_BOOL
or typeof(fields[3]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[4]) != TYPE_INT
or typeof(fields[5]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[6]) != TYPE_BOOL
):
return {}
var state: Dictionary = NetworkPlayerAnimationProtocol.make_state(
StringName(str(fields[1])),
bool(fields[2]),
StringName(str(fields[3])),
int(fields[4]),
float(fields[5]),
bool(fields[6]),
)
if (
int(fields[0]) <= 0
or not NetworkPlayerAnimationProtocol.validate_state(state)
):
return {}
return {"peer_id": int(fields[0]), "state": state}
static func _encode_movement_animation_action(
action: Dictionary,
sitting: bool,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return []
return [
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
sitting,
]
static func _decode_movement_animation_action(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_ACTION_FIELD_COUNT
or typeof(fields[0]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[1]) != TYPE_INT
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_BOOL
or typeof(fields[4]) != TYPE_BOOL
):
return {}
var action: Dictionary = NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[0])),
int(fields[1]),
float(fields[2]),
bool(fields[3]),
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return {}
return {"action": action, "sitting": bool(fields[4])}
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if state != State.JOINED_CLIENT:
@ -2058,11 +2533,13 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if avatar == null:
continue
if peer_id == local_peer_id:
_discard_acknowledged_movement_inputs(
int(snapshot.get("acknowledged_input", 0))
)
avatar.apply_local_prediction_correction(
snapshot,
_input_sequence,
_pending_movement_inputs,
INPUT_INTERVAL,
estimated_transit_seconds,
)
else:
avatar.push_network_snapshot(
@ -2071,6 +2548,39 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
)
func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
while (
not _pending_movement_inputs.is_empty()
and int(_pending_movement_inputs[0].get("sequence", 0))
<= acknowledged_sequence
):
_pending_movement_inputs.pop_front()
@rpc(
"authority",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func receive_movement_animations(encoded_states: Array) -> void:
if state != State.JOINED_CLIENT:
return
for value: Variant in encoded_states:
var decoded: Dictionary = _decode_movement_animation(value)
if decoded.is_empty():
continue
var avatar: Player = _spawn_service.get_avatar(
int(decoded["peer_id"])
)
if avatar == null:
_pending_animation_state_by_peer[int(decoded["peer_id"])] = (
Dictionary(decoded["state"]).duplicate(true)
)
continue
avatar.apply_network_animation_state(decoded["state"])
func _estimated_movement_transit_seconds() -> float:
if state != State.JOINED_CLIENT:
return -1.0
@ -2346,7 +2856,15 @@ func _teardown_peer() -> void:
_connection_deadline = 0.0
_input_sequence = 0
_input_accumulator = 0.0
_idle_input_accumulator = 0.0
_last_input_state_hash = 0
_pending_movement_inputs.clear()
_snapshot_accumulator = 0.0
_movement_snapshot_tick = 0
_animation_refresh_accumulator = 0.0
_last_animation_state_by_peer.clear()
_pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray()
_server_identity_fingerprint = ""
_server_identity_public_key = ""

View file

@ -52,6 +52,7 @@ var _canvas_states: Dictionary[String, Dictionary] = {}
var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {}
var _selected_canvas_id: String = ""
var _hovered_canvas_id: String = ""
var _last_hovered_canvas_id: String = ""
var _brush_preview: MultiMeshInstance3D
var _brush_preview_multimesh: MultiMesh
var _brush_preview_material: ShaderMaterial
@ -236,6 +237,7 @@ func activate(
return
_active = true
_placing_grid = false
_last_hovered_canvas_id = ""
_clear_stamp_mode()
_eraser_mode = false
_clear_armed_guide_action(false)
@ -267,6 +269,7 @@ func deactivate() -> void:
_reset_stroke()
_selected_canvas_id = ""
_hovered_canvas_id = ""
_last_hovered_canvas_id = ""
_aim_hit.clear()
_hide_previews()
_refresh_stencil_visibility()
@ -849,10 +852,22 @@ func select_saved_stamp(path: String) -> bool:
func export_aimed_canvas() -> String:
_update_aim()
if _hovered_canvas_id.is_empty():
var canvas_id: String = (
_hovered_canvas_id
if not _hovered_canvas_id.is_empty()
else _last_hovered_canvas_id
)
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
if (
canvas_id.is_empty()
or canvas == null
or not is_instance_valid(canvas)
or canvas.is_hidden_by_relationship()
):
_last_hovered_canvas_id = ""
_emit_hud_state("aim at artwork before exporting")
return ""
return export_canvas_png(_hovered_canvas_id)
return export_canvas_png(canvas_id)
func export_canvas_png(canvas_id: String) -> String:
@ -1070,6 +1085,8 @@ func _update_aim() -> void:
selected_layer = layer
_selected_canvas_id = found_canvas_id
_hovered_canvas_id = found_hovered_canvas_id
if not _hovered_canvas_id.is_empty():
_last_hovered_canvas_id = _hovered_canvas_id
if previous_canvas_id != _selected_canvas_id:
_reset_stroke()
_update_previews()
@ -2865,6 +2882,7 @@ func _clear_session_artwork_state() -> void:
_canvas_sequence = 0
_selected_canvas_id = ""
_hovered_canvas_id = ""
_last_hovered_canvas_id = ""
_stroke_history_by_peer.clear()
_cell_last_stroke.clear()
_last_local_stroke_id = ""
@ -2900,6 +2918,8 @@ func _remove_canvas_state(canvas_id: String) -> void:
_selected_canvas_id = ""
if _hovered_canvas_id == canvas_id:
_hovered_canvas_id = ""
if _last_hovered_canvas_id == canvas_id:
_last_hovered_canvas_id = ""
func _emit_artwork_changed() -> void:

View file

@ -22,6 +22,25 @@ func connect_to_route(_route: ConnectionRoute) -> Error:
func disconnect_transport() -> void:
if _peer != null:
# Give ENet one poll cycle to emit its graceful disconnect packets before
# closing the socket. This keeps a host from retaining a departed player
# until the extended generated-world liveness timeout expires.
if (
_peer is ENetMultiplayerPeer
and _peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
):
var enet_peer := _peer as ENetMultiplayerPeer
# A host explicitly notifies each still-live client before closing.
# Clients rely on ENetMultiplayerPeer.close(); their cached SceneTree
# peer list can briefly outlive the underlying ENet peer after a remote
# disconnect, making disconnect_peer() race and report an engine error.
if enet_peer.get_unique_id() == 1:
for peer_id: int in multiplayer.get_peers():
if enet_peer.get_peer(peer_id) != null:
enet_peer.disconnect_peer(peer_id, false)
if enet_peer.host != null:
enet_peer.host.flush()
_peer.close()
_peer = null
_route_description = ""

View file

@ -3,8 +3,8 @@ extends RefCounted
const CAPABILITY: StringName = &"world_spawn_envelope_v1"
const ENVELOPE_VERSION: int = 1
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
const SNAPSHOT_CHANNEL: int = 4
const RELIABLE_CHANNEL: int = NetworkProtocol.WORLD_SPAWN_RELIABLE_CHANNEL
const SNAPSHOT_CHANNEL: int = 15
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_EVENT_ID_LENGTH: int = 48
const MAX_ENTITY_ID_LENGTH: int = 128

View file

@ -16,7 +16,9 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
signal local_capture_received(fish_catch: FishCatch)
signal local_interaction_finished(accepted: bool, message: String)
const SNAPSHOT_INTERVAL_SECONDS: float = 0.2
const SIMULATION_INTERVAL_SECONDS: float = 1.0 / 15.0
const SNAPSHOT_INTERVAL_SECONDS: float = 0.1
const PLAYER_SPATIAL_CELL_SIZE: float = 8.0
const CAPACITY_RESPONSE_TIMEOUT_SECONDS: float = 3.0
const MAX_REQUEST_ID_LENGTH: int = 128
const MAX_LEDGER_ENTRIES: int = 96
@ -49,8 +51,16 @@ var _pending_captures: Dictionary = {}
var _received_results: Dictionary = {}
var _showcase_deadlines: Dictionary = {}
var _envelope_sequence: int = 0
var _simulation_elapsed: float = 0.0
var _snapshot_elapsed: float = 0.0
var _population_session_id: String = ""
var _dirty_entity_ids: Dictionary[String, bool] = {}
var _moving_players_by_cell: Dictionary[Vector2i, Array] = {}
var _presentation_enabled: bool = true
var _spawn_events_sent: int = 0
var _despawn_events_sent: int = 0
var _snapshot_packets_sent: int = 0
var _snapshot_states_sent: int = 0
var _rng := RandomNumberGenerator.new()
@ -68,6 +78,7 @@ func setup(
save_manager: PlayerSaveManager,
item_use: NetworkItemUseService,
world_time: WorldTimeService = null,
presentation_enabled: bool = true,
) -> void:
_session = session
_spawn_service = spawn_service
@ -82,6 +93,7 @@ func setup(
_save_manager = save_manager
_item_use = item_use
_world_time = world_time
_presentation_enabled = presentation_enabled
_rng.randomize()
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
@ -136,6 +148,7 @@ func finish_local_interaction(
"target_position": NetworkWorldSpawnProtocol.vector3_to_array(
target_position
),
"movement_sequence": _session.get_latest_movement_input_sequence(),
}
if _session.is_host():
_handle_interaction_finish(_session.get_local_peer_id(), data)
@ -217,6 +230,13 @@ func get_entry_for_entity(entity_id: String) -> GatherableDataType:
return state.get("data") as GatherableDataType
func refresh_world_context() -> void:
_clear_world()
_population_session_id = ""
if _session != null and _session.is_gameplay_session_active():
_begin_population_if_ready.call_deferred()
func _physics_process(delta: float) -> void:
if (
_session == null
@ -225,11 +245,27 @@ func _physics_process(delta: float) -> void:
):
return
_begin_population_if_ready()
# There is no gameplay state to advance while a dedicated server has no
# spawned players. Pausing the roaming/respawn loop also prevents idle
# gatherables from producing a continuous stream of snapshots to nobody.
if _spawn_service == null or _spawn_service.get_peer_ids().is_empty():
_simulation_elapsed = 0.0
_snapshot_elapsed = 0.0
return
_simulation_elapsed += delta
_snapshot_elapsed += delta
if _simulation_elapsed < SIMULATION_INTERVAL_SECONDS:
return
var simulation_delta: float = minf(_simulation_elapsed, 0.2)
_simulation_elapsed = fmod(
_simulation_elapsed,
SIMULATION_INTERVAL_SECONDS,
)
_update_pending_capture_timeouts()
_update_respawns()
_update_showcase_deadlines()
_update_host_entities(delta)
_snapshot_elapsed += delta
_rebuild_moving_player_spatial_index()
_update_host_entities(simulation_delta)
if _snapshot_elapsed >= SNAPSHOT_INTERVAL_SECONDS:
_snapshot_elapsed = fmod(
_snapshot_elapsed,
@ -246,6 +282,7 @@ func _begin_population_if_ready() -> void:
or _catalog == null
or _world == null
or _world_root == null
or not _world.is_world_ready()
):
return
var session_id: String = _session.get_session_id()
@ -273,7 +310,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void:
_world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id)
)
_spawn_anchor_positions[entry.type_id] = anchor_positions
if anchor_positions.is_empty():
if (
anchor_positions.is_empty()
and _world.get_world_layout() == WorldLayout.GENERATED
):
push_warning(
"No gatherable spawn anchors were found for %s." % entry.type_id
)
@ -299,7 +339,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void:
_surface_triangles[entry.type_id] = triangles
_surface_areas[entry.type_id] = areas
_surface_total_areas[entry.type_id] = total_area
if triangles.is_empty():
if (
triangles.is_empty()
and _world.get_world_layout() == WorldLayout.GENERATED
):
push_warning(
"No valid spawn surface was found for %s." % entry.type_id
)
@ -341,6 +384,7 @@ func _spawn_entity(entry: GatherableDataType) -> void:
)
_apply_envelope(envelope)
receive_world_envelope.rpc(envelope)
_spawn_events_sent += 1
func _update_host_entities(delta: float) -> void:
@ -392,6 +436,8 @@ func _update_host_entities(delta: float) -> void:
minf(step / maxf(horizontal_delta.length(), 0.001), 1.0),
)
state["yaw"] = atan2(-direction.x, -direction.z)
state["revision"] = int(state["revision"]) + 1
_dirty_entity_ids[entity_id] = true
state["position"] = position
_entities[entity_id] = state
if entry.can_be_scared() and _should_scare(entry, position, quality):
@ -405,6 +451,34 @@ func _should_scare(
) -> bool:
var scare_radius: float = entry.get_scare_radius_for_quality(quality)
var radius_squared: float = scare_radius * scare_radius
var center_cell := _player_spatial_cell(position)
var cell_radius: int = ceili(scare_radius / PLAYER_SPATIAL_CELL_SIZE)
for cell_x: int in range(
center_cell.x - cell_radius,
center_cell.x + cell_radius + 1,
):
for cell_y: int in range(
center_cell.y - cell_radius,
center_cell.y + cell_radius + 1,
):
for value: Variant in _moving_players_by_cell.get(
Vector2i(cell_x, cell_y),
[],
):
var avatar := value as Player
if avatar == null:
continue
var player_delta := Vector2(
avatar.global_position.x - position.x,
avatar.global_position.z - position.z,
)
if player_delta.length_squared() <= radius_squared:
return true
return false
func _rebuild_moving_player_spatial_index() -> void:
_moving_players_by_cell.clear()
for peer_id: int in _spawn_service.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if (
@ -413,13 +487,17 @@ func _should_scare(
or avatar.is_sneaking()
):
continue
var delta := Vector2(
avatar.global_position.x - position.x,
avatar.global_position.z - position.z,
var cell: Vector2i = _player_spatial_cell(avatar.global_position)
var players: Array = _moving_players_by_cell.get(cell, [])
players.append(avatar)
_moving_players_by_cell[cell] = players
static func _player_spatial_cell(position: Vector3) -> Vector2i:
return Vector2i(
floori(position.x / PLAYER_SPATIAL_CELL_SIZE),
floori(position.z / PLAYER_SPATIAL_CELL_SIZE),
)
if delta.length_squared() <= radius_squared:
return true
return false
func _sample_surface_position(
@ -506,28 +584,69 @@ func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool:
func _broadcast_entity_snapshots() -> void:
if _entities.is_empty():
if _dirty_entity_ids.is_empty() or not _can_broadcast_snapshots():
return
var has_remote_recipients: bool = _has_authenticated_remote_peers()
var snapshots: Array[Dictionary] = []
for entity_id: String in _entities:
var state: Dictionary = _entities[entity_id]
state["revision"] = int(state["revision"]) + 1
_entities[entity_id] = state
for entity_id: String in _dirty_entity_ids:
var state: Dictionary = _entities.get(entity_id, {})
if not state.is_empty():
snapshots.append(_state_to_network(state))
while not snapshots.is_empty():
var chunk: Array[Dictionary] = []
var chunk_size: int = mini(
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE,
snapshots.size(),
_dirty_entity_ids.clear()
var maximum_chunk_size: int = (
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
)
for _index: int in chunk_size:
chunk.append(snapshots.pop_front())
for start_index: int in range(
0,
snapshots.size(),
maximum_chunk_size,
):
var chunk: Array[Dictionary] = []
for value: Dictionary in snapshots.slice(
start_index,
mini(start_index + maximum_chunk_size, snapshots.size()),
):
chunk.append(value)
var envelope: Dictionary = _make_envelope(
&"snapshot",
{"entities": chunk},
)
_apply_envelope(envelope)
if not has_remote_recipients:
continue
# A peer-disconnected callback can resume validation/gameplay code and
# close the host while this physics update is still unwinding. Never send
# through the replacement OfflineMultiplayerPeer in that narrow window.
if not _can_broadcast_snapshots():
return
receive_world_snapshot_envelope.rpc(envelope)
_snapshot_packets_sent += 1
_snapshot_states_sent += chunk.size()
func _has_authenticated_remote_peers() -> bool:
if _session == null:
return false
var local_peer_id: int = _session.get_local_peer_id()
for peer_id: int in _session.get_authenticated_peer_ids():
if peer_id != local_peer_id:
return true
return false
func _can_broadcast_snapshots() -> bool:
if (
_session == null
or not _session.is_host()
or not _session.is_gameplay_session_active()
):
return false
var peer: MultiplayerPeer = multiplayer.multiplayer_peer
return (
peer != null
and peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
)
func _despawn_entity(
@ -541,6 +660,7 @@ func _despawn_entity(
return
var entry := state.get("data") as GatherableDataType
_entities.erase(entity_id)
_dirty_entity_ids.erase(entity_id)
var envelope: Dictionary = _make_envelope(
&"despawn",
{
@ -551,6 +671,7 @@ func _despawn_entity(
)
_apply_envelope(envelope)
receive_world_envelope.rpc(envelope)
_despawn_events_sent += 1
if schedule_respawn and entry != null:
_respawns.append({
"type_id": entry.type_id,
@ -633,6 +754,7 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
var state: Dictionary = _entities.get(entity_id, {})
var entry := state.get("data") as GatherableDataType
var avatar: Player = _spawn_service.get_avatar(peer_id)
var movement_sequence_value: Variant = data.get("movement_sequence", null)
var error: String = ""
if (
str(data.get("session_id", "")) != _session.get_session_id()
@ -640,6 +762,9 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
or request_id.length() > MAX_REQUEST_ID_LENGTH
or str(charge.get("request_id", "")) != request_id
or not target_position.is_finite()
or typeof(movement_sequence_value) != TYPE_INT
or int(movement_sequence_value) < 0
or int(movement_sequence_value) > 2147483647
):
error = "The catch attempt was invalid."
elif state.is_empty() or entry == null or bool(state.get("locked", false)):
@ -650,14 +775,28 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
error = "Equip the correct gathering tool."
elif avatar == null:
error = "The player is unavailable."
elif entry.requires_sneaking and not avatar.is_sneaking():
error = "Sneak closer before using the tool."
else:
var movement_state: Dictionary = (
avatar.get_lag_compensated_movement_state(
int(movement_sequence_value)
)
)
if entry.requires_sneaking and not bool(
movement_state.get("sneaking", avatar.is_sneaking())
):
error = "Sneak closer before using the tool."
if not error.is_empty():
_send_interaction_result(peer_id, request_id, false, error)
return
var entity_position: Vector3 = state["position"]
var target_distance: float = entity_position.distance_to(target_position)
var compensated_position: Vector3 = movement_state.get(
"position",
avatar.global_position,
)
var player_distance: float = Vector2(
avatar.global_position.x - entity_position.x,
avatar.global_position.z - entity_position.z,
compensated_position.x - entity_position.x,
compensated_position.z - entity_position.z,
).length()
if target_distance > entry.capture_radius:
error = "The gathering tool missed."
@ -903,7 +1042,7 @@ func _apply_capture_result(data: Dictionary) -> void:
_local_collection,
)
_local_inventory.add_catch(fish_catch)
_local_collection.mark_quality_discovered(
_local_collection.record_catch(
fish_catch.fish_id,
fish_catch.quality,
)
@ -1078,6 +1217,9 @@ func _apply_entity_state(value: Variant, immediate: bool) -> void:
var position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3(
state["position"]
)
if not _presentation_enabled:
_entity_revisions[entity_id] = revision
return
var presentation := _presentations.get(entity_id) as WorldGatherableType
if presentation == null or not is_instance_valid(presentation):
presentation = WorldGatherableType.new()
@ -1127,6 +1269,8 @@ func _remove_presentation(entity_id: String, with_dust: bool) -> void:
func _apply_showcase(payload: Dictionary) -> void:
if not _presentation_enabled:
return
if (
typeof(payload.get("owner_peer_id")) != TYPE_INT
or typeof(payload.get("visible")) != TYPE_BOOL
@ -1268,6 +1412,8 @@ func _clear_world() -> void:
if presentation != null and is_instance_valid(presentation):
presentation.queue_free()
_entities.clear()
_dirty_entity_ids.clear()
_moving_players_by_cell.clear()
_presentations.clear()
_entity_revisions.clear()
_surface_triangles.clear()
@ -1279,9 +1425,22 @@ func _clear_world() -> void:
_charge_requests.clear()
_pending_captures.clear()
_showcase_deadlines.clear()
_simulation_elapsed = 0.0
_snapshot_elapsed = 0.0
func get_network_metrics() -> Dictionary:
return {
"entities": _entities.size(),
"presentations": _presentations.size(),
"dirty_entities": _dirty_entity_ids.size(),
"spawn_events_sent": _spawn_events_sent,
"despawn_events_sent": _despawn_events_sent,
"snapshot_packets_sent": _snapshot_packets_sent,
"snapshot_states_sent": _snapshot_states_sent,
}
func _bound(values: Dictionary) -> void:
while values.size() > MAX_LEDGER_ENTRIES:
values.erase(values.keys().front())

View file

@ -189,6 +189,7 @@ func activate_process_root(path: String) -> bool:
func path_for(store_owner: StringName) -> String:
var relative: String = {
&"player_save": "player/player_save.nfsave",
&"save_slots": "player/save_slots.json",
&"network_profile": "player/network_profile.json",
&"player_appearance": "player/player_appearance.json",
&"saved_servers": "social/saved_servers.json",
@ -350,7 +351,7 @@ func _test_writable(path: String) -> bool:
func _create_layout(path: String, id: String) -> bool:
for relative: String in [
"player", "social", "backups/saves", "backups/migrations",
"player", "player/saves", "social", "backups/saves", "backups/migrations",
"backups/conflicts", "identity-backups", "progression-backups",
]:
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:

View file

@ -12,6 +12,8 @@ var continuity_state := ""
var ping_to_host_ms := -1
var muted := false
var blocked := false
var is_friend := false
var can_request_friend := false
var can_kick := false
var can_ban := false
var can_clear_art := false

View file

@ -3,8 +3,12 @@ extends Node
signal relationship_changed(fingerprint: String)
const FORMAT_VERSION := 1
const FORMAT_VERSION := 2
const MAX_RECORDS := 500
const MAX_FRIENDS := 200
const SOCIAL_TOKEN_LENGTH := 64
const PRESENCE_CHANNEL_DOMAIN := "NETFISHING_PRESENCE_V1:"
const INVITE_INBOX_DOMAIN := "NETFISHING_INVITE_V1:"
var _records: Dictionary = {}
var _loaded := false
@ -29,6 +33,11 @@ func is_blocked(fingerprint: String) -> bool:
return bool(_records.get(fingerprint, {}).get("blocked", false))
func is_friend(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("friend", false))
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
if not _valid_target(fingerprint, display_name):
return false
@ -48,20 +57,101 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
# fresh visibility boundary for future messages; already suppressed
# messages retain their immutable local suppression flag.
record["muted"] = value
if value:
_clear_friend_fields(record)
return _commit(fingerprint, record)
func create_friend_capabilities() -> Dictionary:
var presence_write_token := NetworkIdentityCrypto.secure_id(32)
var invite_token := NetworkIdentityCrypto.secure_id(32)
if not _valid_social_token(presence_write_token) or not _valid_social_token(invite_token):
return {}
return {
"local_presence_write_token": presence_write_token,
"local_invite_token": invite_token,
"presence_channel": presence_channel_for_write_token(
presence_write_token
),
"invite_token": invite_token,
}
func add_friend(
fingerprint: String,
display_name: String,
local_capabilities: Dictionary,
remote_capabilities: Dictionary,
) -> bool:
if (
not _valid_target(fingerprint, display_name)
or is_blocked(fingerprint)
or not _valid_local_capabilities(local_capabilities)
or not _valid_public_capabilities(remote_capabilities)
):
return false
_ensure_loaded()
if not is_friend(fingerprint) and get_friends().size() >= MAX_FRIENDS:
return false
var record := _record(fingerprint, display_name)
var now := int(Time.get_unix_time_from_system())
record["friend"] = true
record["friend_since_unix"] = int(record.get("friend_since_unix", now))
record["local_presence_write_token"] = str(
local_capabilities["local_presence_write_token"]
)
record["local_invite_token"] = str(
local_capabilities["local_invite_token"]
)
record["remote_presence_channel"] = str(
remote_capabilities["presence_channel"]
)
record["remote_invite_token"] = str(
remote_capabilities["invite_token"]
)
return _commit(fingerprint, record)
func remove_friend(fingerprint: String, display_name: String) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
_clear_friend_fields(record)
return _commit(fingerprint, record)
func get_friend_record(fingerprint: String) -> Dictionary:
_ensure_loaded()
var record: Dictionary = _records.get(fingerprint, {})
return record.duplicate(true) if bool(record.get("friend", false)) else {}
func get_friends() -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in _records.values():
if bool(value.get("friend", false)):
result.append(value.duplicate(true))
_sort_records(result)
return result
static func presence_channel_for_write_token(write_token: String) -> String:
return (PRESENCE_CHANNEL_DOMAIN + write_token).sha256_text()
static func invite_inbox_id(invite_token: String) -> String:
return (INVITE_INBOX_DOMAIN + invite_token).sha256_text()
func get_records() -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in _records.values():
if bool(value.get("muted", false)) or bool(value.get("blocked", false)):
result.append(value.duplicate(true))
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
str(b.get("last_known_display_name", ""))
) < 0
)
_sort_records(result)
return result
@ -72,6 +162,7 @@ func _record(fingerprint: String, display_name: String) -> Dictionary:
"created_unix": now,
"muted": false,
"blocked": false,
"friend": false,
})
record["last_known_display_name"] = display_name
record["updated_unix"] = now
@ -82,7 +173,11 @@ func _commit(fingerprint: String, record: Dictionary) -> bool:
if _write_blocked:
return false
var previous: Dictionary = _records.duplicate(true)
if not bool(record["muted"]) and not bool(record["blocked"]):
if (
not bool(record.get("muted", false))
and not bool(record.get("blocked", false))
and not bool(record.get("friend", false))
):
_records.erase(fingerprint)
else:
_records[fingerprint] = record
@ -115,7 +210,8 @@ func _ensure_loaded() -> void:
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
var format_version: int = int(data.get("format_version", 0))
if format_version not in [1, FORMAT_VERSION]:
_write_blocked = true
return
for value: Variant in data.get("records", []):
@ -127,6 +223,13 @@ func _ensure_loaded() -> void:
continue
record["blocked"] = bool(record.get("blocked", false))
record["muted"] = bool(record.get("muted", false)) or record["blocked"]
record["friend"] = (
bool(record.get("friend", false))
and not record["blocked"]
and _valid_stored_friend_capabilities(record)
)
if not record["friend"]:
_clear_friend_fields(record)
_records[fingerprint] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
@ -145,3 +248,69 @@ func _save() -> bool:
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))
func _clear_friend_fields(record: Dictionary) -> void:
record["friend"] = false
for key: String in [
"friend_since_unix",
"local_presence_write_token",
"local_invite_token",
"remote_presence_channel",
"remote_invite_token",
]:
record.erase(key)
func _valid_local_capabilities(value: Dictionary) -> bool:
var write_token := str(value.get("local_presence_write_token", ""))
var invite_token := str(value.get("local_invite_token", ""))
return (
_valid_social_token(write_token)
and _valid_social_token(invite_token)
and str(value.get("presence_channel", ""))
== presence_channel_for_write_token(write_token)
and str(value.get("invite_token", "")) == invite_token
)
func _valid_public_capabilities(value: Dictionary) -> bool:
return (
_valid_social_token(str(value.get("presence_channel", "")))
and _valid_social_token(str(value.get("invite_token", "")))
)
func _valid_stored_friend_capabilities(record: Dictionary) -> bool:
var local: Dictionary = {
"local_presence_write_token": str(
record.get("local_presence_write_token", "")
),
"local_invite_token": str(record.get("local_invite_token", "")),
"presence_channel": presence_channel_for_write_token(str(
record.get("local_presence_write_token", "")
)),
"invite_token": str(record.get("local_invite_token", "")),
}
var remote: Dictionary = {
"presence_channel": str(record.get("remote_presence_channel", "")),
"invite_token": str(record.get("remote_invite_token", "")),
}
return _valid_local_capabilities(local) and _valid_public_capabilities(remote)
func _valid_social_token(value: String) -> bool:
if value.length() != SOCIAL_TOKEN_LENGTH:
return false
for character: String in value:
if character not in "0123456789abcdef":
return false
return true
func _sort_records(records: Array[Dictionary]) -> void:
records.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
str(b.get("last_known_display_name", ""))
) < 0
)

View file

@ -11,16 +11,19 @@ var _local_player: Player
var _spawn_transform: Transform3D
var _avatars: Dictionary[int, Player] = {}
var _local_peer_id: int = 1
var _dedicated_runtime: bool = false
func setup(
players_root: Node3D,
local_player: Player,
spawn_transform: Transform3D,
dedicated_runtime: bool = false,
) -> void:
_players_root = players_root
_local_player = local_player
_spawn_transform = spawn_transform
_dedicated_runtime = dedicated_runtime
func set_spawn_transform(spawn_transform: Transform3D) -> void:
@ -56,7 +59,10 @@ func spawn_remote_player(
avatar.set_network_peer_id(peer_id)
_players_root.add_child(avatar)
avatar.global_transform = transform
avatar.configure_network_remote(authoritative_simulation)
avatar.configure_network_remote(
authoritative_simulation,
_dedicated_runtime and authoritative_simulation,
)
_avatars[peer_id] = avatar
avatar_spawned.emit(peer_id, avatar)
return avatar

View file

@ -124,6 +124,7 @@ static func migrate_active_to(
for relative: String in [
"player/player_save.nfsave",
"player/player_save.json",
"player/save_slots.json",
"player/network_profile.json",
"player/player_appearance.json",
"social/saved_servers.json",
@ -142,6 +143,16 @@ static func migrate_active_to(
):
_remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for %s." % relative}
var source_slots: String = data_root.root_path.path_join("player/saves")
if (
DirAccess.dir_exists_absolute(source_slots)
and not _copy_progression_slots(
source_slots,
staging.path_join("player/saves"),
)
):
_remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for save slots."}
if DirAccess.dir_exists_absolute(normalized):
DirAccess.remove_absolute(normalized)
if DirAccess.rename_absolute(staging, normalized) != OK:
@ -257,7 +268,7 @@ static func _copy_verified_owned_file(
source: String,
destination: String,
) -> Dictionary:
if source.get_file() != "player_save.nfsave":
if source.get_extension().to_lower() != "nfsave":
return _copy_verified_json(source, destination)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source)
if not bool(decoded.get("ok", false)):
@ -281,12 +292,42 @@ static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json":
var version: int = int(data.get("save_version", -1))
return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION
if filename == "save_slots.json":
return (
int(data.get("format_version", -1)) == PlayerSaveSlotCatalog.FORMAT_VERSION
and typeof(data.get("active_slot_id")) == TYPE_STRING
and typeof(data.get("slots")) == TYPE_ARRAY
)
return (
filename not in LEGACY_FILES
or int(data.get("format_version", -1)) == 1
)
static func _copy_progression_slots(source: String, destination: String) -> bool:
if DirAccess.make_dir_recursive_absolute(destination) != OK:
return false
var access := DirAccess.open(source)
if access == null:
return false
access.list_dir_begin()
var filename: String = access.get_next()
while not filename.is_empty():
if (
not access.current_is_dir()
and filename.get_extension().to_lower() == "nfsave"
and not bool(_copy_verified_owned_file(
source.path_join(filename),
destination.path_join(filename),
).get("ok", false))
):
access.list_dir_end()
return false
filename = access.get_next()
access.list_dir_end()
return true
static func _copy_bytes(source: String, destination: String) -> bool:
return _write_bytes(destination, PortableFileGuard.read_bytes(source))

View file

@ -139,6 +139,10 @@ const NETWORK_SNAPSHOT_JITTER_WEIGHT: float = 0.15
const NETWORK_REMOTE_SMOOTHING_RATE: float = 12.0
const NETWORK_REMOTE_JITTER_SMOOTHING_RATE: float = 6.0
const NETWORK_INPUT_STALE_TIMEOUT_SECONDS: float = 0.25
const NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS: float = 1.25
const NETWORK_INPUT_STALE_RTT_MULTIPLIER: float = 1.5
const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25
const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75
const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25
const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5
const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12
@ -417,6 +421,7 @@ class ShowcaseCameraSnapshot:
@export var controller_camera_speed: float = 2.5
@export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2
@export var invert_camera_y: bool = false
var _swap_hotbar_camera_scroll: bool = false
@export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0
@export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0
@export var minimum_zoom: float = 2.0
@ -506,6 +511,9 @@ var _network_slow_walk: bool = false
var _last_network_input_sequence: int = 0
var _network_input_age: float = 0.0
var _network_input_stale: bool = false
var _network_input_stale_timeout_seconds: float = (
NETWORK_INPUT_STALE_TIMEOUT_SECONDS
)
var _network_jump_intent_active: bool = false
var _network_target_position: Vector3
var _network_target_velocity: Vector3
@ -519,6 +527,9 @@ var _network_target_animation_action_paused: bool = false
var _network_snapshot_ready: bool = false
var _network_snapshot_age: float = 0.0
var _network_snapshot_jitter: float = 0.0
var _network_simulation_only: bool = false
var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO
var _authoritative_movement_history: Array[Dictionary] = []
var _local_network_jump_intent_pending: bool = false
var _local_network_jump_intent_sequence: int = -1
var _animation_action_id: StringName = &""
@ -791,10 +802,16 @@ func _finish_successful_net_strike_pause() -> void:
func _set_animation_action_paused(paused: bool) -> void:
if _animation_action_id.is_empty() or _animation_action_paused == paused:
return
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
_apply_animation_action_state(
NetworkPlayerAnimationProtocol.make_action_state(
_animation_action_id,
_animation_action_sequence,
next_sequence,
_animation_action_elapsed,
paused,
)
@ -972,6 +989,12 @@ func _physics_process(delta: float) -> void:
if _network_interpolation_enabled:
_update_network_interpolation(delta)
return
_simulate_movement_physics(delta)
if _network_authoritative_simulation:
_record_authoritative_movement_state()
func _simulate_movement_physics(delta: float) -> void:
if _network_authoritative_simulation:
_update_network_input_freshness(delta)
if local_control_enabled and _free_camera_active:
@ -1075,6 +1098,9 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void:
if _network_simulation_only:
return
_update_local_reconciliation_visuals(delta)
if not _animation_action_id.is_empty() and not _animation_action_paused:
_animation_action_elapsed = minf(
_animation_action_elapsed + delta,
@ -1607,6 +1633,23 @@ func is_sitting() -> bool:
return _sitting
func get_network_sitting_state() -> bool:
return _sitting or _sit_after_landing
func apply_network_sitting_state(should_sit: bool) -> void:
_set_sitting(should_sit)
func apply_authoritative_network_sitting_state(should_sit: bool) -> void:
if should_sit and not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
return
_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
@ -1648,7 +1691,7 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_zoom_in: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.shift_pressed != _swap_hotbar_camera_scroll
and event.is_action_pressed("camera_zoom_in")
)
var controller_zoom_in: bool = (
@ -1657,7 +1700,7 @@ func _unhandled_input(event: InputEvent) -> void:
)
var mouse_zoom_out: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.shift_pressed != _swap_hotbar_camera_scroll
and event.is_action_pressed("camera_zoom_out")
)
var controller_zoom_out: bool = (
@ -1878,10 +1921,14 @@ func get_network_peer_id() -> int:
return _network_peer_id
func configure_network_remote(authoritative_simulation: bool) -> void:
func configure_network_remote(
authoritative_simulation: bool,
simulation_only: bool = false,
) -> void:
set_local_control(false)
_network_authoritative_simulation = authoritative_simulation
_network_interpolation_enabled = not authoritative_simulation
set_network_simulation_only(simulation_only)
_network_axis = Vector2.ZERO
_network_jump_pending = false
_network_sprint = false
@ -1889,6 +1936,7 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_slow_walk = false
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS
_network_jump_intent_active = false
_network_snapshot_ready = false
_network_snapshot_age = 0.0
@ -1900,10 +1948,43 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_target_animation_action_elapsed = 0.0
_network_target_animation_action_paused = false
_camera.current = false
_authoritative_movement_history.clear()
func set_network_simulation_only(enabled: bool) -> void:
_network_simulation_only = enabled
if not is_node_ready():
return
set_process(not enabled)
for presentation_root: Node in [
_visuals,
get_node_or_null("GroundShadow"),
get_node_or_null("SprintDust"),
_camera_yaw,
]:
if presentation_root == null:
continue
presentation_root.process_mode = (
Node.PROCESS_MODE_DISABLED
if enabled
else Node.PROCESS_MODE_INHERIT
)
if presentation_root is Node3D:
(presentation_root as Node3D).visible = not enabled
func is_network_simulation_only() -> bool:
return _network_simulation_only
func reset_network_movement_state() -> void:
_clear_local_network_jump_intent()
if not _animation_action_id.is_empty():
end_animation_action()
_sit_after_landing = false
_sitting_intent_pending = false
_sitting_intent_sequence = -1
_set_sitting(false)
_network_axis = Vector2.ZERO
_network_jump_pending = false
_network_sprint = false
@ -1911,6 +1992,7 @@ func reset_network_movement_state() -> void:
_network_slow_walk = false
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS
_network_jump_intent_active = false
_last_network_input_sequence = 0
@ -1939,7 +2021,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"sprint": false,
"sneak": false,
"slow_walk": false,
"sitting": _sitting,
"sitting": get_network_sitting_state(),
"casting": (
_fishing_visual_phase == FishingVisualPhase.CASTING
),
@ -1964,13 +2046,66 @@ func capture_network_input(sequence: int) -> Dictionary:
"sprint": Input.is_action_pressed("sprint"),
"sneak": Input.is_action_pressed("sneak"),
"slow_walk": Input.is_action_pressed("slow_walk"),
"sitting": _sitting,
"sitting": get_network_sitting_state(),
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
"animation_action": _make_animation_action_state(),
}
func apply_authoritative_network_input(data: Dictionary) -> void:
func has_active_network_input() -> bool:
if not local_control_enabled:
return false
if _local_network_jump_intent_pending or _sitting_intent_pending:
return true
if not _is_movement_input_enabled() or _free_camera_active:
return false
return (
not Input.get_vector(
"move_left",
"move_right",
"move_forward",
"move_backward",
).is_zero_approx()
or Input.is_action_pressed("sprint")
or Input.is_action_pressed("sneak")
or Input.is_action_pressed("slow_walk")
or _fishing_visual_phase == FishingVisualPhase.CASTING
or not _animation_action_id.is_empty()
)
func get_network_input_state_hash() -> int:
var axis: Vector2 = Vector2.ZERO
if (
local_control_enabled
and _is_movement_input_enabled()
and not _free_camera_active
):
axis = Input.get_vector(
"move_left",
"move_right",
"move_forward",
"move_backward",
)
return hash([
axis,
_camera_yaw.global_rotation.y,
_local_network_jump_intent_pending,
Input.is_action_pressed("sprint"),
Input.is_action_pressed("sneak"),
Input.is_action_pressed("slow_walk"),
get_network_sitting_state(),
_fishing_visual_phase == FishingVisualPhase.CASTING,
_animation_action_id,
_animation_action_sequence,
_animation_action_paused,
])
func apply_authoritative_network_input(
data: Dictionary,
stale_timeout_seconds: float = NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
) -> void:
var sequence: int = int(data.get("sequence", 0))
if sequence <= _last_network_input_sequence:
return
@ -1980,6 +2115,11 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_last_network_input_sequence = sequence
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = clampf(
stale_timeout_seconds,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS,
)
_network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0)
_network_camera_yaw = float(data.get("camera_yaw", 0.0))
var jump_intent_active: bool = bool(data.get("jump", false))
@ -1989,15 +2129,13 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false))
_apply_animation_action_state(data.get("animation_action", {}))
apply_authoritative_network_animation_action(
data.get("animation_action", {})
)
_apply_network_casting(bool(data.get("casting", false)))
var sitting_requested: bool = bool(data.get("sitting", false))
if sitting_requested and not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
else:
_sit_after_landing = false
_set_sitting(sitting_requested)
apply_authoritative_network_sitting_state(
bool(data.get("sitting", false))
)
func make_network_snapshot(peer_id: int) -> Dictionary:
@ -2007,17 +2145,47 @@ func make_network_snapshot(peer_id: int) -> Dictionary:
"position": [global_position.x, global_position.y, global_position.z],
"velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": _visuals.rotation.y,
"animation_state": NetworkPlayerAnimationProtocol.make_state(
"animation_state": make_network_animation_state(),
"grounded": is_on_floor(),
"sitting": get_network_sitting_state(),
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
}
func make_network_animation_state() -> Dictionary:
return NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
_animation_action_paused,
),
"sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
}
)
func apply_network_animation_state(state: Dictionary) -> void:
_apply_network_target_animation_state(state)
func apply_authoritative_network_animation_action(
action_state: Dictionary,
) -> void:
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return
var incoming_sequence: int = int(action_state["sequence"])
var wrapped_sequence: bool = (
_animation_action_sequence
== NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
and incoming_sequence == 1
)
if incoming_sequence < _animation_action_sequence and not wrapped_sequence:
return
if (
incoming_sequence == _animation_action_sequence
and StringName(str(action_state["id"])) != _animation_action_id
):
return
_apply_animation_action_state(action_state)
func push_network_snapshot(
@ -2053,6 +2221,7 @@ func push_network_snapshot(
)
_network_target_velocity = parsed["velocity"]
_network_target_visual_yaw = parsed["visual_yaw"]
if not Dictionary(parsed.get("animation_state", {})).is_empty():
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_apply_network_casting(bool(parsed["casting"]))
@ -2066,9 +2235,8 @@ func push_network_snapshot(
func apply_local_prediction_correction(
snapshot: Dictionary,
latest_input_sequence: int = 0,
pending_inputs: Array[Dictionary] = [],
input_interval_seconds: float = 0.0,
estimated_transit_seconds: float = -1.0,
) -> void:
var parsed: Dictionary = _parse_network_snapshot(snapshot)
if parsed.is_empty():
@ -2090,27 +2258,158 @@ func apply_local_prediction_correction(
_clear_local_network_jump_intent()
if not _sitting_intent_pending:
_set_sitting(bool(parsed["sitting"]))
var authoritative_position: Vector3 = parsed["position"]
var transit_seconds: float = resolve_local_prediction_transit_seconds(
acknowledged_input,
latest_input_sequence,
input_interval_seconds,
estimated_transit_seconds,
var previous_visual_position: Vector3 = _visuals.global_position
var base_visual_local_position: Vector3 = (
_visuals.position - _local_reconciliation_visual_offset
)
if transit_seconds > 0.0:
authoritative_position += (
(parsed["velocity"] as Vector3) * transit_seconds
var previous_position: Vector3 = global_position
global_position = parsed["position"]
velocity = parsed["velocity"]
if input_interval_seconds > 0.0:
for input: Dictionary in pending_inputs:
_replay_network_movement_input(input, input_interval_seconds)
var correction_distance: float = previous_position.distance_to(
global_position
)
var error_distance: float = global_position.distance_to(
authoritative_position
if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE:
_visuals.global_position = previous_visual_position
_local_reconciliation_visual_offset = (
_visuals.position - base_visual_local_position
)
if error_distance > LOCAL_PREDICTION_SNAP_DISTANCE:
global_position = authoritative_position
elif error_distance > LOCAL_PREDICTION_CORRECTION_THRESHOLD:
global_position = global_position.lerp(
authoritative_position,
LOCAL_PREDICTION_CORRECTION_WEIGHT,
else:
_local_reconciliation_visual_offset = Vector3.ZERO
func _replay_network_movement_input(
data: Dictionary,
delta: float,
) -> void:
var axis_value: Variant = data.get("axis", [])
if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2:
return
if bool(data.get("sitting", false)) or _water_recovery_active:
velocity = Vector3.ZERO
return
var input_vector := Vector2(
float(axis_value[0]),
float(axis_value[1]),
).limit_length(1.0)
var camera_basis := Basis(
Vector3.UP,
float(data.get("camera_yaw", 0.0)),
)
var move_direction: Vector3 = (
camera_basis.x * input_vector.x
+ camera_basis.z * input_vector.y
)
move_direction.y = 0.0
move_direction = move_direction.normalized()
_network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false))
# Replay the speed authored by this exact pending input. Consulting the
# current InputMap here would make an older walk replay as a sprint (or the
# reverse) whenever the local button changed while a snapshot was in flight.
var replay_speed: float = walk_speed
if _network_sneak:
replay_speed = sneak_speed
elif _network_slow_walk:
replay_speed = slow_walk_speed
elif _network_sprint:
replay_speed = sprint_speed
if item_effects != null:
replay_speed *= item_effects.get_movement_multiplier()
var input_strength: float = minf(input_vector.length(), 1.0)
velocity.x = move_direction.x * replay_speed * input_strength
velocity.z = move_direction.z * replay_speed * input_strength
if not is_on_floor():
var gravity_multiplier: float = (
upward_gravity_multiplier
if velocity.y > 0.0
else fall_gravity_multiplier
)
velocity.y -= _gravity * gravity_multiplier * delta
elif bool(data.get("jump", false)):
velocity.y = jump_velocity
move_and_slide()
func _update_local_reconciliation_visuals(delta: float) -> void:
if _local_reconciliation_visual_offset.is_zero_approx():
_local_reconciliation_visual_offset = Vector3.ZERO
return
var retained_ratio: float = exp(-14.0 * delta)
var retained_offset: Vector3 = (
_local_reconciliation_visual_offset * retained_ratio
)
_visuals.position += retained_offset - _local_reconciliation_visual_offset
_local_reconciliation_visual_offset = retained_offset
static func resolve_network_input_stale_timeout_seconds(
round_trip_msec: int,
) -> float:
if round_trip_msec < 0:
return NETWORK_INPUT_STALE_TIMEOUT_SECONDS
return clampf(
NETWORK_INPUT_STALE_TIMEOUT_SECONDS
+ float(round_trip_msec) / 1000.0
* NETWORK_INPUT_STALE_RTT_MULTIPLIER,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS,
)
func _record_authoritative_movement_state() -> void:
var now_seconds: float = Time.get_ticks_msec() / 1000.0
_authoritative_movement_history.append({
"sequence": _last_network_input_sequence,
"recorded_at": now_seconds,
"position": global_position,
"velocity": velocity,
"cast_origin": get_cast_origin_position(),
"facing": get_facing_direction(),
"sneaking": is_sneaking(),
})
var oldest_allowed: float = now_seconds - NETWORK_MOVEMENT_HISTORY_SECONDS
while (
not _authoritative_movement_history.is_empty()
and float(
_authoritative_movement_history[0].get("recorded_at", 0.0)
) < oldest_allowed
):
_authoritative_movement_history.pop_front()
func get_lag_compensated_movement_state(
input_sequence: int,
max_age_seconds: float = NETWORK_MAX_LAG_COMPENSATION_SECONDS,
) -> Dictionary:
var now_seconds: float = Time.get_ticks_msec() / 1000.0
for index: int in range(
_authoritative_movement_history.size() - 1,
-1,
-1,
):
var candidate: Dictionary = _authoritative_movement_history[index]
if now_seconds - float(candidate.get("recorded_at", 0.0)) > (
max_age_seconds
):
break
if (
input_sequence <= 0
or int(candidate.get("sequence", 0)) <= input_sequence
):
return candidate.duplicate(true)
return {
"sequence": _last_network_input_sequence,
"recorded_at": now_seconds,
"position": global_position,
"velocity": velocity,
"cast_origin": get_cast_origin_position(),
"facing": get_facing_direction(),
"sneaking": is_sneaking(),
}
static func resolve_local_prediction_transit_seconds(
@ -2154,6 +2453,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
_network_target_position = global_position
_network_target_velocity = velocity
_network_target_visual_yaw = _visuals.rotation.y
if not Dictionary(parsed.get("animation_state", {})).is_empty():
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_network_snapshot_ready = true
@ -2180,10 +2480,14 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
float(network_velocity[2])
)
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
var animation_state: Dictionary = {}
if snapshot.has("animation_state"):
var animation_state_value: Variant = snapshot.get("animation_state")
if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value):
if not NetworkPlayerAnimationProtocol.validate_state(
animation_state_value
):
return {}
var animation_state: Dictionary = (
animation_state = (
animation_state_value as Dictionary
).duplicate(true)
if (
@ -2237,6 +2541,23 @@ func _apply_network_target_animation_state(state: Dictionary) -> void:
StringName(str(state["locomotion_id"]))
)
var action: Dictionary = state["action"]
var incoming_sequence: int = int(action["sequence"])
var wrapped_sequence: bool = (
_network_target_animation_action_sequence
== NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
and incoming_sequence == 1
)
if (
incoming_sequence < _network_target_animation_action_sequence
and not wrapped_sequence
):
return
if (
incoming_sequence == _network_target_animation_action_sequence
and StringName(str(action["id"]))
!= _network_target_animation_action_id
):
return
_network_target_animation_action_id = StringName(str(action["id"]))
_network_target_animation_action_sequence = int(action["sequence"])
_network_target_animation_action_elapsed = float(action["elapsed"])
@ -2414,11 +2735,11 @@ func _update_network_interpolation(delta: float) -> void:
func _update_network_input_freshness(delta: float) -> void:
_network_input_age = minf(
_network_input_age + delta,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 1.0,
_network_input_stale_timeout_seconds + 1.0,
)
if (
_network_input_stale
or _network_input_age <= NETWORK_INPUT_STALE_TIMEOUT_SECONDS
or _network_input_age <= _network_input_stale_timeout_seconds
):
return
_network_input_stale = true
@ -2523,6 +2844,7 @@ func apply_camera_settings(
new_mouse_sensitivity: float,
new_controller_sensitivity: float,
invert_vertical: bool,
swap_hotbar_camera_scroll: bool = false,
) -> void:
mouse_sensitivity = clampf(new_mouse_sensitivity, 0.001, 0.012)
controller_camera_speed = clampf(
@ -2531,6 +2853,7 @@ func apply_camera_settings(
5.0
)
invert_camera_y = invert_vertical
_swap_hotbar_camera_scroll = swap_hotbar_camera_scroll
func is_camera_input_enabled() -> bool:

View file

@ -14,6 +14,10 @@ var detected_version: int = -1
var catch_count: int = 0
var wallet_balance: int = 0
var discovered_species_count: int = 0
var total_experience: int = 0
var player_level: int = 1
var world_layout: StringName = &"generated_world"
var world_seed: int = 0
var has_primary_file: bool = false
var message: String = ""

View file

@ -49,6 +49,7 @@ class LoadSnapshot:
var catches: Array[FishCatchType] = []
var discovered_ids: Array[StringName] = []
var discovered_quality_masks: Dictionary[StringName, int] = {}
var catch_counts: Dictionary[StringName, int] = {}
var wallet_balance: int = 0
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
@ -109,8 +110,22 @@ var _world_seed: int = DEFAULT_WORLD_SEED
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
if not select_storage(path, data_root):
push_error("PlayerSaveManager could not configure progression storage.")
func select_storage(path: String, data_root: PlayerDataRoot) -> bool:
if path.is_empty() or data_root == null:
return false
if _autosave_enabled or _is_dirty:
return false
if _autosave_timer != null:
_autosave_timer.stop()
_save_path = path
_data_root = data_root
_expected_hash = ""
_automatic_saving_blocked = false
return true
func _temp_path() -> String:
@ -297,9 +312,10 @@ func load_player_data() -> bool:
snapshot.next_catch_sequence
)
var collection_restored: bool = (
_collection_log.replace_discovery_state(
_collection_log.replace_collection_state(
snapshot.discovered_ids,
snapshot.discovered_quality_masks,
snapshot.catch_counts,
)
)
var wallet_restored: bool = _wallet.restore_balance(
@ -398,17 +414,30 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
var read_path: String = _read_path()
result.has_primary_file = not read_path.is_empty()
return inspect_progression_at_path(
read_path,
read_path == _legacy_save_path(),
)
func inspect_progression_at_path(
path: String,
allow_legacy_plaintext: bool = false,
) -> SaveInspectionType:
var result := SaveInspectionType.new()
if not path.is_empty():
_remove_if_present(path + ".codec.tmp")
_recover_path_write(path)
result.has_primary_file = not path.is_empty() and FileAccess.file_exists(path)
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR
@ -439,6 +468,12 @@ func inspect_save() -> SaveInspectionType:
result.catch_count = snapshot.catches.size()
result.wallet_balance = snapshot.wallet_balance
result.discovered_species_count = snapshot.discovered_ids.size()
result.total_experience = snapshot.total_experience
result.player_level = PlayerExperienceType.level_for_total_experience(
snapshot.total_experience
)
result.world_layout = snapshot.world_layout
result.world_seed = snapshot.world_seed
result.message = "save ready."
return result
@ -451,31 +486,45 @@ func export_progression_archive(path: String) -> Dictionary:
var source_path: String = _read_path()
if source_path.is_empty():
return {"ok": false, "message": "there is no progression to export."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
return export_progression_archive_from_path(
source_path,
path,
source_path == _legacy_save_path(),
)
func export_progression_archive_from_path(
source_path: String,
destination_path: String,
allow_legacy_plaintext: bool = false,
) -> Dictionary:
if source_path.is_empty() or destination_path.is_empty():
return {"ok": false, "message": "progression export is unavailable."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the active progression could not be read."}
return {"ok": false, "message": "the selected progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive(
prepared["data"],
path + ".codec.tmp",
destination_path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false, "message": "the progression archive could not be encoded."}
var result: Dictionary = PortableFileGuard.write_guarded(
path,
destination_path,
bytes,
PortableFileGuard.hash_file(path),
PortableFileGuard.hash_file(destination_path),
_data_root.conflict_directory(),
_data_root.device_id,
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be written."}
var verified: Dictionary = inspect_progression_archive(path)
var verified: Dictionary = inspect_progression_archive(destination_path)
if not bool(verified.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be verified."}
verified["message"] = "progression archive created."
@ -525,6 +574,83 @@ func import_progression_archive(path: String) -> Dictionary:
}
func install_progression_archive_at_path(
archive_path: String,
destination_path: String,
) -> Dictionary:
if not _is_configured or destination_path.is_empty():
return {"ok": false, "message": "progression import is unavailable."}
var inspected: Dictionary = inspect_progression_archive(archive_path)
if not bool(inspected.get("ok", false)):
return inspected
var result: Dictionary = _write_save_data_at_path(
inspected["data"],
destination_path,
PortableFileGuard.hash_file(destination_path),
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the imported progression could not be installed."}
return {
"ok": true,
"message": "progression imported as a new save slot.",
"catch_count": inspected["catch_count"],
"wallet_balance": inspected["wallet_balance"],
"discovered_species_count": inspected["discovered_species_count"],
"world_layout": inspected["world_layout"],
"world_seed": inspected["world_seed"],
}
func copy_progression_to_path(
source_path: String,
destination_path: String,
allow_legacy_plaintext: bool,
world_layout: StringName,
world_seed: int,
) -> Dictionary:
if (
not _is_configured
or source_path.is_empty()
or destination_path.is_empty()
or not WorldLayoutType.is_valid(world_layout)
or world_seed <= 0
or world_seed > MAX_WORLD_SEED
):
return {"ok": false, "message": "progression duplication is unavailable."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
allow_legacy_plaintext,
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the selected progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var save_data: Dictionary = (prepared["data"] as Dictionary).duplicate(true)
var world_data: Dictionary = {}
if typeof(save_data.get("world")) == TYPE_DICTIONARY:
world_data = (save_data["world"] as Dictionary).duplicate(true)
world_data["layout"] = String(world_layout)
world_data["seed"] = world_seed
save_data["world"] = world_data
prepared = _prepare_external_save_data(save_data)
if not bool(prepared.get("ok", false)):
return prepared
var result: Dictionary = _write_save_data_at_path(
prepared["data"],
destination_path,
PortableFileGuard.hash_file(destination_path),
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the duplicated progression could not be written."}
return {
"ok": true,
"message": "save slot duplicated.",
"world_layout": String(world_layout),
"world_seed": world_seed,
}
func initialize_new_game(
world_seed: int = DEFAULT_WORLD_SEED,
world_layout: StringName = WorldLayoutType.GENERATED,
@ -678,6 +804,20 @@ func _build_save_dictionary() -> Dictionary:
):
return {}
serialized_quality_masks[String(fish_id)] = quality_mask
var serialized_catch_counts: Dictionary = {}
var catch_counts: Dictionary[StringName, int] = (
_collection_log.get_catch_counts()
)
for fish_id: StringName in catch_counts:
var catch_count: int = catch_counts[fish_id]
if (
fish_id.is_empty()
or catch_count <= 0
or catch_count > CollectionLogType.MAX_CATCH_COUNT
or not _collection_log.has_discovered(fish_id)
):
return {}
serialized_catch_counts[String(fish_id)] = catch_count
var serialized_items: Array[Dictionary] = []
for owned: OwnedItemType in _bag.get_all_items():
if owned == null or not owned.is_valid():
@ -725,6 +865,7 @@ func _build_save_dictionary() -> Dictionary:
"collection": {
"discovered_fish_ids": discovered_strings,
"discovered_quality_masks": serialized_quality_masks,
"catch_counts": serialized_catch_counts,
},
"inventory": {
"next_catch_sequence": _inventory.get_next_catch_sequence(),
@ -926,6 +1067,30 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
):
return null
snapshot.discovered_quality_masks[fish_id] = mask
var has_saved_catch_counts: bool = collection_data.has("catch_counts")
if (
has_saved_catch_counts
and typeof(collection_data.get("catch_counts")) != TYPE_DICTIONARY
):
return null
if has_saved_catch_counts:
var saved_catch_counts: Dictionary = collection_data["catch_counts"]
for key: Variant in saved_catch_counts:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]:
return null
var fish_id := StringName(str(key))
var catch_count: int = _read_integer(
saved_catch_counts[key],
-1,
CollectionLogType.MAX_CATCH_COUNT,
)
if (
fish_id.is_empty()
or not seen_discoveries.has(fish_id)
or catch_count <= 0
):
return null
snapshot.catch_counts[fish_id] = catch_count
var seen_ids: Dictionary[StringName, bool] = {}
var seen_sequences: Dictionary[int, bool] = {}
@ -976,6 +1141,13 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
fish_catch.catch_sequence
)
snapshot.catches.append(fish_catch)
if (
not has_saved_catch_counts
and seen_discoveries.has(fish_catch.fish_id)
):
snapshot.catch_counts[fish_catch.fish_id] = (
int(snapshot.catch_counts.get(fish_catch.fish_id, 0)) + 1
)
snapshot.next_catch_sequence = maxi(
requested_next_sequence,
@ -1504,15 +1676,23 @@ func _recover_path_write(path: String) -> void:
func _write_current_save_data(
save_data: Dictionary,
expected_hash: String,
) -> Dictionary:
return _write_save_data_at_path(save_data, _save_path, expected_hash)
func _write_save_data_at_path(
save_data: Dictionary,
destination_path: String,
expected_hash: String,
) -> Dictionary:
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
save_data,
_codec_scratch_path(),
destination_path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false}
return PortableFileGuard.write_guarded(
_save_path,
destination_path,
bytes,
expected_hash,
_data_root.conflict_directory(),
@ -1651,6 +1831,7 @@ func _restore_defaults() -> void:
var empty_catches: Array[FishCatchType] = []
var empty_discoveries: Array[StringName] = []
var empty_quality_masks: Dictionary[StringName, int] = {}
var empty_catch_counts: Dictionary[StringName, int] = {}
var default_items: Array[OwnedItemType] = []
var basic_rod := OwnedItemType.new()
basic_rod.item_id = BASIC_ROD_ID
@ -1661,9 +1842,10 @@ func _restore_defaults() -> void:
default_slots.fill(StringName())
default_slots[0] = BASIC_ROD_ID
_inventory.replace_all_catches(empty_catches, 1)
_collection_log.replace_discovery_state(
_collection_log.replace_collection_state(
empty_discoveries,
empty_quality_masks,
empty_catch_counts,
)
_wallet.restore_balance(0)
_bag.replace_all_items(default_items)

View file

@ -0,0 +1,591 @@
class_name PlayerSaveSlotCatalog
extends RefCounted
const FORMAT_VERSION: int = 1
const MAX_SLOTS: int = 32
const MAX_NAME_LENGTH: int = 32
const LEGACY_SAVE_ID: StringName = &"player_save"
signal slots_changed
var _data_root: PlayerDataRoot
var _save_manager: PlayerSaveManager
var _manifest_path := ""
var _slots_directory := ""
var _slots: Array[Dictionary] = []
var _active_slot_id := ""
var _expected_hash := ""
var _error_message := ""
func configure(
data_root: PlayerDataRoot,
save_manager: PlayerSaveManager,
) -> bool:
if data_root == null or save_manager == null or data_root.root_path.is_empty():
return false
_data_root = data_root
_save_manager = save_manager
_manifest_path = data_root.path_for(&"save_slots")
_slots_directory = data_root.root_path.path_join("player/saves")
_slots.clear()
_active_slot_id = ""
_expected_hash = ""
_error_message = ""
if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK:
_error_message = "the save-slot directory could not be created."
return false
_recover_interrupted_manifest_write()
var loaded: bool = _load_manifest()
if not loaded and FileAccess.file_exists(_manifest_path):
if not _preserve_invalid_manifest():
_error_message = "the invalid save-slot catalog could not be preserved."
return false
var slot_count_before_discovery: int = _slots.size()
var active_before_discovery: String = _active_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 (
not loaded
or _slots.size() != slot_count_before_discovery
or _active_slot_id != active_before_discovery
):
if not _save_manifest():
return false
return _select_configured_storage()
func get_error_message() -> String:
return _error_message
func list_slots() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for entry: Dictionary in _slots:
result.append(_build_slot_summary(entry))
result.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
var a_played: int = int(a.get("last_played_at_unix", 0))
var b_played: int = int(b.get("last_played_at_unix", 0))
if a_played == b_played:
return int(a.get("created_at_unix", 0)) > int(
b.get("created_at_unix", 0)
)
return a_played > b_played
)
return result
func get_slot(slot_id: String) -> Dictionary:
var entry: Dictionary = _find_slot(slot_id)
return _build_slot_summary(entry) if not entry.is_empty() else {}
func get_active_slot_id() -> String:
return _active_slot_id
func has_slots() -> bool:
return not _slots.is_empty()
func ensure_active_slot() -> Dictionary:
if not _active_slot_id.is_empty() and not _find_slot(_active_slot_id).is_empty():
return {"ok": true, "slot_id": _active_slot_id}
var created: Dictionary = create_empty_slot(_next_default_name())
if not bool(created.get("ok", false)):
return created
var slot_id: String = str(created.get("slot_id", ""))
if not activate_slot(slot_id):
return {"ok": false, "message": "the new save slot could not be selected."}
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."}
var clean_name: String = normalized_name(display_name)
if clean_name.is_empty():
return {"ok": false, "message": "enter a name for this save slot."}
var slot_id: String = _generate_slot_id()
var now: int = int(Time.get_unix_time_from_system())
_slots.append({
"slot_id": slot_id,
"display_name": clean_name,
"created_at_unix": now,
"last_played_at_unix": 0,
"legacy": false,
})
if not _save_manifest():
_slots.pop_back()
return {"ok": false, "message": "the save-slot catalog could not be updated."}
slots_changed.emit()
return {"ok": true, "slot_id": slot_id}
func duplicate_slot(
source_slot_id: String,
display_name: String,
world_layout: StringName,
world_seed: int,
) -> Dictionary:
if _slots.size() >= MAX_SLOTS:
return {"ok": false, "message": "the maximum number of save slots has been reached."}
var source: Dictionary = _find_slot(source_slot_id)
var clean_name: String = normalized_name(display_name)
if source.is_empty() or clean_name.is_empty():
return {"ok": false, "message": "the selected save slot cannot be duplicated."}
var source_path: String = _existing_slot_path(source)
if source_path.is_empty():
return {"ok": false, "message": "the selected save slot has no progression to duplicate."}
var slot_id: String = _generate_slot_id()
var destination: String = _slots_directory.path_join(slot_id + ".nfsave")
var copied: Dictionary = _save_manager.copy_progression_to_path(
source_path,
destination,
source_path.ends_with(".json"),
world_layout,
world_seed,
)
if not bool(copied.get("ok", false)):
return copied
var now: int = int(Time.get_unix_time_from_system())
_slots.append({
"slot_id": slot_id,
"display_name": clean_name,
"created_at_unix": now,
"last_played_at_unix": 0,
"legacy": false,
})
if not _save_manifest():
_slots.pop_back()
_remove_if_present(destination)
return {"ok": false, "message": "the duplicated slot could not be recorded."}
slots_changed.emit()
return {"ok": true, "slot_id": slot_id}
func import_slot(archive_path: String, display_name: String) -> Dictionary:
if _slots.size() >= MAX_SLOTS:
return {"ok": false, "message": "the maximum number of save slots has been reached."}
var clean_name: String = normalized_name(display_name)
if clean_name.is_empty():
return {"ok": false, "message": "the imported save needs a slot name."}
var slot_id: String = _generate_slot_id()
var destination: String = _slots_directory.path_join(slot_id + ".nfsave")
var installed: Dictionary = _save_manager.install_progression_archive_at_path(
archive_path,
destination,
)
if not bool(installed.get("ok", false)):
return installed
var now: int = int(Time.get_unix_time_from_system())
_slots.append({
"slot_id": slot_id,
"display_name": clean_name,
"created_at_unix": now,
"last_played_at_unix": 0,
"legacy": false,
})
if not _save_manifest():
_slots.pop_back()
_remove_if_present(destination)
return {"ok": false, "message": "the imported slot could not be recorded."}
slots_changed.emit()
installed["slot_id"] = slot_id
return installed
func export_slot(slot_id: String, destination_path: String) -> Dictionary:
var entry: Dictionary = _find_slot(slot_id)
var source_path: String = _existing_slot_path(entry)
if entry.is_empty() or source_path.is_empty():
return {"ok": false, "message": "the selected save slot cannot be exported."}
return _save_manager.export_progression_archive_from_path(
source_path,
destination_path,
source_path.ends_with(".json"),
)
func activate_slot(slot_id: String) -> bool:
var entry: Dictionary = _find_slot(slot_id)
if entry.is_empty():
return false
var previous_id: String = _active_slot_id
var previous_path: String = _configured_storage_path(previous_id)
var next_path: String = _slot_primary_path(entry)
if not _save_manager.select_storage(next_path, _data_root):
return false
_active_slot_id = slot_id
if _save_manifest():
slots_changed.emit()
return true
_active_slot_id = previous_id
_save_manager.select_storage(previous_path, _data_root)
return false
func mark_played(slot_id: String) -> bool:
var index: int = _find_slot_index(slot_id)
if index < 0:
return false
var previous_played_at: int = int(
_slots[index].get("last_played_at_unix", 0)
)
var previous_active: String = _active_slot_id
_slots[index]["last_played_at_unix"] = int(Time.get_unix_time_from_system())
_active_slot_id = slot_id
if not _save_manifest():
_slots[index]["last_played_at_unix"] = previous_played_at
_active_slot_id = previous_active
return false
slots_changed.emit()
return true
func rename_slot(slot_id: String, display_name: String) -> bool:
var index: int = _find_slot_index(slot_id)
var clean_name: String = normalized_name(display_name)
if index < 0 or clean_name.is_empty():
return false
var previous: String = str(_slots[index].get("display_name", ""))
_slots[index]["display_name"] = clean_name
if not _save_manifest():
_slots[index]["display_name"] = previous
return false
slots_changed.emit()
return true
func delete_slot(slot_id: String) -> bool:
var index: int = _find_slot_index(slot_id)
if index < 0:
return false
var previous_slots: Array[Dictionary] = []
for previous_entry: Dictionary in _slots:
previous_slots.append(previous_entry.duplicate(true))
var previous_active: String = _active_slot_id
var entry: Dictionary = _slots[index]
var source_path: String = _existing_slot_path(entry)
var preserved_path := ""
if not source_path.is_empty():
preserved_path = _deleted_backup_path(entry, source_path)
if not _rename_file(source_path, preserved_path):
return false
_slots.remove_at(index)
if _active_slot_id == slot_id:
_active_slot_id = (
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
_select_configured_storage()
if not preserved_path.is_empty():
_rename_file(preserved_path, source_path)
return false
for auxiliary: String in _slot_auxiliary_paths(entry):
_remove_if_present(auxiliary)
slots_changed.emit()
return true
static func normalized_name(value: String) -> String:
var clean_name: String = value.strip_edges().left(MAX_NAME_LENGTH)
if clean_name.is_empty():
return ""
for index: int in clean_name.length():
var codepoint: int = clean_name.unicode_at(index)
if codepoint < 32 or codepoint == 127:
return ""
return clean_name
func _build_slot_summary(entry: Dictionary) -> Dictionary:
if entry.is_empty():
return {}
var summary: Dictionary = entry.duplicate(true)
var path: String = _existing_slot_path(entry)
var inspection: PlayerSaveInspection = _save_manager.inspect_progression_at_path(
path,
path.ends_with(".json"),
)
summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id
summary["has_save"] = inspection.can_continue()
summary["save_status"] = inspection.status
summary["status_message"] = inspection.message
summary["catch_count"] = inspection.catch_count
summary["wallet_balance"] = inspection.wallet_balance
summary["discovered_species_count"] = inspection.discovered_species_count
summary["player_level"] = inspection.player_level
summary["world_layout"] = String(inspection.world_layout)
summary["world_seed"] = inspection.world_seed
return summary
func _load_manifest() -> bool:
if not FileAccess.file_exists(_manifest_path):
return false
var file := FileAccess.open(_manifest_path, FileAccess.READ)
if file == null:
return false
var parser := JSON.new()
var error: Error = parser.parse(file.get_as_text())
file.close()
if error != OK or typeof(parser.data) != TYPE_DICTIONARY:
return false
var data: Dictionary = parser.data
if int(data.get("format_version", -1)) != FORMAT_VERSION:
return false
if typeof(data.get("slots")) != TYPE_ARRAY:
return false
var loaded_slots: Array[Dictionary] = []
var seen_ids: Dictionary[String, bool] = {}
for value: Variant in data["slots"]:
if typeof(value) != TYPE_DICTIONARY:
return false
var entry: Dictionary = (value as Dictionary).duplicate(true)
if not _valid_slot_entry(entry):
return false
var slot_id: String = str(entry["slot_id"])
if seen_ids.has(slot_id):
return false
seen_ids[slot_id] = true
loaded_slots.append(entry)
_slots = loaded_slots
_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 = ""
_expected_hash = PortableFileGuard.hash_file(_manifest_path)
return true
func _save_manifest() -> bool:
var data: Dictionary = {
"format_version": FORMAT_VERSION,
"active_slot_id": _active_slot_id,
"slots": _slots,
}
var result: Dictionary = PortableFileGuard.write_guarded(
_manifest_path,
JSON.stringify(data, "\t").to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(
str(result.get("message", "")),
str(result.get("conflict_path", "")),
)
if bool(result.get("ok", false)):
_expected_hash = str(result.get("hash", ""))
return true
_error_message = "the save-slot catalog could not be written safely."
return false
func _discover_untracked_saves() -> void:
var known_ids: Dictionary[String, bool] = {}
var has_legacy: bool = false
for entry: Dictionary in _slots:
known_ids[str(entry.get("slot_id", ""))] = true
has_legacy = has_legacy or bool(entry.get("legacy", false))
var legacy_primary: String = _data_root.path_for(LEGACY_SAVE_ID)
var legacy_plaintext: String = legacy_primary.get_base_dir().path_join(
PlayerSaveManager.LEGACY_SAVE_FILENAME
)
if (
not has_legacy
and (
FileAccess.file_exists(legacy_primary)
or FileAccess.file_exists(legacy_plaintext)
)
):
var legacy_id: String = _generate_slot_id()
_slots.append({
"slot_id": legacy_id,
"display_name": _next_default_name(),
"created_at_unix": int(Time.get_unix_time_from_system()),
"last_played_at_unix": 0,
"legacy": true,
})
known_ids[legacy_id] = true
var directory := DirAccess.open(_slots_directory)
if directory == null:
return
directory.list_dir_begin()
var filename: String = directory.get_next()
while not filename.is_empty():
if not directory.current_is_dir() and filename.ends_with(".nfsave"):
var slot_id: String = filename.trim_suffix(".nfsave")
if _valid_slot_id(slot_id) and not known_ids.has(slot_id):
_slots.append({
"slot_id": slot_id,
"display_name": _next_default_name(),
"created_at_unix": int(Time.get_unix_time_from_system()),
"last_played_at_unix": 0,
"legacy": false,
})
known_ids[slot_id] = true
filename = directory.get_next()
directory.list_dir_end()
func _select_configured_storage() -> bool:
return _save_manager.select_storage(
_configured_storage_path(_active_slot_id),
_data_root,
)
func _configured_storage_path(slot_id: String) -> String:
var entry: Dictionary = _find_slot(slot_id)
if not entry.is_empty():
return _slot_primary_path(entry)
return _data_root.path_for(LEGACY_SAVE_ID)
func _slot_primary_path(entry: Dictionary) -> String:
if bool(entry.get("legacy", false)):
return _data_root.path_for(LEGACY_SAVE_ID)
return _slots_directory.path_join(str(entry.get("slot_id", "")) + ".nfsave")
func _existing_slot_path(entry: Dictionary) -> String:
if entry.is_empty():
return ""
var primary: String = _slot_primary_path(entry)
if FileAccess.file_exists(primary):
return primary
if bool(entry.get("legacy", false)):
var plaintext: String = primary.get_base_dir().path_join(
PlayerSaveManager.LEGACY_SAVE_FILENAME
)
if FileAccess.file_exists(plaintext):
return plaintext
return ""
func _slot_auxiliary_paths(entry: Dictionary) -> Array[String]:
var primary: String = _slot_primary_path(entry)
var paths: Array[String] = [
primary + ".tmp",
primary + ".backup",
primary + ".codec.tmp",
]
if bool(entry.get("legacy", false)):
var plaintext: String = primary.get_base_dir().path_join(
PlayerSaveManager.LEGACY_SAVE_FILENAME
)
paths.append_array([
plaintext + ".tmp",
plaintext + ".backup",
])
return paths
func _deleted_backup_path(entry: Dictionary, source_path: String) -> String:
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
var extension: String = ".json" if source_path.ends_with(".json") else ".nfsave"
var destination: String = _data_root.root_path.path_join(
"backups/saves/deleted-slot-%s-%s%s"
% [str(entry.get("slot_id", "")).left(8), timestamp, extension]
)
if FileAccess.file_exists(destination):
destination = destination.trim_suffix(extension) + (
"-%d%s" % [Time.get_ticks_usec(), extension]
)
return destination
func _find_slot(slot_id: String) -> Dictionary:
var index: int = _find_slot_index(slot_id)
return _slots[index] if index >= 0 else {}
func _find_slot_index(slot_id: String) -> int:
for index: int in _slots.size():
if str(_slots[index].get("slot_id", "")) == slot_id:
return index
return -1
func _next_default_name() -> String:
var used: Dictionary[String, bool] = {}
for entry: Dictionary in _slots:
used[str(entry.get("display_name", "")).to_lower()] = true
var number: int = 1
while used.has("save %d" % number):
number += 1
return "save %d" % number
func _generate_slot_id() -> String:
var slot_id: String = Crypto.new().generate_random_bytes(16).hex_encode()
while _find_slot_index(slot_id) >= 0:
slot_id = Crypto.new().generate_random_bytes(16).hex_encode()
return slot_id
func _valid_slot_entry(entry: Dictionary) -> bool:
var display_name: String = str(entry.get("display_name", ""))
return (
_valid_slot_id(str(entry.get("slot_id", "")))
and display_name.length() <= MAX_NAME_LENGTH
and not normalized_name(display_name).is_empty()
and typeof(entry.get("created_at_unix")) in [TYPE_INT, TYPE_FLOAT]
and typeof(entry.get("last_played_at_unix")) in [TYPE_INT, TYPE_FLOAT]
and typeof(entry.get("legacy")) == TYPE_BOOL
)
func _valid_slot_id(slot_id: String) -> bool:
if slot_id.length() != 32:
return false
for index: int in slot_id.length():
var character: String = slot_id.substr(index, 1).to_lower()
if character not in "0123456789abcdef":
return false
return true
func _recover_interrupted_manifest_write() -> void:
var temporary: String = _manifest_path + ".tmp"
var backup: String = _manifest_path + ".backup"
if FileAccess.file_exists(_manifest_path):
_remove_if_present(temporary)
_remove_if_present(backup)
return
if FileAccess.file_exists(backup):
_rename_file(backup, _manifest_path)
_remove_if_present(temporary)
func _preserve_invalid_manifest() -> bool:
var destination: String = _data_root.migration_backup_directory().path_join(
"invalid-save-slots-%s.json"
% Time.get_datetime_string_from_system().replace(":", "-")
)
if not _rename_file(_manifest_path, destination):
return false
_expected_hash = ""
_slots.clear()
_active_slot_id = ""
return true
func _rename_file(source: String, destination: String) -> bool:
if source.is_empty() or destination.is_empty():
return false
if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK:
return false
return DirAccess.rename_absolute(source, destination) == OK
func _remove_if_present(path: String) -> bool:
return not FileAccess.file_exists(path) or DirAccess.remove_absolute(path) == OK

View file

@ -0,0 +1 @@
uid://djfeifv7xrqdt

View file

@ -31,6 +31,7 @@ readonly -a QUICK_TESTS=(
"tests/fishing_shop_controller_validation.gd"
"tests/fishing_audio_validation.gd"
"tests/fishing_surface_validation.gd"
"tests/friend_relationship_validation.gd"
"tests/fur_pattern_validation.gd"
"tests/generated_world_runtime_validation.gd"
"tests/gathering_marker_surface_validation.gd"
@ -84,6 +85,7 @@ readonly -a NETWORK_TESTS=(
"tests/chat_privacy_multiplayer_validation.gd"
"tests/economy_regression_validation.gd"
"tests/fish_showcase_multiplayer_validation.gd"
"tests/friend_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd"

View file

@ -177,6 +177,17 @@ func get_active_bindings() -> Dictionary:
return _default_bindings.duplicate(true)
func get_binding(role: StringName) -> Dictionary:
var binding: Variant = get_active_bindings().get(str(role), {})
if typeof(binding) != TYPE_DICTIONARY:
return {}
return (binding as Dictionary).duplicate(true)
func get_binding_label(role: StringName) -> String:
return binding_label(get_binding(role))
func get_role_label(role: StringName) -> String:
return str(ROLE_LABELS.get(role, str(role).replace("_", " ")))

View file

@ -32,6 +32,7 @@ const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192]
@export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005
@export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5
@export var invert_camera_y: bool = false
@export var swap_hotbar_camera_scroll: bool = false
@export var on_screen_keyboard_enabled: bool = false
@export var chat_draft: String = ""
@export var chat_collapsed: bool = false
@ -79,6 +80,7 @@ func copy() -> PlayerSettings:
result.mouse_camera_sensitivity = mouse_camera_sensitivity
result.controller_camera_sensitivity = controller_camera_sensitivity
result.invert_camera_y = invert_camera_y
result.swap_hotbar_camera_scroll = swap_hotbar_camera_scroll
result.on_screen_keyboard_enabled = on_screen_keyboard_enabled
result.chat_draft = chat_draft
result.chat_collapsed = chat_collapsed

View file

@ -54,6 +54,10 @@ func load_settings() -> bool:
if (
typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL
or typeof(camera.get("invert_vertical")) != TYPE_BOOL
or (
camera.has("swap_hotbar_camera_scroll")
and typeof(camera["swap_hotbar_camera_scroll"]) != TYPE_BOOL
)
or (
accessibility.has("on_screen_keyboard_enabled")
and typeof(accessibility["on_screen_keyboard_enabled"]) != TYPE_BOOL
@ -109,6 +113,9 @@ func load_settings() -> bool:
-1.0
)
loaded.invert_camera_y = camera["invert_vertical"]
loaded.swap_hotbar_camera_scroll = bool(
camera.get("swap_hotbar_camera_scroll", false)
)
loaded.on_screen_keyboard_enabled = bool(
accessibility.get("on_screen_keyboard_enabled", false)
)
@ -237,6 +244,9 @@ func save_now() -> bool:
"mouse_sensitivity": current_settings.mouse_camera_sensitivity,
"controller_sensitivity": current_settings.controller_camera_sensitivity,
"invert_vertical": current_settings.invert_camera_y,
"swap_hotbar_camera_scroll": (
current_settings.swap_hotbar_camera_scroll
),
},
"audio": {
"master": current_settings.master_volume,

View file

@ -81,12 +81,36 @@ func _run() -> void:
"%NetworkSurfaceDrawingService"
) as NetworkSurfaceDrawingService
var game_ui := main.get_node("%GameUI") as GameUI
var keyboard_mapping := main.get_node(
"%KeyboardMouseMappingManager"
) as KeyboardMouseMappingManager
var toolbar := game_ui.get_node(
"%SurfaceDrawingToolbar"
) as SurfaceDrawingToolbar
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
assert(player != null and service != null and toolbar != null)
assert(chat_ui != null)
assert(keyboard_mapping != null)
assert(keyboard_mapping.reset_mapping())
await process_frame
var shop_prompt_key := game_ui.get_node("%ShopPromptKey") as Label
var storage_prompt_message := game_ui.get_node(
"%StoragePromptMessage"
) as Label
assert(shop_prompt_key.text == "E")
assert(storage_prompt_message.text == "E open storage")
var remapped_interact := InputEventKey.new()
remapped_interact.physical_keycode = KEY_F
remapped_interact.pressed = true
assert(keyboard_mapping.set_binding(
KeyboardMouseMappingManager.ROLE_INTERACT,
keyboard_mapping.binding_from_event(remapped_interact),
))
await process_frame
assert(shop_prompt_key.text == "F")
assert(storage_prompt_message.text == "F open storage")
assert(keyboard_mapping.reset_mapping())
await process_frame
assert(toolbar.get_parent() == chat_ui.get_parent())
assert(
toolbar.get_index() > chat_ui.get_index(),
@ -252,6 +276,33 @@ func _run() -> void:
else:
assert(not typed_chat_entry.virtual_keyboard_enabled)
assert(on_screen_keyboard.is_open())
# A bite temporarily owns gameplay input without discarding an in-progress
# message. The draft and insertion point return only after fishing releases
# that ownership, and any controller keyboard follows the restored field.
var chat_fishing_spot := main.get_node("%FishingSpot") as FishingSpot
assert(chat_fishing_spot != null)
var interrupted_draft := "finish this message after fishing"
var interrupted_caret: int = 12
typed_chat_entry.text = interrupted_draft
typed_chat_entry.caret_column = interrupted_caret
chat_fishing_spot.call("_set_fishing_input_priority", true)
await process_frame
assert(not chat_ui.is_open())
assert(not bool(chat_ui.get("_input_lock_applied")))
assert(chat_ui.has_fishing_resume_pending())
assert(not on_screen_keyboard.is_open())
chat_ui.open_chat()
assert(not chat_ui.is_open())
chat_fishing_spot.call("_set_fishing_input_priority", false)
for _frame: int in 4:
await process_frame
assert(chat_ui.is_open())
assert(typed_chat_entry.text == interrupted_draft)
assert(typed_chat_entry.caret_column == interrupted_caret)
assert(bool(chat_ui.get("_input_lock_applied")))
if not DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
assert(on_screen_keyboard.is_open())
on_screen_keyboard.call("_close_keyboard", true)
assert(typed_chat_entry.has_focus())
var left_bumper := InputEventJoypadButton.new()
@ -507,7 +558,7 @@ func _run() -> void:
and stamp_button != null
)
assert(export_button.text == "png")
assert(export_button.tooltip_text == "export aimed artwork as PNG")
assert(export_button.tooltip_text == "aim at artwork, then click to export PNG")
assert(stamp_button.text == "stamp")
assert(stamp_button.disabled)
assert(toolbar.get_node_or_null("%CloseButton") == null)
@ -830,6 +881,18 @@ func _run() -> void:
await process_frame
assert(player.hotbar.get_selected_slot() == 1)
assert(not service.is_active() and not toolbar.visible)
hotbar_ui.set_swap_hotbar_camera_scroll(true)
hotbar_ui._unhandled_input(wheel_down)
await process_frame
assert(player.hotbar.get_selected_slot() == 1)
var shifted_wheel_down := InputEventMouseButton.new()
shifted_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN
shifted_wheel_down.pressed = true
shifted_wheel_down.shift_pressed = true
hotbar_ui._unhandled_input(shifted_wheel_down)
await process_frame
assert(player.hotbar.get_selected_slot() == 2)
hotbar_ui.set_swap_hotbar_camera_scroll(false)
var number_one := InputEventKey.new()
number_one.physical_keycode = KEY_1

View file

@ -54,6 +54,26 @@ func _run() -> void:
player.notification(NOTIFICATION_APPLICATION_FOCUS_OUT)
assert(not bool(player.get("_camera_dragging")))
var plain_wheel_down := InputEventMouseButton.new()
plain_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN
plain_wheel_down.pressed = true
var shifted_wheel_down := InputEventMouseButton.new()
shifted_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN
shifted_wheel_down.pressed = true
shifted_wheel_down.shift_pressed = true
player.apply_camera_settings(0.005, 2.5, false, false)
var default_zoom: float = float(player.get("_target_zoom"))
player._unhandled_input(plain_wheel_down)
assert(is_equal_approx(float(player.get("_target_zoom")), default_zoom))
player._unhandled_input(shifted_wheel_down)
assert(float(player.get("_target_zoom")) > default_zoom)
player.apply_camera_settings(0.005, 2.5, false, true)
var swapped_zoom: float = float(player.get("_target_zoom"))
player._unhandled_input(shifted_wheel_down)
assert(is_equal_approx(float(player.get("_target_zoom")), swapped_zoom))
player._unhandled_input(plain_wheel_down)
assert(float(player.get("_target_zoom")) > swapped_zoom)
player.queue_free()
await process_frame
print("Camera drag validation: PASS")

View file

@ -26,6 +26,10 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")

View file

@ -6,6 +6,7 @@ const JoinGamePageScene = preload(
const TitleScreenScene = preload("res://ui/title_screen.tscn")
const PauseMenuScene = preload("res://ui/pause_menu.tscn")
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
const SaveSlotsPageScene = preload("res://ui/save_slots_page.tscn")
const BubbleConfirmationScene = preload(
"res://ui/components/bubble_menu/bubble_confirmation_page.tscn"
)
@ -43,6 +44,7 @@ func _initialize() -> void:
func _run() -> void:
root.size = Vector2i(1280, 720)
await _validate_primary_menu_navigation()
await _validate_save_slots_navigation()
await _validate_join_game_navigation()
await _validate_data_settings_navigation()
await _validate_settings_adjustment_navigation()
@ -113,8 +115,20 @@ func _validate_primary_menu_navigation() -> void:
(control as BaseButton).disabled = false
title_controls.append(control)
_expect(
title_controls.size() == 7,
"Title menu does not expose all seven primary actions.",
title_controls.size() == 5,
"Title menu does not expose its five primary actions.",
)
var play_button := title.get_node("%PlayButton") as Button
_expect(
play_button.icon != null
and play_button.icon.resource_path
== "res://ui/icons/main_menu/continue.png",
"The Play action does not use the original Continue icon.",
)
_expect(
not (title.get_node("%NewGameButton") as Control).visible
and not (title.get_node("%DeleteSaveButton") as Control).visible,
"Legacy New/Delete title bubbles are still visible.",
)
_assert_directionally_reachable(title_controls.front(), title_controls)
title.queue_free()
@ -141,12 +155,67 @@ func _validate_primary_menu_navigation() -> void:
await process_frame
func _validate_save_slots_navigation() -> void:
var page := SaveSlotsPageScene.instantiate() as SaveSlotsPage
root.add_child(page)
await process_frame
page.open_page()
for _frame: int in 2:
await process_frame
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 content_panel := page.get_node("%ContentPanel") as PanelContainer
var tab_overlap: float = (
saves_tab.get_global_rect().end.y
- content_panel.get_global_rect().position.y
)
_expect(
is_equal_approx(tab_overlap, saves_tab.size.y * 0.5),
"Save-slot content does not cover the lower half of its organizer tabs.",
)
_expect(
page.get_active_page_id() == &"new",
"An empty save catalog does not open directly to New Slot.",
)
_assert_neighbor(saves_tab, &"focus_neighbor_right", new_tab)
_assert_neighbor(new_tab, &"focus_neighbor_left", saves_tab)
var new_controls: Array[Control] = [
saves_tab,
new_tab,
page.get_node("%NewSlotName") as Control,
page.get_node("%GeneratedButton") as Control,
page.get_node("%StarterButton") as Control,
page.get_node("%RandomSeedButton") as Control,
page.get_node("%CustomSeedButton") as Control,
page.get_node("%CreateSlotButton") as Control,
page.get_node("%BackButton") as Control,
]
_assert_directionally_reachable(new_tab, new_controls)
page.call("_select_page", &"saves", false)
await process_frame
var import_button := page.get_node("%ImportSlotButton") as Button
_expect(
saves_tab.find_valid_focus_neighbor(SIDE_BOTTOM) == import_button,
"An empty Save Slots page does not lead from its tab to Import Save.",
)
_expect(
import_button.find_valid_focus_neighbor(SIDE_BOTTOM)
== page.get_node("%BackButton"),
"An empty Save Slots page does not lead from Import Save to Back.",
)
page.queue_free()
await process_frame
func _validate_join_game_navigation() -> void:
var page := JoinGamePageScene.instantiate() as Control
root.add_child(page)
await process_frame
page.show()
await process_frame
var discover := page.get_node("%DiscoverButton") as Button
var friends := page.get_node("%FriendsButton") as Button
var direct := page.get_node("%DirectButton") as Button
var saved := page.get_node("%SavedButton") as Button
var recent := page.get_node("%RecentButton") as Button
@ -161,8 +230,21 @@ func _validate_join_game_navigation() -> void:
var delete := page.get_node("%DeleteButton") as Button
var cancel := page.get_node("%CancelButton") as Button
var back := page.get_node("%BackButton") as Button
var modes: Array[Control] = [discover, direct, saved, recent]
var direct_content := page.get_node("%DirectContent") as Control
var list_content := page.get_node("%ListContent") as Control
var content_panel := page.get_node("%ContentPanel") as PanelContainer
var modes: Array[Control] = [discover, friends, direct, saved, recent]
var tab_overlap: float = (
discover.get_global_rect().end.y
- content_panel.get_global_rect().position.y
)
_expect(
is_equal_approx(tab_overlap, discover.size.y * 0.5),
"Join-game content does not cover the lower half of its organizer tabs.",
)
direct_content.hide()
list_content.show()
address.hide()
name_edit.hide()
server_list.show()
@ -174,14 +256,15 @@ func _validate_join_game_navigation() -> void:
_set_button_state(delete, false)
_set_button_state(cancel, false)
_set_button_state(back, true)
page.set("_mode", 0)
page.set("_mode", JoinGamePage.Mode.DISCOVER)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(discover, &"focus_neighbor_bottom", server_list)
_assert_neighbor(server_list, &"focus_neighbor_top", discover)
_assert_neighbor(server_list, &"focus_neighbor_bottom", refresh)
_assert_neighbor(refresh, &"focus_neighbor_right", join)
_assert_neighbor(back, &"focus_neighbor_left", join)
_assert_neighbor(join, &"focus_neighbor_bottom", back)
_assert_neighbor(back, &"focus_neighbor_top", join)
var discover_controls: Array[Control] = modes.duplicate()
discover_controls.append_array([server_list, refresh, join, back])
_assert_directionally_reachable(discover, discover_controls)
@ -209,18 +292,31 @@ func _validate_join_game_navigation() -> void:
"Discovery's visible cursor and selected room should agree immediately.",
)
page.set("_mode", JoinGamePage.Mode.FRIENDS)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(friends, &"focus_neighbor_bottom", server_list)
_assert_neighbor(server_list, &"focus_neighbor_top", friends)
var friend_controls: Array[Control] = modes.duplicate()
friend_controls.append_array([server_list, refresh, join, back])
_assert_directionally_reachable(friends, friend_controls)
list_content.hide()
direct_content.show()
address.show()
address.editable = true
server_list.hide()
_set_button_state(refresh, false)
_set_button_state(join, true)
_set_button_state(save, true)
page.set("_mode", 1)
page.set("_mode", JoinGamePage.Mode.DIRECT)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(direct, &"focus_neighbor_bottom", address)
_assert_neighbor(address, &"focus_neighbor_top", direct)
_assert_neighbor(address, &"focus_neighbor_bottom", join)
_assert_neighbor(save, &"focus_neighbor_bottom", back)
_assert_neighbor(back, &"focus_neighbor_top", save)
var direct_controls: Array[Control] = modes.duplicate()
direct_controls.append_array([address, join, save, back])
_assert_directionally_reachable(direct, direct_controls)
@ -251,8 +347,10 @@ func _validate_join_game_navigation() -> void:
entry.display_name = "Saved server %d" % index
saved_entries.append(entry)
server_list.add_item(entry.display_name)
page.set("_mode", 2)
page.set("_mode", JoinGamePage.Mode.SAVED)
page.set("_visible_entries", saved_entries)
direct_content.hide()
list_content.show()
server_list.show()
page.call("_configure_controller_navigation")
page.call("_restore_entry_selection_and_focus", 1)
@ -387,8 +485,6 @@ func _validate_data_settings_navigation() -> void:
"Open Data Folder left a controller-activatable dialog behind.",
)
var change_data_folder := panel.get_node("%ChangeDataFolder") as Button
var export_progression := panel.get_node("%ExportProgression") as Button
var import_progression := panel.get_node("%ImportProgression") as Button
var copy_fingerprint := panel.get_node("%CopyPlayerFingerprint") as Button
var export_player := panel.get_node("%ExportPlayerIdentity") as Button
var import_player := panel.get_node("%ImportPlayerIdentity") as Button
@ -398,8 +494,6 @@ func _validate_data_settings_navigation() -> void:
data_tab,
open_data_folder,
change_data_folder,
export_progression,
import_progression,
copy_fingerprint,
export_player,
import_player,
@ -423,7 +517,7 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor(
open_data_folder,
&"focus_neighbor_bottom",
export_progression,
copy_fingerprint,
)
_assert_neighbor(
change_data_folder,
@ -433,43 +527,13 @@ func _validate_data_settings_navigation() -> void:
_assert_neighbor(
change_data_folder,
&"focus_neighbor_bottom",
import_progression,
copy_fingerprint,
)
_assert_neighbor(
export_progression,
copy_fingerprint,
&"focus_neighbor_top",
open_data_folder,
)
_assert_neighbor(
export_progression,
&"focus_neighbor_right",
import_progression,
)
_assert_neighbor(
export_progression,
&"focus_neighbor_bottom",
copy_fingerprint,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_top",
change_data_folder,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_left",
export_progression,
)
_assert_neighbor(
import_progression,
&"focus_neighbor_bottom",
copy_fingerprint,
)
_assert_neighbor(
copy_fingerprint,
&"focus_neighbor_top",
export_progression,
)
_assert_neighbor(
copy_fingerprint,
&"focus_neighbor_bottom",
@ -582,6 +646,8 @@ func _validate_settings_adjustment_navigation() -> void:
var on_screen_keyboard := panel.get_node(
"%OnScreenKeyboardToggle"
) as Button
var swap_scroll := panel.get_node("%SwapScrollToggle") as Button
var invert_y := panel.get_node("%InvertYToggle") as Button
var controller_binds := panel.get_node("%ControllerMapping") as Button
var keyboard_binds := panel.get_node("%KeyboardMapping") as Button
_expect(
@ -594,6 +660,26 @@ func _validate_settings_adjustment_navigation() -> void:
and is_equal_approx(controller_slider.step, 0.1),
"Sensitivity sliders do not retain their authored increments.",
)
_assert_neighbor(
invert_y,
&"focus_neighbor_bottom",
swap_scroll,
)
_assert_neighbor(
swap_scroll,
&"focus_neighbor_top",
invert_y,
)
_assert_neighbor(
swap_scroll,
&"focus_neighbor_bottom",
on_screen_keyboard,
)
_assert_neighbor(
on_screen_keyboard,
&"focus_neighbor_top",
swap_scroll,
)
_assert_neighbor(
on_screen_keyboard,
&"focus_neighbor_bottom",
@ -623,7 +709,8 @@ func _validate_settings_adjustment_navigation() -> void:
panel.get_node("%ControlsTab") as Control,
mouse_slider,
controller_slider,
panel.get_node("%InvertYToggle") as Control,
invert_y,
swap_scroll,
on_screen_keyboard,
controller_binds,
keyboard_binds,

View file

@ -94,11 +94,15 @@ func _run() -> void:
func _run_multiplayer_host() -> void:
root.size = Vector2i(1280, 720)
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
var session := main.get_node("%NetworkSession") as NetworkSession
assert(session.set_host_open(true))
var client_connected := false
# Loading the full client world can take longer on a cold import. Keep the
@ -126,7 +130,10 @@ func _run_multiplayer_host() -> void:
var interaction := main.get("_shop_interaction") as FishingShopInteraction
assert(interaction != null)
remote_avatar.global_position = interaction.global_position
var completion_deadline: int = Time.get_ticks_msec() + 30000
# The client can finish loading its local presentation well after the host
# has authenticated it, especially during a cold import. Keep the authority
# alive until the client completes the transaction sequence or disconnects.
var completion_deadline: int = Time.get_ticks_msec() + 90000
while Time.get_ticks_msec() < completion_deadline:
await process_frame
if session.get_authenticated_peer_ids().size() < 2:

View file

@ -56,7 +56,7 @@ func _run() -> void:
)
fish_catch.ensure_identity()
player.inventory.add_catch(fish_catch)
player.collection_log.mark_quality_discovered(
player.collection_log.record_catch(
fish_catch.fish_id,
fish_catch.quality,
)
@ -132,6 +132,19 @@ func _run() -> void:
int(saved_masks[String(fish.id)])
== FishQuality.bit_for(FishQuality.Tier.EXCEPTIONAL)
)
var saved_catch_counts: Dictionary = (
(parsed as Dictionary)["collection"]["catch_counts"]
)
assert(int(saved_catch_counts[String(fish.id)]) == 1)
var legacy_without_counts: Dictionary = parsed.duplicate(true)
(legacy_without_counts["collection"] as Dictionary).erase("catch_counts")
var legacy_snapshot: RefCounted = save_manager.call(
"_build_load_snapshot",
legacy_without_counts,
)
assert(legacy_snapshot != null)
var recovered_counts: Dictionary = legacy_snapshot.get("catch_counts")
assert(int(recovered_counts.get(fish.id, 0)) == 1)
assert(player.hotbar.clear_slot(1))
assert(player.experience.restore_total_experience(0))
@ -159,6 +172,7 @@ func _run() -> void:
FishQuality.Tier.EXCEPTIONAL,
)
)
assert(player.collection_log.get_catch_count(fish.id) == 1)
assert(service.is_local_showcase_visible())
await _wait_for_held_fish_visibility(player, true)
assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null)
@ -183,8 +197,8 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.PROTOCOL_VERSION == 10)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17)
assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY
== "fish_quality_v1"

View file

@ -25,13 +25,60 @@ func _run() -> void:
_validate_mail_round_trip()
_validate_collection_mastery()
_validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
_validate_fishing_protocol_v2()
assert(NetworkProtocol.PROTOCOL_VERSION == 10)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17)
assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11)
assert(
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY
== "movement_reconciliation_v2"
)
assert(
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
== "fishing_replication_v2"
)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS")
quit()
func _validate_fishing_protocol_v2() -> void:
var request: Dictionary = {
"request_id": "cast-1",
"session_id": "session-1",
"origin": [0.0, 1.0, 0.0],
"target": [0.0, 0.0, -4.0],
"charge": 0.5,
"rod_id": "basic_fishing_rod",
"reel_speed": 0.2,
"barrier_damage": 1,
"bite_multiplier": 1.0,
"rarity_multipliers": [],
"discovered_fish_ids": [],
"capacity_available": true,
"movement_sequence": 17,
}
assert(NetworkFishingProtocol.validate_cast_request(request).is_empty())
var missing_sequence: Dictionary = request.duplicate(true)
missing_sequence.erase("movement_sequence")
assert(
NetworkFishingProtocol.validate_cast_request(missing_sequence)
== "Malformed fishing request."
)
var invalid_sequence: Dictionary = request.duplicate(true)
invalid_sequence["movement_sequence"] = -1
assert(
NetworkFishingProtocol.validate_cast_request(invalid_sequence)
== "Fishing request values are outside allowed limits."
)
assert(NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL == 10)
assert(NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL == 15)
assert(
NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL
!= NetworkFishingProtocol.SNAPSHOT_CHANNEL
)
func _validate_tiers_and_distribution() -> void:
assert(FishQualityType.TIER_COUNT == 5)
assert(FishQualityType.display_name(0) == "boring")
@ -194,7 +241,7 @@ func _validate_barrier_challenge_curve() -> void:
func _validate_fight_pacing_and_reel_upgrades() -> void:
assert(is_equal_approx(CatchController.CHASE_SPEED, 0.07))
assert(is_equal_approx(CatchController.CHASE_START_DELAY, 1.0))
assert(is_equal_approx(CatchController.CHASE_START_DELAY, 1.5))
assert(is_equal_approx(CatchController.CHASE_START_OFFSET, 0.04))
assert(is_equal_approx(Player.BASE_REEL_SPEED, 0.16))
@ -401,6 +448,11 @@ func _validate_collection_mastery() -> void:
collection.get_quality_mask(&"bluegill")
== FishQualityType.ALL_TIERS_MASK
)
assert(collection.get_catch_count(&"bluegill") == 0)
collection.record_catch(&"bluegill", FishQualityType.Tier.BORING)
collection.record_catch(&"bluegill", FishQualityType.Tier.SHINY)
assert(collection.get_catch_count(&"bluegill") == 2)
assert(int(collection.get_catch_counts()[&"bluegill"]) == 2)
collection.queue_free()

View file

@ -24,6 +24,10 @@ func _run() -> void:
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())

View file

@ -19,6 +19,12 @@ func _run() -> void:
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
assert(bool(main.call(
"_apply_world",
WorldLayout.GENERATED,
PlayerSaveManager.DEFAULT_WORLD_SEED,
true,
)))
assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())
@ -151,9 +157,16 @@ func _run() -> void:
attempt = attempts.get(session.get_local_peer_id())
assert(attempt != null)
assert(player.bag.get_quantity(&"worms") == bait_quantity_before)
var fishing_priority_transitions: Array[bool] = []
fishing_spot.fishing_input_priority_changed.connect(
func(active: bool) -> void:
fishing_priority_transitions.append(active)
)
service.call("_start_bite", attempt)
await process_frame
assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING)
assert(fishing_spot.is_fishing_input_priority_active())
assert(fishing_priority_transitions == [true])
assert(player.bag.get_quantity(&"worms") == bait_quantity_before - 1)
var catalog: FishPool = main.get("fish_catalog") as FishPool
assert(catalog != null)
@ -200,6 +213,7 @@ func _run() -> void:
service.call("_on_attempt_escaped", session.get_local_peer_id())
await process_frame
assert(not service.has_local_attempt())
assert(fishing_spot.is_fishing_input_priority_active())
assert(fishing_status.text.is_empty())
assert(not fishing_status.visible)
assert(not fishing_panel.visible)
@ -210,6 +224,8 @@ func _run() -> void:
):
await process_frame
assert(fishing_spot.state == FishingSpotType.FishingState.READY)
assert(not fishing_spot.is_fishing_input_priority_active())
assert(fishing_priority_transitions == [true, false])
# Leaving a session during the cast presentation must release every local
# action and equipment lock. This is the same cleanup path used by an

View file

@ -490,7 +490,7 @@ func _validate_remote_presentation() -> void:
await process_frame
var origin: Vector3 = player.get_fishing_rod_tip().global_position
var target: Vector3 = origin + Vector3(-4.0, -0.5, 0.0)
presentation.show_cast(origin, target)
presentation.show_cast(origin, target, "cast-attempt")
var bobber := presentation.get("_bobber") as MeshInstance3D
assert(bobber.visible)
assert(presentation.get("_cast_tween") != null)
@ -500,6 +500,22 @@ func _validate_remote_presentation() -> void:
await create_timer(0.2).timeout
assert(not is_equal_approx(bobber.global_position.y, first_bob_y))
# A client joining after the reliable cast event reconstructs the current
# remote fishing state from the observer summary instead of waiting for the
# next cast. This also covers a bite/fighting transition received by an
# already active observer.
presentation.cleanup()
presentation.synchronize_active("late-attempt", target, false)
assert(bobber.visible)
assert(
int(player.get("_fishing_visual_phase"))
== Player.FishingVisualPhase.FISHING
)
presentation.synchronize_active("late-attempt", target, true)
assert(bool(player.get("_fighting_visual_active")))
presentation.synchronize_active("late-attempt", target, false)
assert(not bool(player.get("_fighting_visual_active")))
var fish_catch := FishCatchType.new()
var fish: FishData = PondPool.candidates.front()
fish_catch.fish = fish

View file

@ -0,0 +1,161 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18196
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments := OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Friend multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main := await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
assert(session.set_host_open(true))
var remote_peer_id := await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
assert(session.peer_supports_capability(
remote_peer_id, NetworkProtocol.FRIENDS_CAPABILITY
))
var remote_record := session.get_peer_record(remote_peer_id)
assert(remote_record != null)
var game_ui := main.get_node("%GameUI") as GameUI
var prompt_deadline := Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < prompt_deadline
and not game_ui.is_social_prompt_open()
):
await process_frame
assert(game_ui.is_social_prompt_open())
await create_timer(0.15).timeout
game_ui.call("_accept_social_prompt")
var relationships := main.get_node(
"%PlayerRelationshipStore"
) as PlayerRelationshipStore
var accepted_deadline := Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < accepted_deadline
and not relationships.is_friend(remote_record.identity_fingerprint)
):
await process_frame
assert(relationships.is_friend(remote_record.identity_fingerprint))
var friend := relationships.get_friend_record(
remote_record.identity_fingerprint
)
assert(not str(friend.get("remote_presence_channel", "")).is_empty())
assert(not str(friend.get("remote_invite_token", "")).is_empty())
var disconnect_deadline := Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Friend multiplayer host validation: PASS")
await _cleanup(main, session)
func _run_client() -> void:
var main := await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var join_deadline := Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
assert(session.supports_server_capability(
NetworkProtocol.FRIENDS_CAPABILITY
))
var host_record := session.get_peer_record(1)
assert(host_record != null)
var service := main.get_node(
"%NetworkPlayerListService"
) as NetworkPlayerListService
assert(service.send_friend_request(
1,
host_record.identity_fingerprint,
host_record.display_name,
))
var relationships := main.get_node(
"%PlayerRelationshipStore"
) as PlayerRelationshipStore
var accepted_deadline := Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < accepted_deadline
and not relationships.is_friend(host_record.identity_fingerprint)
):
await process_frame
assert(relationships.is_friend(host_record.identity_fingerprint))
var friend := relationships.get_friend_record(
host_record.identity_fingerprint
)
assert(not str(friend.get("remote_presence_channel", "")).is_empty())
assert(not str(friend.get("remote_invite_token", "")).is_empty())
print("Friend multiplayer client validation: PASS")
await _cleanup(main, session)
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main := 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")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline := Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0
func _cleanup(main: Node, session: NetworkSession) -> void:
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()

View file

@ -0,0 +1 @@
uid://cxltqyom4yt3j

View file

@ -0,0 +1,76 @@
extends SceneTree
const FRIEND_FINGERPRINT := (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var data_root := PlayerDataRoot.new()
root.add_child(data_root)
var portable_path := ProjectSettings.globalize_path(
"user://friend-relationship-validation"
)
var created := data_root.create_unbound_root(portable_path)
assert(bool(created.get("ok", false)))
assert(data_root.activate_process_root(portable_path))
var store := PlayerRelationshipStore.new()
root.add_child(store)
store.configure_storage(
data_root.path_for(&"player_relationships"), data_root
)
var local_capabilities := store.create_friend_capabilities()
var remote_capabilities := store.create_friend_capabilities()
assert(not local_capabilities.is_empty())
assert(not remote_capabilities.is_empty())
assert(store.add_friend(
FRIEND_FINGERPRINT,
"Pond Friend",
local_capabilities,
{
"presence_channel": remote_capabilities["presence_channel"],
"invite_token": remote_capabilities["invite_token"],
},
))
assert(store.is_friend(FRIEND_FINGERPRINT))
var reloaded := PlayerRelationshipStore.new()
root.add_child(reloaded)
reloaded.configure_storage(
data_root.path_for(&"player_relationships"), data_root
)
assert(reloaded.is_friend(FRIEND_FINGERPRINT))
var friend := reloaded.get_friend_record(FRIEND_FINGERPRINT)
assert(
str(friend["remote_presence_channel"])
== str(remote_capabilities["presence_channel"])
)
assert(
str(friend["remote_invite_token"])
== str(remote_capabilities["invite_token"])
)
assert(reloaded.set_blocked(FRIEND_FINGERPRINT, "Pond Friend", true))
assert(reloaded.is_blocked(FRIEND_FINGERPRINT))
assert(reloaded.is_muted(FRIEND_FINGERPRINT))
assert(not reloaded.is_friend(FRIEND_FINGERPRINT))
assert(reloaded.set_blocked(FRIEND_FINGERPRINT, "Pond Friend", false))
assert(not reloaded.is_blocked(FRIEND_FINGERPRINT))
assert(not reloaded.is_muted(FRIEND_FINGERPRINT))
assert(not reloaded.is_friend(FRIEND_FINGERPRINT))
var hello := NetworkProtocol.make_client_hello(
"profile",
"Pond Friend",
"nonce",
)
assert(
NetworkProtocol.FRIENDS_CAPABILITY
in PackedStringArray(hello.get("capability_flags", []))
)
print("Friend relationship validation: PASS")
quit()

View file

@ -0,0 +1 @@
uid://d3v4e1xtu8ytq

View file

@ -68,6 +68,7 @@ func _run() -> void:
configured_generator.elevated_cliff_double_third_tier_chance = 0.0
root.add_child(region)
await process_frame
assert(region.generate_world(FIRST_SEED))
await physics_frame
var generator := region.get_node(
@ -348,6 +349,27 @@ func _validate_generated_region(
generator.get_generated_chunks_root().get_child_count()
== expected_chunk_count
)
var generated_root := generator.get_generated_chunks_root()
var terrain_collision_batches := generated_root.find_children(
"TerrainCollisionBatch_*",
"StaticBody3D",
true,
false,
)
assert(not terrain_collision_batches.is_empty())
assert(
terrain_collision_batches.size()
<= ceili(float(generator.grid_size.x) / generator.collision_batch_size)
* ceili(float(generator.grid_size.y) / generator.collision_batch_size)
)
assert(
generated_root.find_children(
"TerrainCollision",
"StaticBody3D",
true,
false,
).is_empty()
)
assert(
generator.get_primary_terrain_meshes().size()
== (
@ -769,7 +791,6 @@ func _validate_elevated_cliff_feature(
stable_id,
)
assert(source_mesh != null and source_mesh.mesh != null)
assert(source_mesh.has_node("TerrainCollision"))
continue
layered_count += 1
var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer")
@ -799,8 +820,6 @@ func _validate_elevated_cliff_feature(
)
assert(base_mesh != null and base_mesh.mesh != null)
assert(overlay_mesh != null and overlay_mesh.mesh != null)
assert(base_mesh.has_node("TerrainBaseLayerCollision"))
assert(overlay_mesh.has_node("TerrainCollision"))
assert(layered_count == (29 if has_coastal_feature else 23))
var stacked_keys := generator.stacked_elevated_placement_keys()
assert(stacked_keys.size() in [0, 4])
@ -837,7 +856,6 @@ func _validate_elevated_cliff_feature(
stacked_id,
)
assert(stacked_mesh != null and stacked_mesh.mesh != null)
assert(stacked_mesh.has_node("TerrainCollision"))
assert(stacked_count == 4)
@ -1056,7 +1074,11 @@ func _validate_decoration_transform(
else:
assert(collision == null)
var query := PhysicsRayQueryParameters3D.create(
prop.global_position + Vector3.UP * 8.0,
# Stay below neighboring cliff overhangs while still beginning above the
# authored walkable surface. Regional collision batches intentionally
# keep every original shape on one body, so excluding an overhang body
# would also exclude the ground beneath the prop.
prop.global_position + Vector3.UP * 0.1,
prop.global_position + Vector3.DOWN * 8.0,
1,
)
@ -1361,7 +1383,10 @@ func _validate_pond_collision(
var hit := region.get_world_3d().direct_space_state.intersect_ray(query)
assert(not hit.is_empty())
var collider := hit.get("collider") as Node
assert(collider != null and collider.get_parent() == pond)
assert(
collider != null
and collider.has_meta(&"terrain_collision_batch")
)
func _decoration_group_count(

View file

@ -24,6 +24,10 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var jobs := main.get_node("%PlayerJobService") as PlayerJobService
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
jobs.begin_progression_session()
await process_frame

View file

@ -89,6 +89,7 @@ func _run() -> void:
net_page.call("_select_view", TheNetPage.View.LIFETIME)
await process_frame
_validate_fishnet_bounds(net_page)
await _validate_compact_value_tooltips(net_page)
await _validate_unavailable_fishnet_layout()
var wallet_before: int = player.wallet.get_balance()
@ -287,6 +288,53 @@ func _validate_fishnet_bounds(page: TheNetPage) -> void:
assert(row.size.x <= jobs_scroll.size.x + 0.5)
func _validate_compact_value_tooltips(page: TheNetPage) -> void:
assert(str(page.call("_compact_integer", 2400)) == "2.4k")
assert(str(page.call("_compact_integer", 2500)) == "2.5k")
var list := page.get("_list") as VBoxContainer
assert(list != null)
var jobs: Array[Dictionary] = [{
"title": "compact tooltip check",
"description": "compact values keep their exact values on hover",
"target": 2500,
"progress": 2400,
"fish_coin": 1200,
"experience": 3400,
}]
page.call("_build_job_rows", jobs, "")
await process_frame
var row := list.get_child(list.get_child_count() - 1) as PanelContainer
assert(row != null)
var progress_bar: ProgressBar
var progress_count: Label
var reward: VBoxContainer
for child: Node in row.find_children("*", "ProgressBar", true, false):
progress_bar = child as ProgressBar
break
for child: Node in row.find_children("*", "Label", true, false):
var label := child as Label
if label != null and label.text == "2.4k / 2.5k":
progress_count = label
break
for child: Node in row.find_children("*", "VBoxContainer", true, false):
var candidate := child as VBoxContainer
if candidate != null and candidate.tooltip_text == (
"1200 fish coins · 3400 xp"
):
reward = candidate
break
assert(progress_bar != null)
assert(progress_bar.tooltip_text == "2400 / 2500")
assert(progress_bar.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(progress_count != null)
assert(progress_count.tooltip_text == "2400 / 2500")
assert(progress_count.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(reward != null)
assert(reward.mouse_filter == Control.MOUSE_FILTER_STOP)
row.queue_free()
await process_frame
func _validate_creature_jobs(jobs: PlayerJobService) -> void:
var fish_count: int = 0
var insect_count: int = 0

View file

@ -28,6 +28,16 @@ func _run() -> void:
defaults.size()
== KeyboardMouseMappingManagerType.ROLE_ORDER.size()
)
assert(
not manager.get_binding(
KeyboardMouseMappingManagerType.ROLE_INTERACT
).is_empty()
)
assert(
manager.get_binding_label(
KeyboardMouseMappingManagerType.ROLE_INTERACT
) == "e"
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
@ -120,6 +130,11 @@ func _run() -> void:
interact_key.pressed = true
panel._input(interact_key)
assert(_has_physical_key(&"interact", KEY_F))
assert(
manager.get_binding_label(
KeyboardMouseMappingManagerType.ROLE_INTERACT
) == "f"
)
assert(not panel.is_capturing())
var settings_panel := SettingsPanelScene.instantiate() as SettingsPanel

View file

@ -3,6 +3,8 @@ extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18194
const LATE_MESSAGE: String = "late join visibility check"
const CLEAR_MESSAGE: String = "late join animation clear check"
const ACTIVE_ACTION: StringName = &"draw"
func _initialize() -> void:
@ -26,8 +28,21 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
true,
)))
assert(session.set_host_world(
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
))
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
assert(save_manager.initialize_new_game(
NetworkProtocol.DEFAULT_WORLD_SEED,
WorldLayout.STARTER_ISLAND,
))
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
@ -35,7 +50,26 @@ func _run_host() -> void:
assert(await _wait_for_registry_size(session, 3))
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
await create_timer(5.0).timeout
var action_peer_id: int = await _wait_for_remote_action(
spawn,
ACTIVE_ACTION,
)
assert(action_peer_id > 1)
assert(await _wait_for_avatar_bool(
spawn,
action_peer_id,
&"_active_item_is_net",
true,
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true))
assert(await _wait_for_avatar_action(
spawn,
action_peer_id,
&"",
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false))
var chat := main.get_node("%NetworkChatService") as NetworkChatService
assert(await _wait_for_history_message(chat, CLEAR_MESSAGE))
print("Late-join multiplayer host validation: PASS")
await _cleanup(main, session)
@ -47,7 +81,23 @@ func _run_first_client() -> void:
var preview: Dictionary = profile.get_persisted_appearance().duplicate(true)
preview["scale"] = 0.9
assert(profile.preview_appearance(preview))
await create_timer(2.0).timeout
var player := main.get("_player") as Player
assert(player != null)
var floor_deadline: int = Time.get_ticks_msec() + 5000
while Time.get_ticks_msec() < floor_deadline and not player.is_on_floor():
await physics_frame
assert(player.is_on_floor())
player.toggle_sitting()
assert(player.is_sitting())
assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0))
for _frame: int in 2:
await process_frame
assert(bool(player.get("_active_item_is_net")))
assert(player.begin_animation_action(ACTIVE_ACTION))
await create_timer(0.5).timeout
assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION)
var chat := main.get_node("%NetworkChatService") as NetworkChatService
var deadline: int = Time.get_ticks_msec() + 15000
while Time.get_ticks_msec() < deadline:
@ -61,6 +111,15 @@ func _run_first_client() -> void:
assert(_history_contains(chat, LATE_MESSAGE))
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
# Keep the action active until the late peer has joined and announced that
# it has received the lifecycle snapshot. This exercises client -> host ->
# existing client/late client replication, including the join-in-progress
# state carried by the spawn envelope.
await create_timer(1.0).timeout
player.end_animation_action()
player.toggle_sitting()
assert(not player.is_sitting())
assert(await _wait_for_history_message(chat, CLEAR_MESSAGE))
print("Late-join first-client validation: PASS")
await _cleanup(main, session)
@ -77,9 +136,28 @@ func _run_late_client() -> void:
assert(session.get_authenticated_peer_ids().size() == 3)
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
var action_peer_id: int = await _wait_for_remote_action(
spawn,
ACTIVE_ACTION,
)
assert(action_peer_id > 1)
assert(await _wait_for_avatar_bool(
spawn,
action_peer_id,
&"_active_item_is_net",
true,
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true))
var chat := main.get_node("%NetworkChatService") as NetworkChatService
assert(chat.send_local_message(LATE_MESSAGE))
await create_timer(1.0).timeout
assert(await _wait_for_avatar_action(
spawn,
action_peer_id,
&"",
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false))
assert(chat.send_local_message(CLEAR_MESSAGE))
await create_timer(0.5).timeout
print("Late-join late-client validation: PASS")
await _cleanup(main, session)
@ -114,6 +192,136 @@ func _history_contains(service: NetworkChatService, body: String) -> bool:
)
func _wait_for_history_message(
service: NetworkChatService,
body: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
if _history_contains(service, body):
return true
return false
func _wait_for_remote_action(
spawn_service: PlayerSpawnService,
action_id: StringName,
) -> int:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in spawn_service.get_peer_ids():
var avatar: Player = spawn_service.get_avatar(peer_id)
if (
avatar != null
and _observed_action_id(avatar) == action_id
):
return peer_id
print(
"animation wait timed out: ",
_action_debug_snapshot(spawn_service),
)
return 0
func _wait_for_avatar_action(
spawn_service: PlayerSpawnService,
peer_id: int,
action_id: StringName,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if (
avatar != null
and _observed_action_id(avatar) == action_id
):
return true
print(
"animation clear wait timed out: ",
_action_debug_snapshot(spawn_service),
)
return false
func _wait_for_avatar_bool(
spawn_service: PlayerSpawnService,
peer_id: int,
property_name: StringName,
expected: bool,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null and bool(avatar.get(property_name)) == expected:
return true
return false
func _wait_for_avatar_sitting(
spawn_service: PlayerSpawnService,
peer_id: int,
expected: bool,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null and avatar.is_sitting() == expected:
return true
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null:
print(
"sitting wait timed out: ",
{
"peer_id": peer_id,
"expected": expected,
"sitting": avatar.is_sitting(),
"sit_after_landing": bool(avatar.get("_sit_after_landing")),
"on_floor": avatar.is_on_floor(),
"position": avatar.global_position,
"velocity": avatar.velocity,
"authoritative": bool(avatar.get(
"_network_authoritative_simulation"
)),
"last_input": int(avatar.get(
"_last_network_input_sequence"
)),
},
)
return false
func _observed_action_id(avatar: Player) -> StringName:
return StringName(str(avatar.get(
"_animation_action_id"
if bool(avatar.get("_network_authoritative_simulation"))
else "_network_target_animation_action_id"
)))
func _action_debug_snapshot(spawn_service: PlayerSpawnService) -> Dictionary:
var result: Dictionary = {}
for peer_id: int in spawn_service.get_peer_ids():
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar == null:
continue
result[peer_id] = {
"authoritative": bool(avatar.get(
"_network_authoritative_simulation"
)),
"source": str(avatar.get("_animation_action_id")),
"target": str(avatar.get(
"_network_target_animation_action_id"
)),
"last_input": int(avatar.get("_last_network_input_sequence")),
}
return result
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()

View file

@ -24,6 +24,12 @@ func _run() -> void:
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
assert(bool(main.call(
"_apply_world",
WorldLayout.GENERATED,
PlayerSaveManager.DEFAULT_WORLD_SEED,
true,
)))
_validate_main_profile(main)
_validate_minimal_weather(main)
_stop_audio_players(main)
@ -42,6 +48,12 @@ func _run() -> void:
for _frame: int in 8:
await process_frame
assert(bool(normal_main.get("_application_initialized")))
assert(bool(normal_main.call(
"_apply_world",
WorldLayout.GENERATED,
PlayerSaveManager.DEFAULT_WORLD_SEED,
true,
)))
_validate_normal_profile(normal_main)
_validate_new_game_music_transition(normal_main)
_stop_audio_players(normal_main)

View file

@ -284,10 +284,11 @@ func _validate_page() -> void:
)
fish_catch.ensure_identity()
inventory.add_catch(fish_catch)
collection.record_catch(fish_catch.fish_id, fish_catch.quality)
page.call("_select_entry", &"bluegill", &"bluegill")
await process_frame
assert(not _detail_text(page).contains("number owned"))
assert(_detail_text(page).contains("number caught\nunknown"))
assert(_detail_text(page).contains("number caught\n1"))
assert(_detail_text(page).contains("body of water\nfresh water"))
assert(
_detail_text(page).contains(
@ -412,6 +413,8 @@ func _validate_page() -> void:
assert(stats_view.visible)
var stats_text: String = _descendant_label_text(stats_view)
assert(stats_text.contains("catalog number"))
assert(stats_text.contains("number caught"))
assert(stats_text.contains("1"))
assert(stats_text.contains("seasons"))
assert(not stats_text.contains("number owned"))
_validate_overlay_fonts(stats_view)

View file

@ -32,7 +32,10 @@ func _validate_latency_smoothing() -> void:
await process_frame
avatar.set_process(false)
avatar.set_physics_process(false)
_validate_compact_input_encoding()
_validate_compact_snapshot_encoding()
_validate_compact_animation_encoding()
_validate_animation_action_ordering(avatar)
_validate_transit_estimation()
_validate_remote_snapshot_smoothing(avatar)
_validate_reliable_jump_intent(avatar)
@ -41,6 +44,26 @@ func _validate_latency_smoothing() -> void:
await process_frame
func _validate_compact_input_encoding() -> void:
var input: Dictionary = _movement_input(
12,
true,
true,
true,
&"strike",
4,
true,
)
var encoded: Array = NetworkSession._encode_movement_input(input)
assert(encoded.size() == NetworkSession.MOVEMENT_INPUT_FIELD_COUNT)
assert(var_to_bytes(encoded).size() < var_to_bytes(input).size())
assert(NetworkSession._decode_movement_input(encoded) == input)
assert(NetworkSession._decode_movement_input(encoded.slice(0, 3)).is_empty())
var unknown_flags: Array = encoded.duplicate()
unknown_flags[3] = 1 << 12
assert(NetworkSession._decode_movement_input(unknown_flags).is_empty())
func _validate_compact_snapshot_encoding() -> void:
var moving_snapshot: Dictionary = _network_snapshot(
Vector3.ZERO,
@ -54,14 +77,28 @@ func _validate_compact_snapshot_encoding() -> void:
NetworkSession._encode_movement_snapshot(moving_snapshot)
)
assert(var_to_bytes(encoded_snapshots).size() < 1200)
var expected_snapshot: Dictionary = moving_snapshot.duplicate(true)
expected_snapshot.erase("animation_state")
assert(
NetworkSession._decode_movement_snapshot(encoded_snapshots[0])
== expected_snapshot
)
assert(
NetworkSession._decode_movement_snapshot(
encoded_snapshots[0]
) == moving_snapshot
encoded_snapshots[0].slice(0, 4)
).is_empty()
)
var paused_snapshot: Dictionary = moving_snapshot.duplicate(true)
paused_snapshot["animation_state"] = (
NetworkPlayerAnimationProtocol.make_state(
var invalid_snapshot_flags: Array = encoded_snapshots[0].duplicate()
invalid_snapshot_flags[5] = 1 << 12
assert(
NetworkSession._decode_movement_snapshot(
invalid_snapshot_flags
).is_empty()
)
func _validate_compact_animation_encoding() -> void:
var paused_state := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
&"strike",
@ -69,16 +106,104 @@ func _validate_compact_snapshot_encoding() -> void:
0.5,
true,
)
var encoded: Array = NetworkSession._encode_movement_animation(
2,
paused_state,
)
var paused_encoded: Array = NetworkSession._encode_movement_snapshot(
paused_snapshot
assert(encoded.size() == NetworkSession.MOVEMENT_ANIMATION_FIELD_COUNT)
var decoded: Dictionary = NetworkSession._decode_movement_animation(encoded)
assert(int(decoded.get("peer_id", 0)) == 2)
assert(Dictionary(decoded.get("state", {})) == paused_state)
var advanced_state: Dictionary = paused_state.duplicate(true)
advanced_state["action"]["elapsed"] = 0.75
assert(
NetworkSession._movement_animation_signature(advanced_state)
== NetworkSession._movement_animation_signature(paused_state)
)
var action: Dictionary = paused_state["action"]
var encoded_action: Array = (
NetworkSession._encode_movement_animation_action(action, true)
)
assert(
encoded_action.size()
== NetworkSession.MOVEMENT_ANIMATION_ACTION_FIELD_COUNT
)
var decoded_action: Dictionary = (
NetworkSession._decode_movement_animation_action(encoded_action)
)
assert(Dictionary(decoded_action.get("action", {})) == action)
assert(bool(decoded_action.get("sitting", false)))
assert(
NetworkSession._decode_movement_animation_action(
encoded_action.slice(0, 2)
).is_empty()
)
func _validate_animation_action_ordering(avatar: Player) -> void:
var draw := NetworkPlayerAnimationProtocol.make_action_state(
&"draw", 5, 0.2
)
avatar.apply_authoritative_network_animation_action(draw)
assert(StringName(str(avatar.get("_animation_action_id"))) == &"draw")
var cleared := NetworkPlayerAnimationProtocol.make_action_state(
&"", 6, 0.0
)
avatar.apply_authoritative_network_animation_action(cleared)
assert(StringName(str(avatar.get("_animation_action_id"))).is_empty())
avatar.apply_authoritative_network_animation_action(draw)
assert(StringName(str(avatar.get("_animation_action_id"))).is_empty())
avatar.apply_authoritative_network_animation_action(
NetworkPlayerAnimationProtocol.make_action_state(&"strike", 6, 0.0)
)
assert(StringName(str(avatar.get("_animation_action_id"))).is_empty())
var strike := NetworkPlayerAnimationProtocol.make_action_state(
&"strike", 7, 0.2
)
avatar.apply_authoritative_network_animation_action(strike)
avatar.apply_authoritative_network_animation_action(
NetworkPlayerAnimationProtocol.make_action_state(
&"strike", 8, 0.4, true
)
)
avatar.apply_authoritative_network_animation_action(strike)
assert(bool(avatar.get("_animation_action_paused")))
avatar.apply_authoritative_network_animation_action(
NetworkPlayerAnimationProtocol.make_action_state(&"", 9, 0.0)
)
avatar.configure_network_remote(false)
avatar.apply_network_animation_state(
NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
&"strike",
9,
0.4,
true,
)
)
avatar.apply_network_animation_state(
NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING,
true,
&"draw",
8,
0.1,
)
)
assert(
StringName(str(avatar.get("_network_target_animation_action_id")))
== &"strike"
)
avatar.apply_network_animation_state(
NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
&"",
10,
)
assert(paused_encoded.size() == NetworkSession.MOVEMENT_SNAPSHOT_FIELD_COUNT)
var paused_decoded: Dictionary = (
NetworkSession._decode_movement_snapshot(paused_encoded)
)
assert(not paused_decoded.is_empty())
assert(bool(paused_decoded["animation_state"]["action"]["paused"]))
func _validate_transit_estimation() -> void:
@ -158,14 +283,16 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void:
avatar.set_local_control(true)
avatar.global_position = Vector3(0.8, 0.0, 0.0)
var replay_input: Dictionary = _movement_input(2, false)
replay_input["axis"] = [1.0, 0.0]
var pending_inputs: Array[Dictionary] = [replay_input]
avatar.apply_local_prediction_correction(
moving_snapshot,
10,
pending_inputs,
1.0 / 30.0,
0.1,
)
assert(avatar.global_position.x < 0.8)
assert(avatar.global_position.x > 0.7)
assert(avatar.global_position.x > 0.0)
func _validate_reliable_jump_intent(avatar: Player) -> void:
@ -178,9 +305,8 @@ func _validate_reliable_jump_intent(avatar: Player) -> void:
assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20)
avatar.apply_local_prediction_correction(
_network_snapshot(avatar.global_position, Vector3.ZERO, 20),
21,
[],
1.0 / 30.0,
0.0,
)
assert(not bool(avatar.capture_network_input(22)["jump"]))
@ -209,12 +335,24 @@ func _validate_reliable_jump_intent(avatar: Player) -> void:
func _validate_stale_input_expiry(avatar: Player) -> void:
avatar.configure_network_remote(true)
avatar.apply_authoritative_network_input(_movement_input(40, true))
var high_latency_timeout := (
Player.resolve_network_input_stale_timeout_seconds(400)
)
assert(high_latency_timeout > Player.NETWORK_INPUT_STALE_TIMEOUT_SECONDS)
avatar.apply_authoritative_network_input(
_movement_input(40, true),
high_latency_timeout,
)
assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0)
avatar.call(
"_update_network_input_freshness",
Player.NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 0.01,
)
assert(not bool(avatar.get("_network_input_stale")))
avatar.call(
"_update_network_input_freshness",
high_latency_timeout,
)
assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO)
assert(not bool(avatar.get("_network_sprint")))
assert(bool(avatar.get("_network_input_stale")))
@ -234,6 +372,7 @@ func _network_snapshot(
"position": [position.x, position.y, position.z],
"velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": 0.0,
"grounded": true,
"animation_state": NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING,
true,
@ -247,8 +386,15 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(session.set_host_world(
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
))
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
assert(save_manager.initialize_new_game(
NetworkProtocol.DEFAULT_WORLD_SEED,
WorldLayout.STARTER_ISLAND,
))
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame

View file

@ -55,6 +55,7 @@ func _run() -> void:
func _validate_default_and_persistence() -> void:
var defaults := PlayerSettings.new()
assert(not defaults.on_screen_keyboard_enabled)
assert(not defaults.swap_hotbar_camera_scroll)
assert(
KeyboardType.should_enable_for_controller(false, false, "Linux")
)
@ -87,11 +88,13 @@ func _validate_default_and_persistence() -> void:
assert(manager.load_settings())
var edited: PlayerSettings = manager.current_settings.copy()
edited.on_screen_keyboard_enabled = true
edited.swap_hotbar_camera_scroll = true
assert(manager.apply_settings(edited))
var reloaded := SettingsManagerType.new()
root.add_child(reloaded)
assert(reloaded.load_settings())
assert(reloaded.current_settings.on_screen_keyboard_enabled)
assert(reloaded.current_settings.swap_hotbar_camera_scroll)
manager.queue_free()
reloaded.queue_free()

View file

@ -36,6 +36,10 @@ func _run_host() -> void:
as NetworkPlayerListService
)
var bans := main.get_node("%HostBanStore") as HostBanStore
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
assert(session.set_host_open(true))
@ -65,7 +69,7 @@ func _run_host() -> void:
assert(players_page != null)
players_page.call("_refresh")
assert(_has_button_text(players_page, "deop"))
assert(_has_button_text(players_page, "clear art"))
assert(_has_button_tooltip(players_page, "scrub art"))
var unban_deadline: int = Time.get_ticks_msec() + 12000
while (
@ -149,9 +153,9 @@ func _run_client() -> void:
assert(players_page != null)
players_page.call("_refresh")
var tabs := players_page.get("_tabs") as HBoxContainer
assert((tabs.get_child(2) as Button).visible)
players_page.call("_select_tab", 2)
assert(int(players_page.get("_current_tab")) == 2)
assert((tabs.get_child(3) as Button).visible)
players_page.call("_select_tab", 3)
assert(int(players_page.get("_current_tab")) == 3)
var local_entry: PlayerListEntry = _entry_for_peer(
service, session.get_local_peer_id()
)
@ -176,7 +180,7 @@ func _run_client() -> void:
await process_frame
assert(not session.is_local_operator())
assert(not service.is_local_moderator())
assert(not (tabs.get_child(2) as Button).visible)
assert(not (tabs.get_child(3) as Button).visible)
assert(int(players_page.get("_current_tab")) == 0)
service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
var disconnect_deadline: int = Time.get_ticks_msec() + 10000
@ -252,3 +256,11 @@ func _has_button_text(root_node: Node, text: String) -> bool:
if button != null and button.text == text:
return true
return false
func _has_button_tooltip(root_node: Node, tooltip: String) -> bool:
for node: Node in root_node.find_children("*", "Button", true, false):
var button := node as Button
if button != null and button.tooltip_text == tooltip:
return true
return false

View file

@ -56,6 +56,10 @@ func _run_host() -> void:
"kim",
))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
var local_avatar := main.get_node("%PlayerSpawnService").get_avatar(
1

View file

@ -82,27 +82,64 @@ func _run() -> void:
assert(not FileAccess.file_exists(legacy_path))
_assert_opaque(save_path)
save_manager.set_autosave_enabled(false)
var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog
assert(save_slots != null)
assert(save_slots.configure(data_root, save_manager))
var adopted_slots: Array[Dictionary] = save_slots.list_slots()
assert(adopted_slots.size() == 1)
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)))
var duplicated: Dictionary = save_slots.duplicate_slot(
adopted_slot_id,
"another island",
WorldLayout.GENERATED,
97531,
)
assert(bool(duplicated.get("ok", false)))
var duplicated_slot_id: String = str(duplicated.get("slot_id", ""))
var duplicate_summary: Dictionary = save_slots.get_slot(duplicated_slot_id)
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_manager.load_player_data())
assert(player.wallet.get_balance() == 4321)
assert(save_manager.get_world_seed() == 97531)
assert(save_slots.rename_slot(duplicated_slot_id, "renamed island"))
var slot_archive: String = data_root.progression_backup_directory().path_join(
"save-slot-export.nfsave"
)
assert(bool(save_slots.export_slot(
duplicated_slot_id,
slot_archive,
).get("ok", false)))
var imported_slot: Dictionary = save_slots.import_slot(
slot_archive,
"imported island",
)
assert(bool(imported_slot.get("ok", false)))
var imported_slot_id: String = str(imported_slot.get("slot_id", ""))
assert(not save_slots.get_slot(imported_slot_id).is_empty())
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)
var settings_panels: Array[Node] = main.find_children(
"*", "SettingsPanel", true, false
)
assert(settings_panels.size() == 2)
for settings_panel: SettingsPanel in settings_panels:
var export_button := settings_panel.get_node(
"%ExportProgression"
) as Button
var import_button := settings_panel.get_node(
"%ImportProgression"
) as Button
assert(export_button != null and import_button != null)
assert(not export_button.disabled and not import_button.disabled)
assert(
export_button.get_node(export_button.focus_neighbor_right)
== import_button
)
assert(
import_button.get_node(import_button.focus_neighbor_left)
== export_button
)
assert(settings_panel.get_node_or_null("%ExportProgression") == null)
assert(settings_panel.get_node_or_null("%ImportProgression") == null)
var save_slots_page := main.find_child(
"SaveSlotsPage", true, false
) as SaveSlotsPage
assert(save_slots_page != null)
assert(save_slots_page.get_node("%ExportSlotButton") is Button)
assert(save_slots_page.get_node("%ImportSlotButton") is Button)
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data"
@ -118,6 +155,18 @@ func _run() -> void:
assert(bool(
ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false)
))
assert(FileAccess.file_exists(migrated_root.path_join(
"player/save_slots.json"
)))
var migrated_imported_slot: String = migrated_root.path_join(
"player/saves/%s.nfsave" % imported_slot_id
)
assert(FileAccess.file_exists(migrated_imported_slot))
assert(bool(
ProgressionSaveCodec.read_local_save(migrated_imported_slot).get(
"ok", false
)
))
main.queue_free()
for _frame: int in 4:

View file

@ -31,6 +31,10 @@ func _run() -> void:
func _run_host(port: int, label: String) -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_dedicated_host(port, 8, "127.0.0.1"))
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)

View file

@ -24,6 +24,10 @@ func _run() -> void:
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())

View file

@ -67,11 +67,23 @@ func _run() -> void:
state["cells"][0]["author_fingerprint"]
== session.get_local_identity_fingerprint()
)
var export_path: String = service.export_canvas_png(canvas_id)
service.set("_last_hovered_canvas_id", canvas_id)
service.set("_hovered_canvas_id", "")
var game_ui := main.get_node("%GameUI") as GameUI
var toolbar := game_ui.get_node(
"%SurfaceDrawingToolbar"
) as SurfaceDrawingToolbar
var export_button := toolbar.get_node("%ExportButton") as Button
export_button.pressed.emit()
var exported_entries: Array[Dictionary] = service.get_saved_stamp_entries()
assert(exported_entries.size() == 1)
var export_path: String = str(exported_entries[0]["path"])
assert(not export_path.is_empty())
var data_root := main.get("_data_root") as PlayerDataRoot
assert(export_path.begins_with(data_root.root_path.path_join("artwork")))
assert(FileAccess.file_exists(export_path))
var status_label := game_ui.get_node("%StatusLabel") as Label
assert(status_label.text == "artwork exported to the data folder • artwork")
var exported_image: Image = Image.load_from_file(export_path)
assert(exported_image.get_size() == Vector2i(16, 16))
assert(exported_image.get_pixel(8, 7).is_equal_approx(

View file

@ -84,6 +84,11 @@ func _validate_world_switching() -> void:
root.add_child(world)
await process_frame
assert(world.get_world_layout() == WorldLayout.GENERATED)
var initial_generator := world.get_node(
"Regions/GeneratedWorldRegion/Terrain/TerrainChunkGenerator"
) as TerrainChunkGenerator
assert(initial_generator != null)
assert(initial_generator.get_generated_chunks_root() == null)
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
_validate_world_boundary_clearance(world)

View file

@ -38,6 +38,10 @@ func _run_host() -> void:
var session := main.get_node("%NetworkSession") as NetworkSession
var service: Node = main.get_node("%NetworkWorldSpawnService")
var world_time := main.get_node("%WorldTimeService") as WorldTimeService
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
assert(world_time.synchronize_calendar_time(12.0, SUMMER_DATE_ID))
assert(world_time.set_authoritative_time(12.0))

View file

@ -16,7 +16,7 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
func _initialize() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(NetworkProtocol.PROTOCOL_VERSION == 10)
assert(
NetworkWorldSpawnProtocol.CAPABILITY
== NetworkProtocol.WORLD_SPAWN_CAPABILITY

View file

@ -30,6 +30,10 @@ func _run_host() -> void:
var world_weather := (
main.get_node("%WorldWeatherService") as WorldWeatherService
)
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT))
assert(world_time.set_authoritative_time(INITIAL_HOST_TIME))
assert(world_weather.set_authoritative_weather(

View file

@ -28,6 +28,7 @@ const SPEECH_POINTER_OVERLAP: float = 3.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 MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000
const MOBILE_COMPACT_WIDTH: float = 620.0
const MOBILE_EXPANDED_WIDTH: float = 820.0
const MOBILE_COMPACT_HEIGHT: float = 220.0
@ -142,6 +143,14 @@ var _send_pending: bool = false
var _pending_send_body: String = ""
var _controller_refocused: bool = false
var _input_lock_applied: bool = false
var _fishing_input_priority_active: bool = false
var _fishing_resume_pending: bool = false
var _suspended_chat_state_valid: bool = false
var _suspended_chat_text: String = ""
var _suspended_chat_caret: int = 0
var _suspended_chat_had_selection: bool = false
var _suspended_chat_selection_from: int = 0
var _suspended_chat_selection_to: int = 0
var _last_submit_frame: int = -1
var _output_scale: float = 1.0
var _dock_right: bool = false
@ -325,6 +334,7 @@ func _update_status_effect_icons() -> void:
func open_chat() -> void:
if (
_opened or not _available or _service == null
or _fishing_input_priority_active
or not _session.is_gameplay_session_active()
):
return
@ -361,12 +371,115 @@ func open_command_chat() -> void:
func set_available(value: bool) -> void:
_available = value
if not value:
_clear_suspended_chat_state()
_send_pending = false
_pending_send_body = ""
_entry.editable = true
close_chat()
_flush_draft()
_refresh_visibility()
elif _fishing_resume_pending and not _fishing_input_priority_active:
call_deferred("_resume_chat_after_fishing")
func set_fishing_input_priority(active: bool) -> void:
if _fishing_input_priority_active == active:
return
_fishing_input_priority_active = active
if active:
if not _opened:
return
_capture_suspended_chat_state()
_fishing_resume_pending = not _send_pending
close_chat(true)
elif _fishing_resume_pending:
call_deferred("_resume_chat_after_fishing")
func has_fishing_resume_pending() -> bool:
return _fishing_resume_pending
func get_text_entry_control() -> LineEdit:
return _entry
func _capture_suspended_chat_state() -> void:
_suspended_chat_state_valid = true
_suspended_chat_text = _entry.text
_suspended_chat_caret = _entry.caret_column
_suspended_chat_had_selection = _entry.has_selection()
if _suspended_chat_had_selection:
_suspended_chat_selection_from = (
_entry.get_selection_from_column()
)
_suspended_chat_selection_to = _entry.get_selection_to_column()
else:
_suspended_chat_selection_from = 0
_suspended_chat_selection_to = 0
func _resume_chat_after_fishing() -> void:
if _fishing_input_priority_active or not _fishing_resume_pending:
return
if (
not _suspended_chat_state_valid
or not _available
or _service == null
or _session == null
or not _session.is_gameplay_session_active()
):
_clear_suspended_chat_state()
return
var restored_text: String = _suspended_chat_text
var restored_caret: int = _suspended_chat_caret
var restored_had_selection: bool = _suspended_chat_had_selection
var restored_selection_from: int = _suspended_chat_selection_from
var restored_selection_to: int = _suspended_chat_selection_to
_clear_suspended_chat_state()
_entry.text = restored_text
_entry.caret_column = clampi(restored_caret, 0, restored_text.length())
open_chat()
if not _opened:
return
call_deferred(
"_restore_suspended_chat_edit_state",
restored_caret,
restored_had_selection,
restored_selection_from,
restored_selection_to,
)
func _restore_suspended_chat_edit_state(
caret: int,
had_selection: bool,
selection_from: int,
selection_to: int,
) -> void:
if not _opened or not _entry.visible:
return
var text_length: int = _entry.text.length()
_entry.caret_column = clampi(caret, 0, text_length)
if had_selection:
_entry.select(
clampi(selection_from, 0, text_length),
clampi(selection_to, 0, text_length),
)
else:
_entry.deselect()
_entry.grab_focus()
_refresh_input_ownership()
func _clear_suspended_chat_state() -> void:
_fishing_resume_pending = false
_suspended_chat_state_valid = false
_suspended_chat_text = ""
_suspended_chat_caret = 0
_suspended_chat_had_selection = false
_suspended_chat_selection_from = 0
_suspended_chat_selection_to = 0
func close_chat(preserve_status: bool = false) -> void:
@ -942,7 +1055,7 @@ func _send() -> void:
if submitted_text.length() == 0:
close_chat()
return
if _handle_editor_world_command(submitted_text):
if _handle_editor_command(submitted_text):
return
_send_pending = true
_pending_send_body = submitted_text
@ -959,29 +1072,27 @@ func _send() -> void:
_set_status("Sending…")
func _handle_editor_world_command(body: String) -> bool:
func _handle_editor_command(body: String) -> bool:
# These commands are deliberately limited to sessions launched by the
# Godot editor. Exported builds do not have the editor feature tag, and a
# joined editor client must not be able to mutate its host's world.
# Godot editor. Exported builds do not have the editor feature tag.
if not OS.has_feature("editor"):
return false
var command_text: String = body.strip_edges()
if not (
command_text.begins_with("/time")
or command_text.begins_with("/weather")
):
return false
var parts: PackedStringArray = command_text.split(" ", false)
if parts.is_empty() or not String(parts[0]).begins_with("/"):
return false
var command: String = String(parts[0]).trim_prefix("/").to_lower()
if command not in ["give", "time", "weather"]:
return false
var result: String = ""
if _session == null or not _session.is_host():
if command == "give":
result = _apply_editor_give_command(parts)
elif _session == null or not _session.is_host():
result = "Editor world commands require the authoritative host."
elif command == "time":
result = _apply_editor_time_command(parts)
elif command == "weather":
result = _apply_editor_weather_command(parts)
else:
return false
result = _apply_editor_weather_command(parts)
_entry.clear()
_flush_draft()
_set_status(result)
@ -989,6 +1100,28 @@ func _handle_editor_world_command(body: String) -> bool:
return true
func _apply_editor_give_command(parts: PackedStringArray) -> String:
if parts.size() != 2 or _player == null or _player.wallet == null:
return "Usage: /give [positive integer]"
var amount_text: String = String(parts[1])
if not amount_text.is_valid_int():
return "Usage: /give [positive integer]"
var amount: int = amount_text.to_int()
var current_balance: int = _player.wallet.get_balance()
if amount <= 0:
return "Usage: /give [positive integer]"
if current_balance > MAX_EDITOR_GIVE_BALANCE - amount:
return "Editor balance cannot exceed %d fish coins." % (
MAX_EDITOR_GIVE_BALANCE
)
if not _player.wallet.credit(amount):
return "Editor fish coins could not be added."
return "Added %d fish coins. Balance: %d." % [
amount,
_player.wallet.get_balance(),
]
func _apply_editor_time_command(parts: PackedStringArray) -> String:
if parts.size() != 2 or _world_time == null:
return "Usage: /time [dawn, day, dusk, night]"
@ -1043,6 +1176,7 @@ func _on_local_message_confirmed(message: Dictionary) -> void:
return
_send_pending = false
_pending_send_body = ""
_clear_suspended_chat_state()
_entry.editable = true
_entry.clear()
_set_status("")
@ -1194,6 +1328,11 @@ func _on_rejected(message: String) -> void:
_pending_send_body = ""
_entry.editable = true
_set_status(message)
if _suspended_chat_state_valid:
_fishing_resume_pending = true
if not _fishing_input_priority_active:
call_deferred("_resume_chat_after_fishing")
return
if not _opened:
open_chat()
else:

View file

@ -79,6 +79,8 @@ 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
const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu"
@ -139,10 +141,12 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
@onready var _screen_fade: ScreenFade = %ScreenFade
@onready var _title_screen: TitleScreenType = %TitleScreen
@onready var _pause_menu: PauseMenuType = %PauseMenu
@onready var _social_prompt: BubbleConfirmationPage = %SocialPrompt
@onready var _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _player_storage: PlayerStorageType = %PlayerStorage
@onready var _storage_prompt: PanelContainer = %StoragePrompt
@onready var _storage_prompt_message: Label = %StoragePromptMessage
@onready var _shop_prompt: Control = %ShopPrompt
@onready var _shop_prompt_bubble: PanelContainer = %ShopPromptBubble
@onready var _shop_prompt_message: Label = %ShopPromptMessage
@ -182,6 +186,8 @@ var _gameplay_ui_enabled: bool = false
var _gameplay_hud_hidden: bool = false
var _fishing_spot: FishingSpotType
var _system_menu_open: bool = false
var _social_prompt_open: bool = false
var _social_prompt_restore_system_menu: bool = false
var _shop_open: bool = false
var _storage_open: bool = false
var _chat_input_open: bool = false
@ -208,16 +214,22 @@ var _virtual_mouse_stick: Vector2 = Vector2.ZERO
var _virtual_mouse_trigger_rest_by_device: Dictionary[int, float] = {}
var _shared_trigger_rest_by_device: Dictionary[int, float] = {}
var _controller_mapping_manager: ControllerMappingManagerType
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
var _settings_manager: PlayerSettingsManagerType
var _controller_text_entry_request: Callable
var _controller_text_entry_is_open: Callable
var _controller_text_entry_close: Callable
var _restore_chat_keyboard_after_fishing: bool = false
func _ready() -> void:
_prioritize_surface_drawing_pointer_input()
_bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
_social_prompt.confirmed.connect(_accept_social_prompt)
_social_prompt.cancelled.connect(_decline_social_prompt)
_apply_active_bait_indicator_style()
_refresh_active_bait_indicator()
_refresh_interaction_prompt_bindings()
# Reward feedback must remain above full-screen canonical menus. Keeping the
# overlay as the final stage child makes that ownership explicit instead of
# relying on scene declaration order when another menu adds high-z children.
@ -268,9 +280,11 @@ func _ready() -> void:
func set_controller_text_entry_request(
request: Callable,
is_open: Callable = Callable(),
close: Callable = Callable(),
) -> void:
_controller_text_entry_request = request
_controller_text_entry_is_open = is_open
_controller_text_entry_close = close
func request_controller_text_entry_for(control: Control = null) -> bool:
@ -278,7 +292,15 @@ func request_controller_text_entry_for(control: Control = null) -> bool:
if target == null:
target = get_viewport().gui_get_focus_owner()
return (
bool(_controller_text_entry_request.call(target))
bool(_controller_text_entry_request.call(target, false))
if _controller_text_entry_request.is_valid()
else false
)
func _resume_controller_text_entry_for(control: Control) -> bool:
return (
bool(_controller_text_entry_request.call(control, true))
if _controller_text_entry_request.is_valid()
else false
)
@ -292,6 +314,14 @@ func is_controller_text_entry_open() -> bool:
)
func _close_controller_text_entry_for(control: Control) -> bool:
return (
bool(_controller_text_entry_close.call(control))
if _controller_text_entry_close.is_valid()
else false
)
func setup(
player: PlayerType,
inventory: FishInventoryType,
@ -386,6 +416,9 @@ func setup(
)
fishing_spot.status_changed.connect(_on_fishing_status_changed)
fishing_spot.bite_prompt_changed.connect(_on_bite_prompt_changed)
fishing_spot.fishing_input_priority_changed.connect(
_on_fishing_input_priority_changed
)
fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
fishing_spot.showcase_changed.connect(_on_showcase_changed)
_player_menu.menu_visibility_changed.connect(
@ -480,6 +513,15 @@ func setup(
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_surface_drawing = surface_drawing
if (
_surface_drawing != null
and not _surface_drawing.hud_state_changed.is_connected(
_on_surface_drawing_hud_state_changed
)
):
_surface_drawing.hud_state_changed.connect(
_on_surface_drawing_hud_state_changed
)
_surface_drawing_toolbar.setup(_surface_drawing, art_unlocks)
set_edge_docks(
settings_manager.current_settings.chat_dock_right,
@ -520,6 +562,10 @@ func _prioritize_surface_drawing_pointer_input() -> void:
func _input(event: InputEvent) -> void:
if _social_prompt_open and event.is_action_pressed("ui_cancel"):
_decline_social_prompt()
get_viewport().set_input_as_handled()
return
# The on-screen keyboard owns controller input while it is open. Its
# overlay is processed before the UI beneath it and consumes the event.
if is_controller_text_entry_open():
@ -1336,7 +1382,6 @@ func _drawing_pointer_window_position() -> Vector2:
func setup_data_and_identity(
data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
@ -1348,7 +1393,6 @@ func setup_data_and_identity(
]:
panel.setup_data_and_identity(
data_root,
progression_saves,
identity_backups,
player_identity,
host_identity,
@ -1375,10 +1419,64 @@ func setup_controller_mapping(
func setup_keyboard_mouse_mapping(
mapping_manager: KeyboardMouseMappingManagerType,
) -> void:
if (
_keyboard_mouse_mapping_manager != null
and _keyboard_mouse_mapping_manager.mapping_changed.is_connected(
_refresh_interaction_prompt_bindings
)
):
_keyboard_mouse_mapping_manager.mapping_changed.disconnect(
_refresh_interaction_prompt_bindings
)
_keyboard_mouse_mapping_manager = mapping_manager
if (
_keyboard_mouse_mapping_manager != null
and not _keyboard_mouse_mapping_manager.mapping_changed.is_connected(
_refresh_interaction_prompt_bindings
)
):
_keyboard_mouse_mapping_manager.mapping_changed.connect(
_refresh_interaction_prompt_bindings
)
for panel: SettingsPanelType in [
_title_settings_panel, _pause_settings_panel
]:
panel.setup_keyboard_mouse_mapping(mapping_manager)
_refresh_interaction_prompt_bindings()
func _refresh_interaction_prompt_bindings() -> void:
if not is_node_ready():
return
var interact_label: String = "interact"
if _keyboard_mouse_mapping_manager != null:
var resolved_label: String = (
_keyboard_mouse_mapping_manager.get_binding_label(
KeyboardMouseMappingManagerType.ROLE_INTERACT
)
)
if resolved_label not in ["", "unmapped", "unknown key"]:
interact_label = resolved_label
if interact_label.length() == 1:
interact_label = interact_label.to_upper()
_shop_prompt_key.text = interact_label
_storage_prompt_message.text = "%s open storage" % interact_label
_resize_interaction_prompts()
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
var storage_width: float = maxf(
220.0,
ceilf(_storage_prompt_message.get_combined_minimum_size().x) + 24.0,
)
_storage_prompt.custom_minimum_size.x = storage_width
_storage_prompt.size.x = storage_width
func is_controller_mapping_capturing() -> bool:
@ -1709,6 +1807,52 @@ func set_system_menu_open(is_open: bool) -> void:
_emit_interactive_pointer_ui_changed()
func show_social_prompt(
message: String,
accept_text: String = "accept",
decline_text: String = "decline",
) -> bool:
if _social_prompt_open:
return false
_social_prompt_open = true
_social_prompt_restore_system_menu = _system_menu_open
set_system_menu_open(true)
_social_prompt.configure(
message,
accept_text,
decline_text,
BubbleConfirmationPage.InitialFocus.CONFIRM,
)
_social_prompt.transition_in(0.08, func() -> void: pass)
return true
func is_social_prompt_open() -> bool:
return _social_prompt_open
func _accept_social_prompt() -> void:
_resolve_social_prompt(true)
func _decline_social_prompt() -> void:
_resolve_social_prompt(false)
func _resolve_social_prompt(accepted: bool) -> void:
if not _social_prompt_open or _social_prompt.is_transitioning():
return
_social_prompt.lock_interaction()
_social_prompt.transition_out(0.08, func() -> void:
_social_prompt_open = false
set_system_menu_open(_social_prompt_restore_system_menu)
if accepted:
social_prompt_accepted.emit()
else:
social_prompt_declined.emit()
)
func get_fishing_shop() -> FishingShopType:
return _fishing_shop
@ -1930,6 +2074,9 @@ func _on_player_settings_changed(settings: PlayerSettings) -> void:
_player_menu.set_profile_preview_world_pixel_size(
settings.world_pixel_size
)
_hotbar_ui.set_swap_hotbar_camera_scroll(
settings.swap_hotbar_camera_scroll
)
func set_edge_docks(
@ -1979,6 +2126,18 @@ func _on_fishing_status_changed(status: String) -> void:
_set_fishing_status(status)
func _on_surface_drawing_hud_state_changed(
_is_active: bool,
_mode_name: String,
_color_name: String,
_color_value: Color,
_brush_size: int,
_grid_size: int,
status: String,
) -> void:
_set_fishing_status(status)
func _set_fishing_status(text: String) -> void:
var normalized_text: String = text.strip_edges()
_status_label.text = normalized_text
@ -2004,6 +2163,31 @@ func _on_bite_prompt_changed(prompt_visible: bool) -> void:
_refresh_fishing_panel_visibility()
func _on_fishing_input_priority_changed(active: bool) -> void:
if active:
_restore_chat_keyboard_after_fishing = (
_chat_ui.is_open() and is_controller_text_entry_open()
)
if _restore_chat_keyboard_after_fishing:
_close_controller_text_entry_for(
_chat_ui.get_text_entry_control()
)
_chat_ui.set_fishing_input_priority(active)
if not active and _restore_chat_keyboard_after_fishing:
call_deferred("_restore_chat_keyboard_after_fishing_ends")
func _restore_chat_keyboard_after_fishing_ends() -> void:
await get_tree().process_frame
await get_tree().process_frame
if not _restore_chat_keyboard_after_fishing:
return
_restore_chat_keyboard_after_fishing = false
if not _chat_ui.is_open():
return
_resume_controller_text_entry_for(_chat_ui.get_text_entry_control())
func _on_bite_prompt_pressed() -> void:
if _fishing_spot != null:
_fishing_spot.confirm_pending_bite()
@ -2023,7 +2207,6 @@ func _on_catch_display_changed(
and _fishing_spot != null
and _fishing_spot.is_fighting()
)
_hotbar_ui.set_item_name_suppressed(encounter_visible)
_green_catch_progress.value = progress * 100.0
_red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0
_catch_track.visible = encounter_visible

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=21 format=3]
[gd_scene load_steps=22 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
@ -14,6 +14,7 @@
[ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"]
[ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"]
[ext_resource type="PackedScene" path="res://ui/player_storage.tscn" id="14_storage"]
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="15_social_prompt"]
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.032, 0.118, 0.15, 1)
@ -461,7 +462,6 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_font_sizes/font_size = 15
text = "E"
horizontal_alignment = 1
vertical_alignment = 1
@ -491,9 +491,10 @@ theme_override_constants/margin_top = 7
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 7
[node name="Message" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"]
[node name="StoragePromptMessage" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"]
unique_name_in_owner = true
layout_mode = 2
text = "E open storage"
text = "open storage"
horizontal_alignment = 1
vertical_alignment = 1
theme_override_font_sizes/font_size = 18
@ -533,3 +534,8 @@ unique_name_in_owner = true
[node name="PauseMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("6_pause")]
unique_name_in_owner = true
[node name="SocialPrompt" parent="UIRoot/CanonicalStage" instance=ExtResource("15_social_prompt")]
unique_name_in_owner = true
z_index = 3000
z_as_relative = false

View file

@ -9,7 +9,6 @@ const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishCatchType = preload("res://fish/fish_catch.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const BubbleHotbarSlotType = preload(
"res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd"
)
@ -28,8 +27,6 @@ const HOTBAR_MENU_Z_INDEX: int = 90
@onready var _presentation_scale_root: Control = %HotbarPresentationScaleRoot
@onready var _bubble_field: Control = %BubbleField
@onready var _selected_item_label: Label = %SelectedItemLabel
@onready var _item_name_timer: Timer = %ItemNameTimer
var _hotbar: PlayerHotbarType
var _bag: PlayerBagType
@ -39,8 +36,7 @@ var _fishing_spot: FishingSpotType
var _slots: Array[BubbleHotbarSlotType] = []
var _gameplay_input_enabled: bool = false
var _drag_enabled: bool = false
var _hovered_slot_index: int = -1
var _item_name_suppressed: bool = false
var _swap_hotbar_camera_scroll: bool = false
var _motion_elapsed: float = 0.0
var _compact_layout: bool = false
var _player_menu_context: bool = false
@ -56,7 +52,6 @@ var _visibility_generation: int = 0
func _ready() -> void:
_item_name_timer.timeout.connect(_on_item_name_timer_timeout)
resized.connect(_apply_layout)
_collect_slots()
_apply_layout()
@ -100,13 +95,14 @@ func set_gameplay_input_enabled(enabled: bool) -> void:
_gameplay_input_enabled = enabled
func set_swap_hotbar_camera_scroll(enabled: bool) -> void:
_swap_hotbar_camera_scroll = enabled
func set_drag_enabled(enabled: bool) -> void:
_drag_enabled = enabled
for slot: BubbleHotbarSlotType in _slots:
slot.set_drag_enabled(enabled)
if not enabled:
_hovered_slot_index = -1
_hide_item_name()
func begin_controller_placement(
@ -148,7 +144,6 @@ func end_controller_placement() -> void:
for slot: BubbleHotbarSlotType in _slots:
slot.focus_mode = Control.FOCUS_NONE
slot.set_controller_placement_preview(false, null)
_show_selected_item_briefly()
func begin_controller_management(initial_slot: int) -> void:
@ -178,7 +173,6 @@ func end_controller_management() -> void:
_controller_management_active = false
for slot: BubbleHotbarSlotType in _slots:
slot.focus_mode = Control.FOCUS_NONE
_show_selected_item_briefly()
func _resolve_controller_placement_texture() -> Texture2D:
@ -289,13 +283,6 @@ func set_presentation_visible(
)
func set_item_name_suppressed(suppressed: bool) -> void:
_item_name_suppressed = suppressed
if suppressed:
_hovered_slot_index = -1
_hide_item_name()
func _unhandled_input(event: InputEvent) -> void:
if (
not _gameplay_input_enabled
@ -321,7 +308,7 @@ func _unhandled_input(event: InputEvent) -> void:
if (
event is InputEventMouseButton
and event.pressed
and not event.shift_pressed
and event.shift_pressed == _swap_hotbar_camera_scroll
):
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_hotbar.cycle_selection(-1)
@ -337,10 +324,6 @@ func _collect_slots() -> void:
var slot := child as BubbleHotbarSlotType
if slot == null:
continue
slot.item_hovered.connect(_on_slot_item_hovered)
slot.item_hover_ended.connect(_on_slot_item_hover_ended)
slot.item_drag_started.connect(_on_slot_drag_started)
slot.item_drag_finished.connect(_on_slot_drag_finished)
slot.focus_entered.connect(
_on_controller_slot_focused.bind(slot.slot_index)
)
@ -405,9 +388,6 @@ func _on_selected_slot_changed(
_refresh()
if _controller_placement_active:
_refresh_controller_placement_preview()
return
if _hovered_slot_index < 0:
_show_selected_item_briefly()
func _on_controller_slot_focused(slot_index: int) -> void:
@ -419,80 +399,3 @@ func _on_controller_slot_focused(slot_index: int) -> void:
_hotbar.select_slot(slot_index)
if _controller_placement_active:
_refresh_controller_placement_preview()
func _on_slot_item_hovered(
slot_index: int,
item_id: StringName,
) -> void:
if _item_name_suppressed:
return
_hovered_slot_index = slot_index
_item_name_timer.stop()
_show_assignment_name(slot_index, item_id)
func _on_slot_item_hover_ended(slot_index: int) -> void:
if slot_index != _hovered_slot_index:
return
_hovered_slot_index = -1
_show_selected_item_briefly()
func _on_slot_drag_started() -> void:
_hovered_slot_index = -1
_hide_item_name()
func _on_slot_drag_finished() -> void:
_show_selected_item_briefly()
func _show_selected_item_briefly() -> void:
if _item_name_suppressed or _hotbar == null:
_hide_item_name()
return
var selected_slot: int = _hotbar.get_selected_slot()
var identity: StringName = _hotbar.get_selected_item_id()
if identity.is_empty():
identity = _hotbar.get_selected_fish_catch_id()
_show_assignment_name(selected_slot, identity)
if _selected_item_label.visible:
_item_name_timer.start()
func _show_assignment_name(slot_index: int, identity: StringName) -> void:
if identity.is_empty() or _hotbar == null:
_hide_item_name()
return
var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index)
if not catch_id.is_empty() and _fish_inventory != null:
var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id)
if fish_catch != null:
_selected_item_label.text = FishQualityType.qualified_name(
fish_catch.fish.display_name,
fish_catch.quality,
)
_selected_item_label.visible = true
return
var item = (
_catalog.get_item_by_id(identity)
if _catalog != null
else null
)
if item == null:
_hide_item_name()
return
_selected_item_label.text = item.display_name
_selected_item_label.visible = true
func _hide_item_name() -> void:
_item_name_timer.stop()
_selected_item_label.text = ""
_selected_item_label.visible = false
func _on_item_name_timer_timeout() -> void:
if _hovered_slot_index < 0:
_hide_item_name()

View file

@ -105,33 +105,3 @@ slot_index = 8
desktop_anchor = Vector2(735, 49)
compact_anchor = Vector2(530, 42)
motion_phase = 5.51
[node name="SelectedItemLabel" type="Label" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot"]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchors_preset = 12
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -150.0
offset_top = -132.0
offset_right = 150.0
offset_bottom = -112.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
clip_text = true
text_overrun_behavior = 3
horizontal_alignment = 1
vertical_alignment = 1
theme_override_colors/font_color = Color(0.925, 0.953, 0.965, 1)
theme_override_colors/font_outline_color = Color(0.015, 0.02, 0.03, 0.95)
theme_override_constants/outline_size = 2
theme_override_font_sizes/font_size = 12
[node name="ItemNameTimer" type="Timer" parent="."]
unique_name_in_owner = true
wait_time = 1.5
one_shot = true

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bmhhqwmx7lxxy"
path="res://.godot/imported/clean.png-762368d1ca0665fa46111797ef3210a1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://ui/icons/player_options/clean.png"
dest_files=["res://.godot/imported/clean.png-762368d1ca0665fa46111797ef3210a1.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=64
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://brdj550pntc0t"
path="res://.godot/imported/friends.png-2546aa51ed36f2defc5121467d12527d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://ui/icons/player_options/friends.png"
dest_files=["res://.godot/imported/friends.png-2546aa51ed36f2defc5121467d12527d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=64
detect_3d/compress_to=1

View file

@ -962,7 +962,11 @@ func _build_known_details(fish: FishDataType) -> void:
fish.get_maximum_weight(),
],
)
_add_detail_row(left_stats, "number caught", "unknown")
_add_detail_row(
left_stats,
"number caught",
str(_collection_log.get_catch_count(fish.id)),
)
_add_detail_row(right_stats, "rarity", fish.get_rarity_name().to_lower())
_add_detail_row(right_stats, "time of day", _availability_text(fish))
_add_detail_row(right_stats, "seasons", fish.get_season_text())
@ -1127,6 +1131,7 @@ func _stats_overlay_text(fish: FishDataType, catalog_number: int) -> String:
fish.get_minimum_weight(),
fish.get_maximum_weight(),
],
"number caught: %d" % _collection_log.get_catch_count(fish.id),
"rarity: %s" % fish.get_rarity_name().to_lower(),
"time of day: %s" % _availability_text(fish),
"seasons: %s" % fish.get_season_text(),

View file

@ -7,6 +7,7 @@ signal back_requested
enum Mode {
DISCOVER,
FRIENDS,
DIRECT,
SAVED,
RECENT,
@ -26,6 +27,12 @@ const DIRECT_WORKFLOW_HELP: String = (
% ADDRESS_FORMAT_HELP
)
@onready var _main_panel: PanelContainer = %MainPanel
@onready var _content_panel: PanelContainer = %ContentPanel
@onready var _direct_content: Control = %DirectContent
@onready var _list_content: Control = %ListContent
@onready var _list_title: Label = %ListTitle
@onready var _details_panel: PanelContainer = %DetailsPanel
@onready var _address: LineEdit = %Address
@onready var _address_label: Label = %AddressLabel
@onready var _address_helper: Label = %AddressHelper
@ -34,10 +41,11 @@ const DIRECT_WORKFLOW_HELP: String = (
@onready var _name_helper: Label = %NameHelper
@onready var _server_list: ItemList = %ServerList
@onready var _details: Label = %Details
@onready var _discover_button: Button = %DiscoverButton
@onready var _direct_button: Button = %DirectButton
@onready var _saved_button: Button = %SavedButton
@onready var _recent_button: Button = %RecentButton
@onready var _discover_button: OrganizerTab = %DiscoverButton
@onready var _friends_button: OrganizerTab = %FriendsButton
@onready var _direct_button: OrganizerTab = %DirectButton
@onready var _saved_button: OrganizerTab = %SavedButton
@onready var _recent_button: OrganizerTab = %RecentButton
@onready var _join_button: Button = %JoinButton
@onready var _refresh_button: Button = %RefreshButton
@onready var _save_button: Button = %SaveButton
@ -62,6 +70,8 @@ var _visible_entries: Array[SavedServerEntry] = []
var _selected_entry: SavedServerEntry
var _discovery_rooms: Array[Dictionary] = []
var _selected_discovery_index: int = -1
var _friend_entries: Array[Dictionary] = []
var _selected_friend_index: int = -1
var _discovery_refresh_timer: Timer
var _editing_entry_id: String = ""
var _name_entry_active: bool = false
@ -69,24 +79,13 @@ var _delete_armed: bool = false
var _connection_error_latched: bool = false
var _pending_confirmation_endpoint: String = ""
var _pending_confirmation_room: Dictionary = {}
var _owns_pending_public_join: bool = false
func _ready() -> void:
UtilityPageStyle.apply_page(self)
var paper := get_node("Paper") as PanelContainer
paper.add_theme_stylebox_override(
"panel", UtilityPageStyle.panel_style()
)
for button: BaseButton in [
_discover_button, _direct_button, _saved_button, _recent_button,
_refresh_button, _join_button,
_save_button, _edit_button, _favorite_button, _delete_button,
_cancel_button, _back_button,
]:
UtilityPageStyle.apply_ocean_button(button)
UtilityPageStyle.apply_ocean_line_edit(_address)
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
_configure_style()
_discover_button.pressed.connect(_set_mode.bind(Mode.DISCOVER))
_friends_button.pressed.connect(_set_mode.bind(Mode.FRIENDS))
_direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT))
_saved_button.pressed.connect(_set_mode.bind(Mode.SAVED))
_recent_button.pressed.connect(_set_mode.bind(Mode.RECENT))
@ -121,6 +120,96 @@ func _ready() -> void:
hide()
func _configure_style() -> void:
UtilityPageStyle.apply_page(self)
_main_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_MID,
28,
),
)
_content_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_FIELD,
20,
),
)
_details_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.row_style(),
)
_server_list.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_MID,
12,
),
)
_server_list.add_theme_stylebox_override(
"focus",
StyleBoxEmpty.new(),
)
_server_list.add_theme_stylebox_override(
"selected",
UtilityPageStyle.row_style(true),
)
_server_list.add_theme_stylebox_override(
"selected_focus",
UtilityPageStyle.row_style(true),
)
_server_list.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
_server_list.add_theme_color_override(
"font_selected_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
for node: Node in find_children("*", "Label", true, false):
var label := node as Label
label.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
for node: Node in find_children("*", "Button", true, false):
if node is OrganizerTab:
continue
UtilityPageStyle.apply_ocean_button(node as BaseButton)
UtilityPageStyle.apply_ocean_line_edit(_address)
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
_join_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.GREEN,
),
)
_delete_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_DANGER,
),
)
_back_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_PANEL_DEEP,
),
)
for secondary_label: Label in [
_address_helper,
_name_helper,
_details,
_status,
_session_summary,
]:
secondary_label.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_SECONDARY,
)
func setup(
network_session: NetworkSession,
saved_servers: SavedServerStore,
@ -177,6 +266,19 @@ func setup(
_discovery.public_join_status_changed.connect(
_on_public_join_status_changed
)
if not _discovery.friend_presence_updated.is_connected(
_on_friend_presence_updated
):
_discovery.friend_presence_updated.connect(
_on_friend_presence_updated
)
if not _discovery.social_status_changed.is_connected(
_on_social_status_changed
):
_discovery.social_status_changed.connect(
_on_social_status_changed
)
_friend_entries = _discovery.get_friend_presence()
_refresh()
@ -232,7 +334,9 @@ func confirm_pending_join() -> bool:
var room: Dictionary = _pending_confirmation_room.duplicate(true)
cancel_pending_join_confirmation()
if not room.is_empty():
_owns_pending_public_join = true
if _discovery == null or not _discovery.prepare_public_join(room):
_owns_pending_public_join = false
_set_status("Could not prepare the public connection.", true)
return false
return true
@ -244,15 +348,19 @@ func confirm_pending_join() -> bool:
func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
if clear_connection_error:
_connection_error_latched = false
_set_status(_default_mode_status(mode))
_mode = mode
_selected_entry = null
_selected_discovery_index = -1
_selected_friend_index = -1
_clear_edit_state()
_refresh_entries()
if mode == Mode.DISCOVER and not _discovery_rooms.is_empty():
_select_discovery_index(0)
elif mode == Mode.FRIENDS and not _friend_entries.is_empty():
_select_friend_index(0)
_refresh()
if mode == Mode.DISCOVER:
if mode in [Mode.DISCOVER, Mode.FRIENDS]:
_discovery_refresh_timer.start()
_request_discovery_refresh()
else:
@ -262,6 +370,19 @@ func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
_defer_focus_control(_address if mode == Mode.DIRECT else _server_list)
func _default_mode_status(mode: Mode) -> String:
match mode:
Mode.FRIENDS:
return "friend status is live and not stored by discovery"
Mode.DIRECT:
return "direct connection • default port 7777"
Mode.SAVED:
return "saved servers are stored on this device"
Mode.RECENT:
return "recent connections are stored on this device"
return "looking for public rooms…"
func _request_join() -> void:
_connection_error_latched = false
if _network_session.state in [
@ -271,8 +392,22 @@ func _request_join() -> void:
_network_session.reset_failure()
var endpoint_text: String = _address.text
var discovery_room: Dictionary = {}
if _mode == Mode.DISCOVER:
var room: Dictionary = _selected_discovery_room()
if _mode in [Mode.DISCOVER, Mode.FRIENDS]:
var room: Dictionary = (
_selected_discovery_room()
if _mode == Mode.DISCOVER
else _selected_friend_room()
)
if _mode == Mode.FRIENDS:
var friend := _selected_friend()
if not bool(friend.get("online", false)):
_set_status("This person needs to be online to do this.", true)
return
if room.is_empty():
_set_status(
"This friend is online but is not in a joinable room.", true
)
return
if _discovery != null and _discovery.is_own_room(room):
_set_status("You are already hosting this room.", true)
return
@ -296,7 +431,9 @@ func _request_join() -> void:
join_confirmation_requested.emit(endpoint.normalized_display)
return
if not discovery_room.is_empty():
_owns_pending_public_join = true
if not _discovery.prepare_public_join(discovery_room):
_owns_pending_public_join = false
_set_status("Could not prepare the public connection.", true)
return
_set_status("Connecting…")
@ -305,7 +442,10 @@ func _request_join() -> void:
func _on_public_join_prepared(endpoint_text: String) -> void:
if _mode != Mode.DISCOVER or not is_visible_in_tree():
if not _owns_pending_public_join:
return
_owns_pending_public_join = false
if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree():
return
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
@ -317,8 +457,13 @@ func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree():
if (
_owns_pending_public_join
and _mode in [Mode.DISCOVER, Mode.FRIENDS]
and is_visible_in_tree()
):
if is_error:
_owns_pending_public_join = false
_connection_error_latched = true
elif _connection_error_latched:
return
@ -473,6 +618,11 @@ func _on_list_item_selected(index: int) -> void:
return
_refresh()
return
if _mode == Mode.FRIENDS:
if not _select_friend_index(index):
return
_refresh()
return
if index < 0 or index >= _visible_entries.size():
return
_selected_entry = _visible_entries[index]
@ -518,6 +668,8 @@ func _restore_entry_selection_and_focus(
func _current_mode_button() -> Button:
match _mode:
Mode.FRIENDS:
return _friends_button
Mode.DIRECT:
return _direct_button
Mode.SAVED:
@ -536,6 +688,15 @@ func _select_discovery_index(index: int) -> bool:
return true
func _select_friend_index(index: int) -> bool:
if index < 0 or index >= _friend_entries.size():
return false
_selected_friend_index = index
_selected_entry = null
_server_list.select(index)
return true
func _refresh_entries() -> void:
_visible_entries.clear()
_server_list.clear()
@ -556,6 +717,25 @@ func _refresh_entries() -> void:
]
)
return
if _mode == Mode.FRIENDS:
for friend: Dictionary in _friend_entries:
var room: Dictionary = (
friend.get("room", {})
if typeof(friend.get("room", {})) == TYPE_DICTIONARY
else {}
)
var state := "offline"
if bool(friend.get("online", false)):
state = (
"playing in %s"
% str(room.get("room_name", "a public room"))
if not room.is_empty()
else "online"
)
_server_list.add_item("%s%s" % [
str(friend.get("display_name", "Player")), state,
])
return
if _saved_servers == null or _mode == Mode.DIRECT:
return
_visible_entries = (
@ -594,15 +774,24 @@ func _refresh() -> void:
] or (_discovery != null and _discovery.is_public_join_preparing())
var direct: bool = _mode == Mode.DIRECT
var discovery_mode: bool = _mode == Mode.DISCOVER
var friends_mode: bool = _mode == Mode.FRIENDS
var selected: bool = (
_selected_discovery_index >= 0
if discovery_mode
else _selected_friend_index >= 0
if friends_mode
else _selected_entry != null
)
_discover_button.button_pressed = discovery_mode
_direct_button.button_pressed = direct
_saved_button.button_pressed = _mode == Mode.SAVED
_recent_button.button_pressed = _mode == Mode.RECENT
_discover_button.set_selected(discovery_mode, false)
_friends_button.set_selected(friends_mode, false)
_direct_button.set_selected(direct, false)
_saved_button.set_selected(_mode == Mode.SAVED, false)
_recent_button.set_selected(_mode == Mode.RECENT, false)
var direct_content_visible: bool = direct or _name_entry_active
var list_content_visible: bool = not direct_content_visible
_direct_content.visible = direct_content_visible
_list_content.visible = list_content_visible
_list_title.text = _current_list_title()
_address.visible = direct or _name_entry_active
_address_label.visible = _address.visible
_address_helper.visible = _address.visible
@ -613,15 +802,20 @@ func _refresh() -> void:
_name_edit.visible = _name_entry_active
_name_label.visible = _name_entry_active
_name_helper.visible = _name_entry_active
_server_list.visible = not direct and not _name_entry_active
_details.visible = not direct and not _name_entry_active
_server_list.visible = list_content_visible
_details_panel.visible = list_content_visible
_details.visible = list_content_visible
_join_button.disabled = (
connecting
or (not direct and not selected)
or (
discovery_mode
(discovery_mode or friends_mode)
and selected
and _discovery_room_is_full(_selected_discovery_room())
and _discovery_room_is_full(
_selected_discovery_room()
if discovery_mode
else _selected_friend_room()
)
)
or (
discovery_mode
@ -629,16 +823,19 @@ func _refresh() -> void:
and _discovery != null
and _discovery.is_own_room(_selected_discovery_room())
)
or (friends_mode and selected and _selected_friend_room().is_empty())
)
_join_button.text = "join now" if direct else "join"
_refresh_button.visible = (
(discovery_mode or friends_mode) and not _name_entry_active
)
_join_button.text = "join\nnow" if direct else "join"
_refresh_button.visible = discovery_mode and not _name_entry_active
_save_button.visible = (
direct or _mode == Mode.RECENT or _name_entry_active
)
_save_button.disabled = connecting or (
_mode == Mode.RECENT and not selected and not _name_entry_active
)
_save_button.text = "save" if _name_entry_active else "save\nserver"
_save_button.text = "save" if _name_entry_active else "save server"
_edit_button.visible = _mode == Mode.SAVED and selected
_favorite_button.visible = _mode == Mode.SAVED and selected
_favorite_button.text = (
@ -676,16 +873,22 @@ func _refresh() -> void:
_details.text = (
_format_discovery_details(_selected_discovery_room())
if discovery_mode
else _format_friend_details(_selected_friend())
if friends_mode
else _format_entry_details(_selected_entry)
)
elif (
_discovery_rooms.is_empty()
if discovery_mode
else _friend_entries.is_empty()
if friends_mode
else _visible_entries.is_empty()
):
_details.text = (
"No public rooms are available."
if discovery_mode
else "No friends added yet."
if friends_mode
else "No saved servers yet."
if _mode == Mode.SAVED
else "No recent connections yet."
@ -702,9 +905,21 @@ func _refresh() -> void:
_configure_controller_navigation()
func _current_list_title() -> String:
match _mode:
Mode.FRIENDS:
return "friends"
Mode.SAVED:
return "saved servers"
Mode.RECENT:
return "recent connections"
return "public rooms"
func _configure_controller_navigation() -> void:
var mode_buttons: Array[Control] = [
_discover_button,
_friends_button,
_direct_button,
_saved_button,
_recent_button,
@ -722,7 +937,6 @@ func _configure_controller_navigation() -> void:
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if _controller_focus_eligible(control):
action_controls.append(control)
@ -730,6 +944,7 @@ func _configure_controller_navigation() -> void:
all_controls.append_array(mode_buttons)
all_controls.append_array(content_controls)
all_controls.append_array(action_controls)
all_controls.append(_back_button)
for control: Control in all_controls:
control.focus_mode = Control.FOCUS_ALL
for control: Control in [
@ -743,10 +958,10 @@ func _configure_controller_navigation() -> void:
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if control not in all_controls:
control.focus_mode = Control.FOCUS_NONE
_back_button.focus_mode = Control.FOCUS_ALL
var primary_content: Control = (
content_controls.front()
if not content_controls.is_empty()
@ -775,7 +990,7 @@ func _configure_controller_navigation() -> void:
if index < content_controls.size() - 1
else action_controls.front()
if not action_controls.is_empty()
else content
else _back_button
)
_set_controller_neighbors(content, content, content, above, below)
for index: int in action_controls.size():
@ -787,7 +1002,21 @@ func _configure_controller_navigation() -> void:
content_controls.back()
if not content_controls.is_empty()
else mode_buttons[int(_mode)],
action,
_back_button,
)
var back_above: Control = (
action_controls.back()
if not action_controls.is_empty()
else content_controls.back()
if not content_controls.is_empty()
else mode_buttons[int(_mode)]
)
_set_controller_neighbors(
_back_button,
_back_button,
_back_button,
back_above,
_back_button,
)
ControllerFocusNavigationType.configure_traversal(all_controls)
_recover_controller_focus(all_controls, primary_content)
@ -920,6 +1149,29 @@ func _format_discovery_details(room: Dictionary) -> String:
return "\n".join(lines)
func _format_friend_details(friend: Dictionary) -> String:
if friend.is_empty():
return "Select a friend."
var room := _selected_friend_room()
var lines: Array[String] = [
"Friend: %s" % str(friend.get("display_name", "Player")),
"Status: %s" % (
"online" if bool(friend.get("online", false)) else "offline"
),
]
if not room.is_empty():
lines.append("Room: %s" % str(room.get("room_name", "Public room")))
lines.append("Players: %d / %d" % [
int(room.get("current_players", 0)),
int(room.get("max_players", 0)),
])
elif bool(friend.get("online", false)):
lines.append("This friend is not in a joinable public room.")
else:
lines.append("This person needs to be online to join them.")
return "\n".join(lines)
func _selected_discovery_room() -> Dictionary:
if (
_selected_discovery_index < 0
@ -929,6 +1181,25 @@ func _selected_discovery_room() -> Dictionary:
return _discovery_rooms[_selected_discovery_index]
func _selected_friend() -> Dictionary:
if (
_selected_friend_index < 0
or _selected_friend_index >= _friend_entries.size()
):
return {}
return _friend_entries[_selected_friend_index]
func _selected_friend_room() -> Dictionary:
var friend := _selected_friend()
var room: Variant = friend.get("room", {})
return (
(room as Dictionary)
if typeof(room) == TYPE_DICTIONARY
else {}
)
func _discovery_room_is_full(room: Dictionary) -> bool:
if room.is_empty():
return false
@ -938,11 +1209,14 @@ func _discovery_room_is_full(room: Dictionary) -> bool:
func _request_discovery_refresh() -> void:
if (
_discovery == null
or _mode != Mode.DISCOVER
or _mode not in [Mode.DISCOVER, Mode.FRIENDS]
or not is_visible_in_tree()
):
return
if _mode == Mode.DISCOVER:
_discovery.request_rooms()
else:
_discovery.request_friend_presence()
func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
@ -972,6 +1246,30 @@ func _on_discovery_status_changed(message: String, is_error: bool) -> void:
_set_status(message, is_error)
func _on_friend_presence_updated(friends: Array[Dictionary]) -> void:
var selected_fingerprint := str(
_selected_friend().get("fingerprint", "")
)
_friend_entries = friends.duplicate(true)
_selected_friend_index = -1
if _mode != Mode.FRIENDS:
return
_refresh_entries()
if not selected_fingerprint.is_empty():
for index: int in _friend_entries.size():
if str(_friend_entries[index].get("fingerprint", "")) == selected_fingerprint:
_select_friend_index(index)
break
if _selected_friend_index < 0 and not _friend_entries.is_empty():
_select_friend_index(0)
_refresh()
func _on_social_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.FRIENDS and is_visible_in_tree():
_set_status(message, is_error)
func _format_result_code(result_code: String) -> String:
match result_code.strip_edges().to_upper():
"SUCCESS":

View file

@ -1,76 +1,10 @@
[gd_scene load_steps=12 format=3]
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"]
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="4_confirmation"]
[sub_resource type="StyleBoxFlat" id="StyleBox_paper"]
bg_color = Color(0.051, 0.173, 0.227, 1)
corner_radius_top_left = 54
corner_radius_top_right = 46
corner_radius_bottom_right = 58
corner_radius_bottom_left = 48
[sub_resource type="StyleBoxFlat" id="StyleBox_list"]
bg_color = Color(0.071, 0.247, 0.306, 1)
corner_radius_top_left = 12
corner_radius_top_right = 9
corner_radius_bottom_right = 13
corner_radius_bottom_left = 10
[sub_resource type="StyleBoxFlat" id="StyleBox_selected"]
bg_color = Color(0.137, 0.525, 0.592, 1)
corner_radius_top_left = 8
corner_radius_top_right = 7
corner_radius_bottom_right = 9
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxEmpty" id="StyleBox_focus"]
[sub_resource type="StyleBoxFlat" id="StyleBox_input"]
content_margin_left = 12.0
content_margin_top = 7.0
content_margin_right = 12.0
content_margin_bottom = 7.0
bg_color = Color(0.031, 0.122, 0.169, 1)
corner_radius_top_left = 8
corner_radius_top_right = 7
corner_radius_bottom_right = 9
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBox_input_focus"]
content_margin_left = 12.0
content_margin_top = 7.0
content_margin_right = 12.0
content_margin_bottom = 7.0
bg_color = Color(0.137, 0.525, 0.592, 1)
corner_radius_top_left = 8
corner_radius_top_right = 7
corner_radius_bottom_right = 9
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBox_input_read_only"]
content_margin_left = 12.0
content_margin_top = 7.0
content_margin_right = 12.0
content_margin_bottom = 7.0
bg_color = Color(0.031, 0.122, 0.169, 0.82)
corner_radius_top_left = 8
corner_radius_top_right = 7
corner_radius_bottom_right = 9
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBox_status"]
content_margin_left = 10.0
content_margin_top = 4.0
content_margin_right = 10.0
content_margin_bottom = 4.0
bg_color = Color(0.071, 0.247, 0.306, 0.9)
corner_radius_top_left = 7
corner_radius_top_right = 6
corner_radius_bottom_right = 8
corner_radius_bottom_left = 5
[node name="JoinGamePage" type="Control"]
visible = false
layout_mode = 3
@ -82,243 +16,322 @@ grow_vertical = 2
theme = ExtResource("2_theme")
script = ExtResource("1_script")
[node name="Paper" type="PanelContainer" parent="."]
layout_mode = 0
offset_left = 230.0
offset_top = 64.0
offset_right = 1050.0
offset_bottom = 656.0
theme_override_styles/panel = SubResource("StyleBox_paper")
[node name="MainPanel" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -440.0
offset_top = -330.0
offset_right = 440.0
offset_bottom = 330.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="Paper"]
[node name="OuterMargin" type="MarginContainer" parent="MainPanel"]
layout_mode = 2
theme_override_constants/margin_left = 54
theme_override_constants/margin_top = 30
theme_override_constants/margin_right = 54
theme_override_constants/margin_bottom = 30
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 18
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 18
[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
[node name="Layout" type="VBoxContainer" parent="MainPanel/OuterMargin"]
layout_mode = 2
theme_override_constants/separation = 10
alignment = 1
theme_override_constants/separation = -26
[node name="Title" type="Label" parent="Paper/Margin/Layout"]
[node name="Heading" type="Label" parent="MainPanel/OuterMargin/Layout"]
custom_minimum_size = Vector2(0, 66)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 30
text = "join game"
horizontal_alignment = 1
[node name="Modes" type="HBoxContainer" parent="Paper/Margin/Layout"]
[node name="TabBar" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
theme_override_constants/separation = 14
theme_override_constants/separation = 8
alignment = 1
[node name="DiscoverButton" type="Button" parent="Paper/Margin/Layout/Modes"]
[node name="DiscoverButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 72)
custom_minimum_size = Vector2(165, 52)
layout_mode = 2
toggle_mode = true
focus_mode = 2
text = "discover"
script = ExtResource("3_tab")
[node name="DirectButton" type="Button" parent="Paper/Margin/Layout/Modes"]
[node name="FriendsButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 72)
custom_minimum_size = Vector2(140, 52)
layout_mode = 2
toggle_mode = true
focus_mode = 2
text = "friends"
script = ExtResource("3_tab")
palette_index = 1
[node name="DirectButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(140, 52)
layout_mode = 2
focus_mode = 2
text = "direct"
script = ExtResource("3_tab")
palette_index = 1
[node name="SavedButton" type="Button" parent="Paper/Margin/Layout/Modes"]
[node name="SavedButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 72)
custom_minimum_size = Vector2(140, 52)
layout_mode = 2
toggle_mode = true
focus_mode = 2
text = "saved"
script = ExtResource("3_tab")
palette_index = 2
[node name="RecentButton" type="Button" parent="Paper/Margin/Layout/Modes"]
[node name="RecentButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(100, 72)
custom_minimum_size = Vector2(140, 52)
layout_mode = 2
toggle_mode = true
focus_mode = 2
text = "recent"
script = ExtResource("3_tab")
palette_index = 1
[node name="AddressLabel" type="Label" parent="Paper/Margin/Layout"]
[node name="ContentPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 474)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
size_flags_vertical = 3
[node name="ContentMargin" type="MarginContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel"]
layout_mode = 2
theme_override_constants/margin_left = 28
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 28
theme_override_constants/margin_bottom = 20
[node name="ContentLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"]
layout_mode = 2
size_flags_vertical = 3
[node name="ListContent" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack"]
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
theme_override_constants/separation = 10
[node name="ListTitle" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "public rooms"
[node name="ServerList" type="ItemList" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 180)
layout_mode = 2
size_flags_vertical = 3
theme_override_font_sizes/font_size = 17
text = "server address"
horizontal_alignment = 1
[node name="Address" type="LineEdit" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1)
theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1)
theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1)
theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78)
theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1)
theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9)
theme_override_font_sizes/font_size = 19
theme_override_styles/normal = SubResource("StyleBox_input")
theme_override_styles/focus = SubResource("StyleBox_input_focus")
theme_override_styles/read_only = SubResource("StyleBox_input_read_only")
placeholder_text = "example.net or 192.168.1.50:7777"
alignment = 1
max_length = 300
[node name="AddressHelper" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 13
text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally."
horizontal_alignment = 1
autowrap_mode = 2
[node name="NameLabel" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 17
text = "server name"
horizontal_alignment = 1
[node name="NameEdit" type="LineEdit" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1)
theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1)
theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1)
theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78)
theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1)
theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9)
theme_override_font_sizes/font_size = 19
theme_override_styles/normal = SubResource("StyleBox_input")
theme_override_styles/focus = SubResource("StyleBox_input_focus")
theme_override_styles/read_only = SubResource("StyleBox_input_read_only")
placeholder_text = "Friend's server"
alignment = 1
max_length = 80
[node name="NameHelper" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 13
text = "Only visible on this device."
horizontal_alignment = 1
[node name="ServerList" type="ItemList" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 145)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_colors/font_selected_color = Color(0.902, 0.969, 0.969, 1)
theme_override_colors/guide_color = Color(0.624, 0.812, 0.824, 0.22)
theme_override_font_sizes/font_size = 17
theme_override_styles/panel = SubResource("StyleBox_list")
theme_override_styles/focus = SubResource("StyleBox_focus")
theme_override_styles/selected = SubResource("StyleBox_selected")
theme_override_styles/selected_focus = SubResource("StyleBox_selected")
allow_reselect = true
same_column_width = true
[node name="Details" type="Label" parent="Paper/Margin/Layout"]
[node name="DetailsPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 92)
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 16
[node name="Details" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/DetailsPanel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 15
text = "Select a server."
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="Status" type="Label" parent="Paper/Margin/Layout"]
[node name="DirectContent" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 10
[node name="AddressLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 15
theme_override_styles/normal = SubResource("StyleBox_status")
text = "Direct UDP connection • default port 7777"
theme_override_font_sizes/font_size = 20
text = "server address"
[node name="Address" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
focus_mode = 2
theme_override_font_sizes/font_size = 19
placeholder_text = "example.net or 192.168.1.50:7777"
alignment = 1
max_length = 300
[node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally."
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="SessionSummary" type="Label" parent="Paper/Margin/Layout"]
[node name="NameLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "server name"
[node name="NameEdit" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
focus_mode = 2
theme_override_font_sizes/font_size = 19
placeholder_text = "Friend's server"
alignment = 1
max_length = 80
[node name="NameHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 15
text = "1 / 8 players"
theme_override_font_sizes/font_size = 13
text = "Only visible on this device."
horizontal_alignment = 1
[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"]
[node name="Spacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
layout_mode = 2
theme_override_constants/separation = 7
size_flags_vertical = 3
[node name="Actions" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
theme_override_constants/separation = 10
alignment = 1
[node name="RefreshButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="RefreshButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(82, 72)
custom_minimum_size = Vector2(120, 46)
layout_mode = 2
focus_mode = 2
text = "refresh"
[node name="JoinButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="JoinButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(82, 72)
custom_minimum_size = Vector2(120, 46)
layout_mode = 2
focus_mode = 2
text = "join"
[node name="SaveButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="SaveButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(82, 72)
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "save\nserver"
focus_mode = 2
text = "save server"
[node name="EditButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="EditButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(76, 68)
custom_minimum_size = Vector2(110, 46)
layout_mode = 2
focus_mode = 2
text = "edit"
[node name="FavoriteButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="FavoriteButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(88, 68)
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
focus_mode = 2
text = "favorite"
[node name="DeleteButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="DeleteButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(82, 68)
custom_minimum_size = Vector2(110, 46)
layout_mode = 2
focus_mode = 2
text = "delete"
[node name="CancelButton" type="Button" parent="Paper/Margin/Layout/Actions"]
[node name="CancelButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(76, 68)
custom_minimum_size = Vector2(120, 46)
layout_mode = 2
focus_mode = 2
text = "cancel"
[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(76, 68)
[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"]
layout_mode = 2
theme_override_constants/margin_top = 34
[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="InfoRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="Status" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/InfoRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 14
text = "looking for public rooms…"
vertical_alignment = 1
text_overrun_behavior = 3
[node name="SessionSummary" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/InfoRow"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(210, 24)
layout_mode = 2
theme_override_font_sizes/font_size = 14
text = "1 / 8 players"
horizontal_alignment = 2
vertical_alignment = 1
[node name="Footer" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"]
layout_mode = 2
alignment = 2
[node name="BackButton" type="Button" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/Footer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(150, 46)
layout_mode = 2
focus_mode = 2
text = "back"
[node name="DeleteConfirmation" parent="." instance=ExtResource("4_confirmation")]

View file

@ -100,6 +100,23 @@ func is_open() -> bool:
return visible or _native_text_entry_is_active()
func close_for_control(control: Control = null) -> bool:
if visible and (control == null or _target == control):
_close_keyboard(false)
return true
if not _uses_native_virtual_keyboard():
return false
var native_target: Control = _native_session_target()
if native_target != null and (control == null or native_target == control):
_close_native_keyboard()
return true
if control != null and control.has_focus() and _can_edit(control):
_hide_native_keyboard()
control.release_focus()
return true
return false
func setup_controller_mapping(
mapping_manager: ControllerMappingManagerType,
) -> void:
@ -110,7 +127,10 @@ func request_for_focused_control() -> bool:
return request_for_control(get_viewport().gui_get_focus_owner())
func request_for_control(control: Control = null) -> bool:
func request_for_control(
control: Control = null,
preserve_caret: bool = false,
) -> bool:
if visible or not _can_edit(control):
return false
if _uses_native_virtual_keyboard():
@ -118,7 +138,7 @@ func request_for_control(control: Control = null) -> bool:
return true
if not _is_available_for_controller():
return false
_open_for(control)
_open_for(control, preserve_caret)
return true
@ -478,14 +498,20 @@ func _can_edit(control: Control) -> bool:
return false
func _open_for(control: Control) -> void:
func _open_for(control: Control, preserve_caret: bool = false) -> void:
_target = control
_target_virtual_keyboard_enabled = bool(
_target.get("virtual_keyboard_enabled")
)
_target.set("virtual_keyboard_enabled", false)
_buffer = str(_target.get("text"))
if preserve_caret and _target is LineEdit:
_buffer_caret = (_target as LineEdit).caret_column
elif preserve_caret and _target is TextEdit:
_buffer_caret = _text_edit_caret_offset(_target as TextEdit)
else:
_buffer_caret = _buffer.length()
_buffer_caret = clampi(_buffer_caret, 0, _buffer.length())
_set_target_caret(_buffer_caret)
_page = Page.LOWER
_last_focused_key = null

View file

@ -17,6 +17,12 @@ const MODERATION_KICK_ICON: Texture2D = preload(
const MODERATION_MUTE_ICON: Texture2D = preload(
"res://ui/icons/moderation_options/moderation_options_mute_light.png"
)
const FRIEND_ICON: Texture2D = preload(
"res://ui/icons/player_options/friends.png"
)
const CLEAR_ART_ICON: Texture2D = preload(
"res://ui/icons/player_options/clean.png"
)
const MODERATION_BUTTON_SIZE := Vector2(52.0, 40.0)
const MODERATION_ICON_SIZE: int = 40
@ -61,11 +67,31 @@ func setup(
_status.text = message
_refresh()
)
_service.friend_action_finished.connect(func(_ok: bool, message: String) -> void:
_status.text = message
_refresh()
)
if _discovery != null:
_discovery.host_settings_changed.connect(
_on_host_settings_changed
)
_discovery.host_status_changed.connect(_on_host_status_changed)
_discovery.friend_presence_updated.connect(
func(_friends: Array[Dictionary]) -> void: _refresh()
)
_discovery.presence_sharing_changed.connect(
func(_enabled: bool) -> void: _refresh()
)
_discovery.friend_invite_finished.connect(
func(_ok: bool, message: String) -> void:
_status.text = message
_refresh()
)
_discovery.social_status_changed.connect(
func(message: String, is_error: bool) -> void:
if is_error or _current_tab == 1:
_status.text = message
)
_refresh()
@ -185,9 +211,9 @@ func _build() -> void:
_tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_tabs.add_theme_constant_override("separation", 10)
tab_row.add_child(_tabs)
for index: int in 3:
for index: int in 4:
var button := Button.new()
button.text = ["players", "relationships", "banned"][index]
button.text = ["players", "friends", "relationships", "banned"][index]
button.toggle_mode = true
button.pressed.connect(_select_tab.bind(index))
UtilityPageStyle.apply_ocean_button(button)
@ -322,7 +348,7 @@ func _set_host_toggle_state(
func _select_tab(index: int) -> void:
if index == 2 and (_service == null or not _service.is_local_moderator()):
if index == 3 and (_service == null or not _service.is_local_moderator()):
return
_current_tab = index
_refresh()
@ -335,12 +361,12 @@ func _refresh() -> void:
_count_label.text = "%d / %d connected" % [
_service.get_connected_count(), _service.get_max_players(),
]
if _current_tab == 2 and not _service.is_local_moderator():
if _current_tab == 3 and not _service.is_local_moderator():
_current_tab = 0
for index: int in _tabs.get_child_count():
var button := _tabs.get_child(index) as Button
button.button_pressed = index == _current_tab
button.visible = index != 2 or _service.is_local_moderator()
button.visible = index != 3 or _service.is_local_moderator()
_refresh_host_settings()
for child: Node in _list.get_children():
child.queue_free()
@ -348,8 +374,10 @@ func _refresh() -> void:
0:
_build_active_rows()
1:
_build_relationship_rows()
_build_friend_rows()
2:
_build_relationship_rows()
3:
_build_ban_rows()
if _controller_zone == ControllerZone.BODY and _body_controls().is_empty():
_controller_zone = ControllerZone.TABS
@ -520,6 +548,23 @@ func _build_active_player_row(entry: PlayerListEntry) -> void:
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 6)
row.add_child(actions)
var friend := Button.new()
friend.disabled = entry.is_local_player or entry.is_friend or not entry.can_request_friend
var friend_action := "friends" if entry.is_friend else "add friend"
_configure_moderation_button(friend, FRIEND_ICON, friend_action)
friend.tooltip_text = (
"Already friends."
if entry.is_friend
else "This person needs to be online to do this."
if not entry.can_request_friend
else "Send a live friend request."
)
friend.pressed.connect(func() -> void:
_service.send_friend_request(
entry.peer_id, entry.full_fingerprint, entry.display_name
)
)
actions.add_child(friend)
var mute := Button.new()
var mute_action: String = "unmute" if entry.muted else "mute"
mute.disabled = entry.is_local_player
@ -539,14 +584,9 @@ func _build_active_player_row(entry: PlayerListEntry) -> void:
operator.custom_minimum_size = MODERATION_BUTTON_SIZE
actions.add_child(operator)
var clear_art := Button.new()
clear_art.text = "clear art"
clear_art.disabled = not entry.can_clear_art
clear_art.tooltip_text = (
"Remove every shared artwork this player participated in."
)
_configure_moderation_button(clear_art, CLEAR_ART_ICON, "scrub art")
clear_art.pressed.connect(_confirm_clear_art.bind(entry))
UtilityPageStyle.apply_compact_ocean_button(clear_art)
clear_art.custom_minimum_size = Vector2(84.0, 40.0)
actions.add_child(clear_art)
var kick := Button.new()
kick.disabled = not entry.can_kick
@ -661,6 +701,104 @@ func _build_relationship_rows() -> void:
row.add_child(unmute)
func _build_friend_rows() -> void:
_build_presence_controls()
var friends: Array[Dictionary] = (
_discovery.get_friend_presence()
if _discovery != null
else []
)
if friends.is_empty():
_add_empty("No friends added yet. Add someone while you are in a room together.")
return
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 room: Dictionary = (
friend.get("room", {})
if typeof(friend.get("room", {})) == TYPE_DICTIONARY
else {}
)
var label := Label.new()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.text = "%s · %s %s" % [
display_name,
NetworkIdentityCrypto.compact_suffix(fingerprint),
"playing in %s" % str(room.get("room_name", "a public room"))
if not room.is_empty()
else "Online"
if bool(friend.get("online", false))
else "Offline",
]
label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint)
label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
row.add_child(label)
var invite := Button.new()
invite.text = "invite"
invite.tooltip_text = (
"Invite this friend to your listed public room."
if bool(friend.get("online", false))
else "This person needs to be online to do this."
)
invite.pressed.connect(func() -> void:
_discovery.send_friend_invite(fingerprint)
)
UtilityPageStyle.apply_compact_ocean_button(invite)
row.add_child(invite)
var remove := Button.new()
remove.text = "remove"
remove.pressed.connect(func() -> void:
_confirm(
"Remove %s from your friends?" % display_name,
func() -> void:
_service.remove_friend(fingerprint, display_name),
)
)
UtilityPageStyle.apply_compact_ocean_button(remove)
row.add_child(remove)
var block := Button.new()
block.text = "block"
block.pressed.connect(func() -> void:
_confirm(
"Block %s?\nThis also removes the friendship." % display_name,
func() -> void:
_service.set_blocked(fingerprint, display_name, true),
)
)
UtilityPageStyle.apply_compact_ocean_button(block)
row.add_child(block)
func _build_presence_controls() -> void:
var row := _make_row()
var label := Label.new()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text = "share online status with friends"
label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
row.add_child(label)
var toggle := Button.new()
var enabled := _discovery != null and _discovery.is_presence_sharing()
toggle.text = "on" if enabled else "off"
toggle.tooltip_text = (
"Friends can see when you are online and whether your room is joinable."
)
toggle.disabled = _discovery == null or not _discovery.is_configured()
toggle.pressed.connect(func() -> void:
_discovery.set_presence_sharing(
not _discovery.is_presence_sharing()
)
)
UtilityPageStyle.apply_compact_ocean_button(toggle)
row.add_child(toggle)
func _build_ban_rows() -> void:
var records := _service.get_bans()
if records.is_empty():

731
ui/save_slots_page.gd Normal file
View file

@ -0,0 +1,731 @@
class_name SaveSlotsPage
extends Control
const PAGE_SAVES: StringName = &"saves"
const PAGE_NEW: StringName = &"new"
const WorldLayoutType = preload("res://world/world_layout.gd")
const SaveManagerType = preload("res://save/player_save_manager.gd")
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
signal back_requested
signal play_requested(slot_id: String)
signal create_requested(
display_name: String,
world_layout: StringName,
world_seed: int,
duplicate_source_slot_id: String,
)
enum SeedMode {
RANDOM,
CUSTOM,
}
@onready var _main_panel: PanelContainer = %MainPanel
@onready var _content_panel: PanelContainer = %ContentPanel
@onready var _saves_tab: OrganizerTab = %SavesTab
@onready var _new_slot_tab: OrganizerTab = %NewSlotTab
@onready var _saves_page: Control = %SavesPage
@onready var _new_slot_page: Control = %NewSlotPage
@onready var _slot_list: VBoxContainer = %SlotList
@onready var _empty_slots_label: Label = %EmptySlotsLabel
@onready var _slot_name_edit: LineEdit = %SelectedSlotName
@onready var _slot_summary: RichTextLabel = %SlotSummary
@onready var _play_button: Button = %PlaySlotButton
@onready var _rename_button: Button = %RenameSlotButton
@onready var _duplicate_button: Button = %DuplicateSlotButton
@onready var _export_button: Button = %ExportSlotButton
@onready var _delete_button: Button = %DeleteSlotButton
@onready var _import_button: Button = %ImportSlotButton
@onready var _new_slot_heading: Label = %NewSlotHeading
@onready var _new_slot_name: LineEdit = %NewSlotName
@onready var _generated_button: Button = %GeneratedButton
@onready var _starter_button: Button = %StarterButton
@onready var _world_description: Label = %WorldDescription
@onready var _seed_section: VBoxContainer = %SeedSection
@onready var _random_seed_button: Button = %RandomSeedButton
@onready var _custom_seed_button: Button = %CustomSeedButton
@onready var _seed_edit: LineEdit = %SeedEdit
@onready var _seed_help: Label = %SeedHelp
@onready var _create_button: Button = %CreateSlotButton
@onready var _status: Label = %Status
@onready var _back_button: Button = %BackButton
var _catalog: PlayerSaveSlotCatalog
var _data_root: PlayerDataRoot
var _interface_fonts: InterfaceFontController
var _active_page_id: StringName = PAGE_SAVES
var _selected_slot_id := ""
var _duplicate_source_slot_id := ""
var _world_layout: StringName = WorldLayoutType.GENERATED
var _seed_mode: SeedMode = SeedMode.RANDOM
var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED
var _slot_buttons: Array[Button] = []
var _import_dialog: FileDialog
var _export_dialog: FileDialog
var _delete_dialog: ConfirmationDialog
var _busy: bool = false
func _ready() -> void:
_configure_style()
_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)
_rename_button.pressed.connect(_rename_selected_slot)
_duplicate_button.pressed.connect(_prepare_duplicate)
_export_button.pressed.connect(_choose_export_path)
_delete_button.pressed.connect(_confirm_delete_selected_slot)
_import_button.pressed.connect(_choose_import_path)
_generated_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.GENERATED)
)
_starter_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.STARTER_ISLAND)
)
_random_seed_button.pressed.connect(_choose_random_seed)
_custom_seed_button.pressed.connect(_choose_custom_seed)
_new_slot_name.text_changed.connect(_on_new_slot_name_changed)
_seed_edit.text_changed.connect(_on_seed_text_changed)
_seed_edit.text_submitted.connect(_on_seed_submitted)
_new_slot_name.text_submitted.connect(_on_new_slot_name_submitted)
_create_button.pressed.connect(_request_create)
_back_button.pressed.connect(request_back)
visibility_changed.connect(_on_visibility_changed)
hide()
func _configure_style() -> void:
UtilityPageStyle.apply_page(self)
_main_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_MID,
28,
),
)
_content_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_FIELD,
20,
),
)
for node: Node in find_children("*", "Label", true, false):
var label := node as Label
label.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
for node: Node in find_children("*", "Button", true, false):
if node is OrganizerTab:
continue
UtilityPageStyle.apply_ocean_button(node as BaseButton)
_delete_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_DANGER,
),
)
_back_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_PANEL_DEEP,
),
)
_status.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_SECONDARY,
)
func setup(
catalog: PlayerSaveSlotCatalog,
data_root: PlayerDataRoot,
interface_fonts: InterfaceFontController,
) -> void:
_catalog = catalog
_data_root = data_root
_interface_fonts = interface_fonts
if not _catalog.slots_changed.is_connected(_on_slots_changed):
_catalog.slots_changed.connect(_on_slots_changed)
func open_page() -> void:
_busy = false
_status.text = ""
_duplicate_source_slot_id = ""
_refresh_slots()
if _catalog != null and _catalog.has_slots():
_select_page(PAGE_SAVES, false)
else:
_prepare_fresh_slot()
_select_page(PAGE_NEW, false)
show()
_main_panel.modulate.a = 1.0
_main_panel.scale = Vector2.ONE
UtilityPageStyle.animate_in(self)
call_deferred("_focus_open_page")
func close_page() -> void:
_release_owned_focus()
hide()
func request_back() -> void:
if _import_dialog != null and _import_dialog.visible:
_import_dialog.hide()
return
if _export_dialog != null and _export_dialog.visible:
_export_dialog.hide()
return
if _delete_dialog != null and _delete_dialog.visible:
_delete_dialog.hide()
return
if _busy:
return
if _active_page_id == PAGE_NEW and _catalog != null and _catalog.has_slots():
_duplicate_source_slot_id = ""
_select_page(PAGE_SAVES, true)
return
back_requested.emit()
func set_status(message: String) -> void:
_busy = false
_status.text = message
_refresh_action_state()
func finish_request_if_pending(message: String) -> void:
if _busy:
set_status(message)
func refresh_page() -> void:
if visible:
_refresh_slots()
func get_active_page_id() -> StringName:
return _active_page_id if visible else StringName()
func _select_page(page_id: StringName, focus_tab: bool) -> void:
if page_id not in [PAGE_SAVES, PAGE_NEW]:
return
_active_page_id = page_id
_saves_page.visible = page_id == PAGE_SAVES
_new_slot_page.visible = page_id == PAGE_NEW
_saves_tab.set_selected(page_id == PAGE_SAVES, is_inside_tree())
_new_slot_tab.set_selected(page_id == PAGE_NEW, is_inside_tree())
_configure_controller_focus()
if focus_tab:
(_saves_tab if page_id == PAGE_SAVES else _new_slot_tab).grab_focus()
func _open_fresh_slot_page() -> void:
_prepare_fresh_slot()
_select_page(PAGE_NEW, true)
func _prepare_fresh_slot() -> void:
_duplicate_source_slot_id = ""
_new_slot_heading.text = "new progression"
_new_slot_name.text = _suggested_slot_name()
_world_layout = WorldLayoutType.GENERATED
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_refresh_new_slot_presentation()
func _prepare_duplicate() -> void:
var slot: Dictionary = _selected_slot()
if slot.is_empty() or not bool(slot.get("has_save", false)):
return
_duplicate_source_slot_id = _selected_slot_id
_new_slot_heading.text = "duplicate progression"
_new_slot_name.text = PlayerSaveSlotCatalog.normalized_name(
"copy of %s" % str(slot.get("display_name", "save"))
)
_world_layout = WorldLayoutType.GENERATED
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_refresh_new_slot_presentation()
_select_page(PAGE_NEW, true)
func _refresh_slots() -> void:
for button: Button in _slot_buttons:
_slot_list.remove_child(button)
button.queue_free()
_slot_buttons.clear()
if _catalog == null:
_selected_slot_id = ""
_empty_slots_label.show()
_refresh_selected_slot()
return
var slots: Array[Dictionary] = _catalog.list_slots()
var known_selection: bool = false
for slot: Dictionary in slots:
var slot_id: String = str(slot.get("slot_id", ""))
var button := Button.new()
button.name = "Slot_%s" % slot_id.left(8)
button.custom_minimum_size = Vector2(0.0, 56.0)
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 ""
)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
UtilityPageStyle.apply_ocean_button(button)
button.pressed.connect(_select_slot.bind(slot_id, false))
button.focus_entered.connect(_select_slot.bind(slot_id, true))
_slot_list.add_child(button)
_slot_buttons.append(button)
known_selection = known_selection or slot_id == _selected_slot_id
_empty_slots_label.visible = slots.is_empty()
if not known_selection:
_selected_slot_id = _catalog.get_active_slot_id()
if _selected_slot_id.is_empty() and not slots.is_empty():
_selected_slot_id = str(slots.front().get("slot_id", ""))
_refresh_selected_slot()
_configure_controller_focus()
func _select_slot(slot_id: String, _from_focus: bool) -> void:
if _catalog == null or _catalog.get_slot(slot_id).is_empty():
return
_selected_slot_id = slot_id
_refresh_selected_slot()
func _refresh_selected_slot() -> void:
var slot: Dictionary = _selected_slot()
for button: Button in _slot_buttons:
button.set_pressed_no_signal(
button.name == "Slot_%s" % _selected_slot_id.left(8)
)
if slot.is_empty():
_slot_name_edit.text = ""
_slot_name_edit.editable = false
_slot_summary.text = "[center]create or import a save slot to begin.[/center]"
else:
_slot_name_edit.editable = true
_slot_name_edit.text = str(slot.get("display_name", "save"))
_slot_summary.text = _format_slot_summary(slot)
_refresh_action_state()
func _format_slot_summary(slot: Dictionary) -> String:
if not bool(slot.get("has_save", false)):
return "[center]%s[/center]" % str(
slot.get("status_message", "this slot has no progression yet.")
)
var last_played: String = _format_timestamp(
int(slot.get("last_played_at_unix", 0))
)
return (
"[font_size=24]level %d[/font_size]\n\n"
+ "%d catches · %d discovered\n"
+ "%d fishcoins\n\n"
+ "%s · seed %d\n"
+ "last played %s"
) % [
int(slot.get("player_level", 1)),
int(slot.get("catch_count", 0)),
int(slot.get("discovered_species_count", 0)),
int(slot.get("wallet_balance", 0)),
WorldLayoutType.display_name(slot.get("world_layout", "")),
int(slot.get("world_seed", 0)),
last_played,
]
func _refresh_action_state() -> void:
var slot: Dictionary = _selected_slot()
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
_rename_button.disabled = _busy or not has_slot
_duplicate_button.disabled = _busy or not has_save
_export_button.disabled = _busy or not has_save
_delete_button.disabled = _busy or not has_slot
_import_button.disabled = _busy or _catalog == null
_create_button.disabled = _busy or not _new_slot_request_is_valid()
func _request_play() -> void:
if _play_button.disabled or _selected_slot_id.is_empty():
return
_busy = true
_status.text = "loading save slot..."
_refresh_action_state()
play_requested.emit(_selected_slot_id)
func _rename_selected_slot() -> void:
if _catalog == null or _rename_button.disabled:
return
if not _catalog.rename_slot(_selected_slot_id, _slot_name_edit.text):
_status.text = "the save slot could not be renamed."
return
_status.text = "save slot renamed."
func _confirm_delete_selected_slot() -> void:
var slot: Dictionary = _selected_slot()
if slot.is_empty() or _delete_button.disabled:
return
if _delete_dialog == null:
_delete_dialog = ConfirmationDialog.new()
_delete_dialog.title = "delete save slot?"
_delete_dialog.ok_button_text = "delete"
_delete_dialog.confirmed.connect(_delete_selected_slot)
if _interface_fonts != null:
_interface_fonts.apply_utility_theme(_delete_dialog)
add_child(_delete_dialog)
_delete_dialog.dialog_text = (
"delete \"%s\"? this cannot be undone from the game."
% str(slot.get("display_name", "save"))
)
_delete_dialog.popup_centered(Vector2i(560, 260))
_configure_delete_dialog.call_deferred()
func _configure_delete_dialog() -> void:
if (
_delete_dialog != null
and is_instance_valid(_delete_dialog)
and _delete_dialog.visible
):
DialogControllerNavigationType.configure_scope(
_delete_dialog,
_delete_dialog.get_cancel_button(),
)
func _delete_selected_slot() -> void:
if _catalog == null or _selected_slot_id.is_empty():
return
if not _catalog.delete_slot(_selected_slot_id):
_status.text = "the save slot could not be deleted."
return
_selected_slot_id = _catalog.get_active_slot_id()
_status.text = "save slot deleted."
_refresh_slots()
if not _catalog.has_slots():
_prepare_fresh_slot()
_select_page(PAGE_NEW, true)
func _choose_import_path() -> void:
if _catalog == null or _data_root == null:
return
if _import_dialog == null:
_import_dialog = FileDialog.new()
_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_import_dialog.access = FileDialog.ACCESS_FILESYSTEM
_import_dialog.use_native_dialog = false
_import_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_import_dialog.file_selected.connect(_import_file_selected)
if _interface_fonts != null:
_interface_fonts.apply_utility_theme(_import_dialog)
add_child(_import_dialog)
_import_dialog.current_dir = _data_root.progression_backup_directory()
if _interface_fonts != null:
_interface_fonts.popup_file_dialog(_import_dialog)
else:
_import_dialog.popup_centered_ratio(0.85)
func _import_file_selected(path: String) -> void:
var suggested_name: String = path.get_file().get_basename().replace("_", " ")
suggested_name = suggested_name.replace("-", " ")
var result: Dictionary = _catalog.import_slot(path, suggested_name)
_status.text = str(result.get("message", "progression import failed."))
if bool(result.get("ok", false)):
_selected_slot_id = str(result.get("slot_id", ""))
_refresh_slots()
_select_page(PAGE_SAVES, false)
func _choose_export_path() -> void:
var slot: Dictionary = _selected_slot()
if slot.is_empty() or _data_root == null:
return
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
var filename: String = "%s-%s%s" % [
_safe_filename(str(slot.get("display_name", "save"))),
timestamp,
PlayerSaveManager.ARCHIVE_EXTENSION,
]
if _export_dialog == null:
_export_dialog = FileDialog.new()
_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_export_dialog.access = FileDialog.ACCESS_FILESYSTEM
_export_dialog.use_native_dialog = false
_export_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_export_dialog.file_selected.connect(_export_file_selected)
if _interface_fonts != null:
_interface_fonts.apply_utility_theme(_export_dialog)
add_child(_export_dialog)
_export_dialog.current_dir = _data_root.progression_backup_directory()
_export_dialog.current_file = filename
if _interface_fonts != null:
_interface_fonts.popup_file_dialog(_export_dialog)
else:
_export_dialog.popup_centered_ratio(0.85)
func _export_file_selected(path: String) -> void:
var destination: String = (
path
if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION)
else path + PlayerSaveManager.ARCHIVE_EXTENSION
)
var result: Dictionary = _catalog.export_slot(
_selected_slot_id,
destination,
)
_status.text = str(result.get("message", "progression export failed."))
func _request_create() -> void:
if _create_button.disabled:
return
var seed: int = _selected_world_seed()
if _world_layout == WorldLayoutType.GENERATED and seed == 0:
_status.text = "enter text or a whole number from 1 to %d." % SaveManagerType.MAX_WORLD_SEED
_seed_edit.grab_focus()
_seed_edit.select_all()
return
if seed == 0:
seed = SaveManagerType.roll_world_seed()
_busy = true
_status.text = "creating save slot..."
_refresh_action_state()
create_requested.emit(
PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text),
_world_layout,
seed,
_duplicate_source_slot_id,
)
func _set_world_layout(layout: StringName) -> void:
if not WorldLayoutType.is_valid(layout):
return
_world_layout = layout
_status.text = ""
_refresh_new_slot_presentation()
func _choose_random_seed() -> void:
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_status.text = ""
_refresh_new_slot_presentation()
func _choose_custom_seed() -> void:
_seed_mode = SeedMode.CUSTOM
if NewGameSetupPageType.parse_seed_text(_seed_edit.text) == _random_seed:
_seed_edit.text = ""
_status.text = ""
_refresh_new_slot_presentation()
_seed_edit.grab_focus.call_deferred()
_seed_edit.select_all.call_deferred()
func _roll_random_seed() -> void:
_random_seed = SaveManagerType.roll_world_seed()
_seed_edit.text = str(_random_seed)
func _selected_world_seed() -> int:
return (
_random_seed
if _seed_mode == SeedMode.RANDOM
else NewGameSetupPageType.parse_seed_text(_seed_edit.text)
)
func _on_seed_text_changed(_text: String) -> void:
if _seed_mode == SeedMode.CUSTOM:
_status.text = ""
_refresh_action_state()
func _on_new_slot_name_changed(_text: String) -> void:
_refresh_action_state()
func _on_seed_submitted(_text: String) -> void:
_request_create()
func _on_new_slot_name_submitted(_text: String) -> void:
if _world_layout == WorldLayoutType.STARTER_ISLAND:
_request_create()
func _refresh_new_slot_presentation() -> void:
var generated: bool = _world_layout == WorldLayoutType.GENERATED
_generated_button.set_pressed_no_signal(generated)
_starter_button.set_pressed_no_signal(not generated)
_world_description.text = (
"build a new island from terrain chunks.\n"
+ "the same seed always builds the same world."
if generated
else "play on the starter island."
)
_seed_section.visible = generated
_random_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.RANDOM)
_custom_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.CUSTOM)
_seed_edit.editable = _seed_mode == SeedMode.CUSTOM
_seed_edit.focus_mode = (
Control.FOCUS_ALL if _seed_mode == SeedMode.CUSTOM else Control.FOCUS_NONE
)
_seed_help.text = (
"press random seed again to roll another world."
if _seed_mode == SeedMode.RANDOM
else "enter text or a number from 1 to %d."
% SaveManagerType.MAX_WORLD_SEED
)
_create_button.text = (
"duplicate and play"
if not _duplicate_source_slot_id.is_empty()
else "create and play"
)
_refresh_action_state()
_configure_controller_focus()
func _new_slot_request_is_valid() -> bool:
return (
not PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text).is_empty()
and (
_world_layout != WorldLayoutType.GENERATED
or _seed_mode == SeedMode.RANDOM
or NewGameSetupPageType.parse_seed_text(_seed_edit.text) > 0
)
)
func _configure_controller_focus() -> void:
if not is_node_ready():
return
_saves_tab.focus_neighbor_right = _saves_tab.get_path_to(_new_slot_tab)
_new_slot_tab.focus_neighbor_left = _new_slot_tab.get_path_to(_saves_tab)
if _active_page_id == PAGE_SAVES:
var first: Control = _slot_buttons.front() if not _slot_buttons.is_empty() else _import_button
var last: Control = _slot_buttons.back() if not _slot_buttons.is_empty() else _import_button
_saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(first)
_new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(first)
for index: int in _slot_buttons.size():
var button: Button = _slot_buttons[index]
var top: Control = _saves_tab if index == 0 else _slot_buttons[index - 1]
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, _rename_button, _slot_name_edit, _duplicate_button)
_set_neighbors(_rename_button, _play_button, _rename_button, _slot_name_edit, _export_button)
_set_neighbors(_duplicate_button, _import_button, _export_button, _play_button, _delete_button)
_set_neighbors(_export_button, _duplicate_button, _export_button, _rename_button, _delete_button)
_set_neighbors(_delete_button, _import_button, _delete_button, _duplicate_button, _back_button)
_set_neighbors(_back_button, _back_button, _back_button, _import_button, _back_button)
return
_saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(_new_slot_name)
_new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(_new_slot_name)
var generated: bool = _world_layout == WorldLayoutType.GENERATED
var custom: bool = generated and _seed_mode == SeedMode.CUSTOM
_set_neighbors(_new_slot_name, _new_slot_name, _new_slot_name, _new_slot_tab, _generated_button)
_set_neighbors(_generated_button, _generated_button, _starter_button, _new_slot_name, _random_seed_button if generated else _create_button)
_set_neighbors(_starter_button, _generated_button, _starter_button, _new_slot_name, _custom_seed_button if generated else _back_button)
_set_neighbors(_random_seed_button, _random_seed_button, _custom_seed_button, _generated_button, _seed_edit if custom else _create_button)
_set_neighbors(_custom_seed_button, _random_seed_button, _custom_seed_button, _starter_button, _seed_edit if custom else _back_button)
_set_neighbors(_seed_edit, _seed_edit, _seed_edit, _custom_seed_button, _create_button)
var action_top: Control = _seed_edit if custom else _random_seed_button if generated else _generated_button
_set_neighbors(_create_button, _create_button, _back_button, action_top, _create_button)
_set_neighbors(_back_button, _create_button, _back_button, action_top, _back_button)
func _set_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)
func _focus_open_page() -> void:
if not visible:
return
(_saves_tab if _active_page_id == PAGE_SAVES else _new_slot_tab).grab_focus()
func _selected_slot() -> Dictionary:
return _catalog.get_slot(_selected_slot_id) if _catalog != null else {}
func _suggested_slot_name() -> String:
var used: Dictionary[String, bool] = {}
if _catalog != null:
for slot: Dictionary in _catalog.list_slots():
used[str(slot.get("display_name", "")).to_lower()] = true
var number: int = 1
while used.has("save %d" % number):
number += 1
return "save %d" % number
func _format_timestamp(unix_time: int) -> String:
if unix_time <= 0:
return "never"
var value: Dictionary = Time.get_datetime_dict_from_unix_time(unix_time)
return "%04d-%02d-%02d" % [
int(value.get("year", 0)),
int(value.get("month", 0)),
int(value.get("day", 0)),
]
func _safe_filename(value: String) -> String:
var result: String = value.strip_edges().to_lower().replace(" ", "-")
for character: String in ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"]:
result = result.replace(character, "")
return "netfishing-save" if result.is_empty() else result
func _on_slots_changed() -> void:
if visible:
_refresh_slots()
func _on_visibility_changed() -> void:
if not visible:
_release_owned_focus()
func _release_owned_focus() -> void:
if not is_inside_tree():
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and is_ancestor_of(focus_owner):
focus_owner.release_focus()

View file

@ -0,0 +1 @@
uid://cytwdvxf22106

350
ui/save_slots_page.tscn Normal file
View file

@ -0,0 +1,350 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://ui/save_slots_page.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"]
[node name="SaveSlotsPage" type="Control"]
visible = false
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("2_theme")
script = ExtResource("1_script")
[node name="MainPanel" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -440.0
offset_top = -330.0
offset_right = 440.0
offset_bottom = 330.0
grow_horizontal = 2
grow_vertical = 2
[node name="OuterMargin" type="MarginContainer" parent="MainPanel"]
layout_mode = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 18
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 18
[node name="Layout" type="VBoxContainer" parent="MainPanel/OuterMargin"]
layout_mode = 2
theme_override_constants/separation = -26
[node name="Heading" type="Label" parent="MainPanel/OuterMargin/Layout"]
custom_minimum_size = Vector2(0, 66)
layout_mode = 2
theme_override_font_sizes/font_size = 30
text = "play"
horizontal_alignment = 1
[node name="TabBar" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout"]
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
[node name="SavesTab" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 52)
layout_mode = 2
focus_mode = 2
text = "save slots"
script = ExtResource("3_tab")
[node name="NewSlotTab" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 52)
layout_mode = 2
focus_mode = 2
text = "new slot"
script = ExtResource("3_tab")
palette_index = 1
[node name="ContentPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 474)
layout_mode = 2
size_flags_vertical = 3
[node name="ContentMargin" type="MarginContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel"]
layout_mode = 2
theme_override_constants/margin_left = 28
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 28
theme_override_constants/margin_bottom = 20
[node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin"]
layout_mode = 2
[node name="SavesPage" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack"]
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
theme_override_constants/separation = 20
[node name="SlotsColumn" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"]
custom_minimum_size = Vector2(340, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"]
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "save slots"
[node name="SlotScroll" type="ScrollContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
follow_focus = true
[node name="SlotList" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn/SlotScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="EmptySlotsLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn/SlotScroll/SlotList"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
text = "no save slots yet"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ImportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
focus_mode = 2
text = "import save"
[node name="Divider" type="VSeparator" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"]
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
[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "selected slot"
[node name="SelectedSlotName" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
focus_mode = 2
placeholder_text = "save slot name"
max_length = 32
[node name="SlotSummary" type="RichTextLabel" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
fit_content = true
scroll_active = false
autowrap_mode = 2
vertical_alignment = 1
[node name="Actions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
layout_mode = 2
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
columns = 2
[node name="PlaySlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
text = "play"
[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
text = "rename"
[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
text = "duplicate"
[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
text = "export"
[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
text = "delete"
[node name="NewSlotPage" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack"]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 9
[node name="NewSlotHeading" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "new progression"
horizontal_alignment = 1
[node name="NewSlotName" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
focus_mode = 2
placeholder_text = "save slot name"
alignment = 1
max_length = 32
[node name="WorldButtons" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="GeneratedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 48)
layout_mode = 2
focus_mode = 2
toggle_mode = true
text = "generate a world"
[node name="StarterButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 48)
layout_mode = 2
focus_mode = 2
toggle_mode = true
text = "starter island"
[node name="WorldDescription" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_override_font_sizes/font_size = 15
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="SeedSection" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 7
[node name="SeedModes" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="RandomSeedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 44)
layout_mode = 2
focus_mode = 2
toggle_mode = true
text = "random seed"
[node name="CustomSeedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 44)
layout_mode = 2
focus_mode = 2
toggle_mode = true
text = "enter a seed"
[node name="SeedEdit" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
focus_mode = 2
placeholder_text = "number or words"
alignment = 1
max_length = 64
[node name="SeedHelp" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
theme_override_font_sizes/font_size = 13
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
layout_mode = 2
size_flags_vertical = 3
[node name="CreateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"]
unique_name_in_owner = true
custom_minimum_size = Vector2(260, 48)
layout_mode = 2
size_flags_horizontal = 4
focus_mode = 2
text = "create and play"
[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"]
layout_mode = 2
theme_override_constants/margin_top = 34
[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="Status" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 1
text_overrun_behavior = 3
[node name="Footer" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"]
layout_mode = 2
alignment = 2
[node name="BackButton" type="Button" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/Footer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(150, 46)
layout_mode = 2
focus_mode = 2
text = "back"

View file

@ -86,6 +86,7 @@ enum PresentationMode {
%ControllerSensitivityValue
)
@onready var _invert_y_toggle: Button = %InvertYToggle
@onready var _swap_scroll_toggle: Button = %SwapScrollToggle
@onready var _on_screen_keyboard_toggle: Button = %OnScreenKeyboardToggle
@onready var _auto_click_toggle: Button = %AutoClickToggle
@onready var _auto_click_interval_slider: HSlider = (
@ -112,6 +113,7 @@ var _auto_click_interval_value: float = 0.20
var _mouse_sensitivity: float = 0.005
var _controller_sensitivity: float = 2.5
var _invert_camera_y: bool = false
var _swap_hotbar_camera_scroll: bool = false
var _on_screen_keyboard_enabled: bool = false
var _chat_dock_right: bool = false
var _chat_mobile_mode: bool = false
@ -125,7 +127,6 @@ var _environment_volume: float = 1.0
var _network_profile: NetworkProfilePreferences
var _network_session: NetworkSession
var _data_root: PlayerDataRoot
var _progression_saves: PlayerSaveManager
var _identity_backups: IdentityBackupService
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
@ -135,8 +136,6 @@ var _controller_mapping_panel: ControllerMappingPanelType
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
var _data_folder_dialog: FileDialog
var _progression_import_dialog: FileDialog
var _progression_export_dialog: FileDialog
var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog
var _passphrase_dialog: ConfirmationDialog
@ -146,7 +145,6 @@ var _pending_identity_operation := ""
var _pending_identity_type := ""
var _pending_identity_path := ""
var _pending_import_data: Dictionary = {}
var _pending_progression_path := ""
func _notification(what: int) -> void:
@ -239,6 +237,7 @@ func _connect_controls() -> void:
_on_controller_sensitivity_changed
)
_invert_y_toggle.toggled.connect(_set_invert_y)
_swap_scroll_toggle.toggled.connect(_set_swap_scroll)
_on_screen_keyboard_toggle.toggled.connect(_set_on_screen_keyboard)
_auto_click_toggle.toggled.connect(_set_auto_click)
_auto_click_interval_slider.value_changed.connect(
@ -248,8 +247,6 @@ func _connect_controls() -> void:
%KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping)
%OpenDataFolder.pressed.connect(_open_data_folder)
%ChangeDataFolder.pressed.connect(_choose_data_folder)
%ExportProgression.pressed.connect(_choose_progression_export)
%ImportProgression.pressed.connect(_choose_progression_import)
%ExportPlayerIdentity.pressed.connect(
_choose_identity_export.bind("player")
)
@ -343,7 +340,6 @@ func setup_keyboard_mouse_mapping(
func setup_data_and_identity(
data_root: PlayerDataRoot,
progression_saves: PlayerSaveManager,
identity_backups: IdentityBackupService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
@ -351,7 +347,6 @@ func setup_data_and_identity(
interface_fonts: InterfaceFontController,
) -> void:
_data_root = data_root
_progression_saves = progression_saves
_identity_backups = identity_backups
_player_identity = player_identity
_host_identity = host_identity
@ -619,11 +614,6 @@ func _refresh_data_page() -> void:
_data_root.override_active
or (_network_session != null and _network_session.is_session_active())
)
%ExportProgression.disabled = _progression_saves == null
%ImportProgression.disabled = (
_progression_saves == null
or (_network_session != null and _network_session.is_session_active())
)
var fingerprint: String = (
_player_identity.fingerprint if _player_identity != null else ""
)
@ -674,122 +664,6 @@ func _copy_player_fingerprint() -> void:
_feedback.text = "full player fingerprint copied for server operator setup."
func _choose_progression_export() -> void:
if _progression_saves == null or _data_root == null:
_feedback.text = "progression export is unavailable."
return
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
)
var suggested: String = _data_root.progression_backup_directory().path_join(
"NETfishing-progression-%s%s"
% [timestamp, PlayerSaveManager.ARCHIVE_EXTENSION]
)
if _progression_export_dialog == null:
_progression_export_dialog = FileDialog.new()
_progression_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_progression_export_dialog.access = FileDialog.ACCESS_FILESYSTEM
_progression_export_dialog.use_native_dialog = false
_progression_export_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_progression_export_dialog.file_selected.connect(
_progression_export_file_selected
)
_interface_fonts.apply_utility_theme(_progression_export_dialog)
add_child(_progression_export_dialog)
_progression_export_dialog.current_dir = suggested.get_base_dir()
_progression_export_dialog.current_file = suggested.get_file()
_interface_fonts.popup_file_dialog(_progression_export_dialog)
func _progression_export_file_selected(path: String) -> void:
var destination: String = (
path
if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION)
else path + PlayerSaveManager.ARCHIVE_EXTENSION
)
var result: Dictionary = _progression_saves.export_progression_archive(
destination
)
_feedback.text = str(
result.get("message", "progression export failed.")
)
func _choose_progression_import() -> void:
if _progression_saves == null or _data_root == null:
_feedback.text = "progression import is unavailable."
return
if _network_session != null and _network_session.is_session_active():
_feedback.text = "return to title before importing progression."
return
if _progression_import_dialog == null:
_progression_import_dialog = FileDialog.new()
_progression_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_progression_import_dialog.access = FileDialog.ACCESS_FILESYSTEM
_progression_import_dialog.use_native_dialog = false
_progression_import_dialog.filters = PackedStringArray([
"*.nfsave ; NETfishing progression archive",
])
_progression_import_dialog.file_selected.connect(
_progression_import_file_selected
)
_interface_fonts.apply_utility_theme(_progression_import_dialog)
add_child(_progression_import_dialog)
_progression_import_dialog.current_dir = (
_data_root.progression_backup_directory()
)
_interface_fonts.popup_file_dialog(_progression_import_dialog)
func _progression_import_file_selected(path: String) -> void:
var inspected: Dictionary = (
_progression_saves.inspect_progression_archive(path)
)
if not bool(inspected.get("ok", false)):
_feedback.text = str(
inspected.get("message", "progression archive could not be opened.")
)
return
_pending_progression_path = path
var dialog := ConfirmationDialog.new()
dialog.title = "replace saved progression?"
dialog.ok_button_text = "import progression"
dialog.dialog_text = (
"this will replace the current progression after making a backup.\n\n"
+ "fish: %d\ndiscovered: %d\nworld: %s\nworld seed: %d\n\n"
+ "identities, settings, friends, bans, and trusted servers are unchanged."
) % [
int(inspected.get("catch_count", 0)),
int(inspected.get("discovered_species_count", 0)),
WorldLayout.display_name(inspected.get(
"world_layout",
String(WorldLayout.GENERATED),
)),
int(inspected.get("world_seed", 0)),
]
dialog.confirmed.connect(_confirm_progression_import.bind(dialog))
dialog.canceled.connect(dialog.queue_free)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(640, 390))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _confirm_progression_import(dialog: ConfirmationDialog) -> void:
var result: Dictionary = _progression_saves.import_progression_archive(
_pending_progression_path
)
_feedback.text = str(
result.get("message", "progression import failed.")
)
_pending_progression_path = ""
dialog.queue_free()
func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active:
_feedback.text = "the data folder is externally managed."
@ -1067,6 +941,7 @@ func _apply_settings() -> void:
edited.mouse_camera_sensitivity = _mouse_sensitivity
edited.controller_camera_sensitivity = _controller_sensitivity
edited.invert_camera_y = _invert_camera_y
edited.swap_hotbar_camera_scroll = _swap_hotbar_camera_scroll
edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled
edited.chat_dock_right = _chat_dock_right
edited.chat_mobile_mode = _chat_mobile_mode
@ -1099,6 +974,7 @@ func _load_controls() -> void:
_mouse_sensitivity = settings.mouse_camera_sensitivity
_controller_sensitivity = settings.controller_camera_sensitivity
_invert_camera_y = settings.invert_camera_y
_swap_hotbar_camera_scroll = settings.swap_hotbar_camera_scroll
_on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled
_chat_dock_right = settings.chat_dock_right
_chat_mobile_mode = settings.chat_mobile_mode
@ -1125,6 +1001,7 @@ func _load_controls() -> void:
_controller_sensitivity
)
_invert_y_toggle.set_pressed_no_signal(_invert_camera_y)
_swap_scroll_toggle.set_pressed_no_signal(_swap_hotbar_camera_scroll)
_on_screen_keyboard_toggle.set_pressed_no_signal(
_on_screen_keyboard_enabled
)
@ -1142,6 +1019,9 @@ func _refresh_value_labels() -> void:
"on" if _fullscreen_toggle.button_pressed else "off"
)
_invert_y_toggle.text = "on" if _invert_camera_y else "off"
_swap_scroll_toggle.text = (
"on" if _swap_hotbar_camera_scroll else "off"
)
_on_screen_keyboard_toggle.text = (
"on" if _on_screen_keyboard_enabled else "off"
)
@ -1255,6 +1135,11 @@ func _set_invert_y(enabled: bool) -> void:
_refresh_value_labels()
func _set_swap_scroll(enabled: bool) -> void:
_swap_hotbar_camera_scroll = enabled
_refresh_value_labels()
func _set_on_screen_keyboard(enabled: bool) -> void:
_on_screen_keyboard_enabled = enabled
_refresh_value_labels()

View file

@ -402,12 +402,32 @@ unique_name_in_owner = true
custom_minimum_size = Vector2(330, 48)
layout_mode = 2
focus_mode = 2
focus_neighbor_bottom = NodePath("../SwapScrollToggle")
toggle_mode = true
text = "off"
[node name="InvertYSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"]
layout_mode = 2
[node name="SwapScrollLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"]
custom_minimum_size = Vector2(210, 48)
layout_mode = 2
text = "swap scroll controls"
vertical_alignment = 1
[node name="SwapScrollToggle" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"]
unique_name_in_owner = true
custom_minimum_size = Vector2(330, 48)
layout_mode = 2
focus_mode = 2
focus_neighbor_top = NodePath("../InvertYToggle")
focus_neighbor_bottom = NodePath("../OnScreenKeyboardToggle")
toggle_mode = true
text = "off"
[node name="SwapScrollSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"]
layout_mode = 2
[node name="KeyboardLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"]
custom_minimum_size = Vector2(210, 48)
layout_mode = 2
@ -419,6 +439,7 @@ unique_name_in_owner = true
custom_minimum_size = Vector2(330, 48)
layout_mode = 2
focus_mode = 2
focus_neighbor_top = NodePath("../SwapScrollToggle")
focus_neighbor_bottom = NodePath("../../BindingButtons/ControllerMapping")
toggle_mode = true
text = "off"
@ -567,7 +588,7 @@ size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_right = NodePath("../ChangeDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "open data folder"
[node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"]
@ -578,41 +599,15 @@ size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_left = NodePath("../OpenDataFolder")
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "change data folder"
[node name="ProgressionRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="ExportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_right = NodePath("../ImportProgression")
focus_neighbor_top = NodePath("../../FolderRow/OpenDataFolder")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "export progression"
[node name="ImportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 2
focus_neighbor_left = NodePath("../ExportProgression")
focus_neighbor_top = NodePath("../../FolderRow/ChangeDataFolder")
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
text = "import progression"
[node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
focus_mode = 2
focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression")
focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder")
focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity")
text = "copy player fingerprint"

View file

@ -112,9 +112,9 @@ expand_icon = true
[node name="ExportButton" type="Button" parent="TopPanel/Top"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "export aimed artwork as PNG"
tooltip_text = "aim at artwork, then click to export PNG"
focus_mode = 0
accessibility_name = "export aimed artwork as PNG"
accessibility_name = "export last aimed artwork as PNG"
text = "png"
[node name="StampButton" type="Button" parent="TopPanel/Top"]

View file

@ -352,6 +352,9 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void:
progress_bar.max_value = float(target)
progress_bar.value = float(progress)
progress_bar.show_percentage = false
var exact_progress: String = "%d / %d" % [progress, target]
progress_bar.tooltip_text = exact_progress
progress_bar.mouse_filter = Control.MOUSE_FILTER_STOP
progress_bar.add_theme_stylebox_override(
"background", UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_DEEP, 9
@ -372,7 +375,8 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void:
_compact_integer(progress),
_compact_integer(target),
]
count.tooltip_text = "%d / %d" % [progress, target]
count.tooltip_text = exact_progress
count.mouse_filter = Control.MOUSE_FILTER_STOP
count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
count.add_theme_font_size_override("font_size", 14)
@ -469,6 +473,7 @@ func _claim(claim_id: String) -> void:
func _make_reward_display(fish_coin: int, experience: int) -> HBoxContainer:
var reward := HBoxContainer.new()
reward.tooltip_text = "%d fish coins · %d xp" % [fish_coin, experience]
reward.mouse_filter = Control.MOUSE_FILTER_STOP
reward.add_theme_constant_override("separation", 7)
var currency: CurrencyAmount = (
CurrencyPresentationType.instantiate_amount(fish_coin, 18.0)
@ -498,6 +503,7 @@ func _make_job_reward_display(
var reward := VBoxContainer.new()
reward.custom_minimum_size.x = 104.0
reward.tooltip_text = "%d fish coins · %d xp" % [fish_coin, experience]
reward.mouse_filter = Control.MOUSE_FILTER_STOP
reward.add_theme_constant_override("separation", 1)
var currency: CurrencyAmount = (
CurrencyPresentationType.instantiate_amount(fish_coin, 16.0)

View file

@ -27,6 +27,7 @@ const NetworkSessionType = preload("res://network/network_session.gd")
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
const SaveSlotsPageType = preload("res://ui/save_slots_page.gd")
const CurrencyPresentationType = preload(
"res://ui/currency_presentation.gd"
)
@ -89,6 +90,13 @@ const QUICK_MENU_ENTER_SCALE: float = 0.97
signal new_game_requested(world_layout: StringName, world_seed: int)
signal continue_game_requested
signal slot_play_requested(slot_id: String)
signal new_slot_requested(
display_name: String,
world_layout: StringName,
world_seed: int,
duplicate_source_slot_id: String,
)
signal quit_requested
signal join_game_requested(endpoint: String)
@ -97,7 +105,7 @@ enum ConfirmationAction {
DELETE_SAVE,
}
@onready var _continue_button: BubbleButtonType = %ContinueButton
@onready var _play_button: BubbleButtonType = %PlayButton
@onready var _new_game_button: BubbleButtonType = %NewGameButton
@onready var _settings_button: BubbleButtonType = %SettingsButton
@onready var _credits_button: BubbleButtonType = %CreditsButton
@ -134,6 +142,7 @@ enum ConfirmationAction {
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage
@onready var _credits_page: TitleCreditsPageType = %CreditsPage
@onready var _save_slots_page: SaveSlotsPageType = %SaveSlotsPage
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
@ -183,17 +192,17 @@ func _ready() -> void:
).strip_edges()
if not release_version.is_empty():
_playtest_label.text = "v%s" % release_version
_continue_button.pressed.connect(_on_continue_pressed)
_continue_button.mouse_entered.connect(
_play_button.pressed.connect(_on_continue_pressed)
_play_button.mouse_entered.connect(
_on_continue_stats_hover_changed.bind(true)
)
_continue_button.mouse_exited.connect(
_play_button.mouse_exited.connect(
_on_continue_stats_hover_changed.bind(false)
)
_continue_button.focus_entered.connect(
_play_button.focus_entered.connect(
_on_continue_stats_focus_changed.bind(true)
)
_continue_button.focus_exited.connect(
_play_button.focus_exited.connect(
_on_continue_stats_focus_changed.bind(false)
)
_new_game_button.pressed.connect(_on_new_game_pressed)
@ -214,6 +223,9 @@ func _ready() -> void:
_emit_navigation_bubble_flurry
)
_credits_page.back_requested.connect(_close_credits)
_save_slots_page.back_requested.connect(_close_save_slots)
_save_slots_page.play_requested.connect(_on_slot_play_requested)
_save_slots_page.create_requested.connect(_on_new_slot_requested)
_bubble_field.configure(_get_title_buttons())
_bubble_field.motion_scale = 0.0
_decorative_fish_timer.timeout.connect(_on_decorative_fish_timer_timeout)
@ -237,14 +249,18 @@ func _ready() -> void:
func setup(
save_manager: SaveManagerType,
save_slots: PlayerSaveSlotCatalog,
settings_manager: SettingsManagerType,
network_session: NetworkSessionType,
saved_servers: SavedServerStoreType,
server_trust: ServerTrustStore,
discovery: DiscoveryClient,
data_root: PlayerDataRoot,
interface_fonts: InterfaceFontController,
) -> void:
_save_manager = save_manager
_settings_manager = settings_manager
_save_slots_page.setup(save_slots, data_root, interface_fonts)
_join_game_page.setup(
network_session, saved_servers, false, server_trust, discovery
)
@ -276,6 +292,7 @@ func reopen() -> void:
_join_game_page.close_page()
_new_game_setup_page.close_page()
_credits_page.close_page()
_save_slots_page.close_page()
_refresh_save_inspection()
show()
_start_decorative_presentation()
@ -304,11 +321,15 @@ func open_join_game_page(endpoint: String = "") -> void:
_confirmation_page.hide_page()
_credits_page.close_page()
_new_game_setup_page.close_page()
_save_slots_page.close_page()
_join_game_page.open_page(endpoint)
func report_network_error(message: String) -> void:
_join_game_page.set_status(message)
if _save_slots_page.visible:
_save_slots_page.set_status(message)
return
if not _join_game_page.visible:
_feedback_label.text = _center_feedback_text(message)
_feedback_label.show()
@ -321,6 +342,7 @@ func _open_join_game() -> void:
or _is_confirmation_active()
or _new_game_setup_page.visible
or _credits_page.visible
or _save_slots_page.visible
):
return
open_join_game_page()
@ -342,6 +364,7 @@ func _open_credits() -> void:
or _join_game_page.visible
or _new_game_setup_page.visible
or _credits_page.visible
or _save_slots_page.visible
):
return
_hide_continue_stats_context()
@ -469,12 +492,10 @@ func _update_responsive_title_stage() -> void:
func _get_title_buttons() -> Array[BubbleButton]:
return [
_continue_button,
_new_game_button,
_play_button,
_join_game_button,
_settings_button,
_credits_button,
_delete_button,
_quit_button,
]
@ -583,6 +604,11 @@ func _input(event: InputEvent) -> void:
_new_game_setup_page.request_back()
get_viewport().set_input_as_handled()
return
if _save_slots_page.visible:
if event.is_action_pressed("ui_cancel"):
_save_slots_page.request_back()
get_viewport().set_input_as_handled()
return
if (
_title_settings_transition_active
or _title_entry_transition_active
@ -631,6 +657,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
or _settings_panel.visible
or _new_game_setup_page.visible
or _credits_page.visible
or _save_slots_page.visible
):
return false
if event is InputEventMouseMotion:
@ -656,7 +683,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
func _get_first_available_menu_button() -> Button:
return _continue_button if not _continue_button.disabled else _new_game_button
return _play_button
func _primary_menu_has_focus() -> bool:
@ -868,7 +895,7 @@ func _update_continue_stats_visibility() -> void:
)
requested_visible = (
requested_visible
and not _continue_button.disabled
and not _play_button.disabled
and _inspection != null
and _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED
and not _awaiting_start_input
@ -940,14 +967,70 @@ func _on_continue_pressed() -> void:
_action_in_progress
or _is_confirmation_active()
or _settings_panel.visible
or _inspection == null
or not _inspection.can_continue()
or _save_slots_page.visible
):
return
_hide_continue_stats_context()
_modal_restore_navigation_focus = _navigation_focus_active
_set_title_bubbles_interactive(false)
_release_primary_menu_focus()
_presentation_center.hide()
_start_prompt_center.hide()
_join_game_page.close_page()
_credits_page.close_page()
_new_game_setup_page.close_page()
_save_slots_page.open_page()
func _close_save_slots() -> void:
_save_slots_page.close_page()
_presentation_center.show()
_button_center.show()
_start_prompt_center.hide()
_set_title_bubbles_interactive(true)
var restore_navigation_focus: bool = _modal_restore_navigation_focus
_modal_restore_navigation_focus = false
if restore_navigation_focus:
_navigation_focus_active = true
_play_button.grab_focus()
else:
_navigation_focus_active = false
_release_title_focus()
_update_continue_stats_visibility()
func _on_slot_play_requested(slot_id: String) -> void:
if _action_in_progress or not _save_slots_page.visible:
return
_action_in_progress = true
continue_game_requested.emit()
slot_play_requested.emit(slot_id)
_action_in_progress = false
if visible:
_save_slots_page.finish_request_if_pending(
"the save slot could not be started."
)
func _on_new_slot_requested(
display_name: String,
world_layout: StringName,
world_seed: int,
duplicate_source_slot_id: String,
) -> void:
if _action_in_progress or not _save_slots_page.visible:
return
_action_in_progress = true
new_slot_requested.emit(
display_name,
world_layout,
world_seed,
duplicate_source_slot_id,
)
_action_in_progress = false
if visible:
_save_slots_page.finish_request_if_pending(
"the save slot could not be created."
)
func _on_new_game_pressed() -> void:
@ -1252,6 +1335,7 @@ func _open_settings() -> void:
or _action_in_progress
or _settings_panel.visible
or _credits_page.visible
or _save_slots_page.visible
or _title_settings_transition_active
):
return
@ -1467,12 +1551,12 @@ func _restore_settings_focus() -> void:
func _refresh_save_inspection() -> void:
_inspection = _save_manager.inspect_save()
_continue_button.disabled = not _inspection.can_continue()
_play_button.disabled = false
_delete_button.disabled = not _inspection.can_delete()
_feedback_label.text = _center_feedback_text(_inspection.message)
if _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED:
_feedback_label.text = _get_continue_stats_text()
if _continue_button.disabled:
if not _inspection.can_continue():
_hide_continue_stats_context()
else:
_update_continue_stats_visibility()
@ -1485,12 +1569,10 @@ func _focus_initial_button() -> void:
or _awaiting_start_input
or not _button_center.visible
or _credits_page.visible
or _save_slots_page.visible
):
return
if not _continue_button.disabled:
_continue_button.grab_focus()
else:
_new_game_button.grab_focus()
_play_button.grab_focus()
_navigation_focus_active = true
_update_continue_stats_visibility()

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=24 format=3]
[gd_scene load_steps=25 format=3]
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
@ -20,6 +20,7 @@
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/credits.png" id="18_credits"]
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/delete_save.png" id="19_delete_save"]
[ext_resource type="PackedScene" path="res://ui/new_game_setup_page.tscn" id="20_new_game_setup"]
[ext_resource type="PackedScene" path="res://ui/save_slots_page.tscn" id="21_save_slots"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader")
@ -260,21 +261,21 @@ offset_bottom = 318.0
script = ExtResource("8_bubble_cluster")
profile = ExtResource("9_bubble_profile")
[node name="ContinueButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
[node name="PlayButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
offset_left = 123.0
offset_top = 86.0
offset_right = 307.0
offset_bottom = 264.0
offset_left = 106.0
offset_top = 71.0
offset_right = 290.0
offset_bottom = 249.0
texture_filter = 1
icon = ExtResource("16_continue")
tooltip_text = "continue"
accessibility_name = "continue"
tooltip_text = "play"
accessibility_name = "play"
script = ExtResource("7_bubble_button")
profile = ExtResource("9_bubble_profile")
neutral_size = Vector2(184, 178)
desktop_anchor = Vector2(215, 175)
compact_anchor = Vector2(215, 175)
desktop_anchor = Vector2(198, 160)
compact_anchor = Vector2(198, 164)
minimum_font_size = 19
maximum_font_size = 30
horizontal_amplitude = 1.8
@ -285,6 +286,8 @@ deformation_period = 6.7
[node name="NewGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
visible = false
focus_mode = 0
offset_left = 42.0
offset_top = 4.0
offset_right = 170.0
@ -310,18 +313,18 @@ deformation_period = 5.9
[node name="SettingsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
offset_left = 264.0
offset_top = 17.0
offset_right = 384.0
offset_bottom = 133.0
offset_left = 254.0
offset_top = 12.0
offset_right = 374.0
offset_bottom = 128.0
texture_filter = 1
icon = ExtResource("13_settings_dark")
tooltip_text = "settings"
accessibility_name = "settings"
script = ExtResource("7_bubble_button")
profile = ExtResource("9_bubble_profile")
desktop_anchor = Vector2(324, 75)
compact_anchor = Vector2(324, 68)
desktop_anchor = Vector2(314, 70)
compact_anchor = Vector2(333, 75)
compact_minimum_size = Vector2(94, 90)
minimum_font_size = 15
maximum_font_size = 22
@ -332,10 +335,10 @@ deformation_period = 6.3
[node name="CreditsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
offset_left = 330.0
offset_top = 125.0
offset_right = 414.0
offset_bottom = 207.0
offset_left = 54.0
offset_top = 209.0
offset_right = 138.0
offset_bottom = 291.0
texture_filter = 1
icon = ExtResource("18_credits")
tooltip_text = "credits"
@ -343,8 +346,8 @@ accessibility_name = "credits"
script = ExtResource("7_bubble_button")
profile = ExtResource("9_bubble_profile")
neutral_size = Vector2(84, 82)
desktop_anchor = Vector2(372, 166)
compact_anchor = Vector2(372, 166)
desktop_anchor = Vector2(96, 250)
compact_anchor = Vector2(82, 258)
compact_minimum_size = Vector2(62, 62)
minimum_font_size = 12
maximum_font_size = 17
@ -357,10 +360,10 @@ deformation_period = 5.6
[node name="JoinGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
offset_left = 126.0
offset_top = 224.0
offset_right = 248.0
offset_bottom = 342.0
offset_left = 21.0
offset_top = 11.0
offset_right = 143.0
offset_bottom = 129.0
texture_filter = 1
icon = ExtResource("12_online_dark")
tooltip_text = "join game"
@ -368,8 +371,8 @@ accessibility_name = "join game"
script = ExtResource("7_bubble_button")
profile = ExtResource("9_bubble_profile")
neutral_size = Vector2(122, 118)
desktop_anchor = Vector2(187, 268)
compact_anchor = Vector2(187, 268)
desktop_anchor = Vector2(82, 70)
compact_anchor = Vector2(63, 75)
compact_minimum_size = Vector2(82, 80)
minimum_font_size = 15
maximum_font_size = 22
@ -382,6 +385,8 @@ deformation_period = 5.7
[node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
visible = false
focus_mode = 0
offset_left = -11.0
offset_top = 122.0
offset_right = 123.0
@ -406,10 +411,10 @@ deformation_period = 5.4
[node name="QuitButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true
offset_left = 275.0
offset_top = 217.0
offset_right = 369.0
offset_bottom = 309.0
offset_left = 255.0
offset_top = 204.0
offset_right = 349.0
offset_bottom = 296.0
texture_filter = 1
icon = ExtResource("14_x_dark")
tooltip_text = "quit"
@ -417,8 +422,8 @@ accessibility_name = "quit"
script = ExtResource("7_bubble_button")
profile = ExtResource("9_bubble_profile")
neutral_size = Vector2(94, 92)
desktop_anchor = Vector2(322, 263)
compact_anchor = Vector2(322, 263)
desktop_anchor = Vector2(302, 250)
compact_anchor = Vector2(317, 254)
compact_minimum_size = Vector2(68, 68)
minimum_font_size = 14
maximum_font_size = 18
@ -497,3 +502,13 @@ anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="SaveSlotsPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("21_save_slots")]
unique_name_in_owner = true
z_index = 220
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2

View file

@ -46,6 +46,7 @@ func _ready() -> void:
_game_ui.set_controller_text_entry_request(
Callable(_on_screen_keyboard, "request_for_control"),
Callable(_on_screen_keyboard, "is_open"),
Callable(_on_screen_keyboard, "close_for_control"),
)
var controller_focus_presentation := ControllerFocusPresentationType.new()
_ui_root.add_child(controller_focus_presentation)

View file

@ -79,8 +79,6 @@ func _ready() -> void:
_validate_biome_catalog()
_configure_static_water()
_build_shoreline_reference()
if not generate_world(initial_seed):
push_error("The initial generated world could not be built.")
func generate_world(seed: int) -> bool:
@ -100,6 +98,10 @@ func get_generation_seed() -> int:
return _current_seed
func is_world_generated() -> bool:
return is_instance_valid(_generator.get_generated_chunks_root())
func get_playable_half_extents() -> Vector2:
var size := Vector2(
float(_generator.grid_size.x),

View file

@ -25,6 +25,7 @@ const MAX_PACKED_SOLVER_VARIANTS := 62
@export var generation_seed := 13001
@export var generate_on_ready := true
@export var build_collision := true
@export_range(1, 16, 1) var collision_batch_size := 4
@export var show_chunk_labels := false
@export var force_center_chunk_id: StringName = &"chunk_0000"
@export var required_chunk_ids := PackedStringArray()
@ -3928,9 +3929,102 @@ func _build_solution_root() -> Node3D:
if not _add_stacked_elevated_chunks(solution_root):
solution_root.free()
return null
if build_collision and collision_batch_size > 1:
_batch_generated_terrain_collision(solution_root)
return solution_root
func _batch_generated_terrain_collision(solution_root: Node3D) -> void:
var shapes_by_batch: Dictionary[Vector2i, Array] = {}
var host_by_batch: Dictionary[Vector2i, Node3D] = {}
var bodies_to_remove: Array[StaticBody3D] = []
for chunk_node: Node in solution_root.get_children():
var chunk_root := chunk_node as Node3D
if chunk_root == null:
continue
var coordinate: Vector2i = chunk_root.get_meta(
&"terrain_chunk_coordinate",
Vector2i.ZERO,
)
var batch_coordinate := Vector2i(
floori(float(coordinate.x) / float(collision_batch_size)),
floori(float(coordinate.y) / float(collision_batch_size)),
)
if not host_by_batch.has(batch_coordinate):
host_by_batch[batch_coordinate] = chunk_root
var batch_shapes: Array = shapes_by_batch.get(
batch_coordinate,
[],
)
for value: Node in chunk_root.find_children(
"TerrainShape",
"CollisionShape3D",
true,
false,
):
var collision_shape := value as CollisionShape3D
if collision_shape == null:
continue
var collision_body := collision_shape.get_parent() as StaticBody3D
if collision_shape.shape == null or collision_body == null:
continue
var shape_to_solution := _transform_to_ancestor(
collision_shape,
solution_root,
)
batch_shapes.append({
"shape": collision_shape.shape,
"transform": shape_to_solution,
})
if collision_body not in bodies_to_remove:
bodies_to_remove.append(collision_body)
shapes_by_batch[batch_coordinate] = batch_shapes
while not bodies_to_remove.is_empty():
var collision_body: StaticBody3D = bodies_to_remove.pop_back()
collision_body.free()
for batch_coordinate: Vector2i in shapes_by_batch:
var batch_shapes: Array = shapes_by_batch[batch_coordinate]
if batch_shapes.is_empty():
continue
var host_root: Node3D = host_by_batch.get(batch_coordinate)
if host_root == null:
continue
var body := StaticBody3D.new()
body.name = "TerrainCollisionBatch_%d_%d" % [
batch_coordinate.x,
batch_coordinate.y,
]
body.collision_layer = 1
body.collision_mask = 0
body.set_meta(&"terrain_collision_batch", batch_coordinate)
host_root.add_child(body)
var solution_to_body: Transform3D = host_root.transform.affine_inverse()
for shape_index: int in batch_shapes.size():
var record: Dictionary = batch_shapes[shape_index]
var collision := CollisionShape3D.new()
collision.name = "TerrainShape_%d" % shape_index
collision.shape = record.get("shape") as Shape3D
collision.transform = (
solution_to_body
* (record.get("transform", Transform3D.IDENTITY) as Transform3D)
)
body.add_child(collision)
static func _transform_to_ancestor(
node: Node3D,
ancestor: Node3D,
) -> Transform3D:
var result := Transform3D.IDENTITY
var current: Node3D = node
while current != ancestor:
result = current.transform * result
current = current.get_parent() as Node3D
if current == null:
return Transform3D.IDENTITY
return result
func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool:
for index: int in _stacked_elevated_placements.size():
var record := _stacked_elevated_placements[index]

View file

@ -40,6 +40,10 @@ func _ready() -> void:
return
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
# Generated worlds can contain many independent water volumes. Keep an
# empty trigger completely asleep instead of polling an empty array on every
# physics tick.
set_physics_process(false)
func _physics_process(_delta: float) -> void:
@ -57,6 +61,8 @@ func _physics_process(_delta: float) -> void:
if entry_height <= active_surface_height - entry_depth_threshold:
_triggered_players[player_key] = true
recovery_requested.emit(player, active_surface_height)
if _tracked_players.is_empty():
set_physics_process(false)
func get_surface_height() -> float:
@ -80,6 +86,7 @@ func _on_body_entered(body: Node3D) -> void:
if player == null or player in _tracked_players:
return
_tracked_players.append(player)
set_physics_process(true)
func _on_body_exited(body: Node3D) -> void:
@ -88,3 +95,5 @@ func _on_body_exited(body: Node3D) -> void:
return
_tracked_players.erase(player)
_triggered_players.erase(StringName(str(player.get_instance_id())))
if _tracked_players.is_empty():
set_physics_process(false)

View file

@ -30,6 +30,7 @@ const WORLD_BOUNDARY_SHORELINE_CLEARANCE := 18.0
var _world_layout: StringName = WorldLayoutType.GENERATED
var _world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _light_performance_profile: bool = false
var _dedicated_simulation: bool = false
func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
@ -74,6 +75,62 @@ func set_light_performance_profile(enabled: bool) -> void:
_active_region.set_light_performance_profile(enabled)
func set_dedicated_simulation(enabled: bool) -> void:
_dedicated_simulation = enabled
if _active_region != null:
_set_region_presentation_enabled(_active_region, not enabled)
_world_environment.process_mode = (
Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT
)
_sun.visible = not enabled
_sun.process_mode = (
Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT
)
func _set_region_presentation_enabled(
region: WorldRegion,
enabled: bool,
) -> void:
for class_name_value: String in [
"WaterSurfaceMotion",
"LocalStormCloudLayer",
"WorldCharacterDisplay",
]:
for value: Node in region.find_children(
"*", class_name_value, true, false
):
value.process_mode = (
Node.PROCESS_MODE_INHERIT
if enabled
else Node.PROCESS_MODE_DISABLED
)
for value: Node in region.find_children("*", "GeometryInstance3D", true, false):
(value as GeometryInstance3D).visible = enabled
for value: Node in region.find_children("*", "GPUParticles3D", true, false):
var particles := value as GPUParticles3D
particles.emitting = enabled
particles.process_mode = (
Node.PROCESS_MODE_INHERIT if enabled else Node.PROCESS_MODE_DISABLED
)
for value: Node in region.find_children("*", "AnimationPlayer", true, false):
var animation_player := value as AnimationPlayer
animation_player.active = enabled
animation_player.process_mode = (
Node.PROCESS_MODE_INHERIT if enabled else Node.PROCESS_MODE_DISABLED
)
for class_name_value: String in ["AudioStreamPlayer", "AudioStreamPlayer3D"]:
for value: Node in region.find_children(
"*", class_name_value, true, false
):
value.call("stop")
value.process_mode = (
Node.PROCESS_MODE_INHERIT
if enabled
else Node.PROCESS_MODE_DISABLED
)
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
return (
_active_region.get_fishable_water_regions()
@ -129,6 +186,17 @@ func get_generation_seed() -> int:
return _world_seed
func is_world_ready() -> bool:
if _active_region == null:
return false
if _world_layout != WorldLayoutType.GENERATED:
return true
return (
_active_region.has_method(&"is_world_generated")
and bool(_active_region.call(&"is_world_generated"))
)
func get_diggable_area_triangles(
area_id: StringName,
) -> Array[PackedVector3Array]:
@ -182,6 +250,8 @@ func _replace_active_region(layout: StringName, seed: int) -> bool:
_active_region = replacement
_regions_root.add_child(_active_region)
_active_region.set_light_performance_profile(_light_performance_profile)
if _dedicated_simulation:
_set_region_presentation_enabled(_active_region, false)
return true