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:
parent
1db1a5b754
commit
3b84bfe3a0
97 changed files with 7869 additions and 982 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
382
main/main.gd
382
main/main.gd
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
409
player/player.gd
409
player/player.gd
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
591
save/player_save_slot_catalog.gd
Normal file
591
save/player_save_slot_catalog.gd
Normal 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
|
||||
1
save/player_save_slot_catalog.gd.uid
Normal file
1
save/player_save_slot_catalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://djfeifv7xrqdt
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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("_", " ")))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
161
tests/friend_multiplayer_validation.gd
Normal file
161
tests/friend_multiplayer_validation.gd
Normal 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()
|
||||
1
tests/friend_multiplayer_validation.gd.uid
Normal file
1
tests/friend_multiplayer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cxltqyom4yt3j
|
||||
76
tests/friend_relationship_validation.gd
Normal file
76
tests/friend_relationship_validation.gd
Normal 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()
|
||||
1
tests/friend_relationship_validation.gd.uid
Normal file
1
tests/friend_relationship_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3v4e1xtu8ytq
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
165
ui/chat_ui.gd
165
ui/chat_ui.gd
|
|
@ -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:
|
||||
|
|
|
|||
191
ui/game_ui.gd
191
ui/game_ui.gd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
ui/hotbar.gd
109
ui/hotbar.gd
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
ui/icons/player_options/clean.png
Normal file
BIN
ui/icons/player_options/clean.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
40
ui/icons/player_options/clean.png.import
Normal file
40
ui/icons/player_options/clean.png.import
Normal 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
|
||||
BIN
ui/icons/player_options/friends.png
Normal file
BIN
ui/icons/player_options/friends.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
40
ui/icons/player_options/friends.png.import
Normal file
40
ui/icons/player_options/friends.png.import
Normal 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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
731
ui/save_slots_page.gd
Normal 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()
|
||||
1
ui/save_slots_page.gd.uid
Normal file
1
ui/save_slots_page.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cytwdvxf22106
|
||||
350
ui/save_slots_page.tscn
Normal file
350
ui/save_slots_page.tscn
Normal 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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue