From f910e6bdbbecad3fd1dd50218ecf6e04cd3d8601 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 26 Aug 2026 00:05:51 -0400 Subject: [PATCH 01/14] Update Forgejo links for Straywild rename --- README.md | 4 ++-- docs/README.md | 4 ++-- scripts/build_playtest.sh | 2 +- scripts/build_portmaster.sh | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 536afab..e228e7b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io -Repo: https://forge.makearmy.io/woofmeow/netfishing +Repo: https://forge.makearmy.io/woofmeow/straywild -Releases: https://forge.makearmy.io/woofmeow/netfishing/releases +Releases: https://forge.makearmy.io/woofmeow/straywild/releases ## Installing release builds diff --git a/docs/README.md b/docs/README.md index 5327b30..444ebc7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,10 +17,10 @@ for local README files. ## Related repositories -- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md) +- [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md) stays with the service implementation and defines its versioned HTTP contract. -- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md) +- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/straywild-dedicated-server/src/branch/main/README.md) stays with its installer and update scripts. It consumes this repository's attribution and legal notices from the exact pinned game commit instead of maintaining copies. diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 1c74943..2347d3d 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -9,7 +9,7 @@ readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" -readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" +readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-windows-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh index 0487b5b..a40e0cd 100755 --- a/scripts/build_portmaster.sh +++ b/scripts/build_portmaster.sh @@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" < Date: Sat, 29 Aug 2026 17:41:10 -0400 Subject: [PATCH 02/14] Fix clam spurt origin artifact (#118) --- gathering/world_gatherable.gd | 7 +++-- tests/world_spawn_protocol_validation.gd | 39 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/gathering/world_gatherable.gd b/gathering/world_gatherable.gd index 8a07ba0..1a71477 100644 --- a/gathering/world_gatherable.gd +++ b/gathering/world_gatherable.gd @@ -29,12 +29,16 @@ func configure( entity_id = configured_entity_id data = configured_data type_id = data.type_id if data != null else StringName() + # Establish the authoritative transform before constructing transient visuals. + # Effects emitted before this point would begin at the default world origin. + apply_network_state(position, yaw, true) if data != null and data.is_stationary_hotspot(): _ensure_water_spurt_visual() _water_spurt_elapsed = ( float(abs(hash(entity_id)) % 1000) / 1000.0 * WATER_SPURT_INTERVAL_SECONDS ) + _emit_water_spurt() else: _ensure_visual() if data != null and data.catch_data != null and _sprite != null: @@ -50,7 +54,6 @@ func configure( ) ) _sprite.rotation_degrees.x = data.sprite_tilt_degrees - apply_network_state(position, yaw, true) func apply_network_state( @@ -147,7 +150,6 @@ func _ensure_water_spurt_visual() -> void: hole.mesh = hole_mesh hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _water_spurt_root.add_child(hole) - _emit_water_spurt() func _emit_water_spurt() -> void: @@ -159,6 +161,7 @@ func _emit_water_spurt() -> void: particles.amount = 6 particles.lifetime = 0.42 particles.one_shot = true + particles.local_coords = true particles.explosiveness = 1.0 particles.direction = Vector3.UP particles.spread = 32.0 diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index a8365a1..d100260 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -16,6 +16,10 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd") func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: assert(NetworkProtocol.PROTOCOL_VERSION == 11) assert( NetworkWorldSpawnProtocol.CAPABILITY @@ -23,7 +27,7 @@ func _initialize() -> void: ) assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4) _validate_catalog_statuses() - _validate_billboard_presentation() + await _validate_billboard_presentation() _validate_envelopes() print("World spawn protocol validation: PASS") quit() @@ -148,8 +152,27 @@ func _validate_billboard_presentation() -> void: assert(not sprite.shaded) gatherable.free() + var unconfigured_hotspot := WorldGatherableType.new() + unconfigured_hotspot.call("_ensure_water_spurt_visual") + assert( + unconfigured_hotspot.get_node_or_null( + "WaterSpurt/WaterDroplets" + ) == null + ) + unconfigured_hotspot.free() + + var clam: GatherableData = Gatherables.get_entry(&"clam_manila") + assert(clam != null) var hotspot := WorldGatherableType.new() - hotspot.call("_ensure_water_spurt_visual") + root.add_child(hotspot) + var configured_position := Vector3(24.0, 0.3, -11.0) + hotspot.configure( + "world:clam-presentation-regression", + clam, + configured_position, + 0.0, + ) + assert(hotspot.global_position.is_equal_approx(configured_position)) var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D assert(hole != null) assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF) @@ -158,6 +181,18 @@ func _validate_billboard_presentation() -> void: assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED) assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED) assert(is_equal_approx(hole_material.albedo_color.a, 1.0)) + var particles := hotspot.get_node( + "WaterSpurt/WaterDroplets" + ) as CPUParticles3D + assert(particles != null and particles.emitting) + assert(particles.local_coords) + assert(particles.global_position.is_equal_approx( + configured_position + Vector3.UP * 0.035 + )) + hotspot.set_process(false) + await create_timer(0.6).timeout + await process_frame + assert(hotspot.get_node_or_null("WaterSpurt/WaterDroplets") == null) hotspot.queue_free() From 4575830857c18dd6d9a3b4761c0df7c19cacf18c Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 29 Aug 2026 17:43:11 -0400 Subject: [PATCH 03/14] Improve nearby water recovery and camera prompts Recover players on generated worlds to the nearest safe grass or sand surface. Use the active gameplay camera when positioning shop and storage prompts. --- main/main.gd | 18 +- tests/art_tools_validation.gd | 6 + tests/fishing_authority_validation.gd | 9 + tests/generated_world_runtime_validation.gd | 75 ++++++ tests/world_layout_validation.gd | 8 + ui/game_ui.gd | 4 +- world/generation/generated_world_region.gd | 239 ++++++++++++++++++++ world/regions/world_region.gd | 18 ++ world/test_world.gd | 10 + world/water_recovery_controller.gd | 34 ++- 10 files changed, 396 insertions(+), 25 deletions(-) diff --git a/main/main.gd b/main/main.gd index 442c97c..7fae866 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1006,7 +1006,8 @@ func _initialize_application(dedicated: bool) -> void: _game_ui, _game_ui.get_screen_fade(), _test_world.get_player_water_triggers(), - _test_world.get_safe_respawn_points() + _test_world.get_safe_respawn_points(), + Callable(_test_world, "get_water_recovery_position"), ) _ui_pixelation.effective_pixel_size_changed.connect( _on_effective_ui_pixel_size_changed @@ -2344,17 +2345,9 @@ func _on_remote_recovery_requested( peer_id, "Fishing attempt ended." ) - var target_position: Vector3 = _test_world.get_player_spawn_transform().origin - var nearest_distance: float = INF - for point: SafeRespawnPoint in _test_world.get_safe_respawn_points(): - if point == null or not point.enabled: - continue - var distance: float = point.get_horizontal_distance_squared( - entry_position - ) - if distance < nearest_distance: - nearest_distance = distance - target_position = point.global_position + var target_position := _test_world.get_water_recovery_position( + entry_position + ) target_position.y += _water_recovery.respawn_height_offset avatar.global_position = target_position avatar.velocity = Vector3.ZERO @@ -2432,6 +2425,7 @@ func _apply_world( spawn_transform, _test_world.get_player_water_triggers(), _test_world.get_safe_respawn_points(), + Callable(_test_world, "get_water_recovery_position"), ) _shoreline_ambience.configure( _player, diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 3b9d6a3..4bfab98 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -416,6 +416,12 @@ func _run() -> void: ) assert(normal_camera.is_position_behind(chat_anchor)) assert(not free_camera.is_position_behind(chat_anchor)) + var shop_prompt := game_ui.get_node("%ShopPrompt") as Control + var storage_prompt := game_ui.get_node("%StoragePrompt") as Control + game_ui.set_shop_prompt_visible(true, chat_anchor) + game_ui.set_storage_prompt_visible(true, chat_anchor) + assert(shop_prompt.visible) + assert(storage_prompt.visible) chat_ui.show_local_speech("hello there friend") await process_frame assert(bool(player.get("_speech_mouth_active"))) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index e7c87bb..b3c17dd 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -265,6 +265,15 @@ func _run() -> void: assert(bool(water_recovery.get("_prior_movement_enabled"))) assert(bool(water_recovery.get("_prior_camera_input_enabled"))) assert(not pause_menu.visible) + var test_world := main.get_node("TestWorld") as TestWorld + assert(test_world != null) + var recovery_entry := water_recovery.get("_entry_position") as Vector3 + var resolved_recovery := water_recovery.call( + "_resolve_recovery_position" + ) as Vector3 + assert(resolved_recovery.is_equal_approx( + test_world.get_water_recovery_position(recovery_entry) + )) water_recovery.call("_finish_recovery") assert(player.is_movement_enabled()) diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index f487ae7..6a3f6c8 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -536,6 +536,11 @@ func _validate_generated_region( -INF, 0.35, ) + _validate_water_recovery_positions( + region, + grass_prop_surface_triangles, + sand_prop_surface_triangles, + ) var anchors := region.get_node( "GatherableAnchors/ReachableTreeTrunks" ) as GatherableAnchorSet3D @@ -1268,6 +1273,76 @@ func _triangle_surface_height_at( return highest +func _validate_water_recovery_positions( + region: GeneratedWorldRegion, + grass_triangles: Array[PackedVector3Array], + sand_triangles: Array[PackedVector3Array], +) -> void: + var fresh_water_root := region.get_node( + "WaterBodies/FreshWaterBodies" + ) as Node3D + assert(fresh_water_root != null and fresh_water_root.get_child_count() > 0) + var fresh_water := fresh_water_root.get_child(0) as WaterBodyAuthoring + assert(fresh_water != null) + var entry_position := fresh_water.global_position + var fallback_position := region.get_player_spawn_transform().origin + var recovery_position := region.get_water_recovery_position( + entry_position, + fallback_position, + ) + assert(recovery_position.is_finite()) + assert( + _horizontal_distance_squared(entry_position, recovery_position) + < _horizontal_distance_squared(entry_position, fallback_position), + "Fresh-water recovery returned the distant generated-world spawn.", + ) + assert( + recovery_position.y + > GeneratedWorldRegion.WATER_HEIGHT + + GeneratedWorldRegion.WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE + ) + var recovery_triangles: Array[PackedVector3Array] = [] + recovery_triangles.append_array(grass_triangles) + recovery_triangles.append_array(sand_triangles) + var center_height := _triangle_surface_height_at( + recovery_position, + recovery_triangles, + ) + assert(center_height > -INF) + assert(absf(center_height - recovery_position.y) <= 0.01) + for direction: Vector2 in GeneratedWorldRegion.PROP_SURFACE_SAMPLE_DIRECTIONS: + var footprint_position := recovery_position + Vector3( + direction.x * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS, + 0.0, + direction.y * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS, + ) + var footprint_height := _triangle_surface_height_at( + footprint_position, + recovery_triangles, + ) + assert(footprint_height > -INF) + assert( + absf(footprint_height - recovery_position.y) + <= GeneratedWorldRegion.PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE + ) + assert( + region.get_water_recovery_position( + entry_position, + fallback_position, + ).is_equal_approx(recovery_position) + ) + assert( + region.get_water_recovery_position( + Vector3(INF, 0.0, 0.0), + fallback_position, + ).is_equal_approx(fallback_position) + ) + + +func _horizontal_distance_squared(a: Vector3, b: Vector3) -> float: + return Vector2(a.x - b.x, a.z - b.z).length_squared() + + func _validate_tree_gatherable_anchors( region: GeneratedWorldRegion, decorations: Node3D, diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd index dd40fcf..e6eec39 100644 --- a/tests/world_layout_validation.gd +++ b/tests/world_layout_validation.gd @@ -100,6 +100,14 @@ func _validate_world_switching() -> void: assert(world.get_fishing_shop() != null) assert(world.get_player_storage() != null) assert(not world.get_fishable_water_regions().is_empty()) + var starter_safe_points := world.get_safe_respawn_points() + assert(not starter_safe_points.is_empty()) + var authored_safe_point: SafeRespawnPoint = starter_safe_points.back() + assert( + world.get_water_recovery_position( + authored_safe_point.global_position + ).is_equal_approx(authored_safe_point.global_position) + ) assert(world.activate_world(WorldLayout.GENERATED, 24680)) assert(world.get_world_layout() == WorldLayout.GENERATED) assert(world.get_generation_seed() == 24680) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 6e4241b..7f8f0a6 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -1892,7 +1892,7 @@ func _position_storage_prompt(world_anchor: Vector3) -> void: if _player == null: _storage_prompt.hide() return - var camera := _player.get_gameplay_camera() + var camera := _player.get_active_gameplay_camera() if camera == null or camera.is_position_behind(world_anchor): _storage_prompt.hide() return @@ -2006,7 +2006,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void: if _player == null: _shop_prompt.hide() return - var camera: Camera3D = _player.get_gameplay_camera() + var camera: Camera3D = _player.get_active_gameplay_camera() if camera == null or camera.is_position_behind(world_anchor): _shop_prompt.hide() return diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index aa27a65..780ddb8 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -43,6 +43,12 @@ const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE := 0.35 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 +const WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE := 0.05 +const WATER_RECOVERY_MINIMUM_UP_DOT := 0.6 +const WATER_RECOVERY_FOOTPRINT_RADIUS := 0.6 +const WATER_RECOVERY_INSET_STEP := 0.25 +const WATER_RECOVERY_MAXIMUM_INSET := 1.5 +const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0] const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [ Vector2(1.0, 0.0), Vector2(0.70710678, 0.70710678), @@ -90,6 +96,7 @@ var _placed_prop_clearance_radii: Array[float] = [] var _placed_prop_groups: Array[StringName] = [] var _placed_group_coordinates: Dictionary[StringName, Array] = {} var _biome_assignments: Dictionary[Vector2i, StringName] = {} +var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {} func _ready() -> void: @@ -190,6 +197,37 @@ func get_spawn_surface_triangles( return triangles +func get_water_recovery_position( + entry_position: Vector3, + fallback_position: Vector3, +) -> Vector3: + if ( + not entry_position.is_finite() + or _water_recovery_triangles_by_coordinate.is_empty() + ): + return super(entry_position, fallback_position) + var nearest_surface_distance := _nearest_recovery_surface_distance( + entry_position + ) + if not is_finite(nearest_surface_distance): + return super(entry_position, fallback_position) + for expansion: float in WATER_RECOVERY_SEARCH_EXPANSIONS: + var maximum_distance := nearest_surface_distance + expansion + var result: Variant = _nearest_safe_recovery_surface_position( + entry_position, + maximum_distance, + ) + if result is Vector3: + return result as Vector3 + var map_wide_result: Variant = _nearest_safe_recovery_surface_position( + entry_position, + get_playable_half_extents().length() * 2.0, + ) + if map_wide_result is Vector3: + return map_wide_result as Vector3 + return super(entry_position, fallback_position) + + func _on_generation_completed(summary: Dictionary) -> void: _diggable_beach.invalidate_surface_cache() var records: Array[Dictionary] = _generator.placement_records() @@ -325,6 +363,10 @@ func _on_generation_completed(summary: Dictionary) -> void: grass_surface_triangles_by_coordinate, sand_surface_triangles_by_coordinate, ) + _cache_water_recovery_surfaces( + grass_surface_triangles_by_coordinate, + sand_surface_triangles_by_coordinate, + ) _place_spawn_amenities(spawn_position) _configure_fresh_water(records) _configure_diggable_area() @@ -1261,6 +1303,203 @@ func _terrain_surface_triangles_by_coordinate( return triangles_by_coordinate +func _cache_water_recovery_surfaces( + grass_triangles: Dictionary[Vector2i, Array], + sand_triangles: Dictionary[Vector2i, Array], +) -> void: + _water_recovery_triangles_by_coordinate.clear() + _append_water_recovery_surfaces(grass_triangles) + _append_water_recovery_surfaces(sand_triangles) + + +func _append_water_recovery_surfaces( + source: Dictionary[Vector2i, Array], +) -> void: + for coordinate: Vector2i in source: + var eligible: Array = _water_recovery_triangles_by_coordinate.get( + coordinate, + [], + ) + for triangle: PackedVector3Array in source[coordinate]: + if triangle.size() != 3: + continue + if minf( + triangle[0].y, + minf(triangle[1].y, triangle[2].y), + ) <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE: + continue + var cross := (triangle[1] - triangle[0]).cross( + triangle[2] - triangle[0] + ) + if ( + cross.length_squared() <= 0.0000001 + or absf(cross.normalized().dot(Vector3.UP)) + < WATER_RECOVERY_MINIMUM_UP_DOT + ): + continue + eligible.append(triangle) + if not eligible.is_empty(): + _water_recovery_triangles_by_coordinate[coordinate] = eligible + + +func _nearest_recovery_surface_distance(entry_position: Vector3) -> float: + var entry_horizontal := Vector2(entry_position.x, entry_position.z) + var nearest_distance_squared := INF + for coordinate: Vector2i in _water_recovery_triangles_by_coordinate: + for triangle: PackedVector3Array in ( + _water_recovery_triangles_by_coordinate[coordinate] + ): + var closest := _closest_horizontal_point_on_triangle( + entry_horizontal, + triangle, + ) + nearest_distance_squared = minf( + nearest_distance_squared, + entry_horizontal.distance_squared_to(closest), + ) + return sqrt(nearest_distance_squared) + + +func _nearest_safe_recovery_surface_position( + entry_position: Vector3, + maximum_distance: float, +) -> Variant: + var entry_horizontal := Vector2(entry_position.x, entry_position.z) + var maximum_distance_squared := maximum_distance * maximum_distance + var best_distance_squared := INF + var best_position: Variant = null + for coordinate: Vector2i in _water_recovery_triangles_by_coordinate: + for triangle: PackedVector3Array in ( + _water_recovery_triangles_by_coordinate[coordinate] + ): + var closest := _closest_horizontal_point_on_triangle( + entry_horizontal, + triangle, + ) + if ( + entry_horizontal.distance_squared_to(closest) + > maximum_distance_squared + ): + continue + var centroid := Vector2( + (triangle[0].x + triangle[1].x + triangle[2].x) / 3.0, + (triangle[0].z + triangle[1].z + triangle[2].z) / 3.0, + ) + var inset_distance := minf( + closest.distance_to(centroid), + WATER_RECOVERY_MAXIMUM_INSET, + ) + var inset_steps := ceili( + inset_distance / WATER_RECOVERY_INSET_STEP + ) + for inset_index: int in inset_steps + 1: + var candidate_horizontal := closest.move_toward( + centroid, + minf( + float(inset_index) * WATER_RECOVERY_INSET_STEP, + inset_distance, + ), + ) + var distance_squared := entry_horizontal.distance_squared_to( + candidate_horizontal + ) + if ( + distance_squared > maximum_distance_squared + or distance_squared >= best_distance_squared + ): + continue + var candidate := Vector3( + candidate_horizontal.x, + entry_position.y, + candidate_horizontal.y, + ) + var safe_position: Variant = _safe_recovery_surface_position( + candidate + ) + if safe_position is Vector3: + best_distance_squared = distance_squared + best_position = safe_position + return best_position + + +func _safe_recovery_surface_position(candidate: Vector3) -> Variant: + var nearby_triangles := _water_recovery_surface_triangles_near(candidate) + var surface_height := _surface_height_at(candidate, nearby_triangles) + if ( + surface_height <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE + or not _surface_supports_prop_footprint( + candidate, + surface_height, + WATER_RECOVERY_FOOTPRINT_RADIUS, + nearby_triangles, + ) + or not _has_prop_clearance( + candidate, + WATER_RECOVERY_FOOTPRINT_RADIUS, + ) + ): + return null + candidate.y = surface_height + return candidate + + +func _water_recovery_surface_triangles_near( + position: Vector3, +) -> Array[PackedVector3Array]: + var result: Array[PackedVector3Array] = [] + if _generator.catalog == null or _generator.catalog.chunk_size <= 0.0: + return result + var local_position := _generator.to_local(position) + var half_grid := Vector2( + float(_generator.grid_size.x - 1) * 0.5, + float(_generator.grid_size.y - 1) * 0.5, + ) + var coordinate := Vector2i( + roundi(local_position.x / _generator.catalog.chunk_size + half_grid.x), + roundi(local_position.z / _generator.catalog.chunk_size + half_grid.y), + ) + for offset_x: int in range(-1, 2): + for offset_y: int in range(-1, 2): + var nearby_coordinate := coordinate + Vector2i(offset_x, offset_y) + result.append_array( + _water_recovery_triangles_by_coordinate.get( + nearby_coordinate, + [], + ) + ) + return result + + +func _closest_horizontal_point_on_triangle( + point: Vector2, + triangle: PackedVector3Array, +) -> Vector2: + var polygon := PackedVector2Array([ + Vector2(triangle[0].x, triangle[0].z), + Vector2(triangle[1].x, triangle[1].z), + Vector2(triangle[2].x, triangle[2].z), + ]) + if Geometry2D.is_point_in_polygon(point, polygon): + return point + var closest := Geometry2D.get_closest_point_to_segment( + point, + polygon[0], + polygon[1], + ) + var nearest_distance_squared := point.distance_squared_to(closest) + for edge_index: int in range(1, 3): + var edge_closest := Geometry2D.get_closest_point_to_segment( + point, + polygon[edge_index], + polygon[(edge_index + 1) % 3], + ) + var distance_squared := point.distance_squared_to(edge_closest) + if distance_squared < nearest_distance_squared: + nearest_distance_squared = distance_squared + closest = edge_closest + return closest + + func _terrain_coordinate_for(mesh_instance: MeshInstance3D) -> Vector2i: var current: Node = mesh_instance while current != null and current != _generator: diff --git a/world/regions/world_region.gd b/world/regions/world_region.gd index a0730e4..792e4ed 100644 --- a/world/regions/world_region.gd +++ b/world/regions/world_region.gd @@ -72,6 +72,24 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]: return points +func get_water_recovery_position( + entry_position: Vector3, + fallback_position: Vector3, +) -> Vector3: + if not entry_position.is_finite(): + return fallback_position + var target_position := fallback_position + var nearest_distance := INF + for point: SafeRespawnPoint in get_safe_respawn_points(): + if point == null or not is_instance_valid(point) or not point.enabled: + continue + var distance := point.get_horizontal_distance_squared(entry_position) + if distance < nearest_distance: + nearest_distance = distance + target_position = point.global_position + return target_position + + func get_diggable_areas() -> Array[DiggableArea3D]: var areas: Array[DiggableArea3D] = [] var root: Node = get_node_or_null(diggable_area_root) diff --git a/world/test_world.gd b/world/test_world.gd index d9d18dc..9f4da5e 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -49,6 +49,16 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]: ) +func get_water_recovery_position(entry_position: Vector3) -> Vector3: + if _active_region == null: + return global_position + var fallback_position := get_player_spawn_transform().origin + return _active_region.get_water_recovery_position( + entry_position, + fallback_position, + ) + + func get_fishing_shop() -> FishingShopInteractionType: return _active_region.get_fishing_shop() diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index 9626881..0d0c5aa 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -25,6 +25,7 @@ var _game_ui: GameUI var _screen_fade: ScreenFade var _water_triggers: Array[PlayerWaterTrigger] = [] var _safe_points: Array[SafeRespawnPoint] = [] +var _recovery_position_resolver := Callable() var _initial_spawn_transform: Transform3D var _entry_position: Vector3 var _bob_base_position: Vector3 @@ -43,6 +44,7 @@ func setup( screen_fade: ScreenFade, water_triggers: Array[PlayerWaterTrigger], safe_points: Array[SafeRespawnPoint], + recovery_position_resolver: Callable = Callable(), ) -> void: _player = player _fishing_spot = fishing_spot @@ -50,6 +52,7 @@ func setup( _screen_fade = screen_fade _water_triggers = water_triggers _safe_points = safe_points + _recovery_position_resolver = recovery_position_resolver _initial_spawn_transform = player.global_transform for water_trigger: PlayerWaterTrigger in _water_triggers: if ( @@ -73,6 +76,7 @@ func update_world_context( initial_spawn_transform: Transform3D, water_triggers: Array[PlayerWaterTrigger], safe_points: Array[SafeRespawnPoint], + recovery_position_resolver: Callable = Callable(), ) -> void: for water_trigger: PlayerWaterTrigger in _water_triggers: if ( @@ -88,6 +92,7 @@ func update_world_context( _initial_spawn_transform = initial_spawn_transform _water_triggers = water_triggers _safe_points = safe_points + _recovery_position_resolver = recovery_position_resolver for water_trigger: PlayerWaterTrigger in _water_triggers: if ( water_trigger != null @@ -183,17 +188,7 @@ func _on_fade_transition_completed( func _respawn_player() -> void: - var target_position: Vector3 = _initial_spawn_transform.origin - var nearest_distance: float = INF - for point: SafeRespawnPoint in _safe_points: - if point == null or not is_instance_valid(point) or not point.enabled: - continue - var distance: float = point.get_horizontal_distance_squared( - _entry_position - ) - if distance < nearest_distance: - nearest_distance = distance - target_position = point.global_position + var target_position := _resolve_recovery_position() target_position.y += respawn_height_offset var respawn_transform: Transform3D = _player.global_transform respawn_transform.basis = _recovery_root_basis @@ -204,6 +199,23 @@ func _respawn_player() -> void: local_respawn_completed.emit(_entry_position) +func _resolve_recovery_position() -> Vector3: + if _recovery_position_resolver.is_valid(): + var resolved: Variant = _recovery_position_resolver.call(_entry_position) + if resolved is Vector3 and (resolved as Vector3).is_finite(): + return resolved as Vector3 + var target_position := _initial_spawn_transform.origin + var nearest_distance := INF + for point: SafeRespawnPoint in _safe_points: + if point == null or not is_instance_valid(point) or not point.enabled: + continue + var distance := point.get_horizontal_distance_squared(_entry_position) + if distance < nearest_distance: + nearest_distance = distance + target_position = point.global_position + return target_position + + func _finish_recovery() -> void: _player.restore_gameplay_orientation_after_recovery() _player.set_water_recovery_active(false) From 81decf2b719c975332f0653e94be7511a1adfbc9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 29 Aug 2026 20:19:47 -0400 Subject: [PATCH 04/14] Add logbook catalog index filters --- fishing/fishing_spot.gd | 44 ++ main/main.gd | 1 + ...ontroller_menu_accessibility_validation.gd | 18 + tests/logbook_runtime_validation.gd | 39 +- tests/logbook_validation.gd | 441 ++++++++++++ ui/game_ui.gd | 4 + ui/logbook_catalog.gd | 338 +++++++++ ui/logbook_page.gd | 649 +++++++++++++++++- ui/player_menu.gd | 16 +- 9 files changed, 1540 insertions(+), 10 deletions(-) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 2559af5..8e3e169 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -150,6 +150,7 @@ var _network_session: NetworkSessionType var _network_fishing: NetworkFishingServiceType var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType +var _fishable_water_regions_resolver := Callable() var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -222,6 +223,7 @@ func setup( network_fishing: NetworkFishingServiceType = null, world_time: WorldTimeServiceType = null, world_weather: WorldWeatherServiceType = null, + fishable_water_regions_resolver: Callable = Callable(), ) -> void: _local_player = local_player _local_inventory = local_inventory @@ -242,6 +244,7 @@ func setup( _network_fishing = network_fishing _world_time = world_time _world_weather = world_weather + _fishable_water_regions_resolver = fishable_water_regions_resolver if ( _network_session != null and not _network_session.state_changed.is_connected( @@ -1664,6 +1667,47 @@ func build_network_context( return _build_fishing_context(region) +func is_species_available_now(fish: FishDataType) -> bool: + if fish == null or not fish.is_selectable(): + return false + var season := ( + _world_time.get_season() + if _world_time != null + else CalendarSeason.UNKNOWN + ) + if not fish.is_available_in_season(season): + return false + if fish.collection_method != FishDataType.CollectionMethod.FISHING: + return true + for region: FishableWaterRegionType in _get_logbook_water_regions(): + if ( + region == null + or region.fish_pool == null + or region.fish_pool.get_fish_by_id(fish.id) == null + ): + continue + var context := _build_fishing_context(region) + if not fish.is_allowed_in_water(context.water_type): + continue + if fish.availability == null or fish.availability.is_available(context): + return true + return false + + +func _get_logbook_water_regions() -> Array[FishableWaterRegionType]: + var regions: Array[FishableWaterRegionType] = [] + if not _fishable_water_regions_resolver.is_valid(): + return regions + var resolved: Variant = _fishable_water_regions_resolver.call() + if resolved is not Array: + return regions + for value: Variant in resolved: + var region := value as FishableWaterRegionType + if region != null and is_instance_valid(region): + regions.append(region) + return regions + + func _get_active_bait_tags() -> Array[StringName]: if _local_player == null or _item_catalog == null: return [] diff --git a/main/main.gd b/main/main.gd index 7fae866..e9dc78f 100644 --- a/main/main.gd +++ b/main/main.gd @@ -920,6 +920,7 @@ func _initialize_application(dedicated: bool) -> void: _network_fishing, _world_time, _world_weather, + Callable(_test_world, "get_fishable_water_regions"), ) if dedicated: return diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index aa2f9e2..37e43d5 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -1356,6 +1356,24 @@ func _validate_player_menu_nested_controller_back() -> void: == LogbookPageType.ControllerZone.ENTRIES, "Global player-menu Back did not return Logbook to creature selection.", ) + logbook_page.call("_set_index_open", true, false) + logbook_page.set( + "_controller_zone", + LogbookPageType.ControllerZone.INDEX, + ) + _expect( + bool(menu.call("consume_escape")), + "Global player-menu Back did not consume the Logbook index zone.", + ) + _expect( + menu.visible and not bool(logbook_page.get("_index_open")), + "Global player-menu Back did not return from the Logbook index page.", + ) + _expect( + int(logbook_page.get("_controller_zone")) + == LogbookPageType.ControllerZone.TABS, + "Global player-menu Back did not return Logbook index focus to tabs.", + ) var net_page := menu.get("_the_net_page") as Control net_page.set("_active", true) diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index 75fc47f..47e795d 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -2,6 +2,7 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const FishCatchType = preload("res://fish/fish_catch.gd") +const MENU_TRANSITION_SETTLE_SECONDS := 3.0 func _initialize() -> void: @@ -36,12 +37,16 @@ func _run() -> void: var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect var hotbar := game_ui.get_node("%Hotbar") as Control + var fishing_spot := main.get("_fishing_spot") as FishingSpot + assert(logbook.get("_fishing_spot") == fishing_spot) + assert(logbook.get("_world_time") == main.get("_world_time")) + assert(logbook.get("_world_weather") == main.get("_world_weather")) var shortcut := InputEventKey.new() shortcut.pressed = true shortcut.physical_keycode = KEY_L assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) - await create_timer(2.2).timeout + await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout assert(player_menu.visible) assert(logbook.visible) assert(backdrop.visible) @@ -51,6 +56,18 @@ func _run() -> void: var entry_buttons: Dictionary = logbook.get("_entry_buttons") assert(entry_buttons.size() == 108) await _capture_if_requested("-unknown") + logbook.call("_set_index_open", true, false) + await process_frame + assert(bool(logbook.get("_index_open"))) + assert((logbook.get("_index_body") as Control).visible) + await _capture_if_requested("-index") + logbook.set_compact_presentation(true) + await process_frame + await _capture_if_requested("-index-compact") + logbook.set_compact_presentation(false) + await process_frame + logbook.call("_set_index_open", false, false) + await process_frame logbook.call( "_select_category", WaterType.Type.FRESH_WATER ) @@ -68,13 +85,29 @@ func _run() -> void: logbook.call("_select_entry", &"bluegill", &"bluegill") await create_timer(0.2).timeout await _capture_if_requested("-known") + var bluegill := ( + main.get("fish_catalog") as FishPool + ).get_fish_by_id(&"bluegill") + var bluegill_available_now := fishing_spot.is_species_available_now(bluegill) + var index_options := logbook.get("_index_options") as Dictionary + index_options["availability"] = ( + LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW + ) + logbook.notify_availability_context_changed() + await process_frame + assert( + (logbook.get("_entry_buttons") as Dictionary).has(&"bluegill") + == bluegill_available_now + ) + logbook.call("_reset_index") + await process_frame assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) - await create_timer(2.2).timeout + await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout assert(not player_menu.visible) assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) - await create_timer(2.2).timeout + await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout player_menu.call( "_show_section_immediate", PlayerMenu.Section.PROFILE ) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 4810e0a..431ed2e 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -17,6 +17,8 @@ const CatalogResource: FishPoolType = preload( "res://fish/pools/fish_catalog.tres" ) +var _query_available_id: StringName + func _initialize() -> void: call_deferred("_run") @@ -35,6 +37,7 @@ func _run() -> void: quit() return _validate_catalog() + _validate_catalog_query() await _validate_page() print("Logbook validation: PASS") quit() @@ -68,6 +71,348 @@ func _validate_catalog() -> void: ) +func _validate_catalog_query() -> void: + var collection := CollectionLogType.new() + var inventory := FishInventoryType.new() + var fresh_species: Array[FishDataType] = [] + for fish: FishDataType in LogbookCatalog.ordered_species( + CatalogResource.candidates + ): + if LogbookCatalog.category_for(fish) == LogbookCatalog.Category.FRESH_WATER: + fresh_species.append(fish) + assert(fresh_species.size() == 108) + var first := fresh_species[0] + var second := fresh_species[1] + var unknown := fresh_species[2] + for quality: int in FishQualityType.TIER_COUNT: + collection.record_catch(first.id, quality) + collection.record_catch(second.id, FishQualityType.Tier.BORING) + var held := _make_test_catch(first, true) + assert(inventory.add_catch(held)) + + var options := _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + var filtered := LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered.size() == 2) + assert(first in filtered and second in filtered) + + options["show"] = LogbookCatalog.DiscoveryFilter.MISSING + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered.size() == fresh_species.size() - 2) + options["sort"] = LogbookCatalog.SortMode.CATALOG_NUMBER + options["descending"] = true + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered.front() == fresh_species.back()) + assert(filtered.back() == unknown) + + options["show"] = LogbookCatalog.DiscoveryFilter.INCOMPLETE + options["descending"] = false + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [second]) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["sort"] = LogbookCatalog.SortMode.CATCH_COUNT + options["descending"] = true + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [first, second]) + + options = _default_query_options() + options["search"] = unknown.display_name + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered.is_empty()) + options["search"] = str(unknown.catalog_number) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [unknown]) + + options = _default_query_options() + options["holding"] = LogbookCatalog.HoldingFilter.FAVORITED + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [first]) + options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [first]) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["group"] = String(first.collection_group) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(first in filtered) + for fish: FishDataType in filtered: + assert(fish.collection_group == first.collection_group) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["rarity"] = ( + int(first.rarity) + LogbookCatalog.RarityFilter.COMMON + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(first in filtered) + for fish: FishDataType in filtered: + assert(fish.rarity == first.rarity) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["method"] = ( + int(first.collection_method) + LogbookCatalog.MethodFilter.FISHING + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(first in filtered) + for fish: FishDataType in filtered: + assert(fish.collection_method == first.collection_method) + + var first_season := -1 + for season: int in 4: + if first.is_available_in_season(season): + first_season = season + break + assert(first_season >= 0) + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["season"] = ( + first_season + LogbookCatalog.SeasonFilter.SPRING + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(first in filtered) + for fish: FishDataType in filtered: + assert(fish.is_available_in_season(first_season)) + + var first_is_available_by_day := ( + first.availability == null or first.availability.allow_day + ) + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["time"] = ( + LogbookCatalog.TimeFilter.DAY + if first_is_available_by_day + else LogbookCatalog.TimeFilter.NIGHT + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(first in filtered) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT + options["sort"] = LogbookCatalog.SortMode.QUALITY_PROGRESS + options["descending"] = true + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered == [first, second]) + for sort_mode: LogbookCatalog.SortMode in [ + LogbookCatalog.SortMode.NAME, + LogbookCatalog.SortMode.RARITY, + LogbookCatalog.SortMode.VALUE, + LogbookCatalog.SortMode.WEIGHT, + ]: + options = _default_query_options() + options["sort"] = sort_mode + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(filtered[0] in [first, second]) + assert(filtered[1] in [first, second]) + assert(filtered[0] != filtered[1]) + assert(filtered[2] == unknown) + + _query_available_id = second.id + options = _default_query_options() + options["availability"] = ( + LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + Callable(self, "_query_species_available_now"), + ) + assert(filtered == [second]) + + _query_available_id = unknown.id + options["show"] = LogbookCatalog.DiscoveryFilter.MISSING + options["rarity"] = ( + int(unknown.rarity) + LogbookCatalog.RarityFilter.COMMON + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + Callable(self, "_query_species_available_now"), + ) + assert(filtered == [unknown]) + + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.MISSING + options["group"] = String(unknown.collection_group) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(unknown in filtered) + assert( + String(unknown.collection_group) + in LogbookCatalog.available_groups( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + ) + ) + + var unknown_is_available_by_day := ( + unknown.availability == null or unknown.availability.allow_day + ) + options = _default_query_options() + options["show"] = LogbookCatalog.DiscoveryFilter.MISSING + options["time"] = ( + LogbookCatalog.TimeFilter.DAY + if unknown_is_available_by_day + else LogbookCatalog.TimeFilter.NIGHT + ) + filtered = LogbookCatalog.filtered_species( + CatalogResource.candidates, + LogbookCatalog.Category.FRESH_WATER, + collection, + inventory, + options, + ) + assert(unknown in filtered) + inventory.free() + collection.free() + + +func _default_query_options() -> Dictionary: + return { + "search": "", + "show": LogbookCatalog.DiscoveryFilter.ALL, + "sort": LogbookCatalog.SortMode.CATALOG_NUMBER, + "descending": false, + "availability": LogbookCatalog.AvailabilityFilter.ANY, + "season": LogbookCatalog.SeasonFilter.ANY, + "time": LogbookCatalog.TimeFilter.ANY, + "rarity": LogbookCatalog.RarityFilter.ANY, + "method": LogbookCatalog.MethodFilter.ANY, + "holding": LogbookCatalog.HoldingFilter.ANY, + "group": "", + } + + +func _make_test_catch(fish: FishDataType, favorited: bool) -> FishCatchType: + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.weight_lb = fish.get_minimum_weight() + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = FishQualityType.Tier.BORING + fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb) + fish_catch.is_favorited = favorited + fish_catch.ensure_identity() + return fish_catch + + +func _query_species_available_now(fish: FishDataType) -> bool: + return fish != null and fish.id == _query_available_id + + func _validate_page() -> void: var collection := CollectionLogType.new() var inventory := FishInventoryType.new() @@ -91,6 +436,10 @@ func _validate_page() -> void: assert(outer_margin.get_theme_constant("margin_right") == 160) assert(outer_margin.get_theme_constant("margin_bottom") == 106) assert(is_equal_approx(book.offset_top, 65.0)) + page.call("_set_index_open", true, false) + await process_frame + _validate_index_content_bounds(page) + page.call("_set_index_open", false, false) page.set_compact_presentation(false) await process_frame assert(outer_margin.get_theme_constant("margin_left") == 52) @@ -158,6 +507,39 @@ func _validate_page() -> void: ) var initial_detail_body := page.get("_detail_body") as VBoxContainer assert(not initial_detail_body.get_parent() is ScrollContainer) + var index_tab := page.get("_index_tab") as Button + var index_body := page.get("_index_body") as VBoxContainer + assert(index_tab != null and index_body != null) + assert(index_tab.size == LogbookPage.INDEX_TAB_SIZE) + assert( + not index_tab.get_global_rect().intersects( + (category_tabs.back() as Button).get_global_rect() + ) + ) + page.call("_set_index_open", true, false) + await process_frame + assert(index_body.visible) + assert(not initial_detail_body.visible) + assert((page.get("_index_option_buttons") as Dictionary).size() == 10) + _validate_index_content_bounds(page) + page.call( + "_cycle_index_option", + &"show", + 1, + ) + await process_frame + assert((page.get("_entry_buttons") as Dictionary).is_empty()) + assert( + (page.get("_empty_state") as Label).text + == "No entries match this index." + ) + page.call("_reset_index") + await process_frame + assert((page.get("_entry_buttons") as Dictionary).size() == 108) + page.call("_set_index_open", false, false) + await process_frame + assert(not index_body.visible) + assert(initial_detail_body.visible) assert( page.call("_entry_label_text", "flathead catfish") == "flathead\ncatfish" @@ -458,8 +840,19 @@ func _validate_page() -> void: (page.get("_portrait_overlay_backdrop") as Button).pressed.emit() await process_frame + var live_index_options := page.get("_index_options") as Dictionary + live_index_options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER + page.call("_refresh_catalog") + await process_frame + assert( + (page.get("_entry_buttons") as Dictionary).keys() + == [&"bluegill"] + ) inventory.remove_catch_by_id(fish_catch.catch_id) await process_frame + assert((page.get("_entry_buttons") as Dictionary).is_empty()) + page.call("_reset_index") + await process_frame assert(not _detail_text(page).contains("number owned")) page.queue_free() collection.queue_free() @@ -474,6 +867,54 @@ func _detail_text(page: LogbookPage) -> String: return "\n".join(values) +func _validate_index_content_bounds(page: LogbookPage) -> void: + var body := page.get("_index_body") as VBoxContainer + assert(body != null and body.visible) + var right_page := body.get_parent() as Control + assert(right_page != null) + var page_rect := right_page.get_global_rect() + var body_rect := body.get_global_rect() + var protected_rect := Rect2( + page_rect.position + Vector2( + LogbookPage.INDEX_CONTENT_LEFT_INSET, + LogbookPage.INDEX_CONTENT_TOP_INSET, + ), + page_rect.size - Vector2( + LogbookPage.INDEX_CONTENT_LEFT_INSET + + LogbookPage.INDEX_CONTENT_RIGHT_INSET, + LogbookPage.INDEX_CONTENT_TOP_INSET + + LogbookPage.INDEX_CONTENT_BOTTOM_INSET, + ), + ) + assert( + body_rect.position.x + >= page_rect.position.x + LogbookPage.INDEX_CONTENT_LEFT_INSET - 1.0 + ) + assert( + body_rect.end.x + <= page_rect.end.x - LogbookPage.INDEX_CONTENT_RIGHT_INSET + 1.0 + ) + assert( + body_rect.position.y >= page_rect.position.y, + "Index starts above the authored page: body=%s page=%s" + % [body_rect, page_rect], + ) + assert( + body_rect.end.y <= page_rect.end.y, + "Index extends below the authored page: body=%s page=%s" + % [body_rect, page_rect], + ) + var controls: Array = page.get("_index_controls") as Array + for value: Variant in controls: + var control := value as Control + if control != null and control.visible: + assert( + protected_rect.grow(2.0).encloses(control.get_global_rect()), + "Index control crossed the authored right-page boundary: %s" + % control.name, + ) + + func _descendant_label_text(parent: Node) -> String: var values: PackedStringArray = [] for node: Node in parent.find_children("*", "Label", true, false): diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 7f8f0a6..3ce0d17 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -260,6 +260,9 @@ func _ready() -> void: _player_menu.controller_hotbar_management_ended.connect( _on_controller_hotbar_management_ended ) + _player_menu.controller_text_entry_requested.connect( + request_controller_text_entry_for + ) _title_settings_panel.panel_visibility_changed.connect( _on_settings_visibility_changed ) @@ -459,6 +462,7 @@ func setup( discovery, player_jobs, world_time, + world_weather, world_environment, world_sun, ) diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd index d5dc639..ac7a910 100644 --- a/ui/logbook_catalog.gd +++ b/ui/logbook_catalog.gd @@ -2,6 +2,9 @@ class_name LogbookCatalog extends RefCounted const FishDataType = preload("res://fish/fish_data.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") +const CalendarSeasonType = preload("res://world/calendar_season.gd") enum Category { FRESH_WATER, @@ -10,6 +13,66 @@ enum Category { SHELLFISH, } +enum DiscoveryFilter { + ALL, + CAUGHT, + MISSING, + INCOMPLETE, +} + +enum SortMode { + CATALOG_NUMBER, + NAME, + RARITY, + CATCH_COUNT, + QUALITY_PROGRESS, + VALUE, + WEIGHT, +} + +enum AvailabilityFilter { + ANY, + AVAILABLE_NOW, +} + +enum SeasonFilter { + ANY, + CURRENT, + SPRING, + SUMMER, + FALL, + WINTER, +} + +enum TimeFilter { + ANY, + CURRENT, + DAY, + NIGHT, +} + +enum RarityFilter { + ANY, + COMMON, + UNCOMMON, + RARE, + EPIC, + LEGENDARY, +} + +enum MethodFilter { + ANY, + FISHING, + NET, + DIGGING, +} + +enum HoldingFilter { + ANY, + IN_COOLER, + FAVORITED, +} + static func category_for(fish: FishDataType) -> Category: if fish == null: @@ -73,3 +136,278 @@ static func ordered_species( return a.catalog_number < b.catalog_number ) return ordered + + +static func filtered_species( + candidates: Array[FishDataType], + category: Category, + collection_log: CollectionLogType, + inventory: FishInventoryType, + options: Dictionary, + available_now_resolver: Callable = Callable(), + current_season: int = CalendarSeasonType.UNKNOWN, + current_is_night: bool = false, +) -> Array[FishDataType]: + var filtered: Array[FishDataType] = [] + for fish: FishDataType in ordered_species(candidates): + if category_for(fish) != category: + continue + if not _matches_filters( + fish, + collection_log, + inventory, + options, + available_now_resolver, + current_season, + current_is_night, + ): + continue + filtered.append(fish) + var sort_mode := int(options.get("sort", SortMode.CATALOG_NUMBER)) + var descending := bool(options.get("descending", false)) + filtered.sort_custom( + func(first: FishDataType, second: FishDataType) -> bool: + return _species_less_than( + first, + second, + collection_log, + sort_mode, + descending, + ) + ) + return filtered + + +static func available_groups( + candidates: Array[FishDataType], + category: Category, + _collection_log: CollectionLogType, +) -> Array[String]: + var groups: Array[String] = [] + for fish: FishDataType in ordered_species(candidates): + if category_for(fish) != category: + continue + var group := _group_label(fish) + if not group.is_empty() and group not in groups: + groups.append(group) + groups.sort_custom( + func(first: String, second: String) -> bool: + return first.naturalnocasecmp_to(second) < 0 + ) + return groups + + +static func quality_progress( + collection_log: CollectionLogType, + fish_id: StringName, +) -> int: + if collection_log == null: + return 0 + var mask: int = collection_log.get_quality_mask(fish_id) + var count: int = 0 + while mask > 0: + count += mask & 1 + mask >>= 1 + return count + + +static func _matches_filters( + fish: FishDataType, + collection_log: CollectionLogType, + inventory: FishInventoryType, + options: Dictionary, + available_now_resolver: Callable, + current_season: int, + current_is_night: bool, +) -> bool: + var discovered := ( + collection_log != null and collection_log.has_discovered(fish.id) + ) + match int(options.get("show", DiscoveryFilter.ALL)): + DiscoveryFilter.CAUGHT: + if not discovered: + return false + DiscoveryFilter.MISSING: + if discovered: + return false + DiscoveryFilter.INCOMPLETE: + if ( + not discovered + or collection_log.has_mastered(fish.id) + ): + return false + + var search_text := str(options.get("search", "")).strip_edges().to_lower() + if not search_text.is_empty(): + var number_text := str(catalog_number(fish)) + var number_query := search_text.trim_prefix("#") + var number_matches := ( + number_query.is_valid_int() + and number_text == number_query + ) + var identity_matches := false + if discovered: + identity_matches = " ".join([ + fish.display_name, + _group_label(fish), + fish.get_habitat_label(), + ]).to_lower().contains(search_text) + if not number_matches and not identity_matches: + return false + + if ( + int(options.get("availability", AvailabilityFilter.ANY)) + == AvailabilityFilter.AVAILABLE_NOW + ): + if available_now_resolver.is_valid(): + var resolved: Variant = available_now_resolver.call(fish) + if typeof(resolved) != TYPE_BOOL or not bool(resolved): + return false + elif not _matches_season(fish, current_season): + return false + + var season_filter := int(options.get("season", SeasonFilter.ANY)) + if season_filter != SeasonFilter.ANY: + var requested_season := current_season + if season_filter >= SeasonFilter.SPRING: + requested_season = season_filter - SeasonFilter.SPRING + if not _matches_season(fish, requested_season): + return false + + var time_filter := int(options.get("time", TimeFilter.ANY)) + if time_filter != TimeFilter.ANY: + var requested_night := current_is_night + if time_filter == TimeFilter.DAY: + requested_night = false + elif time_filter == TimeFilter.NIGHT: + requested_night = true + if not _matches_time(fish, requested_night): + return false + + var rarity_filter := int(options.get("rarity", RarityFilter.ANY)) + if ( + rarity_filter != RarityFilter.ANY + and fish.rarity != rarity_filter - RarityFilter.COMMON + ): + return false + + var method_filter := int(options.get("method", MethodFilter.ANY)) + if ( + method_filter != MethodFilter.ANY + and fish.collection_method != method_filter - MethodFilter.FISHING + ): + return false + + var holding_filter := int(options.get("holding", HoldingFilter.ANY)) + if holding_filter != HoldingFilter.ANY: + var favorites_only := holding_filter == HoldingFilter.FAVORITED + if not _has_current_catch(inventory, fish.id, favorites_only): + return false + + var requested_group := str(options.get("group", "")) + if ( + not requested_group.is_empty() + and _group_label(fish).nocasecmp_to(requested_group) != 0 + ): + return false + return true + + +static func _species_less_than( + first: FishDataType, + second: FishDataType, + collection_log: CollectionLogType, + sort_mode: int, + descending: bool, +) -> bool: + var first_discovered := ( + collection_log != null and collection_log.has_discovered(first.id) + ) + var second_discovered := ( + collection_log != null and collection_log.has_discovered(second.id) + ) + if sort_mode != SortMode.CATALOG_NUMBER: + if first_discovered != second_discovered: + return first_discovered + if not first_discovered and not second_discovered: + return _catalog_less_than(first, second) + + var comparison: int = 0 + match sort_mode: + SortMode.NAME: + comparison = first.display_name.naturalnocasecmp_to( + second.display_name + ) + SortMode.RARITY: + comparison = first.rarity - second.rarity + SortMode.CATCH_COUNT: + comparison = ( + collection_log.get_catch_count(first.id) + - collection_log.get_catch_count(second.id) + ) + SortMode.QUALITY_PROGRESS: + comparison = ( + quality_progress(collection_log, first.id) + - quality_progress(collection_log, second.id) + ) + SortMode.VALUE: + comparison = first.sell_value_max - second.sell_value_max + SortMode.WEIGHT: + comparison = _compare_floats( + first.get_maximum_weight(), + second.get_maximum_weight(), + ) + _: + comparison = first.catalog_number - second.catalog_number + if comparison != 0: + return comparison > 0 if descending else comparison < 0 + return _catalog_less_than(first, second) + + +static func _catalog_less_than(first: FishDataType, second: FishDataType) -> bool: + if first.catalog_number == second.catalog_number: + return String(first.id) < String(second.id) + return first.catalog_number < second.catalog_number + + +static func _compare_floats(first: float, second: float) -> int: + if is_equal_approx(first, second): + return 0 + return -1 if first < second else 1 + + +static func _matches_season(fish: FishDataType, season: int) -> bool: + return fish.is_available_in_season(season) + + +static func _matches_time(fish: FishDataType, requested_night: bool) -> bool: + if ( + fish.collection_method != FishDataType.CollectionMethod.FISHING + or fish.availability == null + ): + return true + return ( + fish.availability.allow_night + if requested_night + else fish.availability.allow_day + ) + + +static func _has_current_catch( + inventory: FishInventoryType, + fish_id: StringName, + favorites_only: bool, +) -> bool: + if inventory == null: + return false + for fish_catch in inventory.get_catches_by_fish_id(fish_id): + if fish_catch != null and (not favorites_only or fish_catch.is_favorited): + return true + return false + + +static func _group_label(fish: FishDataType) -> String: + if fish == null: + return "" + var group := String(fish.collection_group).strip_edges() + return group if not group.is_empty() else fish.get_habitat_label() diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 6319dd6..cc490a0 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -6,10 +6,21 @@ const FishDataType = preload("res://fish/fish_data.gd") const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishPoolType = preload("res://fish/fish_pool.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const WorldTimeServiceType = preload("res://world/world_time_service.gd") +const WorldWeatherServiceType = preload( + "res://world/world_weather_service.gd" +) const SILHOUETTE_SHADER: Shader = preload( "res://ui/logbook_silhouette.gdshader" ) const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) +const NOTEPAD_INK_ACTION_SCENE: PackedScene = preload( + "res://ui/components/bubble_menu/notepad_ink_action.tscn" +) const InventoryNotepadType = preload( "res://ui/components/inventory_notepad.gd" ) @@ -48,6 +59,8 @@ const LOGBOOK_TAB_LEFT_INSET: float = 92.0 const LOGBOOK_TAB_SIZE := Vector2(108.0, 38.0) const LOGBOOK_TAB_SEPARATION: float = 6.0 const LOGBOOK_TAB_TEXT_SIDE_INSET: float = 10.0 +const INDEX_TAB_SIZE := Vector2(88.0, 38.0) +const INDEX_TAB_RIGHT_INSET: float = 72.0 const PAGE_CONTENT_SCALE: float = 0.97 const WIDE_OUTER_MARGINS := Rect2(52.0, 85.0, 52.0, 18.0) const COMPACT_OUTER_MARGINS := Rect2(160.0, 85.0, 160.0, 106.0) @@ -61,17 +74,53 @@ const DETAIL_SECTION_SEPARATION: int = 10 const DETAIL_QUALITY_SECTION_SIZE := Vector2(300.0, 104.0) const DETAIL_STATS_SECTION_SIZE := Vector2(400.0, 160.0) const DETAIL_STATS_ROW_SEPARATION: int = 8 +const INDEX_OPTION_HEIGHT: float = 34.0 +const INDEX_CONTENT_LEFT_INSET: float = 44.0 +const INDEX_CONTENT_TOP_INSET: float = 4.0 +const INDEX_CONTENT_RIGHT_INSET: float = 22.0 +const INDEX_CONTENT_BOTTOM_INSET: float = 10.0 +const INDEX_SHOW_LABELS := [ + "all entries", "caught", "missing", "needs quality", +] +const INDEX_SORT_LABELS := [ + "catalog no.", "name", "rarity", "number caught", + "quality progress", "value", "weight", +] +const INDEX_AVAILABILITY_LABELS := [ + "any time", "available now", +] +const INDEX_SEASON_LABELS := [ + "any season", "current season", "spring", "summer", "fall", "winter", +] +const INDEX_TIME_LABELS := [ + "any time", "current time", "day", "night", +] +const INDEX_RARITY_LABELS := [ + "any rarity", "common", "uncommon", "rare", "epic", "legendary", +] +const INDEX_METHOD_LABELS := [ + "any method", "fishing", "netting", "digging", +] +const INDEX_HOLDING_LABELS := [ + "any ownership", "in cooler", "favorited", +] + +signal controller_text_entry_requested(control: Control) enum ControllerZone { TABS, ENTRIES, DETAILS, + INDEX, OVERLAY, } var _collection_log: CollectionLogType var _inventory: FishInventoryType var _catalog: FishPoolType +var _fishing_spot: FishingSpotType +var _world_time: WorldTimeServiceType +var _world_weather: WorldWeatherServiceType var _category: LogbookCatalog.Category = ( LogbookCatalog.Category.FRESH_WATER ) @@ -111,6 +160,31 @@ var _portrait_overlay_stats_view: Control var _portrait_overlay_stats: GridContainer var _overlay_return_focus: Control var _controller_zone: ControllerZone = ControllerZone.TABS +var _index_tab: OrganizerTabType +var _index_body: VBoxContainer +var _index_search: LineEdit +var _index_summary: Label +var _index_note: Label +var _index_reset: Button +var _index_option_buttons: Dictionary[StringName, Button] = {} +var _index_controls: Array[Control] = [] +var _index_open: bool = false +var _index_update_in_progress: bool = false +var _index_visible_count: int = 0 +var _index_category_total: int = 0 +var _index_options: Dictionary = { + "search": "", + "show": LogbookCatalog.DiscoveryFilter.ALL, + "sort": LogbookCatalog.SortMode.CATALOG_NUMBER, + "descending": false, + "availability": LogbookCatalog.AvailabilityFilter.ANY, + "season": LogbookCatalog.SeasonFilter.ANY, + "time": LogbookCatalog.TimeFilter.ANY, + "rarity": LogbookCatalog.RarityFilter.ANY, + "method": LogbookCatalog.MethodFilter.ANY, + "holding": LogbookCatalog.HoldingFilter.ANY, + "group": "", +} func _ready() -> void: @@ -124,10 +198,16 @@ func setup( collection_log: CollectionLogType, inventory: FishInventoryType, catalog: FishPoolType, + fishing_spot: FishingSpotType = null, + world_time: WorldTimeServiceType = null, + world_weather: WorldWeatherServiceType = null, ) -> void: _collection_log = collection_log _inventory = inventory _catalog = catalog + _fishing_spot = fishing_spot + _world_time = world_time + _world_weather = world_weather if not _collection_log.fish_discovered.is_connected( _on_fish_discovered ): @@ -138,12 +218,28 @@ func setup( _collection_log.collection_changed.connect(_on_collection_changed) if not _inventory.catches_changed.is_connected(_on_inventory_changed): _inventory.catches_changed.connect(_on_inventory_changed) + if ( + _world_time != null + and not _world_time.time_changed.is_connected( + _on_logbook_context_changed + ) + ): + _world_time.time_changed.connect(_on_logbook_context_changed) + _world_time.calendar_date_changed.connect(_on_logbook_context_changed) + if ( + _world_weather != null + and not _world_weather.weather_changed.is_connected( + _on_logbook_context_changed + ) + ): + _world_weather.weather_changed.connect(_on_logbook_context_changed) _refresh_catalog() func set_compact_presentation(compact: bool) -> void: _compact_presentation = compact _apply_presentation_margins() + _apply_index_presentation() func activate() -> void: @@ -180,6 +276,17 @@ func set_interactive(value: bool) -> void: if _interactive and not tab.button_pressed else Control.MOUSE_FILTER_IGNORE ) + if _index_tab != null: + _index_tab.focus_mode = ( + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.TABS + else Control.FOCUS_NONE + ) + _index_tab.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if _interactive + else Control.MOUSE_FILTER_IGNORE + ) for entry: Button in _entry_buttons.values(): entry.focus_mode = ( Control.FOCUS_ALL @@ -199,6 +306,19 @@ func set_interactive(value: bool) -> void: if _interactive and _controller_zone == ControllerZone.DETAILS else Control.FOCUS_NONE ) + for index_control: Control in _index_controls: + if not is_instance_valid(index_control): + continue + index_control.focus_mode = ( + Control.FOCUS_ALL + if _interactive and _controller_zone == ControllerZone.INDEX + else Control.FOCUS_NONE + ) + index_control.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if _interactive and _index_open + else Control.MOUSE_FILTER_IGNORE + ) if _portrait_overlay_backdrop != null: _portrait_overlay_backdrop.focus_mode = ( Control.FOCUS_ALL @@ -214,6 +334,9 @@ func focus_initial() -> void: _portrait_overlay_backdrop.grab_focus() return if _controller_zone == ControllerZone.TABS: + if _index_open and _index_tab != null: + _index_tab.grab_focus() + return var category_tab_index: int = _category_tab_categories.find(_category) if category_tab_index >= 0: _category_tabs[category_tab_index].grab_focus() @@ -222,6 +345,10 @@ func focus_initial() -> void: if not _detail_buttons.is_empty(): _detail_buttons.front().grab_focus() return + if _controller_zone == ControllerZone.INDEX: + if _index_search != null: + _index_search.grab_focus() + return var selected := _entry_buttons.get(_selected_entry_key) as Button if selected != null: selected.grab_focus() @@ -247,6 +374,8 @@ func handle_controller_input(event: InputEvent) -> bool: _hide_portrait_overlay() ControllerZone.DETAILS: _set_controller_zone(ControllerZone.ENTRIES) + ControllerZone.INDEX: + _set_index_open(false) ControllerZone.ENTRIES: _set_controller_zone(ControllerZone.TABS) ControllerZone.TABS: @@ -254,15 +383,43 @@ func handle_controller_input(event: InputEvent) -> bool: return true if _controller_zone == ControllerZone.TABS: if event.is_action_pressed("ui_left"): + if _index_open: + _set_index_open(false, false) + _category_tabs.back().grab_focus() + return true _select_adjacent_controller_category(-1) return true if event.is_action_pressed("ui_right"): + if ( + _category_tab_categories.find(_category) + == _category_tab_categories.size() - 1 + ): + _set_index_open(true, false) + _index_tab.grab_focus() + return true _select_adjacent_controller_category(1) return true if accept_pressed: - if not _entry_buttons.is_empty(): + if _index_open: + _set_controller_zone(ControllerZone.INDEX) + elif not _entry_buttons.is_empty(): _set_controller_zone(ControllerZone.ENTRIES) return true + if _controller_zone == ControllerZone.INDEX: + var focus_owner := get_viewport().gui_get_focus_owner() + if accept_pressed and focus_owner == _index_search: + controller_text_entry_requested.emit(_index_search) + return true + if event.is_action_pressed("ui_left"): + var previous_key := _index_key_for_control(focus_owner) + if not previous_key.is_empty(): + _cycle_index_option(previous_key, -1) + return true + if event.is_action_pressed("ui_right") or accept_pressed: + var next_key := _index_key_for_control(focus_owner) + if not next_key.is_empty(): + _cycle_index_option(next_key, 1) + return true if _controller_zone == ControllerZone.ENTRIES and accept_pressed: if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty(): _set_controller_zone(ControllerZone.DETAILS) @@ -329,6 +486,20 @@ func _build_interface() -> void: tabs.add_child(tab) _category_tabs.append(tab) _category_tab_categories.append(category) + _index_tab = OrganizerTabType.new() + _index_tab.name = "CatalogIndexTab" + _index_tab.anchor_left = 1.0 + _index_tab.anchor_right = 1.0 + _index_tab.offset_left = -INDEX_TAB_RIGHT_INSET - INDEX_TAB_SIZE.x + _index_tab.offset_top = 37.0 + _index_tab.offset_right = -INDEX_TAB_RIGHT_INSET + _index_tab.offset_bottom = 37.0 + INDEX_TAB_SIZE.y + _index_tab.text = "index" + _index_tab.palette_index = 0 + _index_tab.tooltip_text = "Open catalog index" + _index_tab.accessibility_name = "Catalog index" + _index_tab.pressed.connect(_toggle_index_page) + stack.add_child(_index_tab) _configure_category_focus() var book := Control.new() @@ -418,9 +589,176 @@ func _build_interface() -> void: right_page.add_child(_detail_body) _apply_page_content_scale(_detail_body) _show_no_selection() + _build_index_page(right_page) _build_portrait_overlay() +func _build_index_page(right_page: Control) -> void: + _index_body = VBoxContainer.new() + _index_body.name = "CatalogIndex" + _index_body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _index_body.offset_left = INDEX_CONTENT_LEFT_INSET + _index_body.offset_top = INDEX_CONTENT_TOP_INSET + _index_body.offset_right = -INDEX_CONTENT_RIGHT_INSET + _index_body.offset_bottom = -INDEX_CONTENT_BOTTOM_INSET + _index_body.add_theme_constant_override("separation", 4) + _index_body.visible = false + right_page.add_child(_index_body) + _apply_page_content_scale(_index_body) + _apply_index_presentation() + + var heading := _label("catalog index", 28) + heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _index_body.add_child(heading) + _index_summary = _field_label("", 14) + _index_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _index_summary.add_theme_color_override("font_color", MUTED_INK) + _index_body.add_child(_index_summary) + + var search_stack := VBoxContainer.new() + search_stack.add_theme_constant_override("separation", 0) + _index_body.add_child(search_stack) + var search_label := _field_label("find by name, group, or catalog no.", 13) + search_label.add_theme_color_override("font_color", MUTED_INK) + search_stack.add_child(search_label) + _index_search = LineEdit.new() + _index_search.name = "CatalogIndexSearch" + _index_search.custom_minimum_size.y = 34.0 + _index_search.placeholder_text = "write here..." + _index_search.max_length = 48 + _index_search.add_theme_font_override( + "font", InventoryNotepadType.HANDWRITTEN_FONT + ) + _index_search.add_theme_font_size_override("font_size", 18) + _index_search.add_theme_color_override("font_color", INK) + _index_search.add_theme_color_override("caret_color", INK) + _index_search.add_theme_color_override( + "font_placeholder_color", Color(MUTED_INK, 0.72) + ) + for style_name: StringName in [ + &"normal", &"focus", &"read_only", + ]: + _index_search.add_theme_stylebox_override( + style_name, + _index_search_style(style_name == &"focus"), + ) + _index_search.text_changed.connect(_on_index_search_changed) + search_stack.add_child(_index_search) + _index_controls.append(_index_search) + + var options_grid := GridContainer.new() + options_grid.columns = 2 + options_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + options_grid.add_theme_constant_override("h_separation", 18) + options_grid.add_theme_constant_override("v_separation", 2) + _index_body.add_child(options_grid) + for descriptor: Dictionary in [ + {"key": &"show", "label": "show"}, + {"key": &"rarity", "label": "rarity"}, + {"key": &"sort", "label": "sort by"}, + {"key": &"availability", "label": "availability"}, + {"key": &"descending", "label": "order"}, + {"key": &"season", "label": "season"}, + {"key": &"method", "label": "method"}, + {"key": &"time", "label": "time of day"}, + {"key": &"holding", "label": "ownership"}, + {"key": &"group", "label": "collection group"}, + ]: + options_grid.add_child(_make_index_option( + descriptor["key"], + str(descriptor["label"]), + )) + + var footer := HBoxContainer.new() + footer.alignment = BoxContainer.ALIGNMENT_CENTER + _index_body.add_child(footer) + _index_reset = NOTEPAD_INK_ACTION_SCENE.instantiate() as Button + _index_reset.name = "CatalogIndexReset" + _index_reset.custom_minimum_size = Vector2(126.0, 34.0) + _index_reset.text = "clear index" + _apply_index_value_font(_index_reset) + _index_reset.tooltip_text = "Clear every catalog filter" + _index_reset.accessibility_name = "Clear catalog index filters" + _index_reset.pressed.connect(_reset_index) + footer.add_child(_index_reset) + _index_controls.append(_index_reset) + + _index_note = _field_label( + "silhouettes keep names hidden in filtered results", + 12, + ) + _index_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _index_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _index_note.add_theme_color_override("font_color", Color(MUTED_INK, 0.82)) + _index_body.add_child(_index_note) + _update_index_controls() + _configure_index_focus.call_deferred() + + +func _make_index_option(key: StringName, label_text: String) -> Control: + var stack := VBoxContainer.new() + stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + stack.add_theme_constant_override("separation", -3) + var label := _field_label(label_text, 12) + label.add_theme_color_override("font_color", MUTED_INK) + stack.add_child(label) + var button := NOTEPAD_INK_ACTION_SCENE.instantiate() as Button + button.name = "CatalogIndex%s" % String(key).capitalize().replace(" ", "") + button.custom_minimum_size = Vector2(0.0, INDEX_OPTION_HEIGHT) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.clip_text = true + _apply_index_value_font(button) + button.tooltip_text = "Change %s; use left or right on a controller" % label_text + button.pressed.connect(_cycle_index_option.bind(key, 1)) + button.gui_input.connect(_on_index_option_gui_input.bind(key)) + stack.add_child(button) + _index_option_buttons[key] = button + _index_controls.append(button) + return stack + + +func _apply_index_value_font(button: Button) -> void: + button.add_theme_font_override( + "font", InventoryNotepadType.HANDWRITTEN_FONT + ) + button.add_theme_font_size_override("font_size", 18) + for color_name: StringName in [ + &"font_color", + &"font_hover_color", + &"font_focus_color", + &"font_pressed_color", + ]: + button.add_theme_color_override(color_name, INK) + + +func _index_search_style(focused: bool) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(1.0, 0.97, 0.82, 0.12 if focused else 0.0) + style.border_color = Color(INK, 0.72 if focused else 0.42) + style.border_width_bottom = 2 + style.content_margin_left = 6 + style.content_margin_right = 22 + style.content_margin_top = 2 + style.content_margin_bottom = 3 + return style + + +func _configure_index_focus() -> void: + var controls: Array[Control] = [] + for control: Control in _index_controls: + if control != null and is_instance_valid(control): + controls.append(control) + if not controls.is_empty(): + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + + +func _apply_index_presentation() -> void: + if _index_body == null: + return + var scale_value := 0.82 if _compact_presentation else PAGE_CONTENT_SCALE + _index_body.scale = Vector2.ONE * scale_value + + func _apply_presentation_margins() -> void: if _outer_margin == null: return @@ -548,11 +886,30 @@ func _refresh_catalog() -> void: var preserved_entry_key: StringName = _selected_entry_key _clear_entries() var species: Array[FishDataType] = [] + var category_total: int = 0 if _catalog != null: - species = LogbookCatalog.ordered_species(_catalog.candidates) + for fish: FishDataType in LogbookCatalog.ordered_species( + _catalog.candidates + ): + if LogbookCatalog.category_for(fish) == _category: + category_total += 1 + var available_now_resolver := Callable() + if _fishing_spot != null and is_instance_valid(_fishing_spot): + available_now_resolver = Callable( + _fishing_spot, + "is_species_available_now", + ) + species = LogbookCatalog.filtered_species( + _catalog.candidates, + _category, + _collection_log, + _inventory, + _index_options, + available_now_resolver, + _current_season(), + _current_is_night(), + ) for fish: FishDataType in species: - if LogbookCatalog.category_for(fish) != _category: - continue var discovered: bool = _collection_log.has_discovered(fish.id) var entry: Button = _make_entry(fish, discovered) var selection_key: StringName = ( @@ -572,7 +929,11 @@ func _refresh_catalog() -> void: ) ) _catalog_grid.add_child(entry) - _empty_state.text = LogbookCatalog.empty_state(_category) + _empty_state.text = ( + "No entries match this index." + if _index_has_active_criteria() + else LogbookCatalog.empty_state(_category) + ) _empty_state.visible = _entry_buttons.is_empty() _catalog_scroll.visible = not _entry_buttons.is_empty() if ( @@ -589,10 +950,269 @@ func _refresh_catalog() -> void: _selected_entry_key = StringName() _refresh_selection_styles() _refresh_details(false) + _update_index_controls(species.size(), category_total) set_interactive(_interactive) _refresh_catalog_scroll_indicators.call_deferred() +func _toggle_index_page() -> void: + _set_index_open(not _index_open) + + +func _set_index_open(open: bool, restore_focus: bool = true) -> void: + _index_open = open + if _index_tab != null: + _index_tab.set_selected(open) + _index_tab.tooltip_text = ( + "Return to catch record" if open else "Open catalog index" + ) + if _index_body != null: + _index_body.visible = open + if _detail_body != null: + _detail_body.visible = not open + if not open: + _refresh_details(false) + _update_index_controls() + if restore_focus and _active and _interactive: + if open: + _controller_zone = ControllerZone.INDEX + set_interactive(true) + _index_search.grab_focus() + elif _controller_zone == ControllerZone.INDEX: + _controller_zone = ControllerZone.TABS + set_interactive(true) + _index_tab.grab_focus() + + +func _on_index_search_changed(value: String) -> void: + if _index_update_in_progress: + return + _index_options["search"] = value + _refresh_catalog() + + +func _cycle_index_option(key: StringName, direction: int = 1) -> void: + if not _index_options.has(key): + return + if key == &"descending": + _index_options[key] = not bool(_index_options[key]) + elif key == &"group": + var groups: Array[String] = [""] + if _catalog != null: + groups.append_array(LogbookCatalog.available_groups( + _catalog.candidates, + _category, + _collection_log, + )) + var current_index := groups.find(str(_index_options[key])) + if current_index < 0: + current_index = 0 + _index_options[key] = groups[wrapi( + current_index + direction, + 0, + groups.size(), + )] + else: + var option_count := _index_option_count(key) + if option_count <= 0: + return + _index_options[key] = wrapi( + int(_index_options[key]) + direction, + 0, + option_count, + ) + _refresh_catalog() + + +func _on_index_option_gui_input( + event: InputEvent, + key: StringName, +) -> void: + var mouse_event := event as InputEventMouseButton + if mouse_event == null or not mouse_event.pressed: + return + if mouse_event.button_index in [MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_WHEEL_UP]: + _cycle_index_option(key, -1) + get_viewport().set_input_as_handled() + elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _cycle_index_option(key, 1) + get_viewport().set_input_as_handled() + + +func _index_key_for_control(control: Control) -> StringName: + for key: StringName in _index_option_buttons: + if _index_option_buttons[key] == control: + return key + return StringName() + + +func _reset_index() -> void: + _index_options = { + "search": "", + "show": LogbookCatalog.DiscoveryFilter.ALL, + "sort": LogbookCatalog.SortMode.CATALOG_NUMBER, + "descending": false, + "availability": LogbookCatalog.AvailabilityFilter.ANY, + "season": LogbookCatalog.SeasonFilter.ANY, + "time": LogbookCatalog.TimeFilter.ANY, + "rarity": LogbookCatalog.RarityFilter.ANY, + "method": LogbookCatalog.MethodFilter.ANY, + "holding": LogbookCatalog.HoldingFilter.ANY, + "group": "", + } + _index_update_in_progress = true + _index_search.text = "" + _index_update_in_progress = false + _refresh_catalog() + + +func _update_index_controls( + visible_count: int = -1, + category_total: int = -1, +) -> void: + if visible_count >= 0: + _index_visible_count = visible_count + if category_total >= 0: + _index_category_total = category_total + if _index_summary != null: + _index_summary.text = "%d shown of %d" % [ + _index_visible_count, + _index_category_total, + ] + for key: StringName in _index_option_buttons: + var button := _index_option_buttons[key] + if button == null: + continue + button.text = _index_option_label(key) + button.accessibility_name = "%s: %s" % [ + String(key).replace("_", " "), + button.text, + ] + if _index_tab != null: + var marked := _index_has_active_criteria() + _index_tab.text = "index •" if marked else "index" + _index_tab.accessibility_name = ( + "Catalog index, filters active" if marked else "Catalog index" + ) + if _index_reset != null: + _index_reset.disabled = not _index_has_active_criteria() + + +func _index_option_label(key: StringName) -> String: + match key: + &"show": + return INDEX_SHOW_LABELS[int(_index_options[key])] + &"sort": + return INDEX_SORT_LABELS[int(_index_options[key])] + &"availability": + return INDEX_AVAILABILITY_LABELS[int(_index_options[key])] + &"season": + return INDEX_SEASON_LABELS[int(_index_options[key])] + &"time": + return INDEX_TIME_LABELS[int(_index_options[key])] + &"rarity": + return INDEX_RARITY_LABELS[int(_index_options[key])] + &"method": + return INDEX_METHOD_LABELS[int(_index_options[key])] + &"holding": + return INDEX_HOLDING_LABELS[int(_index_options[key])] + &"group": + var group := str(_index_options[key]) + return group if not group.is_empty() else "any group" + &"descending": + var descending := bool(_index_options[key]) + match int(_index_options["sort"]): + LogbookCatalog.SortMode.NAME: + return "z–a" if descending else "a–z" + LogbookCatalog.SortMode.CATALOG_NUMBER: + return "high–low" if descending else "low–high" + LogbookCatalog.SortMode.RARITY: + return ( + "rare–common" if descending else "common–rare" + ) + LogbookCatalog.SortMode.VALUE, LogbookCatalog.SortMode.WEIGHT: + return "high–low" if descending else "low–high" + _: + return "most first" if descending else "least first" + return "any" + + +func _index_option_count(key: StringName) -> int: + match key: + &"show": + return INDEX_SHOW_LABELS.size() + &"sort": + return INDEX_SORT_LABELS.size() + &"availability": + return INDEX_AVAILABILITY_LABELS.size() + &"season": + return INDEX_SEASON_LABELS.size() + &"time": + return INDEX_TIME_LABELS.size() + &"rarity": + return INDEX_RARITY_LABELS.size() + &"method": + return INDEX_METHOD_LABELS.size() + &"holding": + return INDEX_HOLDING_LABELS.size() + return 0 + + +func _index_has_active_criteria() -> bool: + return ( + not str(_index_options["search"]).is_empty() + or int(_index_options["show"]) + != LogbookCatalog.DiscoveryFilter.ALL + or int(_index_options["sort"]) + != LogbookCatalog.SortMode.CATALOG_NUMBER + or bool(_index_options["descending"]) + or int(_index_options["availability"]) + != LogbookCatalog.AvailabilityFilter.ANY + or int(_index_options["season"]) + != LogbookCatalog.SeasonFilter.ANY + or int(_index_options["time"]) + != LogbookCatalog.TimeFilter.ANY + or int(_index_options["rarity"]) + != LogbookCatalog.RarityFilter.ANY + or int(_index_options["method"]) + != LogbookCatalog.MethodFilter.ANY + or int(_index_options["holding"]) + != LogbookCatalog.HoldingFilter.ANY + or not str(_index_options["group"]).is_empty() + ) + + +func _current_season() -> int: + return ( + _world_time.get_season() + if _world_time != null + else CalendarSeason.UNKNOWN + ) + + +func _current_is_night() -> bool: + return _world_time != null and _world_time.is_night_period() + + +func notify_availability_context_changed() -> void: + _on_logbook_context_changed() + + +func _on_logbook_context_changed( + _first: Variant = null, + _second: Variant = null, +) -> void: + if ( + int(_index_options["availability"]) + == LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW + or int(_index_options["season"]) + == LogbookCatalog.SeasonFilter.CURRENT + or int(_index_options["time"]) + == LogbookCatalog.TimeFilter.CURRENT + ): + _refresh_catalog() + + func _make_entry(fish: FishDataType, discovered: bool) -> Button: var entry := Button.new() entry.custom_minimum_size = CATALOG_ENTRY_SIZE @@ -663,8 +1283,11 @@ func _add_entry_content( func _select_category(category: LogbookCatalog.Category) -> void: if category == _category: + if _index_open: + _set_index_open(false) return _category = category + _set_index_open(false, false) _selected_id = StringName() _selected_entry_key = StringName() for index: int in _category_tabs.size(): @@ -683,6 +1306,11 @@ func _configure_category_focus() -> void: tab.focus_neighbor_right = tab.get_path_to( _category_tabs[mini(index + 1, _category_tabs.size() - 1)] ) + if not _category_tabs.is_empty() and _index_tab != null: + var final_tab: Button = _category_tabs.back() + final_tab.focus_neighbor_right = final_tab.get_path_to(_index_tab) + _index_tab.focus_neighbor_left = _index_tab.get_path_to(final_tab) + _index_tab.focus_neighbor_right = _index_tab.get_path_to(_index_tab) func _animate_tab_entry() -> void: @@ -690,11 +1318,15 @@ func _animate_tab_entry() -> void: (_category_tabs[index] as OrganizerTabType).animate_entrance( float(index) * 0.025 ) + if _index_tab != null: + _index_tab.animate_entrance(float(_category_tabs.size()) * 0.025) func _settle_tabs_for_close() -> void: for tab: OrganizerTabType in _category_tabs: tab.settle_for_close() + if _index_tab != null: + _index_tab.settle_for_close() func _begin_category_transition() -> void: @@ -750,9 +1382,12 @@ func _set_category_content_alpha(alpha: float) -> void: _empty_state.modulate.a = alpha if _detail_body != null: _detail_body.modulate.a = alpha + if _index_body != null: + _index_body.modulate.a = alpha func _select_entry(entry_key: StringName, fish_id: StringName) -> void: + _set_index_open(false, false) if fish_id.is_empty(): _selected_id = StringName() _selected_entry_key = entry_key @@ -1284,7 +1919,9 @@ func _on_collection_changed() -> void: func _on_inventory_changed() -> void: - _refresh_details(false) + # Ownership filters and favorite-backed views depend on the live cooler, + # while rebuilding the catalog also keeps the selected detail current. + _refresh_catalog() func _unknown_selection_key(fish: FishDataType) -> StringName: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 3c5dc77..2f9f96f 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -105,6 +105,7 @@ signal controller_hotbar_management_requested(initial_slot: int) signal controller_hotbar_management_ended signal menu_exit_started signal shop_cooler_modal_changed(is_open: bool) +signal controller_text_entry_requested(control: Control) enum Section { COOLER, @@ -409,6 +410,9 @@ func _ready() -> void: _logbook_tab.pressed.connect( _show_section.bind(Section.LOGBOOK) ) + _catalog_logbook.controller_text_entry_requested.connect( + controller_text_entry_requested.emit + ) _the_net_tab.pressed.connect(_show_section.bind(Section.NET)) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE)) @@ -556,6 +560,7 @@ func setup( discovery: DiscoveryClient, player_jobs: PlayerJobService, world_time: WorldTimeService, + world_weather: WorldWeatherService, world_environment: WorldEnvironment, world_sun: DirectionalLight3D, ) -> void: @@ -589,7 +594,14 @@ func setup( world_sun, ) _players_page.setup(network_player_list, discovery) - _catalog_logbook.setup(collection_log, inventory, catalog) + _catalog_logbook.setup( + collection_log, + inventory, + catalog, + fishing_spot, + world_time, + world_weather, + ) _the_net_page.setup(player_jobs, world_time) _network_mail_service.unread_count_changed.connect( _on_mail_unread_count_changed @@ -2779,6 +2791,7 @@ func _toggle_active_tackle() -> void: func _on_active_bait_changed(_item_id: StringName) -> void: _update_tackle_detail() + _catalog_logbook.notify_availability_context_changed() func _on_active_lure_changed(_item_id: StringName) -> void: @@ -3733,6 +3746,7 @@ func _refresh_all() -> void: func _on_bag_changed() -> void: _refresh_bag() _refresh_tackle_box() + _catalog_logbook.notify_availability_context_changed() func _on_hotbar_changed() -> void: From fe099f47dd4e5be4f18387c592c8a0cb5715ee1a Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 30 Aug 2026 21:37:26 -0400 Subject: [PATCH 05/14] Add portable RV homes and decor prototype --- fishing/fishing_spot.gd | 88 +- homes/decor_catalog.gd | 59 + homes/decor_catalog.gd.uid | 1 + homes/home_decor_controller.gd | 457 +++++++ homes/home_decor_controller.gd.uid | 1 + homes/home_world_service.gd | 1183 +++++++++++++++++ homes/home_world_service.gd.uid | 1 + homes/player_home_state.gd | 786 +++++++++++ homes/player_home_state.gd.uid | 1 + inventory/player_bag.gd | 22 + inventory/player_hotbar.gd | 19 + inventory/player_inventory_layout.gd | 84 +- items/catalog/basic_cube.tres | 17 + items/catalog/item_catalog.tres | 5 +- main/main.gd | 267 +++- main/main.tscn | 20 + network/network_home_protocol.gd | 176 +++ network/network_home_protocol.gd.uid | 1 + network/network_home_service.gd | 956 +++++++++++++ network/network_home_service.gd.uid | 1 + network/network_item_use_service.gd | 5 + network/network_protocol.gd | 14 +- network/network_session.gd | 38 +- network/network_shop_protocol.gd | 2 + network/network_shop_service.gd | 156 ++- player/player.gd | 135 +- save/player_save_manager.gd | 36 +- tests/art_tools_validation.gd | 13 +- tests/fish_hotbar_showcase_validation.gd | 5 +- tests/fish_quality_validation.gd | 5 +- tests/home_spawn_multiplayer_validation.gd | 178 +++ .../home_spawn_multiplayer_validation.gd.uid | 1 + tests/job_system_validation.gd | 5 +- tests/player_experience_validation.gd | 5 +- tests/progression_archive_validation.gd | 26 +- tests/unified_inventory_validation.gd | 35 + ui/decor_shop.gd | 323 +++++ ui/decor_shop.gd.uid | 1 + ui/decor_shop.tscn | 14 + ui/game_ui.gd | 97 ++ ui/game_ui.tscn | 32 +- ui/home_decor_toolbar.gd | 126 ++ ui/home_decor_toolbar.gd.uid | 1 + ui/home_decor_toolbar.tscn | 8 + ui/hotbar.gd | 11 +- ui/player_storage.gd | 57 +- world/decor_shop_interaction.gd | 2 + world/decor_shop_interaction.gd.uid | 1 + world/generation/generated_world_region.gd | 13 + world/generation/generated_world_region.tscn | 6 +- world/interactables/decor_shop_world.tscn | 35 + world/interactables/player_storage_box.tscn | 4 + world/regions/starter_island_region.gd | 11 + world/regions/starter_island_region.tscn | 5 + world/regions/world_region.gd | 4 + world/test_world.gd | 16 + 56 files changed, 5459 insertions(+), 112 deletions(-) create mode 100644 homes/decor_catalog.gd create mode 100644 homes/decor_catalog.gd.uid create mode 100644 homes/home_decor_controller.gd create mode 100644 homes/home_decor_controller.gd.uid create mode 100644 homes/home_world_service.gd create mode 100644 homes/home_world_service.gd.uid create mode 100644 homes/player_home_state.gd create mode 100644 homes/player_home_state.gd.uid create mode 100644 items/catalog/basic_cube.tres create mode 100644 network/network_home_protocol.gd create mode 100644 network/network_home_protocol.gd.uid create mode 100644 network/network_home_service.gd create mode 100644 network/network_home_service.gd.uid create mode 100644 tests/home_spawn_multiplayer_validation.gd create mode 100644 tests/home_spawn_multiplayer_validation.gd.uid create mode 100644 ui/decor_shop.gd create mode 100644 ui/decor_shop.gd.uid create mode 100644 ui/decor_shop.tscn create mode 100644 ui/home_decor_toolbar.gd create mode 100644 ui/home_decor_toolbar.gd.uid create mode 100644 ui/home_decor_toolbar.tscn create mode 100644 world/decor_shop_interaction.gd create mode 100644 world/decor_shop_interaction.gd.uid create mode 100644 world/interactables/decor_shop_world.tscn diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 8e3e169..8160eec 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -41,8 +41,6 @@ const FishingSurfaceSampleType = preload( const FishingSurfaceResolverType = preload( "res://fishing/fishing_surface_resolver.gd" ) -const ArtShopStockType = preload("res://economy/art_shop_stock.gd") -const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" @@ -134,6 +132,7 @@ const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate" var state: FishingState = FishingState.READY var _external_input_blocked: bool = false var _gameplay_input_enabled: bool = false +var _fishing_environment_enabled: bool = true var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_player: PlayerType var _local_inventory: FishInventoryType @@ -399,6 +398,22 @@ func set_gameplay_input_enabled(enabled: bool) -> void: reset_transient_state() +func set_fishing_environment_enabled(enabled: bool) -> void: + if _fishing_environment_enabled == enabled: + return + _fishing_environment_enabled = enabled + if enabled: + _new_cast_press_armed = not Input.is_action_pressed("fish_primary") + return + if _network_fishing != null and _network_fishing.has_local_attempt(): + _network_fishing.cancel_local_attempt("") + reset_transient_state() + + +func is_fishing_environment_enabled() -> bool: + return _fishing_environment_enabled + + func reset_transient_state(restore_movement: bool = true) -> void: var local_state_was_active: bool = ( state != FishingState.READY or _active_player != null @@ -533,6 +548,7 @@ func _process(delta: float) -> void: func _unhandled_input(event: InputEvent) -> void: if ( not _gameplay_input_enabled + or not _fishing_environment_enabled or _external_input_blocked or not _local_menu_input_owners.is_empty() ): @@ -545,51 +561,23 @@ func _unhandled_input(event: InputEvent) -> void: alternate_reel and state == FishingState.FIGHTING ): return + # Fishing owns the primary action only after a rod starts a cast, or while + # completing an already-active fishing sequence. An empty hand, decor, + # gathering tools, and other items must remain available to their own world + # interaction controllers. + if state == FishingState.READY and not has_active_fishing_rod(): + return # A held keyboard key emits echo events. Treat the alternate reel exactly # like a mouse button: one barrier hit per physical press, while the normal # held-input path continues driving free reeling between barriers. if event is InputEventKey and event.echo: get_viewport().set_input_as_handled() return - var selected_item: ItemDataType = _get_active_item() - if ( - state == FishingState.READY - and selected_item != null - and FishingShopStockType.is_gathering_tool(selected_item.item_id) - ): - return if event.is_pressed(): match state: FishingState.READY: if not _new_cast_press_armed: return - var active_item: ItemDataType = _get_active_item() - if ( - active_item != null - and ( - active_item.category == ItemDataType.Category.CONSUMABLE - or ( - active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID - ) - ) - ): - if _network_item_use != null: - _network_item_use.request_use(active_item.item_id) - elif _item_effects.use_item(active_item, _local_bag): - status_changed.emit(_item_effects.get_feedback( - active_item.item_id - )) - elif ( - active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID - ): - local_speech_requested.emit( - PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE - ) - get_viewport().set_input_as_handled() - return - if not has_active_fishing_rod(): - get_viewport().set_input_as_handled() - return if ( _is_inventory_full() ): @@ -661,6 +649,34 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func try_use_active_non_fishing_item() -> bool: + if ( + not _gameplay_input_enabled + or _external_input_blocked + or not _local_menu_input_owners.is_empty() + or state != FishingState.READY + ): + return false + var active_item: ItemDataType = _get_active_item() + if ( + active_item == null + or ( + active_item.category != ItemDataType.Category.CONSUMABLE + and active_item.item_id != PlayerItemEffectsType.FISH_FINDER_ID + ) + ): + return false + if _network_item_use != null: + _network_item_use.request_use(active_item.item_id) + elif _item_effects.use_item(active_item, _local_bag): + status_changed.emit(_item_effects.get_feedback(active_item.item_id)) + elif active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID: + local_speech_requested.emit( + PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE + ) + return true + + func _is_reel_input_pressed() -> bool: return ( Input.is_action_pressed("fish_primary") diff --git a/homes/decor_catalog.gd b/homes/decor_catalog.gd new file mode 100644 index 0000000..14e5e29 --- /dev/null +++ b/homes/decor_catalog.gd @@ -0,0 +1,59 @@ +class_name DecorCatalog +extends RefCounted + +const BASIC_CUBE_ID: StringName = &"basic_cube" +const MAX_OWNED_PER_PRODUCT: int = 32 + +const _PRODUCTS: Dictionary = { + BASIC_CUBE_ID: { + "display_name": "basic cube", + "description": "a simple piece of decor for testing your RV layout.", + "price": 1, + "footprint": Vector2(1.0, 0.8), + "height": 0.8, + }, +} + + +static func get_product_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for value: Variant in _PRODUCTS.keys(): + ids.append(StringName(str(value))) + ids.sort_custom(func(first: StringName, second: StringName) -> bool: + return String(first) < String(second) + ) + return ids + + +static func has_product(product_id: StringName) -> bool: + return _PRODUCTS.has(product_id) + + +static func get_product(product_id: StringName) -> Dictionary: + var value: Variant = _PRODUCTS.get(product_id) + return ( + (value as Dictionary).duplicate(true) + if typeof(value) == TYPE_DICTIONARY + else {} + ) + + +static func get_display_name(product_id: StringName) -> String: + return str(get_product(product_id).get("display_name", product_id)) + + +static func get_description(product_id: StringName) -> String: + return str(get_product(product_id).get("description", "")) + + +static func get_price(product_id: StringName) -> int: + return int(get_product(product_id).get("price", -1)) + + +static func get_footprint(product_id: StringName) -> Vector2: + var value: Variant = get_product(product_id).get("footprint", Vector2.ZERO) + return value as Vector2 if value is Vector2 else Vector2.ZERO + + +static func get_height(product_id: StringName) -> float: + return float(get_product(product_id).get("height", 0.0)) diff --git a/homes/decor_catalog.gd.uid b/homes/decor_catalog.gd.uid new file mode 100644 index 0000000..10d2530 --- /dev/null +++ b/homes/decor_catalog.gd.uid @@ -0,0 +1 @@ +uid://bvgwmexas2gps diff --git a/homes/home_decor_controller.gd b/homes/home_decor_controller.gd new file mode 100644 index 0000000..1a97a5d --- /dev/null +++ b/homes/home_decor_controller.gd @@ -0,0 +1,457 @@ +class_name HomeDecorController +extends Node + +const DecorCatalogType = preload("res://homes/decor_catalog.gd") +const DRAG_RESPONSE: float = 22.0 +const DRAG_POSITION_EPSILON: float = 0.005 + +var _player: Player +var _hotbar: PlayerHotbar +var _bag: PlayerBag +var _home_state: PlayerHomeState +var _network_home: NetworkHomeService +var _home_world: HomeWorldService +var _fishing_spot: FishingSpot +var _toolbar: HomeDecorToolbar +var _mode: PlayerHomeState.PlacementMode = PlayerHomeState.PlacementMode.GRID +var _selected_instance_id: String = "" +var _drag_instance_id: String = "" +var _drag_owner_fingerprint: String = "" +var _drag_pointer_offset: Vector3 = Vector3.ZERO +var _drag_original_position: Vector3 = Vector3.ZERO +var _drag_original_yaw: float = 0.0 +var _drag_target_position: Vector3 = Vector3.ZERO +var _drag_target_yaw: float = 0.0 +var _drag_target_valid: bool = false + + +func setup( + player: Player, + hotbar: PlayerHotbar, + bag: PlayerBag, + home_state: PlayerHomeState, + network_home: NetworkHomeService, + home_world: HomeWorldService, + fishing_spot: FishingSpot, + toolbar: HomeDecorToolbar, +) -> void: + _player = player + _hotbar = hotbar + _bag = bag + _home_state = home_state + _network_home = network_home + _home_world = home_world + _fishing_spot = fishing_spot + _toolbar = toolbar + if not _network_home.local_space_changed.is_connected( + _on_local_space_changed + ): + _network_home.local_space_changed.connect(_on_local_space_changed) + if not _home_state.changed.is_connected(_on_home_changed): + _home_state.changed.connect(_on_home_changed) + if not _toolbar.placement_mode_requested.is_connected( + _on_placement_mode_requested + ): + _toolbar.placement_mode_requested.connect( + _on_placement_mode_requested + ) + _toolbar.rotate_requested.connect(_rotate_selected) + _toolbar.store_requested.connect(_store_selected) + _toolbar.cancel_requested.connect(_clear_selection) + _toolbar.set_mode(_mode) + _refresh_toolbar() + + +func begin_decorating() -> void: + _refresh_toolbar() + if _toolbar.visible: + _toolbar.report_status( + "use held decor to place it; click or drag placed decor" + ) + + +func _input(event: InputEvent) -> void: + if _drag_instance_id.is_empty(): + return + var pointer_motion := event as InputEventMouseMotion + if pointer_motion != null: + _update_drag(pointer_motion.position) + get_viewport().set_input_as_handled() + return + var pointer_button := event as InputEventMouseButton + if ( + pointer_button != null + and pointer_button.button_index == MOUSE_BUTTON_LEFT + and not pointer_button.pressed + ): + _finish_drag() + get_viewport().set_input_as_handled() + + +func _unhandled_input(event: InputEvent) -> void: + var pointer_button := event as InputEventMouseButton + if ( + pointer_button != null + and pointer_button.button_index == MOUSE_BUTTON_LEFT + ): + if not pointer_button.pressed or not _can_decorate(): + return + if _begin_drag(pointer_button.position): + get_viewport().set_input_as_handled() + return + var pointer_decor_id: StringName = _get_selected_decor_id() + if ( + not pointer_decor_id.is_empty() + and _bag.get_quantity(pointer_decor_id) > 0 + ): + _place_carried_decor(pointer_decor_id) + get_viewport().set_input_as_handled() + return + if not _can_decorate() or not event.is_action_pressed("fish_primary"): + return + if event is InputEventKey and event.echo: + return + var decor_id: StringName = _get_selected_decor_id() + if not decor_id.is_empty() and _bag.get_quantity(decor_id) > 0: + _place_carried_decor(decor_id) + get_viewport().set_input_as_handled() + + +func _process(delta: float) -> void: + if _drag_instance_id.is_empty(): + return + if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): + _finish_drag() + return + if not _drag_target_valid: + return + if not _can_decorate(): + _cancel_drag() + return + var current: Dictionary = _home_world.get_decor_preview_transform( + _drag_owner_fingerprint, _drag_instance_id + ) + if current.is_empty(): + _cancel_drag() + return + var current_position: Vector3 = current["position"] + var current_yaw: float = float(current["yaw"]) + var blend: float = 1.0 - exp(-DRAG_RESPONSE * delta) + _home_world.set_decor_preview_transform( + _drag_owner_fingerprint, + _drag_instance_id, + current_position.lerp(_drag_target_position, blend), + lerp_angle(current_yaw, _drag_target_yaw, blend), + ) + + +func _begin_drag(screen_position: Vector2) -> bool: + var owner_fingerprint: String = ( + _network_home.get_local_home_owner_fingerprint() + ) + var camera: Camera3D = _player.get_active_gameplay_camera() + var clicked_instance: String = _home_world.find_decor_instance_at_screen( + owner_fingerprint, camera, screen_position + ) + if clicked_instance.is_empty(): + return false + var placement: Dictionary = _home_state.get_placement(clicked_instance) + if placement.is_empty(): + return false + var original_position: Vector3 = _placement_position(placement) + if not original_position.is_finite(): + return false + if not _home_world.set_decor_manipulation_active( + owner_fingerprint, clicked_instance, true + ): + return false + var pointer_position: Vector3 = _home_world.get_floor_position_from_screen( + owner_fingerprint, camera, screen_position + ) + _drag_instance_id = clicked_instance + _drag_owner_fingerprint = owner_fingerprint + _drag_original_position = original_position + _drag_original_yaw = float(placement.get("yaw", 0.0)) + _drag_target_position = original_position + _drag_target_yaw = _drag_original_yaw + _drag_target_valid = true + _drag_pointer_offset = ( + original_position - pointer_position + if pointer_position.is_finite() + else Vector3.ZERO + ) + _drag_pointer_offset.y = 0.0 + _select_instance(clicked_instance) + _toolbar.report_status("drag decor, then release to place it") + return true + + +func _update_drag(screen_position: Vector2) -> void: + if not _can_decorate(): + _cancel_drag() + return + var camera: Camera3D = _player.get_active_gameplay_camera() + var pointer_position: Vector3 = _home_world.get_floor_position_from_screen( + _drag_owner_fingerprint, camera, screen_position + ) + if not pointer_position.is_finite(): + # Keep the most recent valid target when the pointer briefly leaves the + # RV floor. Releasing there should place at that target, not throw the + # decor all the way back to where the drag began. + _toolbar.report_status("keep the pointer over the RV floor") + return + var placement: Dictionary = _home_state.get_placement(_drag_instance_id) + if placement.is_empty(): + _clear_selection() + return + var candidate: Vector3 = pointer_position + _drag_pointer_offset + candidate.y = 0.0 + var normalized: Dictionary = _network_home.get_valid_local_decor_transform( + _drag_instance_id, + candidate, + float(placement.get("yaw", _drag_original_yaw)), + _mode, + ) + if normalized.is_empty(): + # A densely furnished area may have no nearby solution. Preserve the + # last valid preview so the interaction remains stable while the player + # moves the pointer toward open space. + _toolbar.report_status("holding at the nearest available position") + return + _drag_target_position = normalized["position"] + _drag_target_yaw = float(normalized["yaw"]) + _drag_target_valid = true + _toolbar.report_status("release to place decor") + + +func _finish_drag() -> void: + var instance_id: String = _drag_instance_id + var owner_fingerprint: String = _drag_owner_fingerprint + var original_position: Vector3 = _drag_original_position + var original_yaw: float = _drag_original_yaw + var target_position: Vector3 = _drag_target_position + var target_yaw: float = _drag_target_yaw + var target_valid: bool = _drag_target_valid + var placement: Dictionary = _home_state.get_placement(instance_id) + var decor_id := StringName(str(placement.get("decor_id", ""))) + if ( + not target_valid + or decor_id.is_empty() + or not _home_world.is_decor_placement_clear_of_players( + owner_fingerprint, decor_id, target_position, target_yaw + ) + ): + _home_world.set_decor_preview_transform( + owner_fingerprint, + instance_id, + original_position, + original_yaw, + ) + _home_world.set_decor_manipulation_active( + owner_fingerprint, instance_id, false + ) + _reset_drag_state() + _toolbar.report_status( + "a player is in the way" + if target_valid + else "that space is not available" + ) + return + _home_world.set_decor_preview_transform( + owner_fingerprint, instance_id, target_position, target_yaw + ) + if ( + original_position.distance_to(target_position) + <= DRAG_POSITION_EPSILON + and is_zero_approx(angle_difference(original_yaw, target_yaw)) + ): + _home_world.set_decor_manipulation_active( + owner_fingerprint, instance_id, false + ) + _reset_drag_state() + _select_instance(instance_id) + return + if _network_home.update_local_decor( + instance_id, target_position, target_yaw, _mode + ): + _home_world.set_decor_manipulation_active( + owner_fingerprint, instance_id, false + ) + _reset_drag_state() + _toolbar.report_status("decor moved") + return + _home_world.set_decor_preview_transform( + owner_fingerprint, + instance_id, + original_position, + original_yaw, + ) + _home_world.set_decor_manipulation_active( + owner_fingerprint, instance_id, false + ) + _reset_drag_state() + _toolbar.report_status("that space is not available") + + +func _cancel_drag() -> void: + if _drag_instance_id.is_empty(): + return + _home_world.set_decor_preview_transform( + _drag_owner_fingerprint, + _drag_instance_id, + _drag_original_position, + _drag_original_yaw, + ) + _home_world.set_decor_manipulation_active( + _drag_owner_fingerprint, _drag_instance_id, false + ) + _reset_drag_state() + + +func _reset_drag_state() -> void: + _drag_instance_id = "" + _drag_owner_fingerprint = "" + _drag_pointer_offset = Vector3.ZERO + _drag_original_position = Vector3.ZERO + _drag_original_yaw = 0.0 + _drag_target_position = Vector3.ZERO + _drag_target_yaw = 0.0 + _drag_target_valid = false + + +static func _placement_position(placement: Dictionary) -> Vector3: + if placement.is_empty(): + return Vector3.INF + var values: Array = placement.get("position", []) + if values.size() != 3: + return Vector3.INF + return Vector3( + float(values[0]), float(values[1]), float(values[2]) + ) + + +func _place_carried_decor(decor_id: StringName) -> void: + var owner_fingerprint: String = ( + _network_home.get_local_home_owner_fingerprint() + ) + var forward: Vector3 = -_player.global_basis.z + forward.y = 0.0 + forward = forward.normalized() + var global_position: Vector3 = _player.global_position + forward * 1.45 + var local_position: Vector3 = _home_world.interior_global_to_local( + owner_fingerprint, global_position + ) + var instance_id: String = _network_home.place_local_decor( + decor_id, local_position, _player.global_rotation.y, _mode + ) + if instance_id.is_empty(): + _toolbar.report_status("make room in front of you to place this decor") + return + _select_instance(instance_id) + _toolbar.report_status("decor placed") + + +func _select_instance(instance_id: String) -> void: + var placement: Dictionary = _home_state.get_placement(instance_id) + if placement.is_empty(): + _clear_selection() + return + _selected_instance_id = instance_id + var decor_id := StringName(str(placement.get("decor_id", ""))) + _toolbar.set_selection( + instance_id, DecorCatalogType.get_display_name(decor_id) + ) + + +func _rotate_selected() -> void: + if not _can_decorate() or _selected_instance_id.is_empty(): + return + var placement: Dictionary = _home_state.get_placement( + _selected_instance_id + ) + if placement.is_empty(): + _clear_selection() + return + var values: Array = placement.get("position", []) + if values.size() != 3: + return + var position := Vector3( + float(values[0]), float(values[1]), float(values[2]) + ) + if _network_home.update_local_decor( + _selected_instance_id, + position, + float(placement.get("yaw", 0.0)) + PI * 0.5, + _mode, + ): + _toolbar.report_status("decor rotated") + else: + _toolbar.report_status("not enough room to rotate here") + + +func _store_selected() -> void: + if _selected_instance_id.is_empty(): + return + if _network_home.remove_local_decor(_selected_instance_id): + _clear_selection() + _toolbar.report_status("decor packed into storage") + else: + _toolbar.report_status("inventory and storage are full") + + +func _clear_selection() -> void: + _cancel_drag() + _selected_instance_id = "" + if _toolbar != null: + _toolbar.set_selection("") + + +func _on_placement_mode_requested(mode: int) -> void: + _mode = mode as PlayerHomeState.PlacementMode + + +func _on_local_space_changed( + _space_id: StringName, + _owner_fingerprint: String, +) -> void: + _clear_selection() + _refresh_toolbar() + + +func _on_home_changed() -> void: + if ( + not _selected_instance_id.is_empty() + and _home_state.get_placement(_selected_instance_id).is_empty() + ): + _clear_selection() + _refresh_toolbar() + + +func _refresh_toolbar() -> void: + if _toolbar == null: + return + _toolbar.set_active(_can_decorate()) + if not _toolbar.visible: + _clear_selection() + + +func _can_decorate() -> bool: + return ( + _player != null + and _network_home != null + and _network_home.can_local_decorate() + and _fishing_spot != null + and _fishing_spot.can_open_fishing_shop() + and _player.is_movement_enabled() + ) + + +func _get_selected_decor_id() -> StringName: + if ( + _hotbar == null + or _hotbar.get_selected_assignment_kind() + != PlayerHotbar.AssignmentKind.ITEM + ): + return StringName() + var item_id: StringName = _hotbar.get_selected_item_id() + return item_id if DecorCatalogType.has_product(item_id) else StringName() diff --git a/homes/home_decor_controller.gd.uid b/homes/home_decor_controller.gd.uid new file mode 100644 index 0000000..1778125 --- /dev/null +++ b/homes/home_decor_controller.gd.uid @@ -0,0 +1 @@ +uid://b5j0mqcvhmi54 diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd new file mode 100644 index 0000000..4e1b422 --- /dev/null +++ b/homes/home_world_service.gd @@ -0,0 +1,1183 @@ +class_name HomeWorldService +extends Node3D + +const NetworkHomeProtocolType = preload( + "res://network/network_home_protocol.gd" +) +const PlayerHomeStateType = preload("res://homes/player_home_state.gd") +const DecorCatalogType = preload("res://homes/decor_catalog.gd") + +const EXTERIOR_BODY_SIZE := Vector3(5.6, 2.5, 2.35) +const EXTERIOR_DOOR_LOCAL := Vector3(-1.45, 0.85, 1.48) +const EXTERIOR_SPAWN_LOCAL := Vector3(-1.45, 0.25, 2.45) +const EXTERIOR_INTERACTION_DISTANCE: float = 2.7 +const INTERIOR_EXIT_DISTANCE: float = 2.1 +const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0) +const INTERIOR_SPACING: float = 14.0 +const INTERIOR_FLOOR_SIZE := Vector3(8.0, 0.18, 4.5) +const INTERIOR_WALL_HEIGHT: float = 2.7 +const MIN_EXTERIOR_TRIANGLE_AREA: float = 1.0 +const MIN_EXTERIOR_SEPARATION: float = 9.0 +const EXTERIOR_FOOTPRINT_HALF_EXTENTS := Vector2(3.85, 2.55) +const EXTERIOR_SURFACE_INDEX_CELL_SIZE: float = 5.0 +const EXTERIOR_MAX_SURFACE_HEIGHT_DELTA: float = 0.24 +const EXTERIOR_CLEARANCE_HEIGHT: float = 2.7 +const WORLD_GEOMETRY_COLLISION_LAYER: int = 1 +const DECOR_SELECTION_COLLISION_LAYER: int = 1 << 8 +# Includes the player's 0.45 m capsule radius plus enough movement margin for +# a full low-tick-rate frame before a newly solid placement can be reached. +const PLAYER_DECOR_CLEARANCE_RADIUS: float = 0.8 +const BASIC_CUBE_SIZE := Vector3(1.0, 0.8, 0.8) + +var _world: TestWorld +var _spawn_service: PlayerSpawnService +var _session: NetworkSession +var _dedicated_runtime: bool = false +var _exteriors_root: Node3D +var _interiors_root: Node3D +var _presentations_by_fingerprint: Dictionary[String, Dictionary] = {} +var _local_space: StringName = NetworkHomeProtocolType.WORLD_SPACE +var _decor_under_manipulation: Dictionary[String, bool] = {} +var _pending_solid_decor: Dictionary[String, Dictionary] = {} + + +func setup( + world: TestWorld, + spawn_service: PlayerSpawnService, + session: NetworkSession, + dedicated_runtime: bool, +) -> void: + _world = world + _spawn_service = spawn_service + _session = session + _dedicated_runtime = dedicated_runtime + _ensure_roots() + set_process(true) + + +func is_world_ready() -> bool: + return _world != null and _world.is_world_ready() + + +func clear_presentations() -> void: + _presentations_by_fingerprint.clear() + _decor_under_manipulation.clear() + _pending_solid_decor.clear() + if is_instance_valid(_exteriors_root): + for child: Node in _exteriors_root.get_children(): + child.queue_free() + if is_instance_valid(_interiors_root): + for child: Node in _interiors_root.get_children(): + child.queue_free() + + +func remove_presentation(owner_fingerprint: String) -> void: + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + if presentation.is_empty(): + return + _clear_decor_collision_tracking(owner_fingerprint) + _free_presentation_nodes(presentation) + _presentations_by_fingerprint.erase(owner_fingerprint) + + +func apply_manifest(data: Dictionary) -> bool: + if ( + not is_world_ready() + or not NetworkHomeProtocolType.validate_manifest_broadcast(data) + ): + return false + _ensure_roots() + var fingerprint: String = str(data["owner_fingerprint"]) + var prior: Dictionary = _presentations_by_fingerprint.get(fingerprint, {}) + var prior_instance_ids: Dictionary[String, bool] = {} + if not prior.is_empty(): + var prior_home: Dictionary = prior.get("home", {}) + for value: Variant in prior_home.get("placements", []): + if typeof(value) == TYPE_DICTIONARY: + prior_instance_ids[str( + (value as Dictionary).get("instance_id", "") + )] = true + if not prior.is_empty(): + _clear_decor_collision_tracking(fingerprint) + _free_presentation_nodes(prior) + var exterior_transform := NetworkHomeProtocolType.array_to_transform( + data["exterior_transform"] + ) + var interior_index: int = int(data["interior_index"]) + var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data( + data["home"] + ) + var exterior := _build_exterior(fingerprint, exterior_transform) + var interior := _build_interior(fingerprint, interior_index, home_data) + var presentation: Dictionary = { + "owner_peer_id": int(data["owner_peer_id"]), + "fingerprint": fingerprint, + "home": home_data, + "exterior_transform": exterior_transform, + "interior_index": interior_index, + "exterior": exterior, + "interior": interior, + } + _presentations_by_fingerprint[fingerprint] = presentation + _refresh_local_visibility() + if not prior.is_empty(): + for value: Variant in home_data.get("placements", []): + if typeof(value) != TYPE_DICTIONARY: + continue + var instance_id: String = str( + (value as Dictionary).get("instance_id", "") + ) + if not instance_id.is_empty() and not prior_instance_ids.has( + instance_id + ): + call_deferred( + "_spawn_decor_poof", fingerprint, instance_id + ) + return true + + +func choose_exterior_transform( + owner_peer_id: int, + reserved_transforms: Array[Transform3D], +) -> Transform3D: + var spawn_transform: Transform3D = ( + _world.get_player_spawn_transform() + if _world != null else Transform3D.IDENTITY + ) + var triangles: Array[PackedVector3Array] = ( + _world.get_spawn_surface_triangles( + [&"grass_lite", &"grass", &"sand"], -0.5 + ) + if _world != null else [] + ) + if not triangles.is_empty(): + var surface_index: Dictionary = _build_surface_index(triangles) + var strict_result: Transform3D = _find_exterior_transform( + owner_peer_id, + triangles, + surface_index, + spawn_transform, + reserved_transforms, + true, + true, + ) + if strict_result != Transform3D.IDENTITY: + return strict_result + var relaxed_result: Transform3D = _find_exterior_transform( + owner_peer_id, + triangles, + surface_index, + spawn_transform, + reserved_transforms, + false, + true, + ) + if relaxed_result != Transform3D.IDENTITY: + return relaxed_result + var dry_result: Transform3D = _find_exterior_transform( + owner_peer_id, + triangles, + surface_index, + spawn_transform, + reserved_transforms, + false, + false, + ) + if dry_result != Transform3D.IDENTITY: + push_warning( + "RV exterior used a dry fallback with obstructed clearance." + ) + return dry_result + push_error("No dry, level RV exterior footprint was available.") + var fallback: Transform3D = spawn_transform + var column: int = posmod(owner_peer_id - 1, 4) + var row: int = maxi(floori(float(owner_peer_id - 1) / 4.0), 0) + fallback.origin += Vector3( + -12.0 + float(column) * 8.0, + 0.0, + 8.0 + float(row) * 7.0, + ) + return fallback + + +func _find_exterior_transform( + owner_peer_id: int, + triangles: Array[PackedVector3Array], + surface_index: Dictionary, + spawn_transform: Transform3D, + reserved_transforms: Array[Transform3D], + require_spawn_clearance: bool, + require_obstacle_clearance: bool, +) -> Transform3D: + var triangle_count: int = triangles.size() + var start: int = posmod(owner_peer_id * 37, triangle_count) + var step: int = _coprime_step(triangle_count, 29) + for offset: int in triangle_count: + var triangle: PackedVector3Array = triangles[ + (start + offset * step) % triangle_count + ] + if not _is_exterior_candidate_triangle(triangle): + continue + var position: Vector3 = ( + triangle[0] + triangle[1] + triangle[2] + ) / 3.0 + if ( + require_spawn_clearance + and position.distance_to(spawn_transform.origin) < 6.0 + ): + continue + if _too_close_to_reserved(position, reserved_transforms): + continue + var yaw: float = float(posmod(owner_peer_id + offset, 4)) * PI * 0.5 + var grounded_position: Vector3 = _ground_exterior_footprint( + position, yaw, triangles, surface_index + ) + if not grounded_position.is_finite(): + continue + if ( + require_obstacle_clearance + and not _exterior_volume_is_clear(grounded_position, yaw) + ): + continue + return Transform3D(Basis(Vector3.UP, yaw), grounded_position) + return Transform3D.IDENTITY + + +func _is_exterior_candidate_triangle(triangle: PackedVector3Array) -> bool: + if triangle.size() != 3: + return false + var normal: Vector3 = (triangle[1] - triangle[0]).cross( + triangle[2] - triangle[0] + ) + var area: float = normal.length() * 0.5 + return ( + area >= MIN_EXTERIOR_TRIANGLE_AREA + and absf(normal.normalized().y) >= 0.94 + ) + + +func _ground_exterior_footprint( + position: Vector3, + yaw: float, + triangles: Array[PackedVector3Array], + surface_index: Dictionary, +) -> Vector3: + var heights: Array[float] = [] + for local_sample: Vector2 in _exterior_footprint_samples(): + var rotated_sample: Vector3 = Vector3( + local_sample.x, 0.0, local_sample.y + ).rotated(Vector3.UP, yaw) + var sample := Vector2( + position.x + rotated_sample.x, + position.z + rotated_sample.z, + ) + var height: float = _surface_height_at( + sample, position.y, triangles, surface_index + ) + if not is_finite(height): + return Vector3.INF + heights.append(height) + var minimum_height: float = heights.min() + var maximum_height: float = heights.max() + if maximum_height - minimum_height > EXTERIOR_MAX_SURFACE_HEIGHT_DELTA: + return Vector3.INF + return Vector3(position.x, maximum_height, position.z) + + +func _exterior_footprint_samples() -> Array[Vector2]: + var samples: Array[Vector2] = [] + for x_factor: float in [-1.0, -0.5, 0.0, 0.5, 1.0]: + for z_factor: float in [-1.0, 0.0, 1.0]: + samples.append(Vector2( + EXTERIOR_FOOTPRINT_HALF_EXTENTS.x * x_factor, + EXTERIOR_FOOTPRINT_HALF_EXTENTS.y * z_factor, + )) + return samples + + +func _build_surface_index( + triangles: Array[PackedVector3Array], +) -> Dictionary: + var result: Dictionary = {} + for triangle_index: int in triangles.size(): + var triangle: PackedVector3Array = triangles[triangle_index] + if triangle.size() != 3: + continue + var minimum_x: float = minf( + triangle[0].x, minf(triangle[1].x, triangle[2].x) + ) + var maximum_x: float = maxf( + triangle[0].x, maxf(triangle[1].x, triangle[2].x) + ) + var minimum_z: float = minf( + triangle[0].z, minf(triangle[1].z, triangle[2].z) + ) + var maximum_z: float = maxf( + triangle[0].z, maxf(triangle[1].z, triangle[2].z) + ) + var minimum_cell := Vector2i( + floori(minimum_x / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + floori(minimum_z / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + ) + var maximum_cell := Vector2i( + floori(maximum_x / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + floori(maximum_z / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + ) + for cell_x: int in range(minimum_cell.x, maximum_cell.x + 1): + for cell_y: int in range(minimum_cell.y, maximum_cell.y + 1): + var cell := Vector2i(cell_x, cell_y) + var bucket: Array = result.get(cell, []) + bucket.append(triangle_index) + result[cell] = bucket + return result + + +func _surface_height_at( + point: Vector2, + preferred_height: float, + triangles: Array[PackedVector3Array], + surface_index: Dictionary, +) -> float: + var cell := Vector2i( + floori(point.x / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + floori(point.y / EXTERIOR_SURFACE_INDEX_CELL_SIZE), + ) + var best_height: float = INF + var best_delta: float = INF + for value: Variant in surface_index.get(cell, []): + var triangle: PackedVector3Array = triangles[int(value)] + var height: float = _triangle_height_at(point, triangle) + if not is_finite(height): + continue + var delta: float = absf(height - preferred_height) + if delta < best_delta: + best_delta = delta + best_height = height + return best_height + + +func _triangle_height_at( + point: Vector2, + triangle: PackedVector3Array, +) -> float: + var a := Vector2(triangle[0].x, triangle[0].z) + var b := Vector2(triangle[1].x, triangle[1].z) + var c := Vector2(triangle[2].x, triangle[2].z) + var denominator: float = ( + (b.y - c.y) * (a.x - c.x) + + (c.x - b.x) * (a.y - c.y) + ) + if absf(denominator) <= 0.000001: + return INF + var weight_a: float = ( + (b.y - c.y) * (point.x - c.x) + + (c.x - b.x) * (point.y - c.y) + ) / denominator + var weight_b: float = ( + (c.y - a.y) * (point.x - c.x) + + (a.x - c.x) * (point.y - c.y) + ) / denominator + var weight_c: float = 1.0 - weight_a - weight_b + const EDGE_EPSILON: float = 0.001 + if ( + weight_a < -EDGE_EPSILON + or weight_b < -EDGE_EPSILON + or weight_c < -EDGE_EPSILON + ): + return INF + return ( + weight_a * triangle[0].y + + weight_b * triangle[1].y + + weight_c * triangle[2].y + ) + + +func _exterior_volume_is_clear(position: Vector3, yaw: float) -> bool: + if not is_inside_tree() or get_world_3d() == null: + return true + var shape := BoxShape3D.new() + shape.size = Vector3( + EXTERIOR_FOOTPRINT_HALF_EXTENTS.x * 2.0, + EXTERIOR_CLEARANCE_HEIGHT, + EXTERIOR_FOOTPRINT_HALF_EXTENTS.y * 2.0, + ) + var query := PhysicsShapeQueryParameters3D.new() + query.shape = shape + query.transform = Transform3D( + Basis(Vector3.UP, yaw), + position + Vector3.UP * (EXTERIOR_CLEARANCE_HEIGHT * 0.5 + 0.2), + ) + query.collision_mask = 1 + query.collide_with_areas = false + query.collide_with_bodies = true + return get_world_3d().direct_space_state.intersect_shape(query, 1).is_empty() + + +func _coprime_step(value: int, preferred_step: int) -> int: + var step: int = mini(preferred_step, maxi(value - 1, 1)) + while step > 1 and _greatest_common_divisor(step, value) != 1: + step -= 1 + return step + + +func _greatest_common_divisor(first: int, second: int) -> int: + var a: int = absi(first) + var b: int = absi(second) + while b != 0: + var remainder: int = a % b + a = b + b = remainder + return a + + +func get_exterior_transform(owner_fingerprint: String) -> Transform3D: + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + return presentation.get("exterior_transform", Transform3D.IDENTITY) + + +func get_exterior_prompt_position(owner_fingerprint: String) -> Vector3: + return get_exterior_transform(owner_fingerprint) * EXTERIOR_DOOR_LOCAL + + +func get_exterior_spawn_transform(owner_fingerprint: String) -> Transform3D: + var exterior_transform: Transform3D = get_exterior_transform( + owner_fingerprint + ) + return Transform3D( + exterior_transform.basis, + exterior_transform * EXTERIOR_SPAWN_LOCAL, + ) + + +func get_interior_spawn_transform( + owner_fingerprint: String, + visitor_offset: int = 0, +) -> Transform3D: + var anchor: Vector3 = get_interior_anchor(owner_fingerprint) + var result := Transform3D(Basis.IDENTITY, anchor) + result.origin += Vector3( + -1.0 + float(posmod(visitor_offset, 4)) * 0.65, + 0.25, + 0.4 + float(floori(float(visitor_offset) / 4.0)) * 0.55, + ) + return result + + +func get_interior_anchor(owner_fingerprint: String) -> Vector3: + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + var index: int = int(presentation.get("interior_index", 0)) + return INTERIOR_ORIGIN + Vector3(float(index) * INTERIOR_SPACING, 0.0, 0.0) + + +func is_avatar_near_exterior(avatar: Player, owner_fingerprint: String) -> bool: + if avatar == null or not _presentations_by_fingerprint.has(owner_fingerprint): + return false + var transform: Transform3D = get_exterior_transform(owner_fingerprint) + var door_position: Vector3 = transform * EXTERIOR_DOOR_LOCAL + return avatar.global_position.distance_to(door_position) <= ( + EXTERIOR_INTERACTION_DISTANCE + ) + + +func is_avatar_near_interior_exit( + avatar: Player, + owner_fingerprint: String, +) -> bool: + if avatar == null: + return false + return avatar.global_position.distance_to( + get_interior_exit_position(owner_fingerprint) + ) <= INTERIOR_EXIT_DISTANCE + + +func get_interior_exit_position(owner_fingerprint: String) -> Vector3: + return get_interior_anchor(owner_fingerprint) + Vector3( + -INTERIOR_FLOOR_SIZE.x * 0.5 + 0.6, + 0.35, + 0.0, + ) + + +func get_interior_prompt_position(owner_fingerprint: String) -> Vector3: + return get_interior_exit_position(owner_fingerprint) + Vector3.UP * 1.2 + + +func interior_local_to_global( + owner_fingerprint: String, + local_position: Vector3, +) -> Vector3: + return get_interior_anchor(owner_fingerprint) + local_position + + +func interior_global_to_local( + owner_fingerprint: String, + global_position: Vector3, +) -> Vector3: + return global_position - get_interior_anchor(owner_fingerprint) + + +func get_floor_position_from_screen( + owner_fingerprint: String, + camera: Camera3D, + screen_position: Vector2, +) -> Vector3: + if camera == null or owner_fingerprint.is_empty(): + return Vector3.INF + var ray_origin: Vector3 = camera.project_ray_origin(screen_position) + var ray_direction: Vector3 = camera.project_ray_normal(screen_position) + if absf(ray_direction.y) <= 0.0001: + return Vector3.INF + var floor_y: float = get_interior_anchor(owner_fingerprint).y + 0.1 + var distance: float = (floor_y - ray_origin.y) / ray_direction.y + if distance <= 0.0: + return Vector3.INF + return interior_global_to_local( + owner_fingerprint, + ray_origin + ray_direction * distance, + ) + + +func find_decor_instance_at_screen( + owner_fingerprint: String, + camera: Camera3D, + screen_position: Vector2, +) -> String: + if camera == null or owner_fingerprint.is_empty(): + return "" + var ray_origin: Vector3 = camera.project_ray_origin(screen_position) + var ray_end: Vector3 = ray_origin + camera.project_ray_normal( + screen_position + ) * 50.0 + var query := PhysicsRayQueryParameters3D.create(ray_origin, ray_end) + query.collision_mask = DECOR_SELECTION_COLLISION_LAYER + query.collide_with_areas = false + var result: Dictionary = camera.get_world_3d().direct_space_state.intersect_ray( + query + ) + var collider: Object = result.get("collider") + if collider is Node and (collider as Node).has_meta(&"decor_instance_id"): + if str((collider as Node).get_meta(&"decor_owner_fingerprint")) == ( + owner_fingerprint + ): + return str((collider as Node).get_meta(&"decor_instance_id")) + return "" + + +func get_decor_preview_transform( + owner_fingerprint: String, + instance_id: String, +) -> Dictionary: + var decor: Node3D = _get_decor_presentation( + owner_fingerprint, instance_id + ) + if decor == null: + return {} + return { + "position": Vector3(decor.position.x, 0.0, decor.position.z), + "yaw": decor.rotation.y, + } + + +func set_decor_preview_transform( + owner_fingerprint: String, + instance_id: String, + position: Vector3, + yaw_radians: float, +) -> bool: + var decor: Node3D = _get_decor_presentation( + owner_fingerprint, instance_id + ) + if decor == null or not position.is_finite() or not is_finite(yaw_radians): + return false + decor.position = Vector3(position.x, 0.18, position.z) + decor.rotation.y = yaw_radians + return true + + +func set_decor_manipulation_active( + owner_fingerprint: String, + instance_id: String, + active: bool, +) -> bool: + var decor: Node3D = _get_decor_presentation( + owner_fingerprint, instance_id + ) + if decor == null: + return false + var body: StaticBody3D = decor.get_node_or_null( + "BasicCubeCollision" + ) as StaticBody3D + if body == null: + return false + var key: String = _decor_collision_key( + owner_fingerprint, instance_id + ) + if active: + _decor_under_manipulation[key] = true + _pending_solid_decor.erase(key) + body.collision_layer = DECOR_SELECTION_COLLISION_LAYER + return true + _decor_under_manipulation.erase(key) + _queue_decor_world_collision( + owner_fingerprint, + instance_id, + StringName(str(decor.get_meta(&"decor_id", ""))), + body, + ) + return true + + +func is_decor_placement_clear_of_players( + owner_fingerprint: String, + decor_id: StringName, + local_position: Vector3, + yaw_radians: float, +) -> bool: + if ( + owner_fingerprint.is_empty() + or not DecorCatalogType.has_product(decor_id) + or not local_position.is_finite() + or not is_finite(yaw_radians) + ): + return false + if _spawn_service == null: + return true + var global_center: Vector3 = interior_local_to_global( + owner_fingerprint, local_position + ) + var half_size: Vector2 = DecorCatalogType.get_footprint(decor_id) * 0.5 + for peer_id: int in _spawn_service.get_peer_ids(): + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null or not is_instance_valid(avatar): + continue + var offset := Vector2( + avatar.global_position.x - global_center.x, + avatar.global_position.z - global_center.z, + ).rotated(-yaw_radians) + var nearest := Vector2( + clampf(offset.x, -half_size.x, half_size.x), + clampf(offset.y, -half_size.y, half_size.y), + ) + if offset.distance_squared_to(nearest) < ( + PLAYER_DECOR_CLEARANCE_RADIUS + * PLAYER_DECOR_CLEARANCE_RADIUS + ): + return false + return true + + +func _get_decor_presentation( + owner_fingerprint: String, + instance_id: String, +) -> Node3D: + if owner_fingerprint.is_empty() or instance_id.is_empty(): + return null + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + var interior: Node3D = presentation.get("interior") + if not is_instance_valid(interior): + return null + return interior.get_node_or_null( + "Decor/%s" % instance_id + ) as Node3D + + +func find_nearby_exterior_owner(avatar: Player) -> String: + if avatar == null: + return "" + var closest: String = "" + var closest_distance: float = EXTERIOR_INTERACTION_DISTANCE + for fingerprint: String in _presentations_by_fingerprint: + var transform: Transform3D = get_exterior_transform(fingerprint) + var distance: float = avatar.global_position.distance_to( + transform * EXTERIOR_DOOR_LOCAL + ) + if distance <= closest_distance: + closest = fingerprint + closest_distance = distance + return closest + + +func set_local_space(space_id: StringName) -> void: + _local_space = space_id + _refresh_local_visibility() + + +func get_manifest(owner_fingerprint: String) -> Dictionary: + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + return (presentation.get("home", {}) as Dictionary).duplicate(true) + + +func _process(_delta: float) -> void: + _refresh_pending_decor_collisions() + if not _dedicated_runtime: + _refresh_nearest_wall() + + +func _queue_decor_world_collision( + owner_fingerprint: String, + instance_id: String, + decor_id: StringName, + body: StaticBody3D, +) -> void: + if ( + owner_fingerprint.is_empty() + or instance_id.is_empty() + or not DecorCatalogType.has_product(decor_id) + or body == null + ): + return + var key: String = _decor_collision_key( + owner_fingerprint, instance_id + ) + body.collision_layer = DECOR_SELECTION_COLLISION_LAYER + _pending_solid_decor[key] = { + "owner_fingerprint": owner_fingerprint, + "instance_id": instance_id, + "decor_id": decor_id, + "body": body, + } + + +func _refresh_pending_decor_collisions() -> void: + if _pending_solid_decor.is_empty(): + return + var keys: Array[String] = [] + keys.assign(_pending_solid_decor.keys()) + for key: String in keys: + if _decor_under_manipulation.has(key): + continue + var pending: Dictionary = _pending_solid_decor.get(key, {}) + var body: StaticBody3D = pending.get("body") as StaticBody3D + var owner_fingerprint: String = str( + pending.get("owner_fingerprint", "") + ) + var instance_id: String = str(pending.get("instance_id", "")) + var decor_id := StringName(str(pending.get("decor_id", ""))) + var decor: Node3D = _get_decor_presentation( + owner_fingerprint, instance_id + ) + if body == null or decor == null or not is_instance_valid(body): + _pending_solid_decor.erase(key) + continue + if not is_decor_placement_clear_of_players( + owner_fingerprint, + decor_id, + Vector3(decor.position.x, 0.0, decor.position.z), + decor.rotation.y, + ): + continue + body.collision_layer = ( + WORLD_GEOMETRY_COLLISION_LAYER + | DECOR_SELECTION_COLLISION_LAYER + ) + _pending_solid_decor.erase(key) + + +func _clear_decor_collision_tracking(owner_fingerprint: String) -> void: + var prefix: String = "%s/" % owner_fingerprint + for key: String in _decor_under_manipulation.keys(): + if key.begins_with(prefix): + _decor_under_manipulation.erase(key) + for key: String in _pending_solid_decor.keys(): + if key.begins_with(prefix): + _pending_solid_decor.erase(key) + + +static func _decor_collision_key( + owner_fingerprint: String, + instance_id: String, +) -> String: + return "%s/%s" % [owner_fingerprint, instance_id] + + +func _ensure_roots() -> void: + if not is_instance_valid(_exteriors_root): + _exteriors_root = Node3D.new() + _exteriors_root.name = "RVExteriors" + add_child(_exteriors_root) + if not is_instance_valid(_interiors_root): + _interiors_root = Node3D.new() + _interiors_root.name = "RVInteriors" + add_child(_interiors_root) + + +func _build_exterior( + fingerprint: String, + world_transform: Transform3D, +) -> Node3D: + var root := Node3D.new() + root.name = "RV_%s" % fingerprint.left(8) + _exteriors_root.add_child(root) + root.global_transform = world_transform + _add_box( + root, "Body", EXTERIOR_BODY_SIZE, Vector3(0.0, 1.35, 0.0), + Color("d8d2bd"), true, + ) + root.visible = not _dedicated_runtime + return root + + +func _build_interior( + fingerprint: String, + interior_index: int, + home_data: Dictionary, +) -> Node3D: + var root := Node3D.new() + root.name = "RVInterior_%s" % fingerprint.left(8) + root.position = INTERIOR_ORIGIN + Vector3( + float(interior_index) * INTERIOR_SPACING, 0.0, 0.0 + ) + var half_width: float = INTERIOR_FLOOR_SIZE.x * 0.5 + var half_depth: float = INTERIOR_FLOOR_SIZE.z * 0.5 + var wall_center_y: float = INTERIOR_WALL_HEIGHT * 0.5 + _interiors_root.add_child(root) + _add_box( + root, "Floor", INTERIOR_FLOOR_SIZE, Vector3.ZERO, + Color("a6754e"), true, + ) + _add_wall( + root, "WallNorth", + Vector3(INTERIOR_FLOOR_SIZE.x, INTERIOR_WALL_HEIGHT, 0.18), + Vector3(0.0, wall_center_y, -half_depth), + ) + _add_wall( + root, "WallSouth", + Vector3(INTERIOR_FLOOR_SIZE.x, INTERIOR_WALL_HEIGHT, 0.18), + Vector3(0.0, wall_center_y, half_depth), + ) + _add_wall( + root, "WallWest", + Vector3(0.18, INTERIOR_WALL_HEIGHT, INTERIOR_FLOOR_SIZE.z), + Vector3(-half_width, wall_center_y, 0.0), + ) + _add_wall( + root, "WallEast", + Vector3(0.18, INTERIOR_WALL_HEIGHT, INTERIOR_FLOOR_SIZE.z), + Vector3(half_width, wall_center_y, 0.0), + ) + for x_position: float in [-1.75, 1.75]: + _add_box( + root, "WallNorthWindow%s" % ("Left" if x_position < 0.0 else "Right"), + Vector3(1.65, 0.95, 0.04), + Vector3(x_position, 1.55, -half_depth + 0.11), + Color("153640"), false, + ) + _add_box( + root, "WallSouthWindow%s" % ("Left" if x_position < 0.0 else "Right"), + Vector3(1.65, 0.95, 0.04), + Vector3(x_position, 1.55, half_depth - 0.11), + Color("153640"), false, + ) + _add_box( + root, "WallEastWindow", Vector3(0.04, 0.95, 1.55), + Vector3(half_width - 0.11, 1.55, 0.0), Color("153640"), false, + ) + _add_box( + root, "WallWestDoor", Vector3(0.04, 1.9, 1.25), + Vector3(-half_width + 0.11, 1.0, 0.0), Color("2897a8"), false, + ) + var decor := Node3D.new() + decor.name = "Decor" + root.add_child(decor) + _rebuild_decor(decor, home_data, fingerprint) + root.visible = false + if _dedicated_runtime: + _set_geometry_visible(root, false) + return root + + +func _rebuild_decor( + parent: Node3D, + home_data: Dictionary, + owner_fingerprint: String, +) -> void: + for value: Variant in home_data.get("placements", []): + if typeof(value) != TYPE_DICTIONARY: + continue + var placement: Dictionary = value + var decor_id := StringName(str(placement["decor_id"])) + if decor_id != DecorCatalogType.BASIC_CUBE_ID: + continue + var position_values: Array = placement["position"] + var placement_root := Node3D.new() + placement_root.name = str(placement["instance_id"]) + placement_root.position = Vector3( + float(position_values[0]), 0.18, float(position_values[2]) + ) + placement_root.rotation.y = float(placement["yaw"]) + placement_root.set_meta( + &"decor_instance_id", str(placement["instance_id"]) + ) + placement_root.set_meta( + &"decor_owner_fingerprint", owner_fingerprint + ) + placement_root.set_meta(&"decor_id", String(decor_id)) + parent.add_child(placement_root) + var body := _add_box( + placement_root, "BasicCube", BASIC_CUBE_SIZE, + Vector3(0.0, BASIC_CUBE_SIZE.y * 0.5, 0.0), + Color("b77a55"), true, + ) + if body != null: + body.collision_layer = DECOR_SELECTION_COLLISION_LAYER + body.set_meta( + &"decor_instance_id", str(placement["instance_id"]) + ) + body.set_meta( + &"decor_owner_fingerprint", owner_fingerprint + ) + body.set_meta(&"decor_id", String(decor_id)) + _queue_decor_world_collision( + owner_fingerprint, + str(placement["instance_id"]), + decor_id, + body, + ) + _add_box( + placement_root, + "BasicCubeFront", + Vector3(BASIC_CUBE_SIZE.x * 0.82, BASIC_CUBE_SIZE.y * 0.68, 0.025), + Vector3( + 0.0, + BASIC_CUBE_SIZE.y * 0.5, + -BASIC_CUBE_SIZE.z * 0.5 - 0.013, + ), + Color("2897a8"), + false, + ) + + +func _add_wall( + parent: Node3D, + node_name: String, + size: Vector3, + position: Vector3, +) -> void: + _add_box(parent, node_name, size, position, Color("d6caaa"), true) + + +func _add_box( + parent: Node3D, + node_name: String, + size: Vector3, + position: Vector3, + color: Color, + with_collision: bool, + invert_faces: bool = false, +) -> StaticBody3D: + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + var mesh := BoxMesh.new() + mesh.size = size + mesh.material = _material(color) + if invert_faces: + mesh.flip_faces = true + mesh_instance.mesh = mesh + mesh_instance.position = position + parent.add_child(mesh_instance) + if with_collision: + var body := StaticBody3D.new() + body.name = "%sCollision" % node_name + body.position = position + var collision := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = size + collision.shape = shape + body.add_child(collision) + parent.add_child(body) + return body + return null + + +func _spawn_decor_poof( + owner_fingerprint: String, + instance_id: String, +) -> void: + var presentation: Dictionary = _presentations_by_fingerprint.get( + owner_fingerprint, {} + ) + var interior: Node3D = presentation.get("interior") + if not is_instance_valid(interior): + return + var placement_root: Node3D = interior.get_node_or_null( + "Decor/%s" % instance_id + ) + if placement_root == null: + return + var poof := CPUParticles3D.new() + poof.name = "DecorPoof" + poof.amount = 12 + poof.lifetime = 0.45 + poof.one_shot = true + poof.explosiveness = 0.9 + poof.direction = Vector3.UP + poof.spread = 80.0 + poof.initial_velocity_min = 0.7 + poof.initial_velocity_max = 1.5 + poof.scale_amount_min = 0.08 + poof.scale_amount_max = 0.16 + var particle_mesh := SphereMesh.new() + particle_mesh.radius = 0.06 + particle_mesh.height = 0.12 + particle_mesh.material = _material(Color("d9c5a3")) + poof.mesh = particle_mesh + placement_root.add_child(poof) + poof.emitting = true + get_tree().create_timer(poof.lifetime + 0.1).timeout.connect( + poof.queue_free + ) + + +func _material(color: Color) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.albedo_color = color + material.roughness = 1.0 + material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX + return material + + +func _free_presentation_nodes(presentation: Dictionary) -> void: + for key: String in ["exterior", "interior"]: + var node: Node = presentation.get(key) + if is_instance_valid(node): + node.queue_free() + + +func _refresh_local_visibility() -> void: + var active_fingerprint: String = "" + if String(_local_space).begins_with("rv:"): + active_fingerprint = String(_local_space).trim_prefix("rv:") + if _world != null: + _world.set_local_home_interior_presentation_active( + not active_fingerprint.is_empty() + ) + for fingerprint: String in _presentations_by_fingerprint: + var presentation: Dictionary = _presentations_by_fingerprint[fingerprint] + var interior: Node3D = presentation.get("interior") + if is_instance_valid(interior): + interior.visible = ( + not _dedicated_runtime and fingerprint == active_fingerprint + ) + var exterior: Node3D = presentation.get("exterior") + if is_instance_valid(exterior): + exterior.visible = ( + not _dedicated_runtime and active_fingerprint.is_empty() + ) + var local_player: Player = ( + _spawn_service.get_local_player() + if _spawn_service != null else null + ) + if local_player == null: + return + var excluded_wall_rids: Array[RID] = [] + if not active_fingerprint.is_empty(): + var active: Dictionary = _presentations_by_fingerprint.get( + active_fingerprint, {} + ) + var active_interior: Node3D = active.get("interior") + if is_instance_valid(active_interior): + for wall_name: String in [ + "WallNorth", "WallSouth", "WallWest", "WallEast", + ]: + var body: StaticBody3D = active_interior.get_node_or_null( + "%sCollision" % wall_name + ) + if body != null: + excluded_wall_rids.append(body.get_rid()) + local_player.set_rv_interior_camera_mode( + not active_fingerprint.is_empty(), excluded_wall_rids + ) + + +func _refresh_nearest_wall() -> void: + if not String(_local_space).begins_with("rv:") or _spawn_service == null: + return + var fingerprint: String = String(_local_space).trim_prefix("rv:") + var presentation: Dictionary = _presentations_by_fingerprint.get( + fingerprint, {} + ) + var interior: Node3D = presentation.get("interior") + var player: Player = _spawn_service.get_local_player() + if not is_instance_valid(interior) or player == null: + return + var camera: Camera3D = player.get_active_gameplay_camera() + if camera == null: + return + var local_camera: Vector3 = interior.to_local(camera.global_position) + var local_view_direction: Vector3 = ( + interior.global_basis.inverse() * -camera.global_basis.z + ).normalized() + var horizontal_view_weight: float = absf(local_view_direction.x) + var depth_view_weight: float = absf(local_view_direction.z) + var hidden_walls: Dictionary[String, bool] = {} + var outside_horizontal_wall: bool = ( + absf(local_camera.x) >= INTERIOR_FLOOR_SIZE.x * 0.5 - 0.05 + ) + var outside_depth_wall: bool = ( + absf(local_camera.z) >= INTERIOR_FLOOR_SIZE.z * 0.5 - 0.05 + ) + # At a diagonal, both near walls occupy the view. Use the camera's viewing + # direction to hide both instead of choosing only the dominant position + # axis and leaving the adjacent wall across the screen. + if ( + outside_horizontal_wall + and horizontal_view_weight >= depth_view_weight * 0.35 + ): + hidden_walls[ + "WallEast" if local_camera.x >= 0.0 else "WallWest" + ] = true + if ( + outside_depth_wall + and depth_view_weight >= horizontal_view_weight * 0.35 + ): + hidden_walls[ + "WallSouth" if local_camera.z >= 0.0 else "WallNorth" + ] = true + if hidden_walls.is_empty(): + var fallback_wall: String = ( + "WallEast" if local_camera.x >= 0.0 else "WallWest" + ) + if depth_view_weight > horizontal_view_weight: + fallback_wall = ( + "WallSouth" if local_camera.z >= 0.0 else "WallNorth" + ) + hidden_walls[fallback_wall] = true + for wall_name: String in [ + "WallNorth", "WallSouth", "WallWest", "WallEast", + ]: + var wall: MeshInstance3D = interior.get_node_or_null(wall_name) + if wall != null: + wall.visible = not hidden_walls.has(wall_name) + for child: Node in interior.get_children(): + if ( + child is GeometryInstance3D + and String(child.name).begins_with(wall_name) + ): + (child as GeometryInstance3D).visible = ( + not hidden_walls.has(wall_name) + ) + + +func _too_close_to_reserved( + position: Vector3, + reserved_transforms: Array[Transform3D], +) -> bool: + for transform: Transform3D in reserved_transforms: + if position.distance_to(transform.origin) < MIN_EXTERIOR_SEPARATION: + return true + return false + + +func _set_geometry_visible(root: Node, visible: bool) -> void: + for node: Node in root.find_children("*", "GeometryInstance3D", true, false): + (node as GeometryInstance3D).visible = visible diff --git a/homes/home_world_service.gd.uid b/homes/home_world_service.gd.uid new file mode 100644 index 0000000..5505d06 --- /dev/null +++ b/homes/home_world_service.gd.uid @@ -0,0 +1 @@ +uid://cy2kjoxhwxujd diff --git a/homes/player_home_state.gd b/homes/player_home_state.gd new file mode 100644 index 0000000..decd34a --- /dev/null +++ b/homes/player_home_state.gd @@ -0,0 +1,786 @@ +class_name PlayerHomeState +extends Node + +signal changed + +const DecorCatalogType = preload("res://homes/decor_catalog.gd") + +const HOME_SCHEMA_VERSION: int = 1 +const STARTER_TIER: int = 0 +const STARTER_EXTERIOR_ID: StringName = &"starter_motorcoach" +const MAX_TIER: int = 8 +const MAX_PLACEMENTS: int = 128 +const MAX_INSTANCE_ID_LENGTH: int = 96 +const MAX_REVISION: int = 2147483647 +const GRID_SIZE: float = 0.5 +const FREE_PLACEMENT_SEARCH_STEP: float = 0.1 +const FREE_PLACEMENT_SEARCH_ANGLES: int = 24 +const NEAREST_PLACEMENT_SEARCH_DISTANCE: float = 2.0 +# The starter RV is intentionally cramped. Later RV tiers can replace these +# bounds with larger layouts when tiered interiors are introduced. +# The starter interior is 8.0 m by 4.5 m with 0.18 m walls. These bounds +# follow the walls' inner faces, with a small visual gap to avoid z-fighting. +const INTERIOR_HALF_EXTENTS := Vector2(3.91, 2.16) +const PLACEMENT_EDGE_PADDING: float = 0.02 + +enum Privacy { + OPEN, + CLOSED, +} + +enum PlacementMode { + GRID, + FREE, +} + +var _tier: int = STARTER_TIER +var _exterior_id: StringName = STARTER_EXTERIOR_ID +var _privacy: Privacy = Privacy.OPEN +var _owned_decor: Dictionary[StringName, int] = {} +var _placements: Array[Dictionary] = [] +var _revision: int = 0 +var _next_instance_sequence: int = 1 + + +func get_tier() -> int: + return _tier + + +func get_exterior_id() -> StringName: + return _exterior_id + + +func get_privacy() -> Privacy: + return _privacy + + +func get_revision() -> int: + return _revision + + +func get_owned_count(decor_id: StringName) -> int: + return int(_owned_decor.get(decor_id, 0)) + + +func get_placed_count(decor_id: StringName) -> int: + var count: int = 0 + for placement: Dictionary in _placements: + if StringName(str(placement.get("decor_id", ""))) == decor_id: + count += 1 + return count + + +func get_available_count(decor_id: StringName) -> int: + return maxi( + get_owned_count(decor_id) - get_placed_count(decor_id), + 0, + ) + + +func get_owned_decor() -> Dictionary[StringName, int]: + return _owned_decor.duplicate() + + +func get_placements() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for placement: Dictionary in _placements: + result.append(placement.duplicate(true)) + return result + + +func get_placement(instance_id: String) -> Dictionary: + var index: int = _placement_index(instance_id) + return _placements[index].duplicate(true) if index >= 0 else {} + + +func can_place_decor( + decor_id: StringName, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, +) -> bool: + if ( + get_available_count(decor_id) <= 0 + or _placements.size() >= MAX_PLACEMENTS + or not _valid_mode(mode) + ): + return false + return not get_nearest_valid_placement_transform( + decor_id, + position, + yaw_radians, + mode, + "", + ).is_empty() + + +func set_privacy(value: Privacy) -> bool: + if value < Privacy.OPEN or value > Privacy.CLOSED: + return false + if _privacy == value: + return true + _privacy = value + _touch() + return true + + +func add_owned_decor( + decor_id: StringName, + quantity: int = 1, +) -> bool: + if ( + not DecorCatalogType.has_product(decor_id) + or quantity <= 0 + ): + return false + var current: int = get_owned_count(decor_id) + if current + quantity > DecorCatalogType.MAX_OWNED_PER_PRODUCT: + return false + _owned_decor[decor_id] = current + quantity + _touch() + return true + + +func remove_owned_decor( + decor_id: StringName, + quantity: int = 1, +) -> bool: + if quantity <= 0: + return false + var current: int = get_owned_count(decor_id) + if current - quantity < get_placed_count(decor_id): + return false + var remaining: int = current - quantity + if remaining < 0: + return false + if remaining == 0: + _owned_decor.erase(decor_id) + else: + _owned_decor[decor_id] = remaining + _touch() + return true + + +func place_decor( + decor_id: StringName, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, +) -> String: + if ( + get_available_count(decor_id) <= 0 + or _placements.size() >= MAX_PLACEMENTS + or not _valid_mode(mode) + ): + return "" + var normalized := get_nearest_valid_placement_transform( + decor_id, + position, + yaw_radians, + mode, + "", + ) + if normalized.is_empty(): + return "" + var instance_id := _make_instance_id() + var placement: Dictionary = { + "instance_id": instance_id, + "decor_id": String(decor_id), + "mode": int(mode), + "position": _vector3_to_array(normalized["position"]), + "yaw": float(normalized["yaw"]), + } + _placements.append(placement) + _touch() + return instance_id + + +func update_placement( + instance_id: String, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, +) -> bool: + var index: int = _placement_index(instance_id) + var normalized: Dictionary = get_valid_update_transform( + instance_id, position, yaw_radians, mode + ) + if index < 0 or normalized.is_empty(): + return false + var placement: Dictionary = _placements[index].duplicate(true) + placement["mode"] = int(mode) + placement["position"] = _vector3_to_array(normalized["position"]) + placement["yaw"] = float(normalized["yaw"]) + _placements[index] = placement + _touch() + return true + + +func get_valid_update_transform( + instance_id: String, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, + candidate_validator: Callable = Callable(), +) -> Dictionary: + var index: int = _placement_index(instance_id) + if index < 0 or not _valid_mode(mode): + return {} + var decor_id := StringName( + str(_placements[index].get("decor_id", "")) + ) + return get_nearest_valid_placement_transform( + decor_id, + position, + yaw_radians, + mode, + instance_id, + candidate_validator, + ) + + +func get_nearest_valid_placement_transform( + decor_id: StringName, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, + ignored_instance_id: String = "", + candidate_validator: Callable = Callable(), +) -> Dictionary: + var normalized: Dictionary = normalized_placement_transform( + decor_id, position, yaw_radians, mode + ) + if normalized.is_empty(): + return {} + if ( + not _overlaps_existing( + decor_id, normalized, ignored_instance_id + ) + and _candidate_is_accepted(normalized, candidate_validator) + ): + return normalized + return ( + _find_nearest_grid_transform( + decor_id, + normalized, + ignored_instance_id, + candidate_validator, + ) + if mode == PlacementMode.GRID + else _find_nearest_free_transform( + decor_id, + normalized, + ignored_instance_id, + candidate_validator, + ) + ) + + +func remove_placement(instance_id: String) -> bool: + var index: int = _placement_index(instance_id) + if index < 0: + return false + _placements.remove_at(index) + _touch() + return true + + +func reset_to_defaults() -> void: + _tier = STARTER_TIER + _exterior_id = STARTER_EXTERIOR_ID + _privacy = Privacy.OPEN + _owned_decor.clear() + _placements.clear() + _revision = 0 + _next_instance_sequence = 1 + changed.emit() + + +func to_save_data() -> Dictionary: + var owned: Dictionary = {} + var ids: Array[StringName] = [] + ids.assign(_owned_decor.keys()) + ids.sort_custom(func(first: StringName, second: StringName) -> bool: + return String(first) < String(second) + ) + for decor_id: StringName in ids: + owned[String(decor_id)] = _owned_decor[decor_id] + return { + "schema_version": HOME_SCHEMA_VERSION, + "tier": _tier, + "exterior_id": String(_exterior_id), + "privacy": int(_privacy), + "owned_decor": owned, + "placements": get_placements(), + "revision": _revision, + "next_instance_sequence": _next_instance_sequence, + } + + +func restore_from_save_data(data: Dictionary) -> bool: + var sanitized: Dictionary = sanitize_save_data(data) + if sanitized.is_empty(): + return false + _tier = int(sanitized["tier"]) + _exterior_id = StringName(str(sanitized["exterior_id"])) + _privacy = int(sanitized["privacy"]) + _owned_decor.clear() + for key: Variant in (sanitized["owned_decor"] as Dictionary): + _owned_decor[StringName(str(key))] = int( + (sanitized["owned_decor"] as Dictionary)[key] + ) + _placements.clear() + for value: Variant in sanitized["placements"] as Array: + _placements.append((value as Dictionary).duplicate(true)) + _revision = int(sanitized["revision"]) + _next_instance_sequence = int(sanitized["next_instance_sequence"]) + changed.emit() + return true + + +static func default_save_data() -> Dictionary: + return { + "schema_version": HOME_SCHEMA_VERSION, + "tier": STARTER_TIER, + "exterior_id": String(STARTER_EXTERIOR_ID), + "privacy": int(Privacy.OPEN), + "owned_decor": {}, + "placements": [], + "revision": 0, + "next_instance_sequence": 1, + } + + +static func sanitize_save_data(data: Dictionary) -> Dictionary: + if ( + not _is_integer_value(data.get("schema_version")) + or int(data.get("schema_version", -1)) != HOME_SCHEMA_VERSION + or not _is_integer_value(data.get("tier")) + or typeof(data.get("exterior_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] + or not _is_integer_value(data.get("privacy")) + or typeof(data.get("owned_decor")) != TYPE_DICTIONARY + or typeof(data.get("placements")) != TYPE_ARRAY + or not _is_integer_value(data.get("revision")) + or not _is_integer_value(data.get("next_instance_sequence")) + ): + return {} + var tier: int = int(data["tier"]) + var exterior_id: String = str(data["exterior_id"]) + var privacy: int = int(data["privacy"]) + var revision: int = int(data["revision"]) + var next_sequence: int = int(data["next_instance_sequence"]) + if ( + tier < STARTER_TIER + or tier > MAX_TIER + or exterior_id.is_empty() + or exterior_id.length() > 96 + or privacy < Privacy.OPEN + or privacy > Privacy.CLOSED + or revision < 0 + or revision > MAX_REVISION + or next_sequence < 1 + or next_sequence > MAX_REVISION + ): + return {} + var sanitized_owned: Dictionary = {} + var owned: Dictionary = data["owned_decor"] + for key: Variant in owned: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {} + var decor_id := StringName(str(key)) + var quantity_value: Variant = owned[key] + if ( + not DecorCatalogType.has_product(decor_id) + or not _is_integer_value(quantity_value) + or int(quantity_value) <= 0 + or int(quantity_value) > DecorCatalogType.MAX_OWNED_PER_PRODUCT + ): + return {} + sanitized_owned[String(decor_id)] = int(quantity_value) + var values: Array = data["placements"] + if values.size() > MAX_PLACEMENTS: + return {} + var sanitized_placements: Array[Dictionary] = [] + var seen_instance_ids: Dictionary[String, bool] = {} + var placed_counts: Dictionary[StringName, int] = {} + for value: Variant in values: + if typeof(value) != TYPE_DICTIONARY: + return {} + var sanitized: Dictionary = sanitize_placement(value) + if sanitized.is_empty(): + return {} + var instance_id: String = str(sanitized["instance_id"]) + var decor_id := StringName(str(sanitized["decor_id"])) + if seen_instance_ids.has(instance_id): + return {} + seen_instance_ids[instance_id] = true + placed_counts[decor_id] = int( + placed_counts.get(decor_id, 0) + ) + 1 + if ( + int(placed_counts[decor_id]) + > int(sanitized_owned.get(String(decor_id), 0)) + ): + return {} + for previous: Dictionary in sanitized_placements: + if placements_overlap(previous, sanitized): + return {} + sanitized_placements.append(sanitized) + return { + "schema_version": HOME_SCHEMA_VERSION, + "tier": tier, + "exterior_id": exterior_id, + "privacy": privacy, + "owned_decor": sanitized_owned, + "placements": sanitized_placements, + "revision": revision, + "next_instance_sequence": next_sequence, + } + + +static func sanitize_placement(value: Dictionary) -> Dictionary: + for key: String in [ + "instance_id", "decor_id", "mode", "position", "yaw", + ]: + if not value.has(key): + return {} + if ( + typeof(value["instance_id"]) != TYPE_STRING + or typeof(value["decor_id"]) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] + or not _is_integer_value(value["mode"]) + or typeof(value["position"]) != TYPE_ARRAY + or typeof(value["yaw"]) not in [TYPE_FLOAT, TYPE_INT] + ): + return {} + var instance_id: String = value["instance_id"] + var decor_id := StringName(str(value["decor_id"])) + var mode: int = int(value["mode"]) + var position_values: Array = value["position"] + var yaw: float = float(value["yaw"]) + if ( + instance_id.is_empty() + or instance_id.length() > MAX_INSTANCE_ID_LENGTH + or not DecorCatalogType.has_product(decor_id) + or not _valid_mode(mode) + or position_values.size() != 3 + or not is_finite(yaw) + ): + return {} + for component: Variant in position_values: + if typeof(component) not in [TYPE_FLOAT, TYPE_INT]: + return {} + var position := Vector3( + float(position_values[0]), + float(position_values[1]), + float(position_values[2]), + ) + var normalized := normalized_placement_transform( + decor_id, + position, + yaw, + mode as PlacementMode, + ) + if normalized.is_empty(): + return {} + return { + "instance_id": instance_id, + "decor_id": String(decor_id), + "mode": mode, + "position": _vector3_to_array(normalized["position"]), + "yaw": float(normalized["yaw"]), + } + + +static func _is_integer_value(value: Variant) -> bool: + if typeof(value) == TYPE_INT: + return true + if typeof(value) != TYPE_FLOAT: + return false + var number: float = float(value) + return is_finite(number) and is_equal_approx(number, roundf(number)) + + +static func normalized_placement_transform( + decor_id: StringName, + position: Vector3, + yaw_radians: float, + mode: PlacementMode, +) -> Dictionary: + if ( + not DecorCatalogType.has_product(decor_id) + or not position.is_finite() + or not is_finite(yaw_radians) + or not _valid_mode(mode) + ): + return {} + var normalized_position := Vector3(position.x, 0.0, position.z) + var normalized_yaw: float = wrapf(yaw_radians, -PI, PI) + if mode == PlacementMode.GRID: + normalized_position.x = snappedf(normalized_position.x, GRID_SIZE) + normalized_position.z = snappedf(normalized_position.z, GRID_SIZE) + normalized_yaw = snappedf(normalized_yaw, PI * 0.5) + var footprint: Vector2 = DecorCatalogType.get_footprint(decor_id) + var cosine: float = absf(cos(normalized_yaw)) + var sine: float = absf(sin(normalized_yaw)) + var half_size := Vector2( + (footprint.x * cosine + footprint.y * sine) * 0.5, + (footprint.x * sine + footprint.y * cosine) * 0.5, + ) + var maximum_x: float = ( + INTERIOR_HALF_EXTENTS.x - half_size.x - PLACEMENT_EDGE_PADDING + ) + var maximum_z: float = ( + INTERIOR_HALF_EXTENTS.y - half_size.y - PLACEMENT_EDGE_PADDING + ) + if maximum_x < 0.0 or maximum_z < 0.0: + return {} + # Clamp after grid snapping so a grid-placed item can still sit flush at a + # wall even when the wall itself is not on an exact grid interval. + normalized_position.x = clampf( + normalized_position.x, -maximum_x, maximum_x + ) + normalized_position.z = clampf( + normalized_position.z, -maximum_z, maximum_z + ) + return { + "position": normalized_position, + "yaw": normalized_yaw, + } + + +func _find_nearest_grid_transform( + decor_id: StringName, + origin: Dictionary, + ignored_instance_id: String, + candidate_validator: Callable, +) -> Dictionary: + var origin_position: Vector3 = origin["position"] + var yaw: float = float(origin["yaw"]) + var maximum_ring: int = ceili( + NEAREST_PLACEMENT_SEARCH_DISTANCE / GRID_SIZE + ) + var seen: Dictionary[String, bool] = {} + for ring: int in range(1, maximum_ring + 1): + var nearest: Dictionary = {} + var nearest_distance_squared: float = INF + for x_offset: int in range(-ring, ring + 1): + for z_offset: int in range(-ring, ring + 1): + if maxi(absi(x_offset), absi(z_offset)) != ring: + continue + var candidate: Dictionary = normalized_placement_transform( + decor_id, + origin_position + Vector3( + float(x_offset) * GRID_SIZE, + 0.0, + float(z_offset) * GRID_SIZE, + ), + yaw, + PlacementMode.GRID, + ) + if candidate.is_empty() or not _mark_candidate_unseen( + candidate, seen + ): + continue + if _overlaps_existing( + decor_id, candidate, ignored_instance_id + ) or not _candidate_is_accepted( + candidate, candidate_validator + ): + continue + var candidate_position: Vector3 = candidate["position"] + var distance_squared: float = origin_position.distance_squared_to( + candidate_position + ) + if distance_squared < nearest_distance_squared: + nearest = candidate + nearest_distance_squared = distance_squared + if not nearest.is_empty(): + return nearest + return {} + + +func _find_nearest_free_transform( + decor_id: StringName, + origin: Dictionary, + ignored_instance_id: String, + candidate_validator: Callable, +) -> Dictionary: + var origin_position: Vector3 = origin["position"] + var yaw: float = float(origin["yaw"]) + var maximum_ring: int = ceili( + NEAREST_PLACEMENT_SEARCH_DISTANCE / FREE_PLACEMENT_SEARCH_STEP + ) + var seen: Dictionary[String, bool] = {} + for ring: int in range(1, maximum_ring + 1): + var radius: float = float(ring) * FREE_PLACEMENT_SEARCH_STEP + var nearest: Dictionary = {} + var nearest_distance_squared: float = INF + for angle_index: int in FREE_PLACEMENT_SEARCH_ANGLES: + var angle: float = ( + TAU * float(angle_index) / float(FREE_PLACEMENT_SEARCH_ANGLES) + ) + var candidate: Dictionary = normalized_placement_transform( + decor_id, + origin_position + Vector3( + cos(angle) * radius, 0.0, sin(angle) * radius + ), + yaw, + PlacementMode.FREE, + ) + if candidate.is_empty() or not _mark_candidate_unseen( + candidate, seen + ): + continue + if _overlaps_existing( + decor_id, candidate, ignored_instance_id + ) or not _candidate_is_accepted( + candidate, candidate_validator + ): + continue + var candidate_position: Vector3 = candidate["position"] + var distance_squared: float = origin_position.distance_squared_to( + candidate_position + ) + if distance_squared < nearest_distance_squared: + nearest = candidate + nearest_distance_squared = distance_squared + if not nearest.is_empty(): + return nearest + return {} + + +static func _mark_candidate_unseen( + candidate: Dictionary, + seen: Dictionary[String, bool], +) -> bool: + var position: Vector3 = candidate["position"] + var key := "%d:%d" % [ + roundi(position.x * 10000.0), + roundi(position.z * 10000.0), + ] + if seen.has(key): + return false + seen[key] = true + return true + + +static func _candidate_is_accepted( + candidate: Dictionary, + validator: Callable, +) -> bool: + return not validator.is_valid() or bool(validator.call(candidate)) + + +static func placements_overlap(first: Dictionary, second: Dictionary) -> bool: + var first_id := StringName(str(first.get("decor_id", ""))) + var second_id := StringName(str(second.get("decor_id", ""))) + if ( + not DecorCatalogType.has_product(first_id) + or not DecorCatalogType.has_product(second_id) + ): + return true + var first_position: Vector3 = _array_to_vector3(first.get("position", [])) + var second_position: Vector3 = _array_to_vector3(second.get("position", [])) + var first_yaw: float = float(first.get("yaw", 0.0)) + var second_yaw: float = float(second.get("yaw", 0.0)) + var first_half: Vector2 = ( + DecorCatalogType.get_footprint(first_id) * 0.5 + ) + var second_half: Vector2 = ( + DecorCatalogType.get_footprint(second_id) * 0.5 + ) + var first_axes: Array[Vector2] = _placement_axes(first_yaw) + var second_axes: Array[Vector2] = _placement_axes(second_yaw) + var delta := Vector2( + second_position.x - first_position.x, + second_position.z - first_position.z, + ) + for axis: Vector2 in [ + first_axes[0], first_axes[1], second_axes[0], second_axes[1], + ]: + var first_radius: float = ( + absf(axis.dot(first_axes[0])) * first_half.x + + absf(axis.dot(first_axes[1])) * first_half.y + ) + var second_radius: float = ( + absf(axis.dot(second_axes[0])) * second_half.x + + absf(axis.dot(second_axes[1])) * second_half.y + ) + if absf(delta.dot(axis)) >= ( + first_radius + second_radius - 0.001 + ): + return false + return true + + +func _make_instance_id() -> String: + var result := "decor-%d-%d" % [ + Time.get_ticks_usec(), + _next_instance_sequence, + ] + _next_instance_sequence = mini( + _next_instance_sequence + 1, + MAX_REVISION, + ) + return result + + +func _placement_index(instance_id: String) -> int: + if instance_id.is_empty(): + return -1 + for index: int in _placements.size(): + if str(_placements[index].get("instance_id", "")) == instance_id: + return index + return -1 + + +func _overlaps_existing( + decor_id: StringName, + normalized: Dictionary, + ignored_instance_id: String, +) -> bool: + var candidate: Dictionary = { + "decor_id": String(decor_id), + "position": _vector3_to_array(normalized["position"]), + "yaw": float(normalized["yaw"]), + } + for placement: Dictionary in _placements: + if str(placement.get("instance_id", "")) == ignored_instance_id: + continue + if placements_overlap(candidate, placement): + return true + return false + + +func _touch() -> void: + _revision = mini(_revision + 1, MAX_REVISION) + changed.emit() + + +static func _valid_mode(value: int) -> bool: + return value >= PlacementMode.GRID and value <= PlacementMode.FREE + + +static func _vector3_to_array(value: Vector3) -> Array[float]: + return [value.x, value.y, value.z] + + +static func _array_to_vector3(value: Variant) -> Vector3: + if typeof(value) != TYPE_ARRAY or (value as Array).size() != 3: + return Vector3.INF + var components: Array = value + return Vector3( + float(components[0]), + float(components[1]), + float(components[2]), + ) + + +static func _placement_axes(yaw: float) -> Array[Vector2]: + var cosine: float = cos(yaw) + var sine: float = sin(yaw) + return [Vector2(cosine, sine), Vector2(-sine, cosine)] diff --git a/homes/player_home_state.gd.uid b/homes/player_home_state.gd.uid new file mode 100644 index 0000000..37836c6 --- /dev/null +++ b/homes/player_home_state.gd.uid @@ -0,0 +1 @@ +uid://ggfdoak2ixxx diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd index e33f1e4..d94845d 100644 --- a/inventory/player_bag.gd +++ b/inventory/player_bag.gd @@ -60,6 +60,28 @@ func add_item(item_id: StringName, quantity: int = 1) -> bool: return true +func add_item_to_storage(item_id: StringName, quantity: int = 1) -> bool: + var existing: OwnedItemType = get_owned_item(item_id) + if existing != null: + return add_item(item_id, quantity) + var item: ItemDataType = _resolve_available_item(item_id) + if ( + item == null + or quantity <= 0 + or quantity > (item.max_stack if item.stackable else 1) + or _inventory_layout == null + or not _inventory_layout.can_accept_item_in_storage(item_id) + ): + return false + var owned := OwnedItemType.new() + owned.item_id = item_id + owned.quantity = quantity + owned.storage_slot = _first_available_storage_slot(item, _items) + _items.append(owned) + contents_changed.emit() + return true + + func can_add_item(item_id: StringName, quantity: int = 1) -> bool: var item: ItemDataType = _resolve_available_item(item_id) if item == null or quantity <= 0: diff --git a/inventory/player_hotbar.gd b/inventory/player_hotbar.gd index b7d5197..36068ea 100644 --- a/inventory/player_hotbar.gd +++ b/inventory/player_hotbar.gd @@ -314,6 +314,25 @@ func get_fish_slots() -> Array[StringName]: return _fish_slots.duplicate() +func find_item_slot(item_id: StringName) -> int: + return _slots.find(item_id) + + +func find_first_empty_slot() -> int: + for slot_index: int in SLOT_COUNT: + if get_assignment_kind(slot_index) != AssignmentKind.EMPTY: + continue + if ( + _inventory_layout == null + or _inventory_layout.get_key_at( + PlayerInventoryLayout.InventoryContainer.HOTBAR, + slot_index, + ).is_empty() + ): + return slot_index + return -1 + + func replace_state( slots: Array[StringName], selected_slot: int, diff --git a/inventory/player_inventory_layout.gd b/inventory/player_inventory_layout.gd index 244e10b..bce726e 100644 --- a/inventory/player_inventory_layout.gd +++ b/inventory/player_inventory_layout.gd @@ -37,6 +37,7 @@ var _fish_inventory: FishInventoryType var _item_catalog: ItemCatalogType var _storage_capacity: PlayerCoolerCapacityType var _placements: Dictionary[String, Dictionary] = {} +var _prepared_item_placements: Dictionary[String, Dictionary] = {} var _reconciling: bool = false var _backpack_level: int = 0 @@ -72,7 +73,44 @@ func can_accept_item(item_id: StringName) -> bool: if not is_managed_item(item_id): return true var key := item_key(item_id) - return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0 + return ( + _placements.has(key) + or _is_prepared_item_placement_available(key) + or _first_free_slot(InventoryContainer.INVENTORY) >= 0 + ) + + +func prepare_new_item_placement( + item_id: StringName, + target_container: InventoryContainer, + target_slot: int, +) -> bool: + if not is_managed_item(item_id): + return false + var key := item_key(item_id) + if ( + _placements.has(key) + or not _is_slot_valid(target_container, target_slot) + or not _key_at(target_container, target_slot).is_empty() + or _prepared_slot_is_occupied(target_container, target_slot) + ): + return false + _prepared_item_placements[key] = { + "container": int(target_container), + "slot": target_slot, + } + return true + + +func cancel_prepared_item_placement(item_id: StringName) -> void: + _prepared_item_placements.erase(item_key(item_id)) + + +func can_accept_item_in_storage(item_id: StringName) -> bool: + if not is_managed_item(item_id): + return true + var key := item_key(item_id) + return _placements.has(key) or _first_free_slot(InventoryContainer.STORAGE) >= 0 func can_accept_catch(catch_id: StringName = StringName()) -> bool: @@ -332,6 +370,7 @@ func restore_from_save_data(data: Dictionary) -> bool: "container": container, "slot": slot, } + _prepared_item_placements.clear() _placements = restored _reconcile(true) return true @@ -340,6 +379,7 @@ func restore_from_save_data(data: Dictionary) -> bool: func reset_to_defaults() -> void: var capacity_changed := _backpack_level != 0 _backpack_level = 0 + _prepared_item_placements.clear() _placements.clear() _reconcile(true) if capacity_changed: @@ -402,8 +442,17 @@ func _reconcile(force_signal: bool) -> void: for key: String in expected: if _placements.has(key): continue + var prepared: Dictionary = _prepared_item_placements.get(key, {}) var target_container := InventoryContainer.INVENTORY - var target_slot := _first_free_slot(target_container) + var target_slot: int = -1 + if _is_prepared_item_placement_available(key): + target_container = int( + prepared["container"] + ) as InventoryContainer + target_slot = int(prepared["slot"]) + _prepared_item_placements.erase(key) + if target_slot < 0: + target_slot = _first_free_slot(target_container) if target_slot < 0: target_container = InventoryContainer.STORAGE target_slot = _first_free_slot(target_container) @@ -484,6 +533,37 @@ func _first_free_slot(container: InventoryContainer) -> int: return -1 +func _is_prepared_item_placement_available(key: String) -> bool: + var placement: Dictionary = _prepared_item_placements.get(key, {}) + if placement.is_empty(): + return false + var container: int = int(placement.get("container", -1)) + var slot: int = int(placement.get("slot", -1)) + if ( + container not in [ + InventoryContainer.INVENTORY, + InventoryContainer.STORAGE, + InventoryContainer.HOTBAR, + ] + or not _is_slot_valid(container as InventoryContainer, slot) + ): + return false + return _key_at(container as InventoryContainer, slot).is_empty() + + +func _prepared_slot_is_occupied( + container: InventoryContainer, + slot: int, +) -> bool: + for placement: Dictionary in _prepared_item_placements.values(): + if ( + int(placement.get("container", -1)) == int(container) + and int(placement.get("slot", -1)) == slot + ): + return true + return false + + func _key_at(container: InventoryContainer, slot: int) -> String: for key: String in _placements: var placement: Dictionary = _placements[key] diff --git a/items/catalog/basic_cube.tres b/items/catalog/basic_cube.tres new file mode 100644 index 0000000..86a2cf9 --- /dev/null +++ b/items/catalog/basic_cube.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] +[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="2_icon"] + +[resource] +script = ExtResource("1_item") +item_id = &"basic_cube" +display_name = "basic cube" +description = "a simple piece of decor for testing your RV layout." +category = 6 +icon = ExtResource("2_icon") +stackable = true +max_stack = 32 +usable = false +equippable = true +hotbar_allowed = true diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 63ef3ea..2f15e0a 100644 --- a/items/catalog/item_catalog.tres +++ b/items/catalog/item_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="ItemCatalog" load_steps=41 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=42 format=3] [ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"] [ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"] @@ -40,7 +40,8 @@ [ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"] [ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"] [ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"] +[ext_resource type="Resource" path="res://items/catalog/basic_cube.tres" id="42_basic_cube"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net"), ExtResource("42_basic_cube")] diff --git a/main/main.gd b/main/main.gd index e9dc78f..8335c69 100644 --- a/main/main.gd +++ b/main/main.gd @@ -37,6 +37,9 @@ const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const DecorShopInteractionType = preload( + "res://world/decor_shop_interaction.gd" +) const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) @@ -70,6 +73,7 @@ const NetworkFishingServiceType = preload( "res://network/network_fishing_service.gd" ) const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const DecorCatalogType = preload("res://homes/decor_catalog.gd") const NetworkSaleServiceType = preload( "res://network/network_sale_service.gd" ) @@ -123,6 +127,14 @@ const GatheringControllerType = preload( const NetworkWorldSpawnServiceType = preload( "res://network/network_world_spawn_service.gd" ) +const PlayerHomeStateType = preload("res://homes/player_home_state.gd") +const HomeWorldServiceType = preload("res://homes/home_world_service.gd") +const NetworkHomeServiceType = preload( + "res://network/network_home_service.gd" +) +const HomeDecorControllerType = preload( + "res://homes/home_decor_controller.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_PATH: String = ( @@ -153,6 +165,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30 @onready var _game_ui: GameUIType = %GameUI @onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery @onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager +@onready var _home_state: PlayerHomeStateType = %PlayerHomeState @onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager @onready var _controller_mapping_manager: ControllerMappingManagerType = ( %ControllerMappingManager @@ -212,6 +225,10 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30 ) @onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService @onready var _network_shop: NetworkShopServiceType = %NetworkShopService +@onready var _network_home: NetworkHomeServiceType = %NetworkHomeService +@onready var _home_decor_controller: HomeDecorControllerType = ( + %HomeDecorController +) @onready var _network_item_use: NetworkItemUseServiceType = ( %NetworkItemUseService ) @@ -234,6 +251,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30 @onready var _players_root: Node3D = $Players @onready var _surface_drawings_root: Node3D = $SurfaceDrawings @onready var _world_gatherables_root: Node3D = $WorldGatherables +@onready var _home_world: HomeWorldServiceType = %HomeWorldService @onready var _title_background: ColorRect = %TitleBackground @onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop @onready var _shop_backdrop: ColorRect = %ShopBackdrop @@ -241,6 +259,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30 var _gameplay_started: bool = false var _save_slots := PlayerSaveSlotCatalogType.new() var _shop_interaction: FishingShopInteractionType +var _decor_shop_interaction: DecorShopInteractionType var _storage_interaction: PlayerStorageInteractionType var _title_music_tween: Tween var _title_music_transition_generation: int = 0 @@ -740,6 +759,7 @@ func _initialize_application(dedicated: bool) -> void: _player.inventory_layout, ) _shop_interaction = _test_world.get_fishing_shop() + _decor_shop_interaction = _test_world.get_decor_shop() _storage_interaction = _test_world.get_player_storage() if not dedicated: _shop_interaction.setup_local_player(_player) @@ -749,6 +769,10 @@ func _initialize_application(dedicated: bool) -> void: _game_ui.set_shop_npc_player_in_range( _shop_interaction.is_local_player_in_range() ) + _decor_shop_interaction.setup_local_player(_player) + _decor_shop_interaction.local_player_range_changed.connect( + _on_decor_shop_range_changed + ) _storage_interaction.setup_local_player(_player) _storage_interaction.local_player_range_changed.connect( _on_storage_range_changed @@ -770,6 +794,23 @@ func _initialize_application(dedicated: bool) -> void: _world_weather, _player_jobs, _player, + _home_state, + ) + _home_world.setup( + _test_world, + _player_spawn_service, + _network_session, + dedicated, + ) + _network_home.setup( + _network_session, + _player_spawn_service, + _home_state, + _save_manager, + _home_world, + _player.bag, + _player.inventory_layout, + _player.hotbar, ) _player_jobs.set_save_manager(_save_manager) _save_manager.set_autosave_enabled(false) @@ -903,6 +944,9 @@ func _initialize_application(dedicated: bool) -> void: _save_manager, _asset_reservations, _player.inventory_layout, + _home_state, + _network_home, + _decor_shop_interaction, ) _fishing_spot.setup( _player, @@ -942,11 +986,13 @@ func _initialize_application(dedicated: bool) -> void: _player.fishing_upgrades, _shop_interaction, _storage_interaction, + _decor_shop_interaction, _player.item_effects, _player.cooler_capacity, _network_session, _network_sale, _network_shop, + _home_state, _network_chat, _network_profile, _player_spawn_service, @@ -964,6 +1010,21 @@ func _initialize_application(dedicated: bool) -> void: _test_world.get_world_environment(), _test_world.get_sun(), ) + _home_decor_controller.setup( + _player, + _player.hotbar, + _player.bag, + _home_state, + _network_home, + _home_world, + _fishing_spot, + _game_ui.get_home_decor_toolbar(), + ) + _game_ui.get_player_storage().decorate_requested.connect( + _home_decor_controller.begin_decorating + ) + _network_home.local_space_changed.connect(_on_local_home_space_changed) + _game_ui.rv_storage_requested.connect(_on_rv_storage_requested) _game_ui.setup_data_and_identity( _data_root, _identity_backups, @@ -1475,6 +1536,9 @@ func _input(event: InputEvent) -> void: if _game_ui.get_player_storage().consume_escape(): get_viewport().set_input_as_handled() return + if _game_ui.get_decor_shop().consume_escape(): + get_viewport().set_input_as_handled() + return if fishing_shop.consume_escape(): get_viewport().set_input_as_handled() return @@ -1573,19 +1637,50 @@ func _is_pause_open_request(event: InputEvent) -> bool: func _unhandled_input(event: InputEvent) -> void: + if ( + _gameplay_started + and event.is_action_pressed("fish_primary") + and not (event is InputEventKey and event.echo) + and _fishing_spot.try_use_active_non_fishing_item() + ): + get_viewport().set_input_as_handled() + return if ( not _gameplay_started or not event.is_action_pressed("interact") or (event is InputEventKey and event.echo) ): return + var home_owner: String = _network_home.get_local_home_owner_fingerprint() + if not home_owner.is_empty(): + if _home_world.is_avatar_near_interior_exit(_player, home_owner): + if _network_home.request_exit(): + _game_ui.set_storage_prompt_visible(false) + get_viewport().set_input_as_handled() + return + if ( + _network_home.can_local_decorate() + and _fishing_spot.can_open_fishing_shop() + and _game_ui.get_player_storage().open_storage_from_home(true) + ): + _game_ui.set_storage_prompt_visible(false) + get_viewport().set_input_as_handled() + return + var nearby_home_owner: String = _home_world.find_nearby_exterior_owner( + _player + ) + if not nearby_home_owner.is_empty(): + if _network_home.request_enter(nearby_home_owner): + _game_ui.set_storage_prompt_visible(false) + get_viewport().set_input_as_handled() + return if ( - _storage_interaction != null - and _storage_interaction.is_local_player_in_range() + _decor_shop_interaction != null + and _decor_shop_interaction.is_local_player_in_range() and _fishing_spot.can_open_fishing_shop() - and _game_ui.get_player_storage().open_storage() + and _game_ui.get_decor_shop().open_shop() ): - _game_ui.set_storage_prompt_visible(false) + _game_ui.set_shop_prompt_visible(false) get_viewport().set_input_as_handled() return if ( @@ -1608,25 +1703,28 @@ func _process(delta: float) -> void: ) _print_dedicated_metrics() return - var show_shop_prompt := _can_show_shop_prompt() + var show_decor_shop_prompt := _can_show_decor_shop_prompt() + var show_shop_prompt := show_decor_shop_prompt or _can_show_shop_prompt() var shop_prompt_anchor := ( - _shop_interaction.get_prompt_anchor_position() - if _shop_interaction != null - else Vector3.ZERO + _decor_shop_interaction.get_prompt_anchor_position() + if show_decor_shop_prompt + else ( + _shop_interaction.get_prompt_anchor_position() + if _shop_interaction != null else Vector3.ZERO + ) ) _game_ui.set_shop_prompt_visible( show_shop_prompt, shop_prompt_anchor, ) - var show_storage_prompt := _can_show_storage_prompt() - var storage_prompt_anchor := ( - _storage_interaction.get_prompt_anchor_position() - if _storage_interaction != null - else Vector3.ZERO - ) + var home_prompt := _get_home_prompt() _game_ui.set_storage_prompt_visible( - show_storage_prompt, - storage_prompt_anchor, + not home_prompt.is_empty(), + home_prompt.get("anchor", Vector3.ZERO), + str(home_prompt.get("label", "enter RV")), + ) + _game_ui.set_rv_storage_button_visible( + _can_show_rv_storage_button() ) @@ -2421,6 +2519,7 @@ func _apply_world( if _application_initialized: _refresh_active_world_bindings() _network_world_spawns.refresh_world_context() + _network_home.refresh_world_presentations() if _application_initialized and not _dedicated_runtime: _water_recovery.update_world_context( spawn_transform, @@ -2450,10 +2549,19 @@ func _refresh_active_world_bindings() -> void: _storage_interaction.local_player_range_changed.disconnect( _on_storage_range_changed ) + if is_instance_valid(_decor_shop_interaction): + if _decor_shop_interaction.local_player_range_changed.is_connected( + _on_decor_shop_range_changed + ): + _decor_shop_interaction.local_player_range_changed.disconnect( + _on_decor_shop_range_changed + ) _shop_interaction = _test_world.get_fishing_shop() + _decor_shop_interaction = _test_world.get_decor_shop() _storage_interaction = _test_world.get_player_storage() _network_sale.set_shop_interaction(_shop_interaction) _network_shop.set_shop_interaction(_shop_interaction) + _network_shop.set_decor_shop_interaction(_decor_shop_interaction) if _dedicated_runtime: return if _shop_interaction != null: @@ -2466,9 +2574,15 @@ func _refresh_active_world_bindings() -> void: _storage_interaction.local_player_range_changed.connect( _on_storage_range_changed ) + if _decor_shop_interaction != null: + _decor_shop_interaction.setup_local_player(_player) + _decor_shop_interaction.local_player_range_changed.connect( + _on_decor_shop_range_changed + ) _game_ui.set_world_interactions( _shop_interaction, _storage_interaction, + _decor_shop_interaction, ) _on_shop_range_changed( _shop_interaction != null @@ -2478,6 +2592,10 @@ func _refresh_active_world_bindings() -> void: _storage_interaction != null and _storage_interaction.is_local_player_in_range() ) + _on_decor_shop_range_changed( + _decor_shop_interaction != null + and _decor_shop_interaction.is_local_player_in_range() + ) func _on_water_recovery_starting() -> void: @@ -2491,6 +2609,7 @@ func _on_water_recovery_starting() -> void: _game_ui.close_player_menu_for_water_recovery() _game_ui.get_pause_menu().close_for_water_recovery() _game_ui.get_fishing_shop().close_for_water_recovery() + _game_ui.get_decor_shop().close_for_water_recovery() _game_ui.get_player_storage().close_for_water_recovery() @@ -2552,6 +2671,12 @@ func _on_active_hotbar_item_changed( and item.is_available() and _player.bag.owns_item(item_id) ) + var active_is_decor: bool = ( + DecorCatalogType.has_product(item_id) + and item != null + and item.is_available() + and _player.bag.owns_item(item_id) + ) _player.set_active_fishing_rod( item as FishingRodDataType if active_is_rod else null, true, @@ -2562,6 +2687,7 @@ func _on_active_hotbar_item_changed( ) _player.set_active_catching_net(active_is_catching_net) _player.set_active_shovel(active_is_shovel) + _player.set_active_decor_package(active_is_decor) _game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit) _network_item_use.submit_local_equipped(item_id, active_is_rod or ( item != null and _player.bag.owns_item(item_id) @@ -2698,7 +2824,13 @@ func _exit_tree() -> void: func _on_shop_range_changed(in_range: bool) -> void: - _game_ui.set_shop_npc_player_in_range(in_range) + _game_ui.set_shop_npc_player_in_range( + in_range + or ( + _decor_shop_interaction != null + and _decor_shop_interaction.is_local_player_in_range() + ) + ) if not in_range: _game_ui.get_fishing_shop().close_for_range_exit() _game_ui.set_shop_prompt_visible(_can_show_shop_prompt()) @@ -2707,7 +2839,50 @@ func _on_shop_range_changed(in_range: bool) -> void: func _on_storage_range_changed(in_range: bool) -> void: if not in_range: _game_ui.get_player_storage().close_for_range_exit() - _game_ui.set_storage_prompt_visible(_can_show_storage_prompt()) + var prompt := _get_home_prompt() + _game_ui.set_storage_prompt_visible( + not prompt.is_empty(), + prompt.get("anchor", Vector3.ZERO), + str(prompt.get("label", "enter RV")), + ) + _game_ui.set_rv_storage_button_visible( + _can_show_rv_storage_button() + ) + + +func _on_decor_shop_range_changed(in_range: bool) -> void: + _game_ui.set_shop_npc_player_in_range( + in_range + or ( + _shop_interaction != null + and _shop_interaction.is_local_player_in_range() + ) + ) + if not in_range: + _game_ui.get_decor_shop().close_for_range_exit() + _game_ui.set_shop_prompt_visible( + _can_show_shop_prompt() or _can_show_decor_shop_prompt() + ) + + +func _on_local_home_space_changed( + space_id: StringName, + _owner_fingerprint: String, +) -> void: + var inside_rv: bool = String(space_id).begins_with("rv:") + _fishing_spot.set_fishing_environment_enabled(not inside_rv) + _game_ui.set_rv_interior_camera_mode( + inside_rv + ) + _game_ui.set_rv_storage_button_visible( + _can_show_rv_storage_button() + ) + + +func _on_rv_storage_requested() -> void: + if not _can_show_rv_storage_button(): + return + _game_ui.get_player_storage().open_storage_from_home(true) func _can_show_shop_prompt() -> bool: @@ -2716,18 +2891,66 @@ func _can_show_shop_prompt() -> bool: and _shop_interaction != null and _shop_interaction.is_local_player_in_range() and not _game_ui.get_fishing_shop().visible + and not _game_ui.get_decor_shop().visible + and not _water_recovery.is_recovery_active() + and _fishing_spot.can_open_fishing_shop() + ) + + +func _can_show_decor_shop_prompt() -> bool: + return ( + _gameplay_started + and _decor_shop_interaction != null + and _decor_shop_interaction.is_local_player_in_range() + and not _game_ui.get_decor_shop().visible + and not _game_ui.get_fishing_shop().visible and not _water_recovery.is_recovery_active() and _fishing_spot.can_open_fishing_shop() ) func _can_show_storage_prompt() -> bool: + return not _get_home_prompt().is_empty() + + +func _can_show_rv_storage_button() -> bool: return ( _gameplay_started - and _storage_interaction != null - and _storage_interaction.is_local_player_in_range() - and not _game_ui.get_player_storage().visible - and not _game_ui.get_fishing_shop().visible + and not _network_home.get_local_home_owner_fingerprint().is_empty() + and _network_home.can_local_decorate() and not _water_recovery.is_recovery_active() and _fishing_spot.can_open_fishing_shop() ) + + +func _get_home_prompt() -> Dictionary: + if ( + not _gameplay_started + or _game_ui.get_player_storage().visible + or _game_ui.get_fishing_shop().visible + or _game_ui.get_decor_shop().visible + or _water_recovery.is_recovery_active() + or not _fishing_spot.can_open_fishing_shop() + ): + return {} + var owner_fingerprint: String = ( + _network_home.get_local_home_owner_fingerprint() + ) + if not owner_fingerprint.is_empty(): + if _home_world.is_avatar_near_interior_exit( + _player, owner_fingerprint + ): + return { + "label": "leave RV", + "anchor": _home_world.get_interior_prompt_position( + owner_fingerprint + ), + } + return {} + var nearby_owner: String = _home_world.find_nearby_exterior_owner(_player) + if nearby_owner.is_empty(): + return {} + return { + "label": "enter RV", + "anchor": _home_world.get_exterior_prompt_position(nearby_owner), + } diff --git a/main/main.tscn b/main/main.tscn index c1380d6..778a1d4 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -58,6 +58,10 @@ [ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"] [ext_resource type="Script" path="res://network/network_world_spawn_service.gd" id="59_world_spawns"] [ext_resource type="Script" path="res://gathering/gathering_controller.gd" id="60_gathering_controller"] +[ext_resource type="Script" path="res://homes/player_home_state.gd" id="61_home_state"] +[ext_resource type="Script" path="res://homes/home_world_service.gd" id="62_home_world"] +[ext_resource type="Script" path="res://network/network_home_service.gd" id="63_network_home"] +[ext_resource type="Script" path="res://homes/home_decor_controller.gd" id="64_home_decor_controller"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_grass_native"] shader = ExtResource("42_menu_pattern") @@ -259,6 +263,14 @@ script = ExtResource("22_network_sale") unique_name_in_owner = true script = ExtResource("23_network_shop") +[node name="NetworkHomeService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("63_network_home") + +[node name="HomeDecorController" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("64_home_decor_controller") + [node name="NetworkItemUseService" type="Node" parent="." unique_id=255154670] unique_name_in_owner = true script = ExtResource("24_network_item") @@ -322,6 +334,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081) [node name="WorldGatherables" type="Node3D" parent="."] +[node name="HomeWorldService" type="Node3D" parent="."] +unique_name_in_owner = true +script = ExtResource("62_home_world") + [node name="GatheringController" type="Node3D" parent="."] unique_name_in_owner = true script = ExtResource("60_gathering_controller") @@ -338,6 +354,10 @@ script = ExtResource("8_recovery") unique_name_in_owner = true script = ExtResource("9_save") +[node name="PlayerHomeState" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("61_home_state") + [node name="PlayerSettingsManager" type="Node" parent="." unique_id=906991991] unique_name_in_owner = true script = ExtResource("10_settings") diff --git a/network/network_home_protocol.gd b/network/network_home_protocol.gd new file mode 100644 index 0000000..62b8488 --- /dev/null +++ b/network/network_home_protocol.gd @@ -0,0 +1,176 @@ +class_name NetworkHomeProtocol +extends RefCounted + +const PlayerHomeStateType = preload("res://homes/player_home_state.gd") + +const CAPABILITY: StringName = &"portable_home_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL +const MAX_ID_LENGTH: int = 96 +const MAX_MESSAGE_LENGTH: int = 160 +const WORLD_SPACE: StringName = &"world" + +enum TransitionAction { + ENTER, + EXIT, +} + + +static func make_space_id(owner_fingerprint: String) -> StringName: + return StringName("rv:%s" % owner_fingerprint) + + +static func manifest_digest(home_data: Dictionary) -> String: + var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data(home_data) + if sanitized.is_empty(): + return "" + var canonical_owned: Array[Dictionary] = [] + var owned: Dictionary = sanitized["owned_decor"] + var owned_ids: Array[String] = [] + for value: Variant in owned.keys(): + owned_ids.append(str(value)) + owned_ids.sort() + for decor_id: String in owned_ids: + canonical_owned.append({ + "id": decor_id, + "quantity": int(owned[decor_id]), + }) + var placements: Array[Dictionary] = [] + for value: Variant in sanitized["placements"]: + placements.append((value as Dictionary).duplicate(true)) + placements.sort_custom(func(first: Dictionary, second: Dictionary) -> bool: + return str(first["instance_id"]) < str(second["instance_id"]) + ) + var canonical: Dictionary = { + "schema_version": int(sanitized["schema_version"]), + "tier": int(sanitized["tier"]), + "exterior_id": str(sanitized["exterior_id"]), + "privacy": int(sanitized["privacy"]), + "owned": canonical_owned, + "placements": placements, + "revision": int(sanitized["revision"]), + "next_instance_sequence": int(sanitized["next_instance_sequence"]), + } + return JSON.stringify(canonical).sha256_text() + + +static func manifest_signature_fields(data: Dictionary) -> Array: + return [ + str(data.get("session_id", "")), + str(data.get("request_id", "")), + str(data.get("owner_fingerprint", "")), + int((data.get("home", {}) as Dictionary).get("revision", -1)), + str(data.get("digest", "")), + ] + + +static func validate_manifest_request(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + if ( + not _valid_id(data.get("request_id")) + or not _valid_id(data.get("session_id")) + or not NetworkIdentityCrypto.valid_fingerprint( + data.get("owner_fingerprint") + ) + or typeof(data.get("home")) != TYPE_DICTIONARY + or typeof(data.get("digest")) != TYPE_STRING + or str(data["digest"]).length() != 64 + or typeof(data.get("sender_signature")) != TYPE_PACKED_BYTE_ARRAY + ): + return false + var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data( + data["home"] + ) + return ( + not sanitized.is_empty() + and manifest_digest(sanitized) == str(data["digest"]) + ) + + +static func validate_manifest_broadcast(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + return ( + typeof(data.get("owner_peer_id")) == TYPE_INT + and int(data["owner_peer_id"]) > 0 + and NetworkIdentityCrypto.valid_fingerprint( + data.get("owner_fingerprint") + ) + and typeof(data.get("home")) == TYPE_DICTIONARY + and not PlayerHomeStateType.sanitize_save_data(data["home"]).is_empty() + and _valid_transform(data.get("exterior_transform")) + and typeof(data.get("interior_index")) == TYPE_INT + and int(data["interior_index"]) >= 0 + and int(data["interior_index"]) < 128 + ) + + +static func transition_signature_fields(data: Dictionary) -> Array: + return [ + str(data.get("session_id", "")), + str(data.get("request_id", "")), + int(data.get("action", -1)), + str(data.get("owner_fingerprint", "")), + ] + + +static func validate_transition_request(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + return ( + _valid_id(data.get("request_id")) + and _valid_id(data.get("session_id")) + and typeof(data.get("action")) == TYPE_INT + and int(data["action"]) >= TransitionAction.ENTER + and int(data["action"]) <= TransitionAction.EXIT + and NetworkIdentityCrypto.valid_fingerprint( + data.get("owner_fingerprint") + ) + and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY + ) + + +static func transform_to_array(value: Transform3D) -> Array[float]: + return [ + value.basis.x.x, value.basis.x.y, value.basis.x.z, + value.basis.y.x, value.basis.y.y, value.basis.y.z, + value.basis.z.x, value.basis.z.y, value.basis.z.z, + value.origin.x, value.origin.y, value.origin.z, + ] + + +static func array_to_transform(value: Variant) -> Transform3D: + if not _valid_transform(value): + return Transform3D() + var fields: Array = value + return Transform3D( + Basis( + Vector3(float(fields[0]), float(fields[1]), float(fields[2])), + Vector3(float(fields[3]), float(fields[4]), float(fields[5])), + Vector3(float(fields[6]), float(fields[7]), float(fields[8])), + ), + Vector3(float(fields[9]), float(fields[10]), float(fields[11])), + ) + + +static func _valid_transform(value: Variant) -> bool: + if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12: + return false + for component: Variant in value: + if ( + typeof(component) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(component)) + ): + return false + return true + + +static func _valid_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_ID_LENGTH + ) diff --git a/network/network_home_protocol.gd.uid b/network/network_home_protocol.gd.uid new file mode 100644 index 0000000..24b6aae --- /dev/null +++ b/network/network_home_protocol.gd.uid @@ -0,0 +1 @@ +uid://fl3qwiluejhm diff --git a/network/network_home_service.gd b/network/network_home_service.gd new file mode 100644 index 0000000..df4fcde --- /dev/null +++ b/network/network_home_service.gd @@ -0,0 +1,956 @@ +class_name NetworkHomeService +extends Node + +const NetworkHomeProtocolType = preload( + "res://network/network_home_protocol.gd" +) +const PlayerHomeStateType = preload("res://homes/player_home_state.gd") + +signal local_transition_finished(accepted: bool, message: String) +signal local_space_changed(space_id: StringName, owner_fingerprint: String) +signal local_manifest_rejected(message: String) + +const MAX_MANIFESTS: int = 128 + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _local_home: PlayerHomeStateType +var _save_manager: PlayerSaveManager +var _world_service: HomeWorldService +var _bag: PlayerBag +var _inventory_layout: PlayerInventoryLayout +var _hotbar: PlayerHotbar +var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {} +var _fingerprint_by_peer: Dictionary[int, String] = {} +var _authorized_owned_counts: Dictionary[int, Dictionary] = {} +var _authorization_result_owner: Dictionary[String, int] = {} +var _next_interior_index: int = 0 +var _suppress_local_submission: bool = false +var _publish_queued: bool = false + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + local_home: PlayerHomeStateType, + save_manager: PlayerSaveManager, + world_service: HomeWorldService, + bag: PlayerBag, + inventory_layout: PlayerInventoryLayout, + hotbar: PlayerHotbar, +) -> void: + _session = session + _spawn_service = spawn_service + _local_home = local_home + _save_manager = save_manager + _world_service = world_service + _bag = bag + _inventory_layout = inventory_layout + _hotbar = hotbar + if not _session.state_changed.is_connected(_on_session_state_changed): + _session.state_changed.connect(_on_session_state_changed) + if not _session.join_authenticated.is_connected(_on_join_authenticated): + _session.join_authenticated.connect(_on_join_authenticated) + if not _session.peer_authenticated.is_connected(_on_peer_authenticated): + _session.peer_authenticated.connect(_on_peer_authenticated) + if not _session.peer_removed.is_connected(_on_peer_removed): + _session.peer_removed.connect(_on_peer_removed) + if not _local_home.changed.is_connected(_on_local_home_changed): + _local_home.changed.connect(_on_local_home_changed) + + +func get_local_space() -> StringName: + if _session == null or not _session.is_gameplay_session_active(): + return NetworkHomeProtocolType.WORLD_SPACE + return _session.get_peer_space(_session.get_local_peer_id()) + + +func get_local_home_owner_fingerprint() -> String: + var space_id: String = String(get_local_space()) + return space_id.trim_prefix("rv:") if space_id.begins_with("rv:") else "" + + +func can_local_decorate() -> bool: + return ( + get_local_home_owner_fingerprint() + == _session.get_local_identity_fingerprint() + ) + + +func get_authoritative_owned_count( + peer_id: int, + decor_id: StringName, +) -> int: + var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) + if authorized.has(String(decor_id)): + return int(authorized[String(decor_id)]) + var fingerprint: String = _fingerprint_for_peer(peer_id) + var manifest: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) + if manifest.is_empty(): + return -1 + var home: Dictionary = manifest.get("home", {}) + var owned: Dictionary = home.get("owned_decor", {}) + return int(owned.get(String(decor_id), 0)) + + +func place_local_decor( + decor_id: StringName, + position: Vector3, + yaw_radians: float, + mode: PlayerHomeStateType.PlacementMode, +) -> String: + if ( + not can_local_decorate() + or _bag == null + or _bag.get_quantity(decor_id) <= 0 + or _world_service == null + ): + return "" + var owner_fingerprint: String = ( + _session.get_local_identity_fingerprint() + ) + var normalized: Dictionary = ( + _local_home.get_nearest_valid_placement_transform( + decor_id, + position, + yaw_radians, + mode, + "", + Callable( + self, "_candidate_clear_of_players" + ).bind(owner_fingerprint, decor_id), + ) + ) + if normalized.is_empty(): + return "" + var instance_id: String = _local_home.place_decor( + decor_id, + normalized["position"], + float(normalized["yaw"]), + mode, + ) + if instance_id.is_empty(): + return "" + if not _bag.remove_item(decor_id, 1): + _local_home.remove_placement(instance_id) + return "" + _save_manager.save_if_dirty() + return instance_id + + +func remove_local_decor(instance_id: String) -> bool: + if ( + not can_local_decorate() + or _bag == null + or _inventory_layout == null + or _hotbar == null + ): + return false + var placement: Dictionary = _local_home.get_placement(instance_id) + if placement.is_empty(): + return false + var decor_id := StringName(str(placement["decor_id"])) + var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id) + if target_hotbar_slot < 0: + target_hotbar_slot = _hotbar.find_first_empty_slot() + var prepared_hotbar_placement: bool = false + if ( + target_hotbar_slot >= 0 + and _inventory_layout.get_container( + PlayerInventoryLayout.EntryKind.ITEM, + decor_id, + ) < 0 + ): + prepared_hotbar_placement = ( + _inventory_layout.prepare_new_item_placement( + decor_id, + PlayerInventoryLayout.InventoryContainer.HOTBAR, + target_hotbar_slot, + ) + ) + var returned_to_inventory: bool = _bag.add_item(decor_id, 1) + if not returned_to_inventory: + if prepared_hotbar_placement: + _inventory_layout.cancel_prepared_item_placement(decor_id) + returned_to_inventory = _bag.add_item_to_storage(decor_id, 1) + if not returned_to_inventory: + return false + if target_hotbar_slot >= 0: + _hotbar.assign_item(target_hotbar_slot, decor_id) + _hotbar.select_slot(target_hotbar_slot) + if not _local_home.remove_placement(instance_id): + _bag.remove_item(decor_id, 1) + return false + _save_manager.save_if_dirty() + return true + + +func update_local_decor( + instance_id: String, + position: Vector3, + yaw_radians: float, + mode: PlayerHomeStateType.PlacementMode, +) -> bool: + var normalized: Dictionary = get_valid_local_decor_transform( + instance_id, position, yaw_radians, mode + ) + if normalized.is_empty(): + return false + if not _local_home.update_placement( + instance_id, + normalized["position"], + float(normalized["yaw"]), + mode, + ): + return false + _save_manager.save_if_dirty() + return true + + +func get_valid_local_decor_transform( + instance_id: String, + position: Vector3, + yaw_radians: float, + mode: PlayerHomeStateType.PlacementMode, +) -> Dictionary: + if not can_local_decorate() or _world_service == null: + return {} + var placement: Dictionary = _local_home.get_placement(instance_id) + if placement.is_empty(): + return {} + var decor_id := StringName(str(placement.get("decor_id", ""))) + return _local_home.get_valid_update_transform( + instance_id, + position, + yaw_radians, + mode, + Callable( + self, "_candidate_clear_of_players" + ).bind( + _session.get_local_identity_fingerprint(), decor_id + ), + ) + + +func _candidate_clear_of_players( + candidate: Dictionary, + owner_fingerprint: String, + decor_id: StringName, +) -> bool: + return ( + _world_service != null + and _world_service.is_decor_placement_clear_of_players( + owner_fingerprint, + decor_id, + candidate["position"], + float(candidate["yaw"]), + ) + ) + + +func request_enter(owner_fingerprint: String) -> bool: + if ( + _session == null + or not _session.is_gameplay_session_active() + or not _manifests_by_fingerprint.has(owner_fingerprint) + or get_local_space() != NetworkHomeProtocolType.WORLD_SPACE + ): + return false + return _submit_transition( + NetworkHomeProtocolType.TransitionAction.ENTER, + owner_fingerprint, + ) + + +func request_exit() -> bool: + var owner_fingerprint: String = get_local_home_owner_fingerprint() + if owner_fingerprint.is_empty(): + return false + return _submit_transition( + NetworkHomeProtocolType.TransitionAction.EXIT, + owner_fingerprint, + ) + + +func authorize_decor_purchase( + peer_id: int, + product_id: StringName, + resulting_count: int, + result_id: String, +) -> void: + if not _session.is_host() or peer_id <= 0 or result_id.is_empty(): + return + var counts: Dictionary = _authorized_owned_counts.get(peer_id, {}) + counts[String(product_id)] = resulting_count + _authorized_owned_counts[peer_id] = counts + _authorization_result_owner[result_id] = peer_id + + +func finalize_decor_purchase( + peer_id: int, + result_id: String, + applied: bool, +) -> void: + if _authorization_result_owner.get(result_id, 0) != peer_id: + return + _authorization_result_owner.erase(result_id) + if applied: + return + _authorized_owned_counts.erase(peer_id) + + +func refresh_world_presentations() -> void: + if _world_service == null: + return + _world_service.clear_presentations() + if not _world_service.is_world_ready(): + return + if not _session.is_host(): + for manifest: Dictionary in _manifests_by_fingerprint.values(): + _world_service.apply_manifest(manifest) + return + var local_fingerprint: String = _session.get_local_identity_fingerprint() + if ( + not _session.is_dedicated_host() + and not _manifests_by_fingerprint.has(local_fingerprint) + ): + publish_local_home() + var fingerprints: Array[String] = [] + fingerprints.assign(_manifests_by_fingerprint.keys()) + fingerprints.sort() + var reserved: Array[Transform3D] = [] + for fingerprint: String in fingerprints: + var manifest: Dictionary = _manifests_by_fingerprint[fingerprint] + var transform: Transform3D = _world_service.choose_exterior_transform( + int(manifest["owner_peer_id"]), reserved + ) + reserved.append(transform) + manifest["exterior_transform"] = ( + NetworkHomeProtocolType.transform_to_array(transform) + ) + _manifests_by_fingerprint[fingerprint] = manifest + _broadcast_manifest(manifest) + for fingerprint: String in fingerprints: + var manifest: Dictionary = _manifests_by_fingerprint[fingerprint] + _spawn_peer_at_exterior( + int(manifest["owner_peer_id"]), fingerprint + ) + + +func publish_local_home() -> bool: + if ( + _session == null + or not _session.is_gameplay_session_active() + or _session.is_dedicated_host() + or _local_home == null + ): + return false + if _session.is_host() and ( + _world_service == null or not _world_service.is_world_ready() + ): + return false + var home_data: Dictionary = _local_home.to_save_data() + var request: Dictionary = { + "request_id": _new_id("home"), + "session_id": _session.get_session_id(), + "owner_fingerprint": _session.get_local_identity_fingerprint(), + "home": home_data, + "digest": NetworkHomeProtocolType.manifest_digest(home_data), + } + request["sender_signature"] = _session.sign_local_action( + "portable_home_manifest", + NetworkHomeProtocolType.manifest_signature_fields(request), + ) + if _session.is_host(): + _process_manifest_request(_session.get_local_peer_id(), request) + else: + submit_home_manifest.rpc_id(1, request) + return true + + +@rpc( + "any_peer", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func submit_home_manifest(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + _session == null + or not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + ): + return + _process_manifest_request(sender_id, data) + + +func _process_manifest_request(peer_id: int, data: Dictionary) -> void: + if ( + not NetworkHomeProtocolType.validate_manifest_request(data) + or str(data["session_id"]) != _session.get_session_id() + ): + _reject_manifest(peer_id, "The RV update was invalid.") + return + var record := _session.get_peer_record(peer_id) + var fingerprint: String = str(data["owner_fingerprint"]) + if ( + record == null + or record.identity_fingerprint != fingerprint + or not _session.verify_peer_action( + peer_id, + "portable_home_manifest", + NetworkHomeProtocolType.manifest_signature_fields(data), + data["sender_signature"], + ) + ): + _reject_manifest(peer_id, "The RV owner could not be verified.") + return + var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data( + data["home"] + ) + var prior: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) + var is_first_manifest: bool = prior.is_empty() + if not _valid_manifest_revision(peer_id, prior, home_data): + _reject_manifest(peer_id, "The RV update was rejected by the host.") + return + if not _moved_decor_clear_of_players(fingerprint, prior, home_data): + _reject_manifest(peer_id, "A player is in the way of that decor.") + return + var manifest: Dictionary + if prior.is_empty(): + if _manifests_by_fingerprint.size() >= MAX_MANIFESTS: + _reject_manifest(peer_id, "This room cannot place another RV.") + return + var reserved: Array[Transform3D] = [] + for existing: Dictionary in _manifests_by_fingerprint.values(): + reserved.append(NetworkHomeProtocolType.array_to_transform( + existing["exterior_transform"] + )) + var exterior_transform: Transform3D = ( + _world_service.choose_exterior_transform(peer_id, reserved) + ) + manifest = { + "owner_peer_id": peer_id, + "owner_fingerprint": fingerprint, + "home": home_data, + "exterior_transform": ( + NetworkHomeProtocolType.transform_to_array(exterior_transform) + ), + "interior_index": _next_interior_index, + } + _next_interior_index += 1 + else: + manifest = prior.duplicate(true) + manifest["home"] = home_data + manifest["owner_peer_id"] = peer_id + _manifests_by_fingerprint[fingerprint] = manifest + _fingerprint_by_peer[peer_id] = fingerprint + _consume_owned_authorizations(peer_id, home_data) + _broadcast_manifest(manifest) + if is_first_manifest: + _spawn_peer_at_exterior(peer_id, fingerprint) + + +func _valid_manifest_revision( + peer_id: int, + prior_manifest: Dictionary, + home_data: Dictionary, +) -> bool: + if prior_manifest.is_empty(): + # A portable RV is owned by the player's signed local save. A new host has + # no prior room ledger with which to compare its first manifest. + return true + var prior_home: Dictionary = prior_manifest["home"] + if ( + int(home_data["revision"]) <= int(prior_home["revision"]) + or int(home_data["tier"]) != int(prior_home["tier"]) + or str(home_data["exterior_id"]) != str(prior_home["exterior_id"]) + ): + return false + var prior_owned: Dictionary = prior_home["owned_decor"] + var next_owned: Dictionary = home_data["owned_decor"] + var all_ids: Dictionary[String, bool] = {} + for value: Variant in prior_owned.keys(): + all_ids[str(value)] = true + for value: Variant in next_owned.keys(): + all_ids[str(value)] = true + var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) + for product_id: String in all_ids: + var prior_count: int = int(prior_owned.get(product_id, 0)) + var next_count: int = int(next_owned.get(product_id, 0)) + if prior_count == next_count: + continue + if next_count < prior_count or int(authorized.get(product_id, -1)) != next_count: + return false + return true + + +func _moved_decor_clear_of_players( + owner_fingerprint: String, + prior_manifest: Dictionary, + home_data: Dictionary, +) -> bool: + if _world_service == null: + return false + var prior_by_instance: Dictionary[String, Dictionary] = {} + if not prior_manifest.is_empty(): + var prior_home: Dictionary = prior_manifest.get("home", {}) + for value: Variant in prior_home.get("placements", []): + if typeof(value) != TYPE_DICTIONARY: + continue + var prior_placement: Dictionary = value + prior_by_instance[str( + prior_placement.get("instance_id", "") + )] = prior_placement + for value: Variant in home_data.get("placements", []): + if typeof(value) != TYPE_DICTIONARY: + return false + var placement: Dictionary = value + var instance_id: String = str(placement.get("instance_id", "")) + var prior_placement: Dictionary = prior_by_instance.get( + instance_id, {} + ) + if ( + not prior_placement.is_empty() + and _same_decor_transform(prior_placement, placement) + ): + continue + var decor_id := StringName(str(placement.get("decor_id", ""))) + var position: Vector3 = _placement_position(placement) + if ( + not position.is_finite() + or not _world_service.is_decor_placement_clear_of_players( + owner_fingerprint, + decor_id, + position, + float(placement.get("yaw", 0.0)), + ) + ): + return false + return true + + +static func _same_decor_transform( + first: Dictionary, + second: Dictionary, +) -> bool: + if str(first.get("decor_id", "")) != str(second.get("decor_id", "")): + return false + var first_position: Vector3 = _placement_position(first) + var second_position: Vector3 = _placement_position(second) + return ( + first_position.is_equal_approx(second_position) + and is_equal_approx( + float(first.get("yaw", 0.0)), + float(second.get("yaw", 0.0)), + ) + ) + + +static func _placement_position(placement: Dictionary) -> Vector3: + var values: Array = placement.get("position", []) + if values.size() != 3: + return Vector3.INF + return Vector3( + float(values[0]), float(values[1]), float(values[2]) + ) + + +func _consume_owned_authorizations(peer_id: int, home_data: Dictionary) -> void: + var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {}) + if authorized.is_empty(): + return + var owned: Dictionary = home_data["owned_decor"] + for product_id: String in authorized.keys(): + if int(owned.get(product_id, -1)) == int(authorized[product_id]): + authorized.erase(product_id) + if authorized.is_empty(): + _authorized_owned_counts.erase(peer_id) + else: + _authorized_owned_counts[peer_id] = authorized + + +func _broadcast_manifest(manifest: Dictionary) -> void: + if _world_service != null and _world_service.is_world_ready(): + _world_service.apply_manifest(manifest) + for peer_id: int in _session.get_authenticated_peer_ids(): + if peer_id == 1: + continue + receive_home_manifest.rpc_id(peer_id, manifest) + + +@rpc( + "authority", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func receive_home_manifest(data: Dictionary) -> void: + if not NetworkHomeProtocolType.validate_manifest_broadcast(data): + return + var fingerprint: String = str(data["owner_fingerprint"]) + _manifests_by_fingerprint[fingerprint] = data.duplicate(true) + _fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint + if _world_service != null and _world_service.is_world_ready(): + _world_service.apply_manifest(data) + + +func _reject_manifest(peer_id: int, message: String) -> void: + var fingerprint: String = _fingerprint_for_peer(peer_id) + var canonical: Dictionary = _manifests_by_fingerprint.get(fingerprint, {}) + if peer_id == _session.get_local_peer_id(): + _apply_manifest_rejection(message, canonical) + else: + receive_home_manifest_rejection.rpc_id(peer_id, message, canonical) + + +@rpc( + "authority", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func receive_home_manifest_rejection( + message: String, + canonical: Dictionary, +) -> void: + _apply_manifest_rejection(message, canonical) + + +func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void: + if ( + canonical.is_empty() + or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical) + ): + local_manifest_rejected.emit(message) + return + _suppress_local_submission = true + _local_home.restore_from_save_data(canonical["home"]) + _suppress_local_submission = false + _save_manager.save_if_dirty() + receive_home_manifest(canonical) + local_manifest_rejected.emit(message) + + +func _submit_transition(action: int, owner_fingerprint: String) -> bool: + var request: Dictionary = { + "request_id": _new_id("home_transition"), + "session_id": _session.get_session_id(), + "action": action, + "owner_fingerprint": owner_fingerprint, + } + request["sender_signature"] = _session.sign_local_action( + "portable_home_transition", + NetworkHomeProtocolType.transition_signature_fields(request), + ) + if _session.is_host(): + _process_transition_request(_session.get_local_peer_id(), request) + else: + submit_home_transition.rpc_id(1, request) + return true + + +@rpc( + "any_peer", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func submit_home_transition(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) + ): + return + _process_transition_request(sender_id, data) + + +func _process_transition_request(peer_id: int, data: Dictionary) -> void: + if ( + not NetworkHomeProtocolType.validate_transition_request(data) + or str(data["session_id"]) != _session.get_session_id() + or not _session.verify_peer_action( + peer_id, + "portable_home_transition", + NetworkHomeProtocolType.transition_signature_fields(data), + data["sender_signature"], + ) + ): + _send_transition_rejection(peer_id, "The RV transition was invalid.") + return + var owner_fingerprint: String = str(data["owner_fingerprint"]) + var action: int = int(data["action"]) + var avatar: Player = _spawn_service.get_avatar(peer_id) + var manifest: Dictionary = _manifests_by_fingerprint.get( + owner_fingerprint, {} + ) + if avatar == null or manifest.is_empty(): + _send_transition_rejection(peer_id, "That RV is unavailable.") + return + if action == NetworkHomeProtocolType.TransitionAction.ENTER: + var record := _session.get_peer_record(peer_id) + var owner_is_local: bool = ( + record != null and record.identity_fingerprint == owner_fingerprint + ) + var privacy: int = int((manifest["home"] as Dictionary)["privacy"]) + if ( + (not owner_is_local and privacy != PlayerHomeStateType.Privacy.OPEN) + or _session.get_peer_space(peer_id) + != NetworkHomeProtocolType.WORLD_SPACE + or not _world_service.is_avatar_near_exterior( + avatar, owner_fingerprint + ) + ): + _send_transition_rejection(peer_id, "You cannot enter that RV.") + return + _apply_host_transition( + peer_id, + NetworkHomeProtocolType.make_space_id(owner_fingerprint), + owner_fingerprint, + _world_service.get_interior_spawn_transform( + owner_fingerprint, peer_id + ), + ) + return + if ( + _session.get_peer_space(peer_id) + != NetworkHomeProtocolType.make_space_id(owner_fingerprint) + or not _world_service.is_avatar_near_interior_exit( + avatar, owner_fingerprint + ) + ): + _send_transition_rejection(peer_id, "Move closer to the RV door.") + return + _apply_host_transition( + peer_id, + NetworkHomeProtocolType.WORLD_SPACE, + owner_fingerprint, + _world_service.get_exterior_spawn_transform(owner_fingerprint), + ) + + +func _spawn_peer_at_exterior( + peer_id: int, + owner_fingerprint: String, +) -> void: + if ( + not _session.is_host() + or _session.get_peer_space(peer_id) + != NetworkHomeProtocolType.WORLD_SPACE + or _spawn_service.get_avatar(peer_id) == null + ): + return + _apply_host_transition( + peer_id, + NetworkHomeProtocolType.WORLD_SPACE, + owner_fingerprint, + _world_service.get_exterior_spawn_transform(owner_fingerprint), + ) + + +func _apply_host_transition( + peer_id: int, + space_id: StringName, + owner_fingerprint: String, + transform: Transform3D, +) -> void: + var result: Dictionary = { + "peer_id": peer_id, + "space_id": String(space_id), + "owner_fingerprint": owner_fingerprint, + "transform": NetworkHomeProtocolType.transform_to_array(transform), + } + _apply_transition_result(result) + for recipient_id: int in _session.get_authenticated_peer_ids(): + if recipient_id == 1: + continue + receive_home_transition.rpc_id(recipient_id, result) + _session.publish_authoritative_teleport(peer_id) + + +@rpc( + "authority", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func receive_home_transition(data: Dictionary) -> void: + if ( + typeof(data.get("peer_id")) != TYPE_INT + or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME] + or not NetworkIdentityCrypto.valid_fingerprint( + data.get("owner_fingerprint") + ) + ): + return + var transform: Transform3D = NetworkHomeProtocolType.array_to_transform( + data.get("transform") + ) + if transform == Transform3D(): + return + _apply_transition_result(data) + + +func _apply_transition_result(data: Dictionary) -> void: + var peer_id: int = int(data["peer_id"]) + var space_id := StringName(str(data["space_id"])) + if not _session.set_peer_space(peer_id, space_id): + return + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.global_transform = NetworkHomeProtocolType.array_to_transform( + data["transform"] + ) + avatar.velocity = Vector3.ZERO + if peer_id == _session.get_local_peer_id(): + _world_service.set_local_space(space_id) + local_space_changed.emit( + space_id, str(data["owner_fingerprint"]) + ) + local_transition_finished.emit(true, "") + _refresh_peer_visibility() + + +func _send_transition_rejection(peer_id: int, message: String) -> void: + if peer_id == _session.get_local_peer_id(): + local_transition_finished.emit(false, message) + else: + receive_home_transition_rejection.rpc_id(peer_id, message) + + +@rpc( + "authority", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func receive_home_transition_rejection(message: String) -> void: + local_transition_finished.emit( + false, message.left(NetworkHomeProtocolType.MAX_MESSAGE_LENGTH) + ) + + +func _refresh_peer_visibility() -> void: + var local_peer_id: int = _session.get_local_peer_id() + if local_peer_id <= 0: + return + for peer_id: int in _spawn_service.get_peer_ids(): + if peer_id == local_peer_id: + continue + _spawn_service.set_peer_presentation_visible( + peer_id, _session.peers_share_space(local_peer_id, peer_id) + ) + + +func _on_local_home_changed() -> void: + if _suppress_local_submission or _publish_queued: + return + _publish_queued = true + call_deferred("_publish_queued_home") + + +func _publish_queued_home() -> void: + _publish_queued = false + if _save_manager != null: + _save_manager.save_if_dirty() + publish_local_home() + + +func _on_join_authenticated() -> void: + publish_local_home() + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if not _session.is_host(): + return + for manifest: Dictionary in _manifests_by_fingerprint.values(): + receive_home_manifest.rpc_id(peer_id, manifest) + for existing_peer_id: int in _session.get_authenticated_peer_ids(): + if existing_peer_id == peer_id: + continue + var avatar: Player = _spawn_service.get_avatar(existing_peer_id) + if avatar == null: + continue + var space_id: StringName = _session.get_peer_space(existing_peer_id) + var owner_fingerprint: String = ( + String(space_id).trim_prefix("rv:") + if String(space_id).begins_with("rv:") + else _fingerprint_for_peer(existing_peer_id) + ) + if owner_fingerprint.is_empty(): + continue + receive_home_transition.rpc_id(peer_id, { + "peer_id": existing_peer_id, + "space_id": String(space_id), + "owner_fingerprint": owner_fingerprint, + "transform": NetworkHomeProtocolType.transform_to_array( + avatar.global_transform + ), + }) + + +func _on_peer_removed(peer_id: int) -> void: + var fingerprint: String = _fingerprint_by_peer.get(peer_id, "") + _fingerprint_by_peer.erase(peer_id) + _authorized_owned_counts.erase(peer_id) + if fingerprint.is_empty(): + return + if _session.is_host(): + for occupant_id: int in _session.get_authenticated_peer_ids(): + if ( + occupant_id == peer_id + or _session.get_peer_space(occupant_id) + != NetworkHomeProtocolType.make_space_id(fingerprint) + ): + continue + _apply_host_transition( + occupant_id, + NetworkHomeProtocolType.WORLD_SPACE, + fingerprint, + _world_service.get_exterior_spawn_transform(fingerprint), + ) + _manifests_by_fingerprint.erase(fingerprint) + _world_service.remove_presentation(fingerprint) + if _session.is_host(): + for recipient_id: int in _session.get_authenticated_peer_ids(): + if recipient_id != 1: + receive_home_removed.rpc_id(recipient_id, fingerprint) + + +@rpc( + "authority", "call_remote", "reliable", + NetworkHomeProtocolType.RELIABLE_CHANNEL, +) +func receive_home_removed(owner_fingerprint: String) -> void: + if not NetworkIdentityCrypto.valid_fingerprint(owner_fingerprint): + return + _manifests_by_fingerprint.erase(owner_fingerprint) + _world_service.remove_presentation(owner_fingerprint) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]: + if ( + not _session.is_dedicated_host() + and _world_service != null + and _world_service.is_world_ready() + ): + publish_local_home() + return + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _manifests_by_fingerprint.clear() + _fingerprint_by_peer.clear() + _authorized_owned_counts.clear() + _authorization_result_owner.clear() + _next_interior_index = 0 + _world_service.clear_presentations() + _world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE) + local_space_changed.emit(NetworkHomeProtocolType.WORLD_SPACE, "") + + +func _fingerprint_for_peer(peer_id: int) -> String: + if _fingerprint_by_peer.has(peer_id): + return _fingerprint_by_peer[peer_id] + var record := _session.get_peer_record(peer_id) + return record.identity_fingerprint if record != null else "" + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, Crypto.new().generate_random_bytes(16).hex_encode(), + ] diff --git a/network/network_home_service.gd.uid b/network/network_home_service.gd.uid new file mode 100644 index 0000000..be233ed --- /dev/null +++ b/network/network_home_service.gd.uid @@ -0,0 +1 @@ +uid://ong8kug36ku7 diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index cf2d5f9..024eca9 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -5,6 +5,7 @@ const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") +const DecorCatalogType = preload("res://homes/decor_catalog.gd") const MAX_LEDGER_ENTRIES: int = 64 @@ -394,6 +395,10 @@ func _apply_equipped(data: Dictionary) -> void: item_id == FishingShopStockType.STANDARD_SHOVEL_ID and bool(data["owns_item"]), ) + avatar.set_active_decor_package( + DecorCatalogType.has_product(item_id) + and bool(data["owns_item"]), + ) equipped_state_changed.emit( peer_id, StringName(str(data["item_id"])), int(data["category"]) ) diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 20be022..49aca95 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -3,7 +3,7 @@ extends RefCounted const WorldLayoutType = preload("res://world/world_layout.gd") -const PROTOCOL_VERSION: int = 11 +const PROTOCOL_VERSION: int = 12 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 @@ -17,9 +17,9 @@ const MAX_SIGNATURE_LENGTH: int = 2048 # 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. +# 14 reliable artwork state, 15 world-spawn snapshots, 16 reliable fishing +# lifecycle, and 17 reliable portable-home state. Bulk world/art payloads must +# not head-of-line block held items, fishing, animation, or home transitions. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 const ITEM_RELIABLE_CHANNEL: int = 7 @@ -30,7 +30,8 @@ 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 HOME_RELIABLE_CHANNEL: int = 17 +const ENET_CHANNEL_COUNT: int = 18 const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const ART_SHOP_CAPABILITY: String = "art_shop_v1" const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1" @@ -45,6 +46,7 @@ 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 HOME_CAPABILITY: String = "portable_home_v1" const DEFAULT_WORLD_SEED: int = 13001 const MAX_WORLD_SEED: int = 2147483646 @@ -202,6 +204,7 @@ static func make_client_hello( FRIENDS_CAPABILITY, MOVEMENT_RECONCILIATION_CAPABILITY, FISHING_REPLICATION_CAPABILITY, + HOME_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -342,6 +345,7 @@ static func make_server_hello( FRIENDS_CAPABILITY, MOVEMENT_RECONCILIATION_CAPABILITY, FISHING_REPLICATION_CAPABILITY, + HOME_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 8b99fd6..8add90f 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -141,6 +141,7 @@ var _last_server_protocol_version: int = 0 var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED var _last_server_world_layout: StringName = WorldLayout.GENERATED var _server_capabilities: PackedStringArray = PackedStringArray() +var _peer_space_ids: Dictionary[int, StringName] = {} var _profile_ready: bool = false var _player_identity: PlayerIdentityStore var _host_identity: HostIdentityStore @@ -334,6 +335,7 @@ func _register_player_host() -> void: NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + NetworkProtocol.HOME_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -661,6 +663,7 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + NetworkProtocol.HOME_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -685,6 +688,21 @@ func get_authenticated_peer_ids() -> Array[int]: return _registry.get_peer_ids() +func set_peer_space(peer_id: int, space_id: StringName) -> bool: + if not _registry.has_peer(peer_id) or space_id.is_empty(): + return false + _peer_space_ids[peer_id] = space_id + return true + + +func get_peer_space(peer_id: int) -> StringName: + return _peer_space_ids.get(peer_id, &"world") + + +func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool: + return get_peer_space(first_peer_id) == get_peer_space(second_peer_id) + + func get_peer_rtt_ms(peer_id: int) -> int: if peer_id == 1: return 0 @@ -1030,6 +1048,7 @@ 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) + _peer_space_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, "") @@ -1313,6 +1332,7 @@ func submit_client_hello(data: Dictionary) -> void: for required_capability: String in [ NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + NetworkProtocol.HOME_CAPABILITY, ]: if required_capability not in client_capabilities: _reject_peer( @@ -1546,6 +1566,7 @@ func receive_server_hello(data: Dictionary) -> void: for required_capability: String in [ NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + NetworkProtocol.HOME_CAPABILITY, ]: if required_capability not in _server_capabilities: _teardown_peer() @@ -1578,6 +1599,7 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + NetworkProtocol.HOME_CAPABILITY, ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) @@ -1623,6 +1645,7 @@ func receive_peer_despawn(peer_id: int) -> void: if state != State.JOINED_CLIENT: return _operator_peer_ids.erase(peer_id) + _peer_space_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) @@ -2247,6 +2270,8 @@ func _broadcast_movement_snapshots() -> void: continue var snapshots: Array = [] for subject_id: int in peer_ids: + if not peers_share_space(recipient_id, subject_id): + continue var subject_avatar: Player = _spawn_service.get_avatar(subject_id) if subject_avatar == null: continue @@ -2315,7 +2340,7 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void: _animation_refresh_accumulator, ANIMATION_REFRESH_INTERVAL, ) - var updates: Array = [] + var updates_by_subject: Dictionary[int, Array] = {} for subject_id: int in peer_ids: var avatar: Player = _spawn_service.get_avatar(subject_id) if avatar == null: @@ -2333,12 +2358,18 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void: _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(): + updates_by_subject[subject_id] = encoded + if updates_by_subject.is_empty(): return for recipient_id: int in peer_ids: if recipient_id == 1: continue + var updates: Array = [] + for subject_id: int in updates_by_subject: + if peers_share_space(recipient_id, subject_id): + updates.append(updates_by_subject[subject_id]) + if updates.is_empty(): + continue receive_movement_animations.rpc_id(recipient_id, updates) _movement_animation_packets_sent += 1 _movement_animation_states_sent += updates.size() @@ -2888,6 +2919,7 @@ func _teardown_peer() -> void: _pending_animation_state_by_peer.clear() _last_local_animation_action_signature.clear() _server_capabilities = PackedStringArray() + _peer_space_ids.clear() _server_identity_fingerprint = "" _server_identity_public_key = "" _session_identity_keys.clear() diff --git a/network/network_shop_protocol.gd b/network/network_shop_protocol.gd index 2de9cc1..da9419a 100644 --- a/network/network_shop_protocol.gd +++ b/network/network_shop_protocol.gd @@ -4,6 +4,7 @@ extends RefCounted const CAPABILITY: StringName = &"shop_v1" const ART_CAPABILITY: StringName = &"art_shop_v1" const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1" +const DECOR_CAPABILITY: StringName = &"portable_home_v1" const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_MESSAGE_LENGTH: int = 160 @@ -18,6 +19,7 @@ enum ProductCategory { ART_KIT, ART_UPGRADE, BACKPACK_CAPACITY_UPGRADE, + DECOR, } diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index 6f31c40..8294b53 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -8,8 +8,10 @@ const ItemDataType = preload("res://items/item_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const OwnedItemType = preload("res://items/owned_item.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd") +const DecorCatalogType = preload("res://homes/decor_catalog.gd") -const SHOP_ID: StringName = &"main_fishing_shop" +const FISHING_SHOP_ID: StringName = &"main_fishing_shop" +const DECOR_SHOP_ID: StringName = &"rv_decor_shop" const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade" const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade" const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade" @@ -31,6 +33,7 @@ var _session: NetworkSession var _spawn_service: PlayerSpawnService var _network_fishing: NetworkFishingService var _interaction: FishingShopInteraction +var _decor_interaction: DecorShopInteraction var _wallet: PlayerWallet var _bag: PlayerBag var _item_catalog: ItemCatalog @@ -47,6 +50,8 @@ var _received_results: Dictionary[String, bool] = {} var _applied_results: Dictionary[String, bool] = {} var _pending_local_request: Dictionary = {} var _reservations: PlayerAssetReservationService +var _home_state: PlayerHomeState +var _network_home: NetworkHomeService func setup( @@ -63,6 +68,9 @@ func setup( save_manager: PlayerSaveManager, reservations: PlayerAssetReservationService, inventory_layout: PlayerInventoryLayout = null, + home_state: PlayerHomeState = null, + network_home: NetworkHomeService = null, + decor_interaction: DecorShopInteraction = null, ) -> void: _session = session _spawn_service = spawn_service @@ -77,6 +85,9 @@ func setup( _save_manager = save_manager _reservations = reservations _inventory_layout = inventory_layout + _home_state = home_state + _network_home = network_home + _decor_interaction = decor_interaction 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): @@ -87,6 +98,12 @@ func set_shop_interaction(interaction: FishingShopInteraction) -> void: _interaction = interaction +func set_decor_shop_interaction( + interaction: DecorShopInteraction, +) -> void: + _decor_interaction = interaction + + func can_request_purchase() -> bool: return ( _session != null @@ -124,6 +141,19 @@ func can_request_backpack_purchase() -> bool: ) +func can_request_decor_purchase() -> bool: + return ( + can_request_purchase() + and _home_state != null + and ( + _session.is_host() + or _session.supports_server_capability( + NetworkShopProtocol.DECOR_CAPABILITY + ) + ) + ) + + func is_local_purchase_pending() -> bool: return not _pending_local_request.is_empty() @@ -193,6 +223,15 @@ func request_backpack_capacity_upgrade() -> String: ) +func request_decor(product_id: StringName) -> String: + return _request_purchase( + product_id, + NetworkShopProtocol.ProductCategory.DECOR, + 1, + _home_state.get_owned_count(product_id) if _home_state != null else 0, + ) + + func request_art_kit() -> String: return _request_purchase( ArtShopStockType.ART_KIT_ITEM_ID, @@ -269,11 +308,25 @@ func _request_purchase( product_id, category, 0, 0, ) return "" + if ( + category == NetworkShopProtocol.ProductCategory.DECOR + and not can_request_decor_purchase() + ): + local_purchase_finished.emit( + "", false, "Decor require a newer server.", + product_id, category, 0, 0, + ) + return "" var request_id: String = _new_id("shop") + var shop_id: StringName = ( + DECOR_SHOP_ID + if category == NetworkShopProtocol.ProductCategory.DECOR + else FISHING_SHOP_ID + ) var request: Dictionary = { "request_id": request_id, "session_id": _session.get_session_id(), - "shop_id": str(SHOP_ID), + "shop_id": str(shop_id), "product_id": str(product_id), "category": category, "quantity": quantity, @@ -345,16 +398,34 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void: _rejected_result(data, "A purchase is already pending.") ) return - if not _is_shop_available_for_peer(peer_id): + var requested_shop_id := StringName(str(data["shop_id"])) + if not _is_shop_available_for_peer(peer_id, requested_shop_id): var avatar: Player = _spawn_service.get_avatar(peer_id) + var requested_interaction: FishingShopInteraction = ( + _decor_interaction + if requested_shop_id == DECOR_SHOP_ID + else _interaction + ) var message: String = ( "The shop is unavailable." - if avatar == null or _interaction == null + if avatar == null or requested_interaction == null else "Move closer to the shop." ) _record_and_send(peer_id, _rejected_result(data, message)) return var result: Dictionary = _build_authoritative_result(peer_id, data) + if ( + bool(result.get("accepted", false)) + and int(result.get("category", -1)) + == NetworkShopProtocol.ProductCategory.DECOR + and _network_home != null + ): + _network_home.authorize_decor_purchase( + peer_id, + StringName(str(result["product_id"])), + int(result["resulting_state"]), + str(result["result_id"]), + ) _pending_by_peer[peer_id] = request_id _record_and_send(peer_id, result) @@ -368,10 +439,24 @@ func _build_authoritative_result( var quantity: int = request["quantity"] var wallet_balance: int = request["wallet_balance"] var current_state: int = request["current_state"] + if ( + category == NetworkShopProtocol.ProductCategory.DECOR + and _network_home != null + ): + var authoritative_count: int = ( + _network_home.get_authoritative_owned_count(peer_id, product_id) + ) + if authoritative_count >= 0: + current_state = authoritative_count var cost: int = -1 var resulting_state: int = current_state var rejection: String = "" - if StringName(str(request["shop_id"])) != SHOP_ID: + var expected_shop_id: StringName = ( + DECOR_SHOP_ID + if category == NetworkShopProtocol.ProductCategory.DECOR + else FISHING_SHOP_ID + ) + if StringName(str(request["shop_id"])) != expected_shop_id: rejection = "The shop is unavailable." elif quantity < 1: rejection = "Purchase could not be completed." @@ -545,6 +630,19 @@ func _build_authoritative_result( resulting_state = PlayerArtUnlocks.resulting_mask( current_state, product_id ) + NetworkShopProtocol.ProductCategory.DECOR: + cost = DecorCatalogType.get_price(product_id) + if ( + not DecorCatalogType.has_product(product_id) + or quantity != 1 + or current_state < 0 + or current_state + >= DecorCatalogType.MAX_OWNED_PER_PRODUCT + or cost < 0 + ): + rejection = "Decor is unavailable." + else: + resulting_state = current_state + 1 if rejection.is_empty() and (cost < 0 or wallet_balance < cost): rejection = "Insufficient funds." if not rejection.is_empty(): @@ -667,6 +765,9 @@ func _apply_purchase_result(data: Dictionary) -> void: if _inventory_layout != null else 0 ) var art_snapshot: int = _art_unlocks.get_unlock_mask() + var home_snapshot: Dictionary = ( + _home_state.to_save_data() if _home_state != null else {} + ) var applied: bool = _apply_local_product(data) if not applied or not _save_manager.save_if_dirty(): _bag.replace_all_items(bag_snapshot) @@ -676,6 +777,8 @@ func _apply_purchase_result(data: Dictionary) -> void: if _inventory_layout != null: _inventory_layout.restore_backpack_level(backpack_snapshot) _art_unlocks.restore_mask(art_snapshot) + if _home_state != null and not home_snapshot.is_empty(): + _home_state.restore_from_save_data(home_snapshot) _wallet.restore_balance(wallet_snapshot) _save_manager.save_if_dirty() _fail_local_apply(data, "Purchase could not be completed.") @@ -696,6 +799,7 @@ func _validate_local_result(data: Dictionary) -> String: or _cooler_capacity == null or _art_unlocks == null or _save_manager == null + or _home_state == null or _wallet.get_balance() != int(data["expected_wallet"]) or str(data["product_id"]) != str(_pending_local_request.get("product_id", "")) @@ -806,6 +910,20 @@ func _validate_local_result(data: Dictionary) -> String: or cost != ArtShopStockType.UPGRADE_PRICE ): return "Purchase could not be completed." + NetworkShopProtocol.ProductCategory.DECOR: + if ( + not DecorCatalogType.has_product(product_id) + or _home_state.get_owned_count(product_id) != expected_state + or resulting_state != expected_state + 1 + or cost != DecorCatalogType.get_price(product_id) + or expected_state >= DecorCatalogType.MAX_OWNED_PER_PRODUCT + or not _bag.can_add_item(product_id, 1) + ): + return ( + "Your inventory is full." + if not _bag.can_add_item(product_id, 1) + else "Purchase could not be completed." + ) _: return "Purchase could not be completed." return "" @@ -845,6 +963,12 @@ func _apply_local_product(data: Dictionary) -> bool: return _art_unlocks.purchase_product( product_id, _wallet, int(data["total_cost"]) ) + NetworkShopProtocol.ProductCategory.DECOR: + return ( + _wallet.debit(int(data["total_cost"])) + and _bag.add_item(product_id, 1) + and _home_state.add_owned_decor(product_id, 1) + ) return false @@ -921,7 +1045,7 @@ func _handle_acknowledgement( peer_id: int, result_id: String, request_id: String, - _applied: bool, + applied: bool, message: String, ) -> void: if ( @@ -933,20 +1057,32 @@ func _handle_acknowledgement( or _result_owners.get(result_id, 0) != peer_id ): return + if _network_home != null: + _network_home.finalize_decor_purchase( + peer_id, result_id, applied + ) _acknowledged_results[result_id] = true _bound_dictionary(_acknowledged_results) if _pending_by_peer.get(peer_id, "") == request_id: _pending_by_peer.erase(peer_id) -func _is_shop_available_for_peer(peer_id: int) -> bool: +func _is_shop_available_for_peer( + peer_id: int, + shop_id: StringName, +) -> bool: + var interaction: FishingShopInteraction = ( + _decor_interaction + if shop_id == DECOR_SHOP_ID + else _interaction + ) if ( _session == null or not _session.is_host() or not _session.is_gameplay_session_active() or _spawn_service == null - or _interaction == null - or not is_instance_valid(_interaction) + or interaction == null + or not is_instance_valid(interaction) ): return false var avatar: Player = _spawn_service.get_avatar(peer_id) @@ -957,7 +1093,7 @@ func _is_shop_available_for_peer(peer_id: int) -> bool: _network_fishing == null or not _network_fishing.has_peer_attempt(peer_id) ) - and _interaction.is_avatar_in_range(avatar) + and interaction.is_avatar_in_range(avatar) ) diff --git a/player/player.gd b/player/player.gd index 64d4ffe..30807be 100644 --- a/player/player.gd +++ b/player/player.gd @@ -427,6 +427,9 @@ class ShowcaseCameraSnapshot: @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 +var _rv_interior_camera_mode: bool = false +var _rv_interior_environment: Environment +var _rv_previous_camera_environment: Environment @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 @@ -471,6 +474,8 @@ var _held_fish_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04) var _held_fish_sprite: Sprite3D var _held_art_kit_display: Node3D var _held_art_kit_sprite: Sprite3D +var _held_decor_package_display: Node3D +var _held_decor_package_visible: bool = false var _catch_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04) var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) @@ -992,6 +997,7 @@ func _initialize_held_item_attachment() -> void: _held_art_kit_sprite = attachment.get_node( "HeldArtKitDisplay/HeldArtKitSprite" ) as Sprite3D + _held_decor_package_display = _build_decor_package_display(attachment) _catch_display = attachment.get_node("CatchDisplay") as Node3D _catch_attachment_offset = _catch_display.position _catch_sprite = attachment.get_node("CatchDisplay/CatchSprite") as Sprite3D @@ -1322,9 +1328,7 @@ func _update_character_animation() -> void: and supports_network_animation_action(animation_action_id) and _character_animation_player.has_animation(animation_action_id) ) - var held_show_item_visible: bool = ( - _held_fish_visible or _held_art_kit_visible - ) + var held_show_item_visible: bool = _has_held_show_item() var requested_animation: Array[StringName] = [] if _pocket_visual_active: requested_animation = [_pocket_visual_animation] @@ -1766,7 +1770,11 @@ func _unhandled_input(event: InputEvent) -> void: var mouse_zoom_in: bool = ( event is InputEventMouseButton - and event.shift_pressed != _swap_hotbar_camera_scroll + and ( + (not event.shift_pressed) + if _rv_interior_camera_mode + else event.shift_pressed != _swap_hotbar_camera_scroll + ) and event.is_action_pressed("camera_zoom_in") ) var controller_zoom_in: bool = ( @@ -1775,7 +1783,11 @@ func _unhandled_input(event: InputEvent) -> void: ) var mouse_zoom_out: bool = ( event is InputEventMouseButton - and event.shift_pressed != _swap_hotbar_camera_scroll + and ( + (not event.shift_pressed) + if _rv_interior_camera_mode + else event.shift_pressed != _swap_hotbar_camera_scroll + ) and event.is_action_pressed("camera_zoom_out") ) var controller_zoom_out: bool = ( @@ -1963,7 +1975,11 @@ func get_active_gameplay_camera() -> Camera3D: func _set_target_zoom(value: float) -> void: - _target_zoom = clampf(value, minimum_zoom, maximum_zoom) + _target_zoom = clampf( + value, + 1.25 if _rv_interior_camera_mode else minimum_zoom, + 16.0 if _rv_interior_camera_mode else maximum_zoom, + ) func _apply_axis_deadzone(value: float) -> float: @@ -3021,6 +3037,49 @@ func apply_camera_settings( _swap_hotbar_camera_scroll = swap_hotbar_camera_scroll +func set_rv_interior_camera_mode( + enabled: bool, + excluded_wall_rids: Array[RID] = [], +) -> void: + if enabled != _rv_interior_camera_mode: + if enabled: + _rv_previous_camera_environment = _camera.environment + _camera.environment = _get_rv_interior_environment() + if _free_camera != null: + _free_camera.environment = _camera.environment + else: + _camera.environment = _rv_previous_camera_environment + if _free_camera != null: + _free_camera.environment = _camera.environment + _rv_previous_camera_environment = null + _rv_interior_camera_mode = enabled + if _spring_arm == null: + return + _spring_arm.clear_excluded_objects() + if enabled: + for collider_rid: RID in excluded_wall_rids: + if collider_rid.is_valid(): + _spring_arm.add_excluded_object(collider_rid) + _set_target_zoom(_target_zoom) + + +func _get_rv_interior_environment() -> Environment: + if _rv_interior_environment != null: + return _rv_interior_environment + _rv_interior_environment = Environment.new() + _rv_interior_environment.background_mode = Environment.BG_COLOR + _rv_interior_environment.background_color = Color("020506") + _rv_interior_environment.ambient_light_source = ( + Environment.AMBIENT_SOURCE_COLOR + ) + _rv_interior_environment.ambient_light_color = Color("d6caaa") + _rv_interior_environment.ambient_light_energy = 1.0 + _rv_interior_environment.reflected_light_source = ( + Environment.REFLECTION_SOURCE_DISABLED + ) + return _rv_interior_environment + + func is_camera_input_enabled() -> bool: return _camera_input_enabled @@ -3147,6 +3206,23 @@ func set_active_shovel(should_show: bool) -> void: ) +func set_active_decor_package(should_show: bool) -> void: + var changed: bool = _held_decor_package_visible != should_show + _held_decor_package_visible = should_show + if _held_decor_package_display != null: + _held_decor_package_display.visible = should_show + if should_show: + _fishing_rod.visible = false + if _catching_net != null: + _catching_net.visible = false + if _shovel != null: + _shovel.visible = false + elif changed: + _apply_active_rod_visibility() + if changed: + _update_character_animation() + + func _apply_fishing_rod_model(rod: FishingRodDataType) -> void: var next_id: StringName = rod.item_id if rod != null else StringName() if next_id == _active_fishing_rod_id: @@ -3319,7 +3395,52 @@ func set_held_fish( func _has_held_show_item() -> bool: - return _held_fish_visible or _held_art_kit_visible + return ( + _held_fish_visible + or _held_art_kit_visible + or _held_decor_package_visible + ) + + +func _build_decor_package_display(parent: Node3D) -> Node3D: + var display := Node3D.new() + display.name = "HeldDecorPackageDisplay" + display.position = Vector3(0.0, 0.1, 0.04) + display.rotation = Vector3(0.15, 0.0, -0.55) + display.visible = false + parent.add_child(display) + + var sack := MeshInstance3D.new() + sack.name = "Sack" + var sack_mesh := SphereMesh.new() + sack_mesh.radius = 0.22 + sack_mesh.height = 0.48 + sack_mesh.radial_segments = 8 + sack_mesh.rings = 4 + sack_mesh.material = _decor_package_material(Color("a87843")) + sack.mesh = sack_mesh + display.add_child(sack) + + var tie := MeshInstance3D.new() + tie.name = "Tie" + var tie_mesh := CylinderMesh.new() + tie_mesh.top_radius = 0.1 + tie_mesh.bottom_radius = 0.12 + tie_mesh.height = 0.08 + tie_mesh.radial_segments = 8 + tie_mesh.material = _decor_package_material(Color("513829")) + tie.mesh = tie_mesh + tie.position = Vector3(0.0, 0.22, 0.0) + display.add_child(tie) + return display + + +func _decor_package_material(color: Color) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.albedo_color = color + material.roughness = 1.0 + material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX + return material func _clear_held_fish() -> void: diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 1ad74a1..56fd49c 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -34,8 +34,9 @@ const WorldWeatherServiceType = preload( const WorldLayoutType = preload("res://world/world_layout.gd") const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") const PlayerType = preload("res://player/player.gd") +const PlayerHomeStateType = preload("res://homes/player_home_state.gd") -const SAVE_VERSION: int = 10 +const SAVE_VERSION: int = 11 const LEGACY_SAVE_FILENAME := "player_save.json" const ARCHIVE_EXTENSION := ".nfsave" const BASIC_ROD_ID: StringName = &"basic_fishing_rod" @@ -76,6 +77,7 @@ class LoadSnapshot: WorldWeatherServiceType.SUNNY_DURATION_RANGE.x ) var jobs_data: Dictionary = PlayerJobServiceType.default_save_data() + var home_data: Dictionary = PlayerHomeStateType.default_save_data() @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -96,6 +98,7 @@ var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType var _jobs: PlayerJobServiceType var _player: PlayerType +var _home_state: PlayerHomeStateType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -175,6 +178,7 @@ func setup( world_weather: WorldWeatherServiceType, jobs: PlayerJobServiceType, player: PlayerType, + home_state: PlayerHomeStateType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -192,6 +196,7 @@ func setup( _world_weather = world_weather _jobs = jobs _player = player + _home_state = home_state _is_configured = ( _inventory != null and _collection_log != null @@ -209,6 +214,7 @@ func setup( and _world_weather != null and _jobs != null and _player != null + and _home_state != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -255,6 +261,8 @@ func setup( _player.active_bait_changed.connect(_on_active_tackle_changed) if not _player.active_lure_changed.is_connected(_on_active_tackle_changed): _player.active_lure_changed.connect(_on_active_tackle_changed) + if not _home_state.changed.is_connected(_mark_dirty): + _home_state.changed.connect(_mark_dirty) func load_player_data() -> bool: @@ -360,6 +368,9 @@ func load_player_data() -> bool: snapshot.world_weather_seconds_remaining, ) var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data) + var home_restored: bool = _home_state.restore_from_save_data( + snapshot.home_data + ) _is_restoring = false if ( not inventory_restored @@ -376,13 +387,14 @@ func load_player_data() -> bool: or not world_time_restored or not world_weather_restored or not jobs_restored + or not home_restored ): push_error( ( "Validated player save could not be restored: " + "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s " + "upgrades=%s cooler=%s layout=%s art=%s experience=%s " - + "time=%s weather=%s jobs=%s" + + "time=%s weather=%s jobs=%s home=%s" ) % [ inventory_restored, @@ -399,6 +411,7 @@ func load_player_data() -> bool: world_time_restored, world_weather_restored, jobs_restored, + home_restored, ] ) return false @@ -899,6 +912,7 @@ func _build_save_dictionary() -> Dictionary: ), }, "jobs": _jobs.to_save_data(), + "home": _home_state.to_save_data(), } @@ -913,6 +927,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY or typeof(save_data.get("jobs")) != TYPE_DICTIONARY + or typeof(save_data.get("home")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] @@ -924,6 +939,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var inventory_layout_data: Dictionary = save_data["inventory_layout"] var experience_data: Dictionary = save_data["experience"] var jobs_data: Dictionary = save_data["jobs"] + var home_data: Dictionary = save_data["home"] var world_data: Dictionary = {} if typeof(save_data.get("world")) == TYPE_DICTIONARY: world_data = save_data["world"] @@ -1006,6 +1022,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: if not PlayerJobServiceType.validate_save_data(jobs_data): return null snapshot.jobs_data = jobs_data.duplicate(true) + var sanitized_home: Dictionary = PlayerHomeStateType.sanitize_save_data( + home_data + ) + if sanitized_home.is_empty(): + return null + snapshot.home_data = sanitized_home if world_data.has("time_hours"): snapshot.world_time_hours = _read_world_time_hours( world_data["time_hours"] @@ -1340,6 +1362,8 @@ func _migrate_save( migrated = _migrate_version_8_to_9(migrated) 9: migrated = _migrate_version_9_to_10(migrated) + 10: + migrated = _migrate_version_10_to_11(migrated) _: return {} if migrated.is_empty(): @@ -1597,6 +1621,13 @@ func _migrate_version_9_to_10(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_10_to_11(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["home"] = PlayerHomeStateType.default_save_data() + migrated["save_version"] = 11 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1866,6 +1897,7 @@ func _restore_defaults() -> void: _world_layout = WorldLayoutType.GENERATED _world_seed = DEFAULT_WORLD_SEED _jobs.reset_to_defaults() + _home_state.reset_to_defaults() _is_restoring = false _is_dirty = false diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 4bfab98..146fb4e 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -19,9 +19,15 @@ func _run() -> void: for _frame: int in 8: await process_frame assert(bool(main.get("_application_initialized"))) + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(bool(main.call( + "_prepare_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()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) main.call("_enter_gameplay") await physics_frame await physics_frame @@ -94,11 +100,7 @@ func _run() -> void: 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 @@ -108,7 +110,6 @@ func _run() -> void: )) 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()) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 753f480..9f4623a 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -104,7 +104,10 @@ func _run() -> void: var hotbar_data: Dictionary = parsed["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int(parsed["save_version"]) == 10) + assert(int(parsed["save_version"]) == 11) + assert(not PlayerHomeState.sanitize_save_data( + parsed.get("home", {}) + ).is_empty()) assert( int(parsed["experience"]["total_experience"]) == 125 diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index b6e6bfd..d8e482b 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -485,7 +485,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 10) + assert(int(migrated.get("save_version", -1)) == 11) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( @@ -509,4 +509,7 @@ func _validate_version_four_migration() -> void: assert( PlayerJobService.validate_save_data(migrated.get("jobs", {})) ) + assert(not PlayerHomeState.sanitize_save_data( + migrated.get("home", {}) + ).is_empty()) manager.queue_free() diff --git a/tests/home_spawn_multiplayer_validation.gd b/tests/home_spawn_multiplayer_validation.gd new file mode 100644 index 0000000..1f16147 --- /dev/null +++ b/tests/home_spawn_multiplayer_validation.gd @@ -0,0 +1,178 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18222 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Home spawn validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var network_home := main.get_node( + "%NetworkHomeService" + ) as NetworkHomeService + var home_world := main.get_node("%HomeWorldService") as HomeWorldService + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(bool(main.call( + "_prepare_host_world", WorldLayout.STARTER_ISLAND, 1 + ))) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) + assert(session.start_private_host(TEST_PORT)) + main.call("_enter_gameplay") + assert(session.set_host_open(true)) + + var host_fingerprint: String = session.get_local_identity_fingerprint() + assert(await _wait_for_home(network_home, host_fingerprint)) + var host_avatar: Player = spawn_service.get_avatar(1) + var host_spawn: Transform3D = home_world.get_exterior_spawn_transform( + host_fingerprint + ) + assert(_same_door_position(host_avatar.global_position, host_spawn.origin)) + assert(network_home.request_enter(host_fingerprint)) + await physics_frame + host_avatar.global_position = home_world.get_interior_exit_position( + host_fingerprint + ) + assert(network_home.request_exit()) + await physics_frame + assert(_same_door_position(host_avatar.global_position, host_spawn.origin)) + + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var remote_record := session.get_peer_record(remote_peer_id) + var remote_fingerprint: String = remote_record.identity_fingerprint + assert(await _wait_for_home(network_home, remote_fingerprint)) + var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) + var remote_spawn: Transform3D = home_world.get_exterior_spawn_transform( + remote_fingerprint + ) + assert(_same_door_position( + remote_avatar.global_position, remote_spawn.origin + )) + + var disconnect_deadline: int = Time.get_ticks_msec() + 12000 + 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("Home spawn multiplayer host validation: PASS") + await _cleanup(main, session) + + +func _run_client() -> void: + var main: Node = 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 network_home := main.get_node( + "%NetworkHomeService" + ) as NetworkHomeService + var home_world := main.get_node("%HomeWorldService") as HomeWorldService + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < 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()) + var fingerprint: String = session.get_local_identity_fingerprint() + assert(await _wait_for_home(network_home, fingerprint)) + var player := main.get("_player") as Player + var exterior_spawn: Transform3D = ( + home_world.get_exterior_spawn_transform(fingerprint) + ) + deadline = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < deadline + and not _same_door_position( + player.global_position, exterior_spawn.origin + ) + ): + await physics_frame + assert(_same_door_position( + player.global_position, exterior_spawn.origin + )) + print("Home spawn multiplayer client validation: PASS") + await _cleanup(main, session) + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _wait_for_home( + network_home: NetworkHomeService, + fingerprint: String, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + await process_frame + var manifests: Dictionary = network_home.get( + "_manifests_by_fingerprint" + ) + if manifests.has(fingerprint): + return true + return false + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = 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 _same_door_position(first: Vector3, second: Vector3) -> bool: + return ( + Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z)) + < 0.08 + and absf(first.y - second.y) < 0.3 + ) + + +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() diff --git a/tests/home_spawn_multiplayer_validation.gd.uid b/tests/home_spawn_multiplayer_validation.gd.uid new file mode 100644 index 0000000..06150f2 --- /dev/null +++ b/tests/home_spawn_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://bndtkip5p6xma diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 1fe5ceb..89e46f6 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -205,8 +205,11 @@ func _run() -> void: ) assert(bool(decoded.get("ok", false))) var save_data: Dictionary = decoded["data"] - assert(int(save_data.get("save_version", -1)) == 10) + assert(int(save_data.get("save_version", -1)) == 11) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) + assert(not PlayerHomeState.sanitize_save_data( + save_data.get("home", {}) + ).is_empty()) _validate_pause_session_switch(main, session) main.queue_free() diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index 5e9dbef..a76a3dd 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,7 +143,7 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 10) + assert(int(migrated.get("save_version", -1)) == 11) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) @@ -155,6 +155,9 @@ func _validate_save_migration() -> void: assert( PlayerJobService.validate_save_data(migrated.get("jobs", {})) ) + assert(not PlayerHomeState.sanitize_save_data( + migrated.get("home", {}) + ).is_empty()) manager.queue_free() diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index c3f9569..30f0df6 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -22,11 +22,24 @@ func _run() -> void: var save_manager := main.get("_save_manager") as PlayerSaveManager var player := main.get("_player") as Player var data_root := main.get("_data_root") as PlayerDataRoot - assert(save_manager != null and player != null and data_root != null) + var home_state := main.get("_home_state") as PlayerHomeState + assert( + save_manager != null + and player != null + and data_root != null + and home_state != null + ) assert(save_manager.initialize_new_game(864209)) save_manager.set_autosave_enabled(true) assert(player.wallet.restore_balance(4321)) assert(player.bag.add_item(&"coffee")) + assert(home_state.add_owned_decor(&"basic_cube")) + assert(not home_state.place_decor( + &"basic_cube", + Vector3.ZERO, + 0.0, + PlayerHomeState.PlacementMode.GRID, + ).is_empty()) assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) @@ -38,7 +51,14 @@ func _run() -> void: _assert_opaque(save_path) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) assert(bool(decoded.get("ok", false))) - assert(int((decoded["data"] as Dictionary)["save_version"]) == 10) + assert(int((decoded["data"] as Dictionary)["save_version"]) == 11) + assert(not PlayerHomeState.sanitize_save_data( + (decoded["data"] as Dictionary).get("home", {}) + ).is_empty()) + assert( + ((decoded["data"] as Dictionary)["home"] as Dictionary) + .get("placements", []).size() == 1 + ) var legacy_container_path: String = data_root.progression_backup_directory().path_join( "netfishing-container.nfsave" ) @@ -74,6 +94,8 @@ func _run() -> void: assert(save_manager.load_player_data()) assert(player.wallet.get_balance() == 4321) assert(save_manager.get_world_seed() == 864209) + assert(home_state.get_owned_count(&"basic_cube") == 1) + assert(home_state.get_placed_count(&"basic_cube") == 1) var legacy_data: Dictionary = ( ProgressionSaveCodec.read_local_save(save_path)["data"] diff --git a/tests/unified_inventory_validation.gd b/tests/unified_inventory_validation.gd index e9a0d85..6928969 100644 --- a/tests/unified_inventory_validation.gd +++ b/tests/unified_inventory_validation.gd @@ -35,6 +35,41 @@ func _run() -> void: hotbar.setup(bag, ItemCatalogResource, catches, layout) assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36]) assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72]) + var basic_cube: ItemData = ItemCatalogResource.get_item_by_id(&"basic_cube") + assert(basic_cube != null) + assert(basic_cube.icon != null) + assert(basic_cube.hotbar_allowed) + assert(bag.add_item(&"basic_cube")) + assert(layout.is_item_in_inventory(&"basic_cube")) + assert(hotbar.assign_item(0, &"basic_cube")) + assert(hotbar.get_item_id(0) == &"basic_cube") + assert( + layout.get_container( + PlayerInventoryLayout.EntryKind.ITEM, + &"basic_cube", + ) == PlayerInventoryLayout.InventoryContainer.HOTBAR + ) + assert(hotbar.clear_slot(0)) + assert(layout.is_item_in_inventory(&"basic_cube")) + assert(bag.remove_item(&"basic_cube")) + var packed_decor_slot: int = hotbar.find_first_empty_slot() + assert(packed_decor_slot == 0) + assert(layout.prepare_new_item_placement( + &"basic_cube", + PlayerInventoryLayout.InventoryContainer.HOTBAR, + packed_decor_slot, + )) + assert(bag.add_item(&"basic_cube")) + assert( + layout.get_container( + PlayerInventoryLayout.EntryKind.ITEM, + &"basic_cube", + ) == PlayerInventoryLayout.InventoryContainer.HOTBAR + ) + assert(hotbar.assign_item(packed_decor_slot, &"basic_cube")) + assert(hotbar.select_slot(packed_decor_slot)) + assert(hotbar.get_selected_item_id() == &"basic_cube") + assert(bag.remove_item(&"basic_cube")) assert(bag.add_item(&"coffee", 3)) assert(layout.get_inventory_count() == 1) diff --git a/ui/decor_shop.gd b/ui/decor_shop.gd new file mode 100644 index 0000000..9292806 --- /dev/null +++ b/ui/decor_shop.gd @@ -0,0 +1,323 @@ +class_name DecorShop +extends Control + +const INPUT_OWNER: StringName = &"decor_shop" +const DecorCatalogType = preload("res://homes/decor_catalog.gd") +const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") + +signal menu_visibility_changed(is_open: bool) +signal menu_exit_started + +var _player: Player +var _wallet: PlayerWallet +var _fishing_spot: FishingSpot +var _interaction: DecorShopInteraction +var _bag: PlayerBag +var _home_state: PlayerHomeState +var _network_shop: NetworkShopService +var _wallet_label: Label +var _owned_label: Label +var _feedback: Label +var _buy_button: Button +var _close_button: Button +var _transaction_pending: bool = false +var _prior_movement_enabled: bool = true +var _prior_camera_enabled: bool = true +var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE + + +func _ready() -> void: + UtilityPageStyleType.apply_page(self) + _build_ui() + hide() + + +func setup( + player: Player, + wallet: PlayerWallet, + fishing_spot: FishingSpot, + interaction: DecorShopInteraction, + bag: PlayerBag, + home_state: PlayerHomeState, + network_shop: NetworkShopService, +) -> void: + _player = player + _wallet = wallet + _fishing_spot = fishing_spot + _interaction = interaction + _bag = bag + _home_state = home_state + _network_shop = network_shop + if not _wallet.balance_changed.is_connected(_on_wallet_changed): + _wallet.balance_changed.connect(_on_wallet_changed) + if not _bag.contents_changed.is_connected(_refresh): + _bag.contents_changed.connect(_refresh) + if not _home_state.changed.is_connected(_refresh): + _home_state.changed.connect(_refresh) + if ( + _network_shop != null + and not _network_shop.local_purchase_pending.is_connected( + _on_purchase_pending + ) + ): + _network_shop.local_purchase_pending.connect(_on_purchase_pending) + _network_shop.local_purchase_finished.connect(_on_purchase_finished) + _refresh() + + +func set_world_interaction(interaction: DecorShopInteraction) -> void: + _interaction = interaction + + +func open_shop() -> bool: + if ( + visible + or _player == null + or _wallet == null + or _fishing_spot == null + or _interaction == null + or not _interaction.is_local_player_in_range() + or not _fishing_spot.can_open_fishing_shop() + ): + return false + _prior_movement_enabled = _player.is_movement_enabled() + _prior_camera_enabled = _player.is_camera_input_enabled() + _prior_mouse_mode = Input.mouse_mode + _player.set_movement_enabled(false) + _player.set_camera_input_enabled(false) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _transaction_pending = ( + _network_shop != null + and _network_shop.is_local_purchase_pending() + ) + _feedback.text = "" + _refresh() + show() + _buy_button.call_deferred("grab_focus") + menu_visibility_changed.emit(true) + return true + + +func close_shop(restore_controls: bool = true) -> void: + if not visible: + return + menu_exit_started.emit() + get_viewport().gui_release_focus() + hide() + if _fishing_spot != null: + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) + if restore_controls and _player != null: + _player.set_movement_enabled(_prior_movement_enabled) + _player.set_camera_input_enabled(_prior_camera_enabled) + Input.mouse_mode = _prior_mouse_mode + menu_visibility_changed.emit(false) + + +func consume_escape() -> bool: + if not visible: + return false + close_shop() + return true + + +func close_for_range_exit() -> void: + close_shop() + + +func close_for_water_recovery() -> void: + close_shop(false) + + +func close_for_session_end() -> void: + close_shop(false) + + +func _unhandled_input(event: InputEvent) -> void: + if not visible or not event.is_action_pressed("ui_cancel"): + return + close_shop() + get_viewport().set_input_as_handled() + + +func _purchase_cube() -> void: + if _network_shop == null or _transaction_pending: + return + var request_id: String = _network_shop.request_decor( + DecorCatalogType.BASIC_CUBE_ID + ) + if request_id.is_empty(): + _refresh() + + +func _on_purchase_pending(_request_id: String) -> void: + _transaction_pending = true + _feedback.text = "purchasing…" + _refresh() + + +func _on_purchase_finished( + _request_id: String, + accepted: bool, + message: String, + product_id: StringName, + category: int, + _quantity: int, + total_cost: int, +) -> void: + if ( + category != NetworkShopProtocol.ProductCategory.DECOR + or product_id != DecorCatalogType.BASIC_CUBE_ID + ): + return + _transaction_pending = false + _feedback.text = ( + "basic cube purchased · %d fishcoin spent" % total_cost + if accepted + else message.to_lower() + ) + _refresh() + + +func _on_wallet_changed(_balance: int, _delta: int) -> void: + _refresh() + + +func _refresh() -> void: + if _wallet_label == null: + return + var balance: int = _wallet.get_balance() if _wallet != null else 0 + var owned: int = ( + _home_state.get_owned_count(DecorCatalogType.BASIC_CUBE_ID) + if _home_state != null else 0 + ) + var carried: int = ( + _bag.get_quantity(DecorCatalogType.BASIC_CUBE_ID) + if _bag != null else 0 + ) + _wallet_label.text = "%d fishcoins" % balance + _owned_label.text = "owned %d · packed %d" % [owned, carried] + _buy_button.disabled = ( + _transaction_pending + or _wallet == null + or not _wallet.can_afford( + DecorCatalogType.get_price(DecorCatalogType.BASIC_CUBE_ID) + ) + or _bag == null + or not _bag.can_add_item(DecorCatalogType.BASIC_CUBE_ID, 1) + or owned >= DecorCatalogType.MAX_OWNED_PER_PRODUCT + ) + + +func _build_ui() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + z_index = 83 + mouse_filter = Control.MOUSE_FILTER_STOP + var blocker := ColorRect.new() + blocker.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + blocker.color = Color(0.02, 0.08, 0.10, 0.55) + blocker.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(blocker) + + var panel := PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_CENTER) + panel.position = Vector2(-440.0, -250.0) + panel.size = Vector2(880.0, 500.0) + panel.add_theme_stylebox_override( + "panel", + UtilityPageStyleType.rounded_style( + UtilityPageStyleType.OCEAN_PANEL_DEEP, 24 + ), + ) + add_child(panel) + var margin := MarginContainer.new() + for side: StringName in [ + &"margin_left", &"margin_top", &"margin_right", &"margin_bottom", + ]: + margin.add_theme_constant_override(side, 28) + panel.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 18) + margin.add_child(layout) + + var header := HBoxContainer.new() + header.add_theme_constant_override("separation", 12) + layout.add_child(header) + var title := Label.new() + title.text = "decor" + title.add_theme_font_size_override("font_size", 38) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(title) + _wallet_label = Label.new() + _wallet_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _wallet_label.add_theme_font_size_override("font_size", 22) + header.add_child(_wallet_label) + + var item_panel := PanelContainer.new() + item_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + item_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyleType.rounded_style( + UtilityPageStyleType.OCEAN_FIELD, 18 + ), + ) + layout.add_child(item_panel) + var item_margin := MarginContainer.new() + for side: StringName in [ + &"margin_left", &"margin_top", &"margin_right", &"margin_bottom", + ]: + item_margin.add_theme_constant_override(side, 24) + item_panel.add_child(item_margin) + var item_row := HBoxContainer.new() + item_row.add_theme_constant_override("separation", 28) + item_margin.add_child(item_row) + var preview := Panel.new() + preview.custom_minimum_size = Vector2(150.0, 150.0) + preview.add_theme_stylebox_override( + "panel", + UtilityPageStyleType.rounded_style(Color("b77a55"), 12), + ) + item_row.add_child(preview) + var details := VBoxContainer.new() + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + details.add_theme_constant_override("separation", 10) + item_row.add_child(details) + var item_name := Label.new() + item_name.text = DecorCatalogType.get_display_name( + DecorCatalogType.BASIC_CUBE_ID + ) + item_name.add_theme_font_size_override("font_size", 32) + details.add_child(item_name) + var description := Label.new() + description.text = DecorCatalogType.get_description( + DecorCatalogType.BASIC_CUBE_ID + ) + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + description.add_theme_font_size_override("font_size", 20) + description.size_flags_vertical = Control.SIZE_EXPAND_FILL + details.add_child(description) + _owned_label = Label.new() + _owned_label.add_theme_font_size_override("font_size", 18) + details.add_child(_owned_label) + _buy_button = Button.new() + _buy_button.text = "buy · 1 fishcoin" + _buy_button.custom_minimum_size = Vector2(260.0, 58.0) + UtilityPageStyleType.apply_ocean_button(_buy_button) + _buy_button.pressed.connect(_purchase_cube) + details.add_child(_buy_button) + + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 12) + layout.add_child(footer) + _feedback = Label.new() + _feedback.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _feedback.add_theme_font_size_override("font_size", 18) + footer.add_child(_feedback) + _close_button = Button.new() + _close_button.text = "close" + _close_button.custom_minimum_size = Vector2(150.0, 50.0) + UtilityPageStyleType.apply_ocean_button(_close_button) + _close_button.pressed.connect(close_shop) + footer.add_child(_close_button) + _buy_button.focus_neighbor_bottom = _buy_button.get_path_to(_close_button) + _close_button.focus_neighbor_top = _close_button.get_path_to(_buy_button) diff --git a/ui/decor_shop.gd.uid b/ui/decor_shop.gd.uid new file mode 100644 index 0000000..ed65203 --- /dev/null +++ b/ui/decor_shop.gd.uid @@ -0,0 +1 @@ +uid://810ck3mvbn5v diff --git a/ui/decor_shop.tscn b/ui/decor_shop.tscn new file mode 100644 index 0000000..2b26f04 --- /dev/null +++ b/ui/decor_shop.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/decor_shop.gd" id="1_script"] + +[node name="DecorShop" type="Control"] +unique_name_in_owner = true +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_script") diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 3ce0d17..585efc9 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -20,6 +20,7 @@ const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") const NetworkSessionType = preload("res://network/network_session.gd") const FishingShopType = preload("res://ui/fishing_shop.gd") +const DecorShopType = preload("res://ui/decor_shop.gd") const SettingsPanelType = preload("res://ui/settings_panel.gd") const PlayerFishingUpgradesType = preload( "res://progression/player_fishing_upgrades.gd" @@ -27,7 +28,11 @@ const PlayerFishingUpgradesType = preload( const ShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const DecorShopInteractionType = preload( + "res://world/decor_shop_interaction.gd" +) const PlayerStorageType = preload("res://ui/player_storage.gd") +const HomeDecorToolbarType = preload("res://ui/home_decor_toolbar.gd") const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) @@ -71,6 +76,9 @@ const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload( const ACTIVE_BAIT_EMPTY_ICON: Texture2D = preload( "res://ui/icons/pictograms/x_dark.png" ) +const RV_STORAGE_ICON: Texture2D = preload( + "res://ui/icons/tab_menu/inventory_simple.png" +) signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested @@ -82,6 +90,7 @@ signal virtual_pointer_mode_changed(is_active: bool) signal social_prompt_accepted signal social_prompt_declined signal data_root_changed +signal rv_storage_requested const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" @@ -145,7 +154,10 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000 @onready var _social_prompt: BubbleConfirmationPage = %SocialPrompt @onready var _hotbar_ui: HotbarUIType = %Hotbar @onready var _fishing_shop: FishingShopType = %FishingShop +@onready var _decor_shop: DecorShopType = %DecorShop @onready var _player_storage: PlayerStorageType = %PlayerStorage +@onready var _home_decor_toolbar: HomeDecorToolbarType = %HomeDecorToolbar +@onready var _rv_storage_button: Button = %RVStorageButton @onready var _storage_prompt: PanelContainer = %StoragePrompt @onready var _storage_prompt_message: Label = %StoragePromptMessage @onready var _shop_prompt: Control = %ShopPrompt @@ -195,6 +207,7 @@ var _chat_input_open: bool = false var _player_menu_hotbar_visible: bool = false var _main_shop_buyer: FishBuyerProfileType var _shop_interaction: ShopInteractionType +var _decor_shop_interaction: DecorShopInteractionType var _storage_interaction: PlayerStorageInteractionType var _surface_drawing: NetworkSurfaceDrawingService var _surface_drawing_hotbar_selected: bool = false @@ -221,14 +234,17 @@ 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 +var _rv_storage_button_requested_visible: bool = false func _ready() -> void: _prioritize_surface_drawing_pointer_input() _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) + _rv_storage_button.pressed.connect(_on_rv_storage_button_pressed) _social_prompt.confirmed.connect(_accept_social_prompt) _social_prompt.cancelled.connect(_decline_social_prompt) _apply_active_bait_indicator_style() + _apply_rv_storage_button_style() _refresh_active_bait_indicator() _refresh_interaction_prompt_bindings() # Reward feedback must remain above full-screen canonical menus. Keeping the @@ -346,11 +362,13 @@ func setup( fishing_upgrades: PlayerFishingUpgradesType, shop_interaction: ShopInteractionType, storage_interaction: PlayerStorageInteractionType, + decor_shop_interaction: DecorShopInteractionType, item_effects: PlayerItemEffectsType, cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType, network_sale_service: NetworkSaleService, network_shop_service: NetworkShopService, + home_state: PlayerHomeState, network_chat_service: NetworkChatService, network_profile: NetworkProfilePreferences, spawn_service: PlayerSpawnService, @@ -494,6 +512,15 @@ func setup( network_sale_service, reservations, ) + _decor_shop.setup( + player, + wallet, + fishing_spot, + decor_shop_interaction, + bag, + home_state, + network_shop_service, + ) _player_storage.setup( player, fishing_spot, @@ -509,6 +536,8 @@ func setup( ) _fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) _fishing_shop.menu_exit_started.connect(_on_shop_exit_started) + _decor_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) + _decor_shop.menu_exit_started.connect(_on_shop_exit_started) _fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested) _fishing_shop.shop_cooler_return_requested.connect( _on_shop_cooler_return_requested @@ -518,6 +547,7 @@ func setup( ) _main_shop_buyer = main_shop_buyer _shop_interaction = shop_interaction + _decor_shop_interaction = decor_shop_interaction _storage_interaction = storage_interaction _surface_drawing = surface_drawing if ( @@ -540,11 +570,14 @@ func setup( func set_world_interactions( shop_interaction: ShopInteractionType, storage_interaction: PlayerStorageInteractionType, + decor_shop_interaction: DecorShopInteractionType, ) -> void: _shop_interaction = shop_interaction _storage_interaction = storage_interaction + _decor_shop_interaction = decor_shop_interaction _fishing_shop.set_world_interaction(shop_interaction) _player_storage.set_world_interaction(storage_interaction) + _decor_shop.set_world_interaction(decor_shop_interaction) func _prioritize_surface_drawing_pointer_input() -> void: @@ -1655,6 +1688,34 @@ func _apply_active_bait_indicator_style() -> void: _active_bait_button.add_theme_color_override(state, Color.WHITE) +func _apply_rv_storage_button_style() -> void: + var normal_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_normal_style() + var hover_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_hover_style() + var pressed_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_pressed_style() + for style: StyleBoxFlat in [normal_style, hover_style, pressed_style]: + style.set_corner_radius_all(29) + style.content_margin_left = 9.0 + style.content_margin_top = 9.0 + style.content_margin_right = 9.0 + style.content_margin_bottom = 9.0 + _rv_storage_button.icon = RV_STORAGE_ICON + _rv_storage_button.add_theme_stylebox_override("normal", normal_style) + _rv_storage_button.add_theme_stylebox_override("hover", hover_style) + _rv_storage_button.add_theme_stylebox_override("focus", hover_style) + _rv_storage_button.add_theme_stylebox_override("pressed", pressed_style) + for state: StringName in [ + &"icon_normal_color", + &"icon_hover_color", + &"icon_focus_color", + &"icon_pressed_color", + ]: + _rv_storage_button.add_theme_color_override(state, Color.WHITE) + + +func _on_rv_storage_button_pressed() -> void: + rv_storage_requested.emit() + + func _refresh_active_bait_indicator() -> void: _refresh_active_bait_indicator_visibility() if not is_node_ready(): @@ -1718,17 +1779,20 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: if not enabled: _gameplay_hud_hidden = false _refresh_active_bait_indicator_visibility() + _refresh_rv_storage_button_visibility() if enabled: _try_start_shop_npc_speech() _refresh_gameplay_hud_visibility() _refresh_chat_availability() if not enabled: + _home_decor_toolbar.set_active(false) _end_virtual_mouse() _close_radial_menus() if _surface_drawing != null: _surface_drawing.deactivate() close_player_menu_for_session_end() _fishing_shop.close_for_session_end() + _decor_shop.close_for_session_end() _player_storage.close_for_session_end() _fishing_panel.visible = false _shop_prompt.hide() @@ -1749,6 +1813,7 @@ func set_gameplay_hud_hidden(hidden: bool) -> void: _quick_radial_menu.set_hud_hidden(hidden) _refresh_gameplay_hud_visibility() _refresh_active_bait_indicator_visibility() + _refresh_rv_storage_button_visibility() _refresh_hotbar_visibility() _refresh_chat_availability() @@ -1864,13 +1929,43 @@ func get_fishing_shop() -> FishingShopType: return _fishing_shop +func get_decor_shop() -> DecorShopType: + return _decor_shop + + func get_player_storage() -> PlayerStorageType: return _player_storage +func get_home_decor_toolbar() -> HomeDecorToolbarType: + return _home_decor_toolbar + + +func set_rv_interior_camera_mode(enabled: bool) -> void: + _hotbar_ui.set_rv_interior_camera_mode(enabled) + + +func set_rv_storage_button_visible(requested_visible: bool) -> void: + _rv_storage_button_requested_visible = requested_visible + _refresh_rv_storage_button_visibility() + + +func _refresh_rv_storage_button_visibility() -> void: + _rv_storage_button.visible = ( + _rv_storage_button_requested_visible + and _gameplay_ui_enabled + and not _gameplay_hud_hidden + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _storage_open + ) + + func set_storage_prompt_visible( requested_visible: bool, world_anchor: Vector3 = Vector3(0.0, INF, 0.0), + action_text: String = "open storage", ) -> void: if not _storage_prompt.has_meta(&"styled"): _storage_prompt.set_meta(&"styled", true) @@ -1879,6 +1974,7 @@ func set_storage_prompt_visible( ) style.anti_aliasing = false _storage_prompt.add_theme_stylebox_override("panel", style) + _storage_prompt_message.text = action_text _storage_prompt.visible = ( requested_visible and _gameplay_ui_enabled @@ -2762,6 +2858,7 @@ func _refresh_hotbar_visibility() -> void: func _emit_interactive_pointer_ui_changed() -> void: _refresh_active_bait_indicator_visibility() + _refresh_rv_storage_button_visibility() interactive_pointer_ui_changed.emit( _system_menu_open or _player_menu_open diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 483433a..c219c84 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=22 format=3] +[gd_scene load_steps=24 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"] @@ -15,6 +15,8 @@ [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"] +[ext_resource type="PackedScene" path="res://ui/decor_shop.tscn" id="16_decor_shop"] +[ext_resource type="PackedScene" path="res://ui/home_decor_toolbar.tscn" id="17_decor_toolbar"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.032, 0.118, 0.15, 1) @@ -472,9 +474,37 @@ polygon = PackedVector2Array(-8, 0, 8, 0, 0, 10) [node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")] unique_name_in_owner = true +[node name="DecorShop" parent="UIRoot/CanonicalStage" instance=ExtResource("16_decor_shop")] +unique_name_in_owner = true + +[node name="HomeDecorToolbar" parent="UIRoot/CanonicalStage" instance=ExtResource("17_decor_toolbar")] +unique_name_in_owner = true + [node name="PlayerStorage" parent="UIRoot/CanonicalStage" instance=ExtResource("14_storage")] unique_name_in_owner = true +[node name="RVStorageButton" type="Button" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 57 +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = 16.0 +offset_right = -16.0 +offset_bottom = 74.0 +grow_horizontal = 0 +mouse_default_cursor_shape = 2 +focus_mode = 0 +tooltip_text = "open RV storage" +theme = ExtResource("3_theme") +texture_filter = 1 +expand_icon = true +icon_alignment = 1 +vertical_icon_alignment = 1 + [node name="StoragePrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] unique_name_in_owner = true visible = false diff --git a/ui/home_decor_toolbar.gd b/ui/home_decor_toolbar.gd new file mode 100644 index 0000000..6f73219 --- /dev/null +++ b/ui/home_decor_toolbar.gd @@ -0,0 +1,126 @@ +class_name HomeDecorToolbar +extends PanelContainer + +signal placement_mode_requested(mode: int) +signal rotate_requested +signal store_requested +signal cancel_requested + +var _mode_button: Button +var _selection_label: Label +var _rotate_button: Button +var _store_button: Button +var _mode: PlayerHomeState.PlacementMode = PlayerHomeState.PlacementMode.GRID + + +func _ready() -> void: + _build_ui() + hide() + + +func set_active(active: bool) -> void: + if visible == active: + return + visible = active + if active and _mode_button != null: + _mode_button.call_deferred("grab_focus") + + +func set_mode(mode: PlayerHomeState.PlacementMode) -> void: + _mode = mode + if _mode_button != null: + _mode_button.text = ( + "placement: grid" + if mode == PlayerHomeState.PlacementMode.GRID + else "placement: free" + ) + + +func set_selection(instance_id: String, display_name: String = "") -> void: + var selected: bool = not instance_id.is_empty() + if _selection_label != null: + _selection_label.text = ( + "selected: %s" % display_name + if selected else "click decor to select; drag to move" + ) + if _rotate_button != null: + _rotate_button.disabled = not selected + if _store_button != null: + _store_button.disabled = not selected + + +func report_status(message: String) -> void: + if _selection_label != null: + _selection_label.text = message + + +func _build_ui() -> void: + custom_minimum_size = Vector2(330.0, 0.0) + position = Vector2(925.0, 540.0) + z_index = 58 + mouse_filter = Control.MOUSE_FILTER_STOP + add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_PANEL_DEEP, 0.96), 16 + ), + ) + var margin := MarginContainer.new() + for side: StringName in [ + &"margin_left", &"margin_top", &"margin_right", &"margin_bottom", + ]: + margin.add_theme_constant_override(side, 12) + add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 8) + margin.add_child(layout) + var title := Label.new() + title.text = "RV decor" + title.add_theme_font_size_override("font_size", 24) + layout.add_child(title) + _selection_label = Label.new() + _selection_label.text = "click decor to select; drag to move" + _selection_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _selection_label.add_theme_font_size_override("font_size", 17) + layout.add_child(_selection_label) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + layout.add_child(row) + _mode_button = Button.new() + _mode_button.text = "placement: grid" + _mode_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UtilityPageStyle.apply_ocean_button(_mode_button) + _mode_button.pressed.connect(_toggle_mode) + row.add_child(_mode_button) + _rotate_button = Button.new() + _rotate_button.text = "rotate" + _rotate_button.disabled = true + UtilityPageStyle.apply_ocean_button(_rotate_button) + _rotate_button.pressed.connect(rotate_requested.emit) + row.add_child(_rotate_button) + var action_row := HBoxContainer.new() + action_row.add_theme_constant_override("separation", 8) + layout.add_child(action_row) + _store_button = Button.new() + _store_button.text = "pack up" + _store_button.disabled = true + _store_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UtilityPageStyle.apply_ocean_button(_store_button) + _store_button.pressed.connect(store_requested.emit) + action_row.add_child(_store_button) + var cancel_button := Button.new() + cancel_button.text = "clear selection" + cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UtilityPageStyle.apply_ocean_button(cancel_button) + cancel_button.pressed.connect(cancel_requested.emit) + action_row.add_child(cancel_button) + + +func _toggle_mode() -> void: + _mode = ( + PlayerHomeState.PlacementMode.FREE + if _mode == PlayerHomeState.PlacementMode.GRID + else PlayerHomeState.PlacementMode.GRID + ) + set_mode(_mode) + placement_mode_requested.emit(int(_mode)) diff --git a/ui/home_decor_toolbar.gd.uid b/ui/home_decor_toolbar.gd.uid new file mode 100644 index 0000000..b1bb23b --- /dev/null +++ b/ui/home_decor_toolbar.gd.uid @@ -0,0 +1 @@ +uid://bmtlixw3wief3 diff --git a/ui/home_decor_toolbar.tscn b/ui/home_decor_toolbar.tscn new file mode 100644 index 0000000..671308d --- /dev/null +++ b/ui/home_decor_toolbar.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/home_decor_toolbar.gd" id="1_script"] + +[node name="HomeDecorToolbar" type="PanelContainer"] +unique_name_in_owner = true +visible = false +script = ExtResource("1_script") diff --git a/ui/hotbar.gd b/ui/hotbar.gd index 24a9aa8..5f54217 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -37,6 +37,7 @@ var _slots: Array[BubbleHotbarSlotType] = [] var _gameplay_input_enabled: bool = false var _drag_enabled: bool = false var _swap_hotbar_camera_scroll: bool = false +var _rv_interior_camera_mode: bool = false var _motion_elapsed: float = 0.0 var _compact_layout: bool = false var _player_menu_context: bool = false @@ -99,6 +100,10 @@ func set_swap_hotbar_camera_scroll(enabled: bool) -> void: _swap_hotbar_camera_scroll = enabled +func set_rv_interior_camera_mode(enabled: bool) -> void: + _rv_interior_camera_mode = enabled + + func set_drag_enabled(enabled: bool) -> void: _drag_enabled = enabled for slot: BubbleHotbarSlotType in _slots: @@ -308,7 +313,11 @@ func _unhandled_input(event: InputEvent) -> void: if ( event is InputEventMouseButton and event.pressed - and event.shift_pressed == _swap_hotbar_camera_scroll + and ( + event.shift_pressed + if _rv_interior_camera_mode + else event.shift_pressed == _swap_hotbar_camera_scroll + ) ): if event.button_index == MOUSE_BUTTON_WHEEL_UP: _hotbar.cycle_selection(-1) diff --git a/ui/player_storage.gd b/ui/player_storage.gd index 25eb0d4..1f553ea 100644 --- a/ui/player_storage.gd +++ b/ui/player_storage.gd @@ -4,6 +4,7 @@ extends Control const INPUT_OWNER: StringName = &"player_storage" signal menu_visibility_changed(is_open: bool) +signal decorate_requested var _player: Player var _fishing_spot: FishingSpot @@ -14,6 +15,9 @@ var _storage_grid: GeneralInventoryGrid var _inventory_count: Label var _storage_count: Label var _feedback: Label +var _title: Label +var _decorate_button: Button +var _home_access: bool = false var _prior_movement_enabled: bool = true var _prior_camera_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE @@ -75,15 +79,34 @@ func set_world_interaction(interaction: PlayerStorageInteraction) -> void: func open_storage() -> bool: + return _open_storage(false) + + +func open_storage_from_home(allow_decorating: bool = true) -> bool: + return _open_storage(true, allow_decorating) + + +func _open_storage( + home_access: bool, + allow_decorating: bool = false, +) -> bool: if ( visible or _player == null - or _interaction == null - or not _interaction.is_local_player_in_range() or _fishing_spot == null or not _fishing_spot.can_open_fishing_shop() + or ( + not home_access + and ( + _interaction == null + or not _interaction.is_local_player_in_range() + ) + ) ): return false + _home_access = home_access + _title.text = "RV storage" if home_access else "private storage" + _decorate_button.visible = home_access and allow_decorating _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_enabled = _player.is_camera_input_enabled() _prior_mouse_mode = Input.mouse_mode @@ -112,10 +135,13 @@ func close_storage(restore_controls: bool = true) -> void: _player.set_movement_enabled(_prior_movement_enabled) _player.set_camera_input_enabled(_prior_camera_enabled) Input.mouse_mode = _prior_mouse_mode + _home_access = false menu_visibility_changed.emit(false) func close_for_range_exit() -> void: + if _home_access: + return close_storage() @@ -248,12 +274,20 @@ func _build_ui() -> void: var header := HBoxContainer.new() layout.add_child(header) - var title := Label.new() - title.text = "private storage" - title.add_theme_font_size_override("font_size", 32) - title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY) - title.size_flags_horizontal = Control.SIZE_EXPAND_FILL - header.add_child(title) + _title = Label.new() + _title.text = "private storage" + _title.add_theme_font_size_override("font_size", 32) + _title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY) + _title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(_title) + _decorate_button = Button.new() + _decorate_button.text = "decorate RV" + _decorate_button.tooltip_text = "Place decor inside your RV" + _decorate_button.custom_minimum_size = Vector2(160.0, 48.0) + UtilityPageStyle.apply_ocean_button(_decorate_button) + _decorate_button.hide() + _decorate_button.pressed.connect(_request_decorating) + header.add_child(_decorate_button) var close := Button.new() close.text = "×" close.tooltip_text = "Close storage" @@ -285,6 +319,13 @@ func _build_ui() -> void: layout.add_child(_feedback) +func _request_decorating() -> void: + if not _home_access: + return + close_storage() + decorate_requested.emit() + + func _build_column(parent: HBoxContainer, title_text: String) -> Dictionary: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(550.0, 0.0) diff --git a/world/decor_shop_interaction.gd b/world/decor_shop_interaction.gd new file mode 100644 index 0000000..049bfbe --- /dev/null +++ b/world/decor_shop_interaction.gd @@ -0,0 +1,2 @@ +class_name DecorShopInteraction +extends FishingShopInteraction diff --git a/world/decor_shop_interaction.gd.uid b/world/decor_shop_interaction.gd.uid new file mode 100644 index 0000000..955738c --- /dev/null +++ b/world/decor_shop_interaction.gd.uid @@ -0,0 +1 @@ +uid://u70jgwedk7ym diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 780ddb8..4e15251 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -9,6 +9,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const DecorShopInteractionType = preload( + "res://world/decor_shop_interaction.gd" +) const PrecipitationOcclusionType = preload( "res://world/environment/precipitation_occlusion.gd" ) @@ -80,8 +83,12 @@ const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ @onready var _player_storage: PlayerStorageInteractionType = ( $Interactables/PlayerStorageBox/InteractionArea ) +@onready var _decor_shop: DecorShopInteractionType = ( + $Interactables/DecorShopWorld/InteractionArea +) @onready var _shop_root: Node3D = %FishingShopWorld @onready var _storage_root: Node3D = %PlayerStorageBox +@onready var _decor_shop_root: Node3D = %DecorShopWorld @onready var _decorations: Node3D = %Decorations @onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks @onready var _diggable_beach: DiggableArea3D = %DiggableBeach @@ -148,6 +155,10 @@ func get_player_storage() -> PlayerStorageInteractionType: return _player_storage +func get_decor_shop() -> DecorShopInteractionType: + return _decor_shop + + func get_saltwater_shoreline_mesh() -> MeshInstance3D: return _shoreline_reference @@ -428,6 +439,8 @@ func _place_spawn_amenities(center: Vector3) -> void: _shop_root.rotation.y = PI _storage_root.position = center + Vector3(-2.4, 0.0, 1.2) _storage_root.rotation.y = PI * 0.5 + _decor_shop_root.position = center + Vector3(-2.4, 0.0, 1.2) + _decor_shop_root.rotation.y = PI * 0.5 func _configure_static_water() -> void: diff --git a/world/generation/generated_world_region.tscn b/world/generation/generated_world_region.tscn index 47ee6bc..04602e1 100644 --- a/world/generation/generated_world_region.tscn +++ b/world/generation/generated_world_region.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=14 format=3] +[gd_scene load_steps=15 format=3] [ext_resource type="Script" path="res://world/generation/generated_world_region.gd" id="1_region"] [ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"] @@ -13,6 +13,7 @@ [ext_resource type="Resource" path="res://fish/pools/starter_pond_pool.tres" id="11_pond_pool"] [ext_resource type="Resource" path="res://world/generation/props/terrain_prop_catalog.tres" id="12_prop_catalog"] [ext_resource type="Resource" path="res://world/generation/biomes/terrain_biome_catalog.tres" id="13_biome_catalog"] +[ext_resource type="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="14_decor_shop"] [node name="GeneratedWorldRegion" type="Node3D"] script = ExtResource("1_region") @@ -98,6 +99,9 @@ unique_name_in_owner = true [node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("6_storage")] unique_name_in_owner = true +[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("14_decor_shop")] +unique_name_in_owner = true + [node name="Decorations" type="Node3D" parent="."] unique_name_in_owner = true diff --git a/world/interactables/decor_shop_world.tscn b/world/interactables/decor_shop_world.tscn new file mode 100644 index 0000000..6b735e2 --- /dev/null +++ b/world/interactables/decor_shop_world.tscn @@ -0,0 +1,35 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://world/decor_shop_interaction.gd" id="1_shop"] +[ext_resource type="PackedScene" path="res://art/exported/characters/base/straywild_base_character.glb" id="2_character"] +[ext_resource type="Script" path="res://world/world_character_display.gd" id="3_display"] + +[sub_resource type="SphereShape3D" id="InteractionShape"] +resource_local_to_scene = true +radius = 3.0 + +[node name="DecorShopWorld" type="Node3D"] + +[node name="Shopkeeper" parent="." instance=ExtResource("2_character")] +script = ExtResource("3_display") +species_id = "round" +fur_color_id = "orange" +ears_id = "pointy_short" +tail_id = "cat" +eyes_id = "simple_shine" +nose_id = "dog_round" +mouth_id = "three" +head_look_enabled = true + +[node name="AnimationPlayer" parent="Shopkeeper"] +autoplay = "idle" + +[node name="InteractionArea" type="Area3D" parent="."] +unique_name_in_owner = true +position = Vector3(0, 1, -0.3) +collision_layer = 0 +collision_mask = 2 +script = ExtResource("1_shop") + +[node name="InteractionShape" type="CollisionShape3D" parent="InteractionArea"] +shape = SubResource("InteractionShape") diff --git a/world/interactables/player_storage_box.tscn b/world/interactables/player_storage_box.tscn index 4bf9a8e..008f0ac 100644 --- a/world/interactables/player_storage_box.tscn +++ b/world/interactables/player_storage_box.tscn @@ -27,6 +27,8 @@ size = Vector3(1.62, 0.86, 1.02) radius = 2.3 [node name="PlayerStorageBox" type="Node3D"] +process_mode = 4 +visible = false [node name="Body" type="MeshInstance3D" parent="."] position = Vector3(0, 0.35, 0) @@ -44,6 +46,7 @@ collision_mask = 0 [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"] position = Vector3(0, 0.43, 0) +disabled = true shape = SubResource("CollisionShape") [node name="InteractionArea" type="Area3D" parent="."] @@ -54,6 +57,7 @@ collision_mask = 2 script = ExtResource("1_interaction") [node name="CollisionShape3D" type="CollisionShape3D" parent="InteractionArea"] +disabled = true shape = SubResource("InteractionShape") [node name="PromptAnchor" type="Marker3D" parent="."] diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index 3af3010..b418292 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -8,6 +8,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const DecorShopInteractionType = preload( + "res://world/decor_shop_interaction.gd" +) const FOLIAGE_WIND_SHADER: Shader = preload( "res://world/materials/foliage_wind.gdshader" ) @@ -49,6 +52,10 @@ var fishing_shop_path: NodePath = ( var player_storage_path: NodePath = ( ^"Interactables/PlayerStorageBox/InteractionArea" ) +@export_node_path("Area3D") +var decor_shop_path: NodePath = ( + ^"Interactables/DecorShopWorld/InteractionArea" +) @export_group("Terrain Collision") # The imported GLB is the collision authority. Rebuild once per region load so # newly authored terrain and props cannot retain a stale saved fallback shape. @@ -84,6 +91,10 @@ func get_player_storage() -> PlayerStorageInteractionType: return get_node_or_null(player_storage_path) as PlayerStorageInteractionType +func get_decor_shop() -> DecorShopInteractionType: + return get_node_or_null(decor_shop_path) as DecorShopInteractionType + + func get_saltwater_shoreline_mesh() -> MeshInstance3D: return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn index 8f4bc4b..9b95dfa 100644 --- a/world/regions/starter_island_region.tscn +++ b/world/regions/starter_island_region.tscn @@ -18,6 +18,7 @@ [ext_resource type="Script" path="res://world/water/shoreline_ribbon_config.gd" id="22_shoreline_config"] [ext_resource type="Script" path="res://world/digging/diggable_area_3d.gd" id="23_diggable_area"] [ext_resource type="Script" path="res://world/gathering/gatherable_anchor_set_3d.gd" id="24_gatherable_anchors"] +[ext_resource type="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="25_decor_shop"] [sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_4e4k5"] data = PackedVector3Array(10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 10.033899, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 0, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.6399, 3.3913999, 16.2405, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 19.798899, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -13.1096, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.7232, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -16.459, 16.495699, 3.3913999, -13.42, 16.4543, 3.3913999, -13.3786, 12.738999, 3.3913999, -15.894899, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -10.033899, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 6.6893, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.4131, 3.2091, -13.357699, 3.3446, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -10.033899, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -3.3446, 0, 3.3913999, -6.6893, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 3.3446, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, -2.9566998, 3.3913999, 0, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, -3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, -3.3446, 3.3913999, -6.6893, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, 0, 3.3913999, -3.3446, 0, 3.3913999, 0, 0, 3.3913999, 0, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -3.3446, -2.9566998, 3.3913999, 3.3446, -2.9566998, 3.3913999, 0, 0, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 0, 0, 3.3913999, 3.3446, -6.6893, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, -3.3446, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, 0, 3.3913999, 3.3446, 0, 3.3913999, 0, 3.3446, 3.3913999, 0, 0, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -3.3446, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -9.8592, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -12.538199, -3.9101999, 3.3913999, -9.8592, -10.033899, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -12.538199, -3.9101999, 3.3913999, -12.538199, -13.3786, 3.3913999, -3.7326, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -6.6893, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -19.7545, 3.3913999, -2.7851999, -16.7232, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -19.7545, 3.3913999, -2.7851999, -19.280699, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -17.1112, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -16.191599, 3.3913999, -3.0971, -17.1112, 3.3913999, 0, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -20.0679, 3.3913999, 0, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 0, -17.1112, 3.3913999, 3.3044999, -13.3786, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -10.033899, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -15.835, -10.699599, 3.3913999, -13.175799, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -15.835, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.175, -12.867, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 3.3044999, -22.5541, 3.3913999, 9.2058, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 6.6893, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -16.7232, 3.3913999, 6.6893, -16.285099, 3.3913999, 9.5865, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.7232, 3.3913999, 6.6893, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -13.1355, 3.3913999, 6.7549996, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -13.1355, 3.3913999, 6.7549996, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -14.877199, 3.3913999, 4.6657, -14.877199, 3.3913999, 4.6657, -17.1112, 3.3913999, 3.3044999, -15.582299, 3.3913999, 4.0366, -13.1355, 3.3913999, 6.7549996, -14.877199, 3.3913999, 4.6657, -14.0948, 3.3913999, 5.5073, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -10.033899, 3.3913999, 7.0772, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -6.6893, 3.3913999, 10.033899, -10.033899, 3.0033998, 6.6893, -13.1355, 3.3913999, 6.7549996, -12.747499, 3.0033998, 6.3671, -10.033899, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -13.1355, 3.3913999, 6.7549996, -2.5674999, 2.4164, 7.6215, -6.6893, 3.3913999, 7.0772, -6.6893, 3.0033998, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1794999, 2.8042998, 8.009399, -6.6893, 3.3913999, 7.0772, -12.747499, 3.0033998, 6.3671, -14.0948, 3.3913999, 5.5073, -13.6942, 3.0033998, 5.1798, -12.747499, 3.0033998, 6.3671, -13.1355, 3.3913999, 6.7549996, -14.0948, 3.3913999, 5.5073, -6.6893, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -10.033899, 3.0033998, 6.6893, -6.6893, 3.0033998, 6.6893, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 7.0772, -16.7232, 3.0033998, 0, -16.191599, 3.3913999, -3.0971, -15.8036995, 3.0033998, -2.7091, -16.7232, 3.0033998, 0, -17.1112, 3.3913999, 0, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -15.8036995, 3.0033998, -2.7091, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -15.8036995, 3.0033998, -2.7091, -2.9566998, 3.3913999, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, -3.3446, 3.0033998, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -6.6893, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -3.6378, 3.3913999, -2.0792, -4.0257998, 3.0033998, -1.6911999, -6.6893, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -13.3786, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -3.7326, -6.6893, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 0, -16.7232, 3.0033998, 0, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 3.3044999, -17.1112, 3.3913999, 0, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -15.372, 3.0033998, 3.6132998, -14.4892, 3.0033998, 4.2778, -14.877199, 3.3913999, 4.6657, -15.582299, 3.3913999, 4.0366, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 0, -2.9566998, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 3.3446, -3.3446, 3.0033998, 0, -2.1794999, 2.8042998, 8.009399, -2.1397, 3.0033998, 5.1795, -1.7832999, 3.3913999, 5.2162, -2.1794999, 2.8042998, 8.009399, -2.5674999, 2.4164, 7.6215, -2.1397, 3.0033998, 5.1795, -6.6893, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, -3.3446, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -2.1794999, 2.8042998, 8.009399, -1.7832999, 3.3913999, 5.2162, 0, 3.3913999, 6.6893, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -1.7832999, 3.3913999, 5.2162, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 6.6893, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 3.3446, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -2.1397, 3.0033998, 5.1795, -2.9566998, 3.3913999, 3.3446, -1.7832999, 3.3913999, 5.2162, -2.1397, 3.0033998, 5.1795, -3.3446, 3.0033998, 3.3446, -2.9566998, 3.3913999, 3.3446, -14.0948, 3.3913999, 5.5073, -14.4892, 3.0033998, 4.2778, -13.6942, 3.0033998, 5.1798, -14.0948, 3.3913999, 5.5073, -14.877199, 3.3913999, 4.6657, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -16.7232, 3.0033998, 2.9166, -15.372, 3.0033998, 3.6132998, -15.582299, 3.3913999, 4.0366, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.0033998, 2.9166, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 20.1539, 3.1299, -13.399799, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -13.1096, 16.756899, 3.1650999, -16.7286, 12.738999, 3.3913999, -15.894899, 12.7471, 3.1766999, -16.1988, 16.756899, 3.1650999, -16.7286, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -16.459, 16.756899, 3.1650999, -16.7286, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -13.42, 16.495699, 3.3913999, -16.459, 16.4543, 3.3913999, -13.3786, 16.8204, 3.138, -13.447599, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -13.42, 16.8204, 3.138, -13.447599, 16.495699, 3.3913999, -13.42, 16.8051, 3.1778, -13.479, 10.033899, 3.0523999, -19.0294, 6.6893, 3.2782998, -18.727999, 6.6893, 3.0440998, -19.036999, 10.033899, 3.0523999, -19.0294, 10.033899, 3.2784998, -18.6997, 6.6893, 3.2782998, -18.727999, 12.723599, 2.9213, -19.2902, 10.033899, 3.2784998, -18.6997, 10.033899, 3.0523999, -19.0294, 12.723599, 2.9213, -19.2902, 12.446099, 3.1699998, -18.9401, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.446099, 3.1699998, -18.9401, 12.723599, 2.9213, -19.2902, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 10.033899, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 12.460899, 3.3913999, -16.1674, 6.6893, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 6.6893, 3.2782998, -18.727999, 10.033899, 3.2784998, -18.6997, 12.738999, 3.3913999, -15.894899, 12.747, 3.1771, -16.1991, 12.7471, 3.1766999, -16.1988, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 3.8388, 3.3913999, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6443, 3.2897, -17.9799, 3.8388, 3.3913999, -16.7232, 3.5895, 3.2033, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6821, 3.3913999, -13.3786, 3.5895, 3.2033, -16.7232, 3.8388, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 3.5895, 3.2033, -16.7232, 6.6640997, 3.0702, -19.018099, 4.6443, 3.2897, -17.9799, 4.4783998, 3.1067, -18.2665, 6.6640997, 3.0702, -19.018099, 6.6705, 3.2772, -18.741, 4.6443, 3.2897, -17.9799, 4.6443, 3.2897, -17.9799, 6.6893, 3.3913999, -16.7232, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6443, 3.2897, -17.9799, 6.6705, 3.2772, -18.741, 6.6893, 3.3913999, -16.7232, 6.6705, 3.2772, -18.741, 6.6893, 3.2782998, -18.727999, 6.6893, 3.2782998, -18.727999, 6.6640997, 3.0702, -19.018099, 6.6893, 3.0440998, -19.036999, 6.6893, 3.2782998, -18.727999, 6.6705, 3.2772, -18.741, 6.6640997, 3.0702, -19.018099, 3.7529, 3.1712, -9.0494995, 0, 3.3913999, -8.7143, 0, 3.179, -9.0652, 3.7529, 3.1712, -9.0494995, 4.0225, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, -3.6538, 3.1585999, -12.813399, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -3.6538, 3.1585999, -12.813399, -3.9101999, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -12.538199, -3.6538, 3.1585999, -12.813399, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -9.8592, -3.9101999, 3.3913999, -12.538199, -0.0081, 3.1864998, -9.063499, -3.9101999, 3.3913999, -9.8592, -3.6076, 3.1676, -10.0696, -0.0081, 3.1864998, -9.063499, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, 0, 3.179, -9.0652, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -13.175799, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -13.3786, 3.3913999, -15.4154, -13.3786, 3.1771998, -15.7625, -10.4319, 3.1557999, -16.1556, -10.699599, 3.3913999, -15.835, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -13.175799, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -16.130499, 3.6227, -13.623799, -16.0243, 3.3509998, -15.752, -15.7941, 3.5591, -15.4641, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -15.7941, 3.5591, -15.4641, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -13.396, 3.3925, -15.4333, -15.7941, 3.5591, -15.4641, -15.7941, 3.5591, -15.4641, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -15.7941, 3.5591, -15.4641, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -13.3786, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -15.4154, -13.3786, 3.3913999, -15.4154, -13.3911, 3.1892, -15.757, -13.3786, 3.1771998, -15.7625, -13.3786, 3.3913999, -15.4154, -13.396, 3.3925, -15.4333, -13.3911, 3.1892, -15.757, -16.3753, 3.4225, -13.6277, -16.1241, 3.3832998, -9.5097, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -19.7545, 3.3913999, -2.7851999, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.3965, 3.3909, -9.5092, -19.280699, 3.3913999, -6.6893, -19.515, 3.2122, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.280699, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -19.280699, 3.3913999, -6.6893, -19.3965, 3.3909, -9.5092, -16.1241, 3.3832998, -9.5097, -16.1241, 3.3832998, -9.5097, -19.6237, 3.1655998, -9.757899, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.1241, 3.3832998, -9.5097, -19.3965, 3.3909, -9.5092, -19.6237, 3.1655998, -9.757899, -19.3965, 3.3909, -9.5092, -19.6161, 3.2075999, -9.7116995, -22.8657, 3.4937, 3.3613, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -22.8657, 3.4937, 3.3613, -23.0809, 3.3083, 3.3615, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -23.170399, 3.6251, 0.122999996, -23.3676, 3.4459999, 0.1372, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -23.192099, 3.5300999, -2.4668999, -20.0679, 3.3913999, 0, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.9489, 3.2063, -2.8082, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -23.0777, 3.3197, 3.3734, -22.3151, 3.3913999, 6.6893, -22.563799, 3.2219, 6.6893, -23.0777, 3.3197, 3.3734, -22.8764, 3.4937, 3.3741, -22.3151, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -20.0679, 3.3913999, 6.6893, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 6.6893, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.0777, 3.3197, 3.3734, -23.0809, 3.3083, 3.3615, -22.8657, 3.4937, 3.3613, -22.8764, 3.4937, 3.3741, -23.0777, 3.3197, 3.3734, -16.285099, 3.3913999, 9.5865, -20.0679, 3.1943, 9.407499, -20.0679, 3.3913999, 9.167399, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -20.0679, 3.3913999, 9.167399, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 9.167399, -22.5541, 3.3913999, 9.2058, -22.563799, 3.2219, 6.6893, -22.3151, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -13.3786, 3.3922, 12.571899, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -15.847199, 3.2614, 12.4973, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -13.3786, 3.3913999, 10.033899, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -16.2818, 3.1913998, 9.8056, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -5.9742, 3.3994, 12.8817, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -5.9742, 3.3994, 12.8817, -6.2748, 3.126, 13.122, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -10.033899, 3.2142, 12.1061, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -10.033899, 3.2142, 12.1061, -6.6893, 3.3913999, 10.033899, -5.9742, 3.3994, 12.8817, -10.033899, 3.2142, 12.1061, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.2142, 12.1061, -13.3786, 3.3922, 12.571899, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -5.9742, 3.3994, 12.8817, -6.6893, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, 3.6399, 3.3913999, 16.2405, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, -3.3446, 3.1497998, 15.914599, -3.3446, 3.3913999, 15.6011, 0, 3.2243998, 15.7093, 0, 2.9636998, 16.0179, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -3.3446, 3.3913999, 15.6011, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 15.6011, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, 3.3446, 3.3913999, 13.3786, 3.6399, 3.3913999, 16.2405, 0, 3.2243998, 15.7093, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 15.6011, -5.9737997, 3.392, 16.087599, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 13.3786, -6.2748, 3.1260998, 16.4145, -5.9737997, 3.392, 16.087599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -6.2748, 3.1260998, 16.4145, -6.2748, 3.126, 13.122, -5.9737997, 3.392, 16.087599, -6.2748, 3.126, 13.122, -5.9742, 3.3994, 12.8817, 6.6893, 3.3913999, 18.9861, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 6.6893, 3.2050998, 19.228699, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 4.1273, 3.7335, 19.3804, 3.9266999, 3.5967, 19.6223, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 6.6893, 3.3913999, 18.9861, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 3.8651, 3.3981998, 16.5647, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 18.9861, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 16.7232, 3.9148, 3.3913999, 16.514599, 3.6425998, 3.1413999, 16.5127, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 3.9148, 3.3913999, 16.514599, 3.8651, 3.3981998, 16.5647, 3.6425998, 3.1413999, 16.5127, 3.8651, 3.3981998, 16.5647, 3.6536, 3.2078998, 16.5669, 9.3066, 3.2186, 19.0653, 10.3743, 3.3913999, 16.7232, 10.5913, 3.1985998, 16.7232, 9.3066, 3.2186, 19.0653, 9.1275, 3.3913999, 18.830599, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 6.6991997, 3.2148, 19.2255, 6.6949, 3.3913999, 18.9914, 9.1275, 3.3913999, 18.830599, 9.3066, 3.2186, 19.0653, 6.6991997, 3.2148, 19.2255, 6.6893, 3.3913999, 18.9861, 6.6991997, 3.2148, 19.2255, 6.6893, 3.2050998, 19.228699, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6991997, 3.2148, 19.2255, 9.8131, 3.3546, 13.1979, 10.5893, 3.2117, 16.7106, 10.403999, 3.3911998, 16.6938, 9.8131, 3.3546, 13.1979, 10.0546, 3.1885, 13.4071, 10.5893, 3.2117, 16.7106, 6.6893, 3.3913999, 13.3786, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 13.3786, 9.8131, 3.3546, 13.1979, 10.3743, 3.3913999, 16.7232, 9.8131, 3.3546, 13.1979, 10.403999, 3.3911998, 16.6938, 6.6893, 3.3913999, 13.3786, 10.033899, 3.3913999, 10.033899, 9.8131, 3.3546, 13.1979, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.3743, 3.3913999, 16.7232, 10.5893, 3.2117, 16.7106, 10.5913, 3.1985998, 16.7232, 10.3743, 3.3913999, 16.7232, 10.403999, 3.3911998, 16.6938, 10.5893, 3.2117, 16.7106, 13.1839, 3.2744, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 13.1839, 3.2744, 6.6893, 13.3786, 3.2744, 6.496, 13.5511, 3.1797, 6.6893, 12.9758, 3.2036, 10.033899, 13.1839, 3.2744, 6.6893, 13.4036, 3.0749998, 6.6893, 12.9758, 3.2036, 10.033899, 12.7325, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 13.2631, 3.5611, 13.4043, 12.7325, 3.3913999, 10.033899, 12.9758, 3.2036, 10.033899, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 9.8131, 3.3546, 13.1979, 12.7325, 3.3913999, 10.033899, 13.025499, 3.6999, 13.1985, 9.8131, 3.3546, 13.1979, 10.033899, 3.3913999, 10.033899, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 13.025499, 3.6999, 13.1985, 10.0546, 3.1885, 13.4071, 9.8131, 3.3546, 13.1979, 13.025499, 3.6999, 13.1985, 13.2631, 3.5611, 13.4043, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 14.0489, 3.0042, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.6027, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.0865998, 13.1206, 3.3028998, -3.3446, 16.7232, 3.1513, -3.602, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.1513, -3.602, 16.7127, 2.8734, -3.3446, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.3913999, -6.6893, 16.7232, 3.1513, -3.602, 13.3786, 3.3028998, -3.6027, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3028998, -3.6027, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 20.961199, 0, -3.3446, 20.0679, 0, 3.3446, 14.0489, 3.0042, -3.3446, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3028998, -3.0865998, 14.0489, 3.0042, -3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 14.0489, 3.0042, -3.3446, 20.0679, 0, 3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 20.0679, 0, 3.3446, 16.7232, 1.5715, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, 0, 3.3446, 20.0679, -0.124199994, 6.6893, 13.3786, 3.2744, 6.496, 16.7232, 1.5715, 6.6893, 13.5511, 3.1797, 6.6893, 10.033899, 3.3913999, -3.3446, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, 0, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.0865998, 19.831999, 3.3902998, -10.0008, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 19.831999, 3.3902998, -10.0008, 20.1796, 3.182, -9.995999, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 19.8322, 3.2891998, -6.6893, 20.147, 3.0588999, -6.6893, 20.1349, 2.7974, -3.3783998, 16.7232, 3.1513, -3.602, 19.8322, 3.2891998, -6.6893, 19.8322, 3.0491, -3.602, 16.7232, 3.1513, -3.602, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 19.798899, 3.3913999, -10.033899, 19.831999, 3.3902998, -10.0008, 20.198, 3.1490998, -10.033899, 19.831999, 3.3902998, -10.0008, 19.798899, 3.3913999, -10.033899, 20.198, 3.1490998, -10.033899, 20.1796, 3.182, -9.995999, 19.831999, 3.3902998, -10.0008, 20.144999, 2.7605999, -3.3446, 19.8322, 3.0491, -3.602, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 20.144999, 2.7605999, -3.3446, 16.7127, 2.8734, -3.3446, 19.8322, 3.0491, -3.602, 16.7127, 2.8734, -3.3446, 16.7232, 3.1513, -3.602, 15.0508995, 0, 6.6893, 16.7232, 1.5715, 6.6893, 16.7232, 0, 6.6893, 16.7232, 0, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, -0.124199994, 6.6893, 15.0508995, 0, 6.6893, 13.5511, 3.1797, 6.6893, 16.7232, 1.5715, 6.6893, 15.0508995, 0, 6.6893, 13.4036, 3.0749998, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 15.0508995, 0, 6.6893, 13.8046, 0, 6.6893, -16.7232, 0.6137, 0, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 0, -13.3786, 0.6137, 0, -16.7232, 0.6137, 0, -15.8036995, 0.6137, -2.7091, -13.3786, 0.6137, -3.3446, -16.7232, 0.6137, 0, -13.3786, 0.6137, -3.3446, -13.3786, 0.6137, 0, -13.3786, 0.6137, 0, -13.3786, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -15.7546, 0.6137, 3.5372, -13.3786, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, -3.3446, -10.033899, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, -3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 0, -6.6893, 0.6137, -3.3446, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, 0, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 0, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 0, -3.3446, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 6.6893, -10.033899, 0.6137, 6.6893, -13.677099, 0.6137, 5.2616, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -10.033899, 0.6137, 6.6893, -12.747499, 0.6137, 6.3671, -6.6893, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -3.3446, 0.6137, 3.3446, -2.1193, 0.6137, 5.2736998, -16.7232, 3.0033998, 2.9166, -15.7546, 0.6137, 3.5372, -15.372, 3.0033998, 3.6132998, -16.7232, 3.0033998, 2.9166, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -13.677099, 0.6137, 5.2616, -13.6942, 3.0033998, 5.1798, -14.4892, 3.0033998, 4.2778, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -15.8036995, 3.0033998, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -15.8036995, 3.0033998, -2.7091, -15.8036995, 0.6137, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 2.9166, -16.7232, 3.0033998, 2.9166, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 0, -16.7232, 0.6137, 2.9166, -3.3446, 3.0033998, 0, -4.0257998, 0.6137, -1.6911999, -4.0257998, 3.0033998, -1.6911999, -3.3446, 3.0033998, 0, -3.3446, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 0, -3.3446, 3.0033998, 0, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 3.3446, -3.3446, 0.6137, 0, -12.747499, 3.0033998, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, 6.6893, -12.747499, 3.0033998, 6.3671, -12.747499, 0.6137, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -10.033899, 3.0033998, -3.3446, -6.6893, 3.0033998, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -15.8036995, 0.6137, -2.7091, -15.8036995, 3.0033998, -2.7091, -13.3786, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -15.8036995, 0.6137, -2.7091, -4.0257998, 3.0033998, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -4.0257998, 3.0033998, -1.6911999, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, 6.6893, -2.5674999, 0.0266, 7.6215, -2.5674999, 2.4164, 7.6215, -6.6893, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -10.033899, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -6.6893, 3.0033998, 6.6893, -10.033899, 3.0033998, 6.6893, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1397, 3.0033998, 5.1795, -2.5674999, 2.4164, 7.6215, -2.5674999, 0.0266, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1193, 0.6137, 5.2736998, -3.3446, 3.0033998, 3.3446, -2.1397, 3.0033998, 5.1795, -2.1193, 0.6137, 5.2736998, -3.3446, 0.6137, 3.3446, -3.3446, 3.0033998, 3.3446, -13.677099, 0.6137, 5.2616, -12.747499, 3.0033998, 6.3671, -13.6942, 3.0033998, 5.1798, -13.677099, 0.6137, 5.2616, -12.747499, 0.6137, 6.3671, -12.747499, 3.0033998, 6.3671, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -15.372, 3.0033998, 3.6132998, -15.7546, 0.6137, 3.5372, -14.4892, 0.6137, 4.2778, -14.4892, 3.0033998, 4.2778, 21.1831, 0, -13.6544, 16.8204, 3.138, -13.447599, 18.0241, 0, -14.3019, 21.1831, 0, -13.6544, 20.1539, 3.1299, -13.399799, 16.8204, 3.138, -13.447599, 21.8888, 0, -10.033899, 20.1539, 3.1299, -13.399799, 21.1831, 0, -13.6544, 21.8888, 0, -10.033899, 20.198, 3.1490998, -10.033899, 20.1539, 3.1299, -13.399799, 17.2285, 0, -16.8036, 12.7471, 3.1766999, -16.1988, 13.7014, 0, -17.416399, 17.2285, 0, -16.8036, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 16.8204, 3.138, -13.447599, 17.2285, 0, -16.8036, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 16.8204, 3.138, -13.447599, 16.8051, 3.1778, -13.479, 17.2285, 0, -16.8036, 16.8051, 3.1778, -13.479, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 13.3786, 0, -20.5992, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 12.7471, 3.1766999, -16.1988, 12.747, 3.1771, -16.1991, 13.3786, 0, -20.5992, 12.747, 3.1771, -16.1991, 12.723599, 2.9213, -19.2902, 13.3786, 0, -20.5992, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 13.3786, 0, -20.5992, 12.723599, 2.9213, -19.2902, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 6.6893, 3.0440998, -19.036999, 6.6893, 0, -20.1318, 10.033899, 0, -20.467, 10.033899, 3.0523999, -19.0294, 6.6893, 3.0440998, -19.036999, 3.0140998, 0, -16.7232, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 3.0140998, 0, -16.7232, 3.5895, 3.2033, -16.7232, 4.4131, 3.2091, -13.357699, 6.6893, 3.0440998, -19.036999, 3.3446, 0, -20.5992, 6.6893, 0, -20.1318, 3.3446, 0, -20.5992, 6.6893, 3.0440998, -19.036999, 6.6640997, 3.0702, -19.018099, 3.3446, 0, -20.5992, 6.6640997, 3.0702, -19.018099, 4.4783998, 3.1067, -18.2665, 3.3446, 0, -20.5992, 3.5895, 3.2033, -16.7232, 3.0140998, 0, -16.7232, 3.3446, 0, -20.5992, 4.4783998, 3.1067, -18.2665, 3.5895, 3.2033, -16.7232, 2.8241, 0, -10.4594, 0, 3.179, -9.0652, 0, 0, -10.7654, 2.8241, 0, -10.4594, 3.7529, 3.1712, -9.0494995, 0, 3.179, -9.0652, 3.3446, -0.3989, -12.9443, 3.7529, 3.1712, -9.0494995, 2.8241, 0, -10.4594, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, -2.6297, 0, -10.5745, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -2.6297, 0, -10.5745, -3.6076, 3.1676, -10.0696, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -3.3446, 0, -13.3786, -3.6538, 3.1585999, -12.813399, -6.6893, 3.175, -12.867, 0, 3.179, -9.0652, -2.6297, 0, -10.5745, 0, 0, -10.7654, -2.6297, 0, -10.5745, 0, 3.179, -9.0652, -0.0081, 3.1864998, -9.063499, -2.6297, 0, -10.5745, -0.0081, 3.1864998, -9.063499, -3.6076, 3.1676, -10.0696, -6.6893, 0, -14.2637, -10.4043, 3.1608999, -13.4295, -9.6168995, 0, -14.127899, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -9.6168995, 0, -14.127899, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -9.6168995, 0, -14.127899, -10.4043, 3.1608999, -13.4295, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3786, 0, -17.3364, -10.033899, 0, -16.7232, -10.4319, 3.1557999, -16.1556, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.7232, 0, -16.7232, -16.0243, 3.3509998, -15.752, -16.3753, 3.4225, -13.6277, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3911, 3.1892, -15.757, -16.7232, 0, -16.7232, -13.3911, 3.1892, -15.757, -16.0243, 3.3509998, -15.752, -17.275, 0, -10.5169, -19.6237, 3.1655998, -9.757899, -20.0679, 0, -10.033899, -17.275, 0, -10.5169, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.7232, 0, -13.3786, -16.4131, 3.1706, -9.788899, -17.275, 0, -10.5169, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -20.3764, 0, -6.6893, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -20.3764, 0, -6.6893, -19.515, 3.2122, -6.6893, -19.9489, 3.2063, -2.8082, -19.6237, 3.1655998, -9.757899, -20.3764, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -19.6237, 3.1655998, -9.757899, -19.6161, 3.2075999, -9.7116995, -20.3764, 0, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.515, 3.2122, -6.6893, -23.412498, -0.2913, 0.26119998, -23.0809, 3.3083, 3.3615, -23.412498, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.3676, 3.4459999, 0.1372, -23.0809, 3.3083, 3.3615, -23.412498, 0, -3.3446, -23.3676, 3.4459999, 0.1372, -23.412498, -0.2913, 0.26119998, -23.412498, 0, -3.3446, -23.390299, 3.3349998, -2.6938999, -23.3676, 3.4459999, 0.1372, -20.0679, -0.3484, -3.5669, -23.390299, 3.3349998, -2.6938999, -23.412498, 0, -3.3446, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.0809, 3.3083, 3.3615, -23.8078, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -23.0809, 3.3083, 3.3615, -23.0777, 3.3197, 3.3734, -23.8078, 0, 6.6893, -23.0777, 3.3197, 3.3734, -22.563799, 3.2219, 6.6893, -23.8078, 0, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -23.8078, 0, 6.6893, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -20.0679, 3.1943, 9.407499, -20.0679, 0, 10.033899, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -20.0679, 0, 10.033899, -20.0679, 3.1943, 9.407499, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 13.3786, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -16.7232, 0, 13.3786, -16.076399, 3.04, 12.735, -13.3786, 3.194, 12.8089, -16.7232, 0, 10.033899, -16.076399, 3.04, 12.735, -16.7232, 0, 13.3786, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -13.3786, 0, 13.3786, -10.033899, 2.9384, 12.4537, -10.033899, 0, 13.3786, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 0, 13.3786, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -10.033899, 0, 13.3786, -10.033899, 2.9384, 12.4537, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -6.2748, 3.1260998, 16.4145, -6.6893, 0, 16.7232, -6.6893, 0, 13.3786, -6.2748, 3.126, 13.122, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, 0, 2.9636998, 16.0179, 0, 0, 16.7232, -3.3446, 0, 16.7232, -3.3446, 3.1497998, 15.914599, 0, 2.9636998, 16.0179, 0, 0, 16.7232, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 16.7232, 0, 0, 16.7232, 0, 2.9636998, 16.0179, 3.6425998, 3.1413999, 16.5127, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -6.2748, 3.1260998, 16.4145, -6.2558, 3.1427999, 16.4098, -3.3446, 0, 16.7232, -6.2558, 3.1427999, 16.4098, -3.3446, 3.1497998, 15.914599, 3.3446, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6893, 0, 20.0679, 3.3446, 0, 20.0679, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 3.6425998, 3.1413999, 16.5127, 3.6536, 3.2078998, 16.5669, 3.3446, 0, 20.0679, 3.6536, 3.2078998, 16.5669, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 10.572599, 0, 20.0679, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6991997, 3.2148, 19.2255, 10.572599, 0, 20.0679, 6.6991997, 3.2148, 19.2255, 9.3066, 3.2186, 19.0653, 10.572599, 0, 20.0679, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 20.0679, 9.3066, 3.2186, 19.0653, 10.5913, 3.1985998, 16.7232, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 13.918799, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 10.5913, 3.1985998, 16.7232, 10.5893, 3.2117, 16.7106, 10.572599, 0, 13.918799, 10.5893, 3.2117, 16.7106, 10.0546, 3.1885, 13.4071, 10.572599, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 10.572599, 0, 13.918799, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 12.9758, 3.2036, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 12.9758, 3.2036, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.8046, 0, 10.033899, 13.4036, 3.0749998, 6.6893, 13.8046, 0, 6.6893, 13.8046, 0, 10.033899, 12.9758, 3.2036, 10.033899, 13.4036, 3.0749998, 6.6893, 20.961199, 0, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 20.961199, 0, -3.3446, 14.0489, 3.0042, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 21.2349, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 20.144999, 2.7605999, -3.3446, 20.1349, 2.7974, -3.3783998, 21.2349, 0, -6.6893, 20.1349, 2.7974, -3.3783998, 20.147, 3.0588999, -6.6893, 20.147, 3.0588999, -6.6893, 21.8888, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 20.147, 3.0588999, -6.6893, 20.1796, 3.182, -9.995999, 21.8888, 0, -10.033899, 20.1796, 3.182, -9.995999, 20.198, 3.1490998, -10.033899, 23.412498, 0, -26.7572, 26.7572, -0.2593, -23.412498, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 23.412498, 0, -26.7572, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -25.084799, 23.412498, 0, -26.7572, 26.7572, -0.2593, -26.7572, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -23.412498, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 26.7572, -3.3446, 0, -23.412498, 0, 0, -26.7572, 0, 0, -23.412498, 0, 0, -23.412498, 0, 0, -26.7572, 3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -3.3446, 0, -26.7572, 0, 0, -26.7572, 0, 0, -23.412498, 3.3446, 0, -26.7572, 3.3446, 0, -23.412498, 3.3446, 0, -23.412498, 3.3446, 0, -26.7572, 6.6893, 0, -26.7572, -3.3446, 0, -20.0679, -3.3446, 0, -23.412498, 0, 0, -23.412498, 3.3446, 0, -23.412498, 6.6893, 0, -26.7572, 6.6893, 0, -23.412498, 6.6893, 0, -23.412498, 6.6893, 0, -26.7572, 10.033899, 0, -26.7572, 0, 0, -20.0679, 0, 0, -23.412498, 3.3446, 0, -23.412498, -3.3446, 0, -20.0679, 0, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -20.5992, 3.3446, 0, -23.412498, 6.6893, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -23.412498, 3.3446, 0, -20.5992, 3.3446, 0, -20.5992, 6.6893, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -26.7572, 10.033899, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -26.7572, 13.3786, 0, -26.7572, 6.6893, 0, -20.1318, 10.033899, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -26.7572, 13.3786, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -26.7572, 16.7232, 0, -26.7572, 10.033899, 0, -20.467, 13.3786, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -26.7572, 16.7232, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -26.7572, 20.0679, 0, -26.7572, 13.3786, 0, -20.5992, 16.7232, 0, -23.412498, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 16.7232, 0, -20.0679, 17.2285, 0, -16.8036, 16.7232, 0, -23.412498, 20.0679, 0, -26.7572, 20.0679, 0, -23.412498, 16.7232, 0, -20.0679, 16.7232, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -26.7572, 23.412498, 0, -26.7572, 20.0679, 0, -23.412498, 23.412498, 0, -26.7572, 23.412498, 0, -23.412498, 16.7232, 0, -20.0679, 20.0679, 0, -23.412498, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -23.412498, 23.412498, 0, -23.412498, 17.2285, 0, -16.8036, 16.7232, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 23.412498, 0, -23.412498, 23.412498, 0, -20.0679, 17.2285, 0, -16.8036, 20.0679, 0, -20.0679, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 20.0679, 0, -20.0679, 23.412498, 0, -20.0679, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 23.412498, 0, -20.0679, 23.412498, 0, -16.7232, 18.0241, 0, -14.3019, 20.0679, 0, -16.7232, 21.1831, 0, -13.6544, 21.1831, 0, -13.6544, 20.0679, 0, -16.7232, 23.412498, 0, -16.7232, 21.1831, 0, -13.6544, 23.412498, 0, -16.7232, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 21.1831, 0, -13.6544, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 23.412498, 0, -13.3786, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 23.412498, 0, -10.033899, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 23.412498, 0, -6.6893, 23.412498, 0, -3.3446, 20.0679, 0, 0, 20.961199, 0, -3.3446, 23.412498, 0, -3.3446, 20.0679, 0, 0, 23.412498, 0, -3.3446, 23.412498, 0, 0, 20.0679, 0, 3.3446, 20.0679, 0, 0, 23.412498, 0, 0, 20.0679, 0, 3.3446, 23.412498, 0, 0, 23.412498, 0, 3.3446, 10.572599, 0, 16.7232, 13.8046, 0, 13.918799, 13.3786, 0, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 13.8046, 0, 13.918799, 10.572599, 0, 20.0679, 10.572599, 0, 16.7232, 13.3786, 0, 16.7232, 13.3786, 0, 16.7232, 13.8046, 0, 13.918799, 15.0508995, 0, 13.3786, 10.572599, 0, 20.0679, 13.3786, 0, 16.7232, 13.3786, 0, 20.0679, 13.8046, 0, 13.918799, 15.0508995, 0, 10.033899, 15.0508995, 0, 13.3786, 13.8046, 0, 13.918799, 13.8046, 0, 10.033899, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 6.6893, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 15.0508995, 0, 6.6893, 16.7232, 0, 6.6893, 15.0508995, 0, 10.033899, 16.7232, 0, 6.6893, 16.7232, 0, 10.033899, 15.0508995, 0, 13.3786, 15.0508995, 0, 10.033899, 16.7232, 0, 10.033899, 13.3786, 0, 16.7232, 15.0508995, 0, 13.3786, 15.0508995, 0, 16.7232, 13.3786, 0, 20.0679, 13.3786, 0, 16.7232, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 10.033899, 16.7232, 0, 13.3786, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 10.033899, 20.0679, 0, 10.033899, 16.7232, 0, 13.3786, 20.0679, 0, 10.033899, 20.0679, 0, 13.3786, 20.0679, 0, 13.3786, 20.0679, 0, 10.033899, 23.412498, 0, 10.033899, 20.0679, 0, 13.3786, 23.412498, 0, 10.033899, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 16.7232, 0, 13.3786, 20.0679, 0, 13.3786, 15.0508995, 0, 16.7232, 16.7232, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 20.0679, 0, 13.3786, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 13.3786, 20.0679, 0, 16.7232, 20.0679, 0, 16.7232, 23.412498, 0, 13.3786, 23.412498, 0, 16.7232, 15.0508995, 0, 20.0679, 15.0508995, 0, 16.7232, 16.7232, 0, 16.7232, 13.3786, 0, 20.0679, 15.0508995, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 20.0679, 0, 16.7232, 23.412498, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 16.7232, 20.0679, 0, 20.0679, 20.0679, 0, 20.0679, 23.412498, 0, 16.7232, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 20.0679, 0, 20.0679, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 23.412498, 0, 20.0679, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 20.0679, 0, 23.412498, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 23.412498, 0, 23.412498, 23.412498, 0, 26.7572, 16.7232, 0, 26.7572, 20.0679, 0, 23.412498, 20.0679, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 20.0679, 20.0679, 0, 23.412498, 16.7232, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 23.412498, 16.7232, 0, 23.412498, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 15.0508995, 0, 26.7572, 16.7232, 0, 23.412498, 16.7232, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 20.0679, 16.7232, 0, 23.412498, 15.0508995, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 23.412498, 15.0508995, 0, 23.412498, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 13.3786, 0, 26.7572, 15.0508995, 0, 23.412498, 15.0508995, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 20.0679, 15.0508995, 0, 23.412498, 13.3786, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 23.412498, 13.3786, 0, 23.412498, 13.3786, 0, 20.0679, 15.0508995, 0, 20.0679, 10.033899, 0, 26.7572, 13.3786, 0, 23.412498, 13.3786, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 20.0679, 13.3786, 0, 23.412498, 10.033899, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 23.412498, 10.033899, 0, 23.412498, 10.572599, 0, 20.0679, 13.3786, 0, 20.0679, 6.6893, 0, 26.7572, 10.033899, 0, 23.412498, 10.033899, 0, 26.7572, 6.6893, 0, 23.412498, 10.572599, 0, 20.0679, 10.033899, 0, 23.412498, 6.6893, 0, 26.7572, 6.6893, 0, 23.412498, 10.033899, 0, 23.412498, 6.6893, 0, 23.412498, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 3.3446, 0, 26.7572, 6.6893, 0, 23.412498, 6.6893, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 20.0679, 6.6893, 0, 23.412498, 3.3446, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 23.412498, 3.3446, 0, 23.412498, 3.3446, 0, 20.0679, 6.6893, 0, 20.0679, 0, 0, 26.7572, 3.3446, 0, 23.412498, 3.3446, 0, 26.7572, 0, 0, 26.7572, 0, 0, 23.412498, 3.3446, 0, 23.412498, 0, 0, 23.412498, 3.3446, 0, 20.0679, 3.3446, 0, 23.412498, -3.3446, 0, 26.7572, 0, 0, 23.412498, 0, 0, 26.7572, 0, 0, 23.412498, 0, 0, 20.0679, 3.3446, 0, 20.0679, 0, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 0, 0, 20.0679, 0, 0, 16.7232, 3.3446, 0, 16.7232, -3.3446, 0, 26.7572, -3.3446, 0, 23.412498, 0, 0, 23.412498, -3.3446, 0, 23.412498, 0, 0, 20.0679, 0, 0, 23.412498, -6.6893, 0, 26.7572, -3.3446, 0, 23.412498, -3.3446, 0, 26.7572, -3.3446, 0, 20.0679, 0, 0, 16.7232, 0, 0, 20.0679, -3.3446, 0, 23.412498, -3.3446, 0, 20.0679, 0, 0, 20.0679, -3.3446, 0, 20.0679, -3.3446, 0, 16.7232, 0, 0, 16.7232, -6.6893, 0, 26.7572, -6.6893, 0, 23.412498, -3.3446, 0, 23.412498, -6.6893, 0, 23.412498, -3.3446, 0, 20.0679, -3.3446, 0, 23.412498, -10.033899, 0, 26.7572, -6.6893, 0, 23.412498, -6.6893, 0, 26.7572, -6.6893, 0, 20.0679, -3.3446, 0, 16.7232, -3.3446, 0, 20.0679, -6.6893, 0, 23.412498, -6.6893, 0, 20.0679, -3.3446, 0, 20.0679, -6.6893, 0, 20.0679, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -10.033899, 0, 26.7572, -10.033899, 0, 23.412498, -6.6893, 0, 23.412498, -10.033899, 0, 23.412498, -6.6893, 0, 20.0679, -6.6893, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 23.412498, -10.033899, 0, 26.7572, -10.033899, 0, 20.0679, -6.6893, 0, 16.7232, -6.6893, 0, 20.0679, -10.033899, 0, 23.412498, -10.033899, 0, 20.0679, -6.6893, 0, 20.0679, -13.3786, 0, 26.7572, -13.3786, 0, 23.412498, -10.033899, 0, 23.412498, -13.3786, 0, 23.412498, -10.033899, 0, 20.0679, -10.033899, 0, 23.412498, -16.7232, 0, 26.7572, -13.3786, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 20.0679, -10.033899, 0, 16.7232, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -6.6893, 0, 13.3786, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -10.033899, 0, 13.3786, -6.6893, 0, 13.3786, -13.3786, 0, 20.0679, -10.033899, 0, 16.7232, -10.033899, 0, 20.0679, -13.3786, 0, 23.412498, -13.3786, 0, 20.0679, -10.033899, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 13.3786, -10.033899, 0, 16.7232, -13.3786, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 16.7232, -13.3786, 0, 16.7232, -13.3786, 0, 13.3786, -10.033899, 0, 13.3786, -16.7232, 0, 26.7572, -16.7232, 0, 23.412498, -13.3786, 0, 23.412498, -16.7232, 0, 23.412498, -13.3786, 0, 20.0679, -13.3786, 0, 23.412498, -20.0679, 0, 26.7572, -16.7232, 0, 23.412498, -16.7232, 0, 26.7572, -16.7232, 0, 20.0679, -13.3786, 0, 16.7232, -13.3786, 0, 20.0679, -16.7232, 0, 23.412498, -16.7232, 0, 20.0679, -13.3786, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 13.3786, -13.3786, 0, 16.7232, -16.7232, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 16.7232, -16.7232, 0, 16.7232, -16.7232, 0, 13.3786, -13.3786, 0, 13.3786, -20.0679, 0, 26.7572, -20.0679, 0, 23.412498, -16.7232, 0, 23.412498, -20.0679, 0, 23.412498, -16.7232, 0, 20.0679, -16.7232, 0, 23.412498, -23.412498, 0, 26.7572, -20.0679, 0, 23.412498, -20.0679, 0, 26.7572, -20.0679, 0, 20.0679, -16.7232, 0, 16.7232, -16.7232, 0, 20.0679, -20.0679, 0, 23.412498, -20.0679, 0, 20.0679, -16.7232, 0, 20.0679, -23.412498, 0, 26.7572, -23.412498, 0, 23.412498, -20.0679, 0, 23.412498, -23.412498, 0, 23.412498, -20.0679, 0, 20.0679, -20.0679, 0, 23.412498, -26.7572, 0, 26.7572, -23.412498, 0, 23.412498, -23.412498, 0, 26.7572, -26.7572, 0, 26.7572, -26.7572, 0, 23.412498, -23.412498, 0, 23.412498, -26.7572, 0, 23.412498, -23.412498, 0, 20.0679, -23.412498, 0, 23.412498, -23.412498, 0, 23.412498, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -26.7572, 0, 23.412498, -26.7572, 0, 20.0679, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 16.7232, -23.412498, 0, 20.0679, -20.0679, 0, 16.7232, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 13.3786, -16.7232, 0, 16.7232, -26.7572, 0, 20.0679, -23.412498, 0, 16.7232, -23.412498, 0, 20.0679, -23.412498, 0, 20.0679, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -26.7572, 0, 20.0679, -26.7572, 0, 16.7232, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 13.3786, -23.412498, 0, 16.7232, -20.0679, 0, 13.3786, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 10.033899, -16.7232, 0, 13.3786, -20.0679, 0, 13.3786, -20.0679, 0, 10.033899, -16.7232, 0, 10.033899, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -20.0679, 0, 13.3786, -23.412498, 0, 13.3786, -20.0679, 0, 10.033899, -20.0679, 0, 13.3786, -26.7572, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 10.033899, -20.0679, 0, 10.033899, -26.7572, 0, 16.7232, -26.7572, 0, 13.3786, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -23.412498, 0, 10.033899, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -26.7572, 0, 10.033899, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -23.8078, 0, 6.6893, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -26.7572, 0, 6.6893, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, 0, 3.3446, -16.7232, 0, -23.412498, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -13.3786, 0, -23.412498, -13.3786, 0, -26.7572, -10.033899, 0, -26.7572, -16.7232, 0, -23.412498, -16.7232, 0, -26.7572, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -10.033899, 0, -26.7572, -10.033899, 0, -23.412498, -10.033899, 0, -23.412498, -10.033899, 0, -26.7572, -6.6893, 0, -26.7572, -16.7232, 0, -20.0679, -16.7232, 0, -23.412498, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -6.6893, 0, -26.7572, -6.6893, 0, -23.412498, -6.6893, 0, -23.412498, -6.6893, 0, -26.7572, -3.3446, 0, -26.7572, -6.6893, 0, -23.412498, -3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -13.3786, 0, -20.0679, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -16.7232, 0, -20.0679, -13.3786, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -10.033899, 0, -23.412498, -6.6893, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -6.6893, 0, -23.412498, -3.3446, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -23.412498, -6.6893, 0, -20.0679, -6.6893, 0, -20.0679, -3.3446, 0, -23.412498, -3.3446, 0, -20.0679, -13.3786, 0, -17.3364, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -13.3786, 0, -17.3364, -10.033899, 0, -20.0679, -10.033899, 0, -16.7232, -10.033899, 0, -16.7232, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -16.7232, 0, -16.7232, -13.3786, 0, -20.0679, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -16.7232, 0, -20.0679, -13.3786, 0, -20.0679, -10.033899, 0, -16.7232, -6.6893, 0, -20.0679, -6.6893, 0, -16.7232, -6.6893, 0, -16.7232, -6.6893, 0, -20.0679, -3.3446, 0, -20.0679, -9.6168995, 0, -14.127899, -10.033899, 0, -16.7232, -6.6893, 0, -16.7232, -9.6168995, 0, -14.127899, -6.6893, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -20.0679, -3.3446, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -20.0679, 0, 0, -20.0679, -6.6893, 0, -14.2637, -3.3446, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -20.0679, 0, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -16.7232, 0, 0, -16.7232, 0, 0, -20.0679, 3.3446, 0, -20.5992, 0, 0, -16.7232, 3.3446, 0, -20.5992, 3.0140998, 0, -16.7232, -3.3446, 0, -13.3786, 0, 0, -16.7232, 0, 0, -13.3786, -2.6297, 0, -10.5745, -3.3446, 0, -13.3786, 0, 0, -13.3786, -2.6297, 0, -10.5745, 0, 0, -13.3786, 0, 0, -10.7654, -20.0679, 0, -16.7232, -16.7232, 0, -20.0679, -16.7232, 0, -16.7232, -20.0679, 0, -16.7232, -20.0679, 0, -20.0679, -16.7232, 0, -20.0679, -20.0679, 0, -20.0679, -16.7232, 0, -23.412498, -16.7232, 0, -20.0679, -20.0679, 0, -13.3786, -20.0679, 0, -16.7232, -16.7232, 0, -16.7232, -20.0679, 0, -13.3786, -16.7232, 0, -16.7232, -16.7232, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -20.0679, -20.0679, 0, -16.7232, -20.0679, 0, -10.033899, -20.0679, 0, -13.3786, -16.7232, 0, -13.3786, -20.0679, 0, -10.033899, -16.7232, 0, -13.3786, -17.275, 0, -10.5169, -23.412498, 0, -13.3786, -20.0679, 0, -16.7232, -20.0679, 0, -13.3786, -23.412498, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -16.7232, -23.412498, 0, -10.033899, -20.0679, 0, -13.3786, -20.0679, 0, -10.033899, -23.412498, 0, -10.033899, -23.412498, 0, -13.3786, -20.0679, 0, -13.3786, -26.7572, 0, -10.033899, -23.412498, 0, -13.3786, -23.412498, 0, -10.033899, -26.7572, 0, -10.033899, -26.7572, 0, -13.3786, -23.412498, 0, -13.3786, -26.7572, 0, -13.3786, -23.412498, 0, -16.7232, -23.412498, 0, -13.3786, -26.7572, 0, -6.6893, -26.7572, 0, -10.033899, -23.412498, 0, -10.033899, -26.7572, 0, -13.3786, -26.7572, 0, -16.7232, -23.412498, 0, -16.7232, -26.7572, 0, -6.6893, -23.412498, 0, -10.033899, -23.412498, 0, -6.6893, -23.412498, 0, -6.6893, -23.412498, 0, -10.033899, -20.0679, 0, -10.033899, -23.412498, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -26.7572, 0, -3.3446, -26.7572, 0, -6.6893, -23.412498, 0, -6.6893, -26.7572, 0, -3.3446, -23.412498, 0, -6.6893, -23.412498, 0, -3.3446, -26.7572, 0, -16.7232, -23.412498, 0, -20.0679, -23.412498, 0, -16.7232, -23.412498, 0, -16.7232, -23.412498, 0, -20.0679, -20.0679, 0, -20.0679, -26.7572, 0, -16.7232, -26.7572, 0, -20.0679, -23.412498, 0, -20.0679, -23.412498, 0, -20.0679, -20.0679, 0, -23.412498, -20.0679, 0, -20.0679, -20.0679, 0, -20.0679, -20.0679, 0, -23.412498, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -16.7232, 0, -26.7572, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -20.0679, 0, -26.7572, -16.7232, 0, -26.7572, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -20.0679, 0, -23.412498, -23.412498, 0, -23.412498, -20.0679, 0, -26.7572, -20.0679, 0, -23.412498, -26.7572, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -26.7572, -20.0679, 0, -26.7572, -26.7572, 0, -20.0679, -26.7572, 0, -23.412498, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -23.412498, 0, -26.7572, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -26.7572, 0, -26.7572, -23.412498, 0, -26.7572, -23.412498, 0, -3.3446, -20.3764, 0, -6.6893, -20.0679, -0.3484, -3.5669, -23.412498, 0, -3.3446, -23.412498, 0, -6.6893, -20.3764, 0, -6.6893, 16.7232, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 16.7232, 0, 10.033899, 16.7232, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 23.412498, 0, 6.6893, 23.412498, 0, 10.033899, 20.0679, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.412498, 0, 3.3446, -26.7572, 0, 3.3446, -26.7572, 0, 0, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -23.412498, 0, -3.3446, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -26.7572, 0, -3.3446, -23.412498, 0, -3.3446, 0, 0, -13.3786, 3.0140998, 0, -16.7232, 3.3446, -0.3989, -12.9443, 0, 0, -13.3786, 0, 0, -16.7232, 3.0140998, 0, -16.7232, 0, 0, -10.7654, 3.3446, -0.3989, -12.9443, 2.8241, 0, -10.4594, 0, 0, -10.7654, 0, 0, -13.3786, 3.3446, -0.3989, -12.9443, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 3.3446, 23.412498, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 3.3446, 23.412498, 0, 3.3446, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, -1.6723, 54.072197, -3.9413, 0, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 0, 54.072197, -3.9413, 0, 54.072197, -3.9413, 1.6723, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, 0, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 54.072197, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 1.6723, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 45.5107, -3.9413, 0, 48.3645, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 26.7572, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 39.7973, -3.9413, 0, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 0, 42.6568, -3.9413, 0, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, 0, 39.7973, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, 0, 42.6568, -3.9413, 0, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, 0, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -20.0679, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 26.7572, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 0, 39.7973, -3.9413, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 26.7572, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 21.7402, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 25.084799, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 26.7572, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 11.7063, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 8.3616, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 5.0169997, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, 1.6723, 26.7572, -0.2593, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -1.6723, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -5.0169997, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -8.3616, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -11.7063, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -15.0508995, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -18.3955, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -21.7402, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -25.084799, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -26.7572, 29.679499, -0.6044, -25.084799) @@ -288,3 +289,7 @@ transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9 [node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("8_storage")] position = Vector3(-3.25, 3.42, 11.25) + +[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("25_decor_shop")] +position = Vector3(-3.25, 3.42, 11.25) +rotation = Vector3(0, 1.5707964, 0) diff --git a/world/regions/world_region.gd b/world/regions/world_region.gd index 792e4ed..85f6e1b 100644 --- a/world/regions/world_region.gd +++ b/world/regions/world_region.gd @@ -18,6 +18,10 @@ func get_fishing_shop() -> FishingShopInteraction: return null +func get_decor_shop() -> DecorShopInteraction: + return null + + func get_player_storage() -> PlayerStorageInteraction: return null diff --git a/world/test_world.gd b/world/test_world.gd index 9f4da5e..1b0a813 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -7,6 +7,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const DecorShopInteractionType = preload( + "res://world/decor_shop_interaction.gd" +) const WorldLayoutType = preload("res://world/world_layout.gd") const GENERATED_REGION_SCENE: PackedScene = preload( "res://world/generation/generated_world_region.tscn" @@ -31,6 +34,7 @@ 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 +var _local_home_interior_presentation_active: bool = false func get_player_water_triggers() -> Array[PlayerWaterTrigger]: @@ -67,6 +71,10 @@ func get_player_storage() -> PlayerStorageInteractionType: return _active_region.get_player_storage() +func get_decor_shop() -> DecorShopInteractionType: + return _active_region.get_decor_shop() + + func get_player_spawn_transform() -> Transform3D: return _active_region.get_player_spawn_transform() @@ -85,6 +93,12 @@ func set_light_performance_profile(enabled: bool) -> void: _active_region.set_light_performance_profile(enabled) +func set_local_home_interior_presentation_active(enabled: bool) -> void: + _local_home_interior_presentation_active = enabled + if _active_region != null and not _dedicated_simulation: + _active_region.visible = not enabled + + func set_dedicated_simulation(enabled: bool) -> void: _dedicated_simulation = enabled if _active_region != null: @@ -272,6 +286,8 @@ func _replace_active_region(layout: StringName, seed: int) -> bool: _active_region.set_light_performance_profile(_light_performance_profile) if _dedicated_simulation: _set_region_presentation_enabled(_active_region, false) + else: + _active_region.visible = not _local_home_interior_presentation_active return true From 983e4d0ec3cbd28b0bf26fdc62f9352d8e9d3da7 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 30 Aug 2026 22:00:04 -0400 Subject: [PATCH 06/14] Update validation for portable home protocol --- tests/fish_hotbar_showcase_validation.gd | 4 ++-- tests/fish_quality_validation.gd | 4 ++-- tests/world_spawn_protocol_validation.gd | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 9f4623a..af3644b 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -200,8 +200,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 == 11) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) + assert(NetworkProtocol.PROTOCOL_VERSION == 12) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18) assert( NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1" diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index d8e482b..99a2169 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -26,8 +26,8 @@ func _run() -> void: _validate_collection_mastery() _validate_version_four_migration() _validate_fishing_protocol_v2() - assert(NetworkProtocol.PROTOCOL_VERSION == 11) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) + assert(NetworkProtocol.PROTOCOL_VERSION == 12) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18) assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11) assert( NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index d100260..0840f2d 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -20,7 +20,7 @@ func _initialize() -> void: func _run() -> void: - assert(NetworkProtocol.PROTOCOL_VERSION == 11) + assert(NetworkProtocol.PROTOCOL_VERSION == 12) assert( NetworkWorldSpawnProtocol.CAPABILITY == NetworkProtocol.WORLD_SPAWN_CAPABILITY From d2dce7f9009d7ed8bab748e2a24a76c9ff8ba28b Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 30 Aug 2026 22:32:27 -0400 Subject: [PATCH 07/14] Stabilize runtime validation under dynamic UI load --- main/texture_sampling_policy.gd | 17 +++++++++++++++-- tests/economy_regression_validation.gd | 18 ++++++++++++++---- tests/gathering_showcase_validation.gd | 10 +++++++--- tests/job_system_validation.gd | 6 +++++- tests/surface_drawing_runtime_validation.gd | 6 +++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/main/texture_sampling_policy.gd b/main/texture_sampling_policy.gd index 822299c..35a92a0 100644 --- a/main/texture_sampling_policy.gd +++ b/main/texture_sampling_policy.gd @@ -15,8 +15,12 @@ func _enter_tree() -> void: func _on_node_added(node: Node) -> void: enforce_node(node) # Some runtime presenters assign their mesh or material immediately after - # add_child(). Recheck once deferred so those resources are covered too. - call_deferred("_enforce_deferred_node", weakref(node)) + # add_child(). Recheck those 3D resource owners once deferred so the newly + # assigned resources are covered too. CanvasItems keep their filter setting + # when their texture changes, so deferring every dynamic UI node only floods + # the message queue during large inventory and catalog refreshes. + if _needs_deferred_resource_check(node): + call_deferred("_enforce_deferred_node", weakref(node)) func _enforce_existing_tree() -> void: @@ -29,6 +33,15 @@ func _enforce_deferred_node(node_ref: WeakRef) -> void: enforce_node(node) +static func _needs_deferred_resource_check(node: Node) -> bool: + return ( + node is GeometryInstance3D + or node is MultiMeshInstance3D + or node is GPUParticles3D + or node is CPUParticles3D + ) + + static func enforce_subtree(node: Node) -> void: enforce_node(node) for child: Node in node.get_children(): diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index ec80d1b..ff82495 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -31,9 +31,14 @@ func _run() -> void: for _frame: int in 8: await process_frame assert(bool(main.get("_application_initialized"))) + 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()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) main.call("_enter_gameplay") for _frame: int in 8: await process_frame @@ -42,7 +47,6 @@ func _run() -> void: var catalog := main.get("fish_catalog") as FishPool var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService var shop_service := main.get_node("%NetworkShopService") as NetworkShopService - var session := main.get_node("%NetworkSession") as NetworkSession var reservations := ( main.get_node("%PlayerAssetReservationService") as PlayerAssetReservationService @@ -554,7 +558,10 @@ func _test_host_backpack_purchase( for expected_capacity: int in [18, 27, 36]: _shop_result.clear() assert(not shop_service.request_backpack_capacity_upgrade().is_empty()) - assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert( + not _shop_result.is_empty() and bool(_shop_result[1]), + "Backpack purchase failed: %s" % [_shop_result], + ) assert(layout.get_inventory_capacity() == expected_capacity) assert(player.wallet.get_balance() == balance_before - total_cost) assert(layout.get_next_backpack_cost() == -1) @@ -575,7 +582,10 @@ func _test_host_storage_purchase( for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]: _shop_result.clear() assert(not shop_service.request_cooler_capacity_upgrade().is_empty()) - assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert( + not _shop_result.is_empty() and bool(_shop_result[1]), + "Cooler purchase failed: %s" % [_shop_result], + ) assert(capacity.get_capacity() == expected_capacity) assert(player.wallet.get_balance() == balance_before - total_cost) assert(capacity.get_next_cost() == -1) diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd index 7099f7c..51d6f38 100644 --- a/tests/gathering_showcase_validation.gd +++ b/tests/gathering_showcase_validation.gd @@ -21,9 +21,13 @@ func _run() -> void: assert(bool(main.get("_application_initialized"))) 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()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) main.call("_enter_gameplay") for _frame: int in 8: await process_frame @@ -38,7 +42,7 @@ func _run() -> void: and game_ui != null and catalog != null ) - fishing_spot.minimum_showcase_duration = 0.15 + fishing_spot.minimum_showcase_duration = 1.0 assert(player.bag.add_item(&"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net")) assert(player.hotbar.select_slot(0)) @@ -81,7 +85,7 @@ func _run() -> void: await process_frame assert(fishing_spot.get("_pending_catch") == crab_catch) Input.action_release("fish_primary") - await create_timer(0.2).timeout + fishing_spot.set("_showcase_input_lock_remaining", 0.0) await process_frame assert(bool(fishing_spot.get("_put_away_press_armed"))) Input.parse_input_event(pocket_event) diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 89e46f6..65b7164 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -21,13 +21,17 @@ func _run() -> void: assert(bool(main.get("_application_initialized"))) var session := main.get_node("%NetworkSession") as NetworkSession assert(session != null) + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) var occupied_port := PacketPeerUDP.new() assert(occupied_port.bind(18137, "127.0.0.1") == OK) assert(session.start_private_host(18137, 3)) assert(session.get_host_port() == 18138) occupied_port.close() var save_manager := main.get("_save_manager") as PlayerSaveManager - assert(save_manager.initialize_new_game()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) main.call("_enter_gameplay") await physics_frame await physics_frame diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index e04afc2..85110cd 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -20,9 +20,13 @@ func _run() -> void: await process_frame assert(bool(main.get("_application_initialized"))) 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()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) main.call("_enter_gameplay") await physics_frame await physics_frame From b48cb73f3aa2cc9f1948d709e0b426ba0b518702 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 30 Aug 2026 23:52:25 -0400 Subject: [PATCH 08/14] Harden multiplayer validation for portable homes --- scripts/run_validations.sh | 1 + tests/fishing_multiplayer_validation.gd | 172 ++++++++++++++++-- tests/late_join_multiplayer_validation.gd | 34 +++- tests/movement_multiplayer_validation.gd | 149 ++++++++++++--- .../surface_drawing_multiplayer_validation.gd | 74 +++++++- 5 files changed, 381 insertions(+), 49 deletions(-) diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 5d15b8e..ff108a8 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -87,6 +87,7 @@ readonly -a NETWORK_TESTS=( "tests/fish_showcase_multiplayer_validation.gd" "tests/friend_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" + "tests/home_spawn_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd" "tests/movement_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd" diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index 73de1bd..9d7ef7f 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -63,12 +63,38 @@ func _run_host() -> void: var spawn_service := main.get_node( "%PlayerSpawnService" ) as PlayerSpawnService + var network_home := main.get_node( + "%NetworkHomeService" + ) as NetworkHomeService + var home_world := main.get_node("%HomeWorldService") as HomeWorldService var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) assert(remote_avatar != null) assert(remote_avatar.bag.get_quantity(&"worms") == 0) - remote_avatar.global_position = Vector3(-0.5, 3.95, 2.1) - var remote_visuals := remote_avatar.get_node("Visuals") as Node3D - remote_visuals.rotation.y = PI * 0.5 + var remote_record := session.get_peer_record(remote_peer_id) + assert(remote_record != null) + assert(await _wait_for_home( + network_home, remote_record.identity_fingerprint + )) + var exterior_spawn: Transform3D = home_world.get_exterior_spawn_transform( + remote_record.identity_fingerprint + ) + var exterior_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < exterior_deadline + and remote_avatar.global_position.distance_to(exterior_spawn.origin) > 0.25 + ): + await physics_frame + assert(remote_avatar.global_position.distance_to(exterior_spawn.origin) <= 0.25) + # Give the client enough local frames to observe the initial RV-door spawn + # before this test publishes its separate fishing-position teleport. + for _frame: int in 30: + await physics_frame + var cast_setup: Dictionary = await _find_clear_fishing_setup( + main, remote_avatar + ) + assert(not cast_setup.is_empty(), "no clear fishing setup was found") + var expected_position: Vector3 = cast_setup["position"] + var expected_direction: Vector3 = cast_setup["direction"] session.publish_authoritative_teleport(remote_peer_id) var aiming_deadline: int = Time.get_ticks_msec() + 15000 while ( @@ -82,7 +108,7 @@ func _run_host() -> void: == Player.FishingVisualPhase.CASTING ) var remote_position_error := ( - remote_avatar.global_position - Vector3(-0.5, 3.95, 2.1) + remote_avatar.global_position - expected_position ) remote_position_error.y = 0.0 assert( @@ -90,7 +116,7 @@ func _run_host() -> void: "remote casting position drifted: %s" % remote_avatar.global_position ) assert( - remote_avatar.get_facing_direction().dot(Vector3.LEFT) > 0.99, + remote_avatar.get_facing_direction().dot(expected_direction) > 0.99, "remote casting facing drifted: %s" % remote_avatar.get_facing_direction() ) @@ -196,6 +222,23 @@ func _run_client() -> void: joined = true break assert(joined) + var network_home := main.get_node( + "%NetworkHomeService" + ) as NetworkHomeService + var home_world := main.get_node("%HomeWorldService") as HomeWorldService + var local_fingerprint: String = session.get_local_identity_fingerprint() + assert(await _wait_for_home(network_home, local_fingerprint)) + var player := main.get("_player") as Player + var exterior_spawn: Transform3D = ( + home_world.get_exterior_spawn_transform(local_fingerprint) + ) + var home_spawn_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < home_spawn_deadline + and player.global_position.distance_to(exterior_spawn.origin) > 0.25 + ): + await physics_frame + assert(player.global_position.distance_to(exterior_spawn.origin) <= 0.25) var service := main.get_node( "%NetworkFishingService" @@ -206,7 +249,6 @@ func _run_client() -> void: cast_rejections.append(message) ) var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType - var player := main.get("_player") as Player var item_catalog := main.get("item_catalog") as ItemCatalog var worms: ItemData = item_catalog.get_item_by_id(&"worms") if player.bag.get_quantity(&"worms") == 0: @@ -217,15 +259,20 @@ func _run_client() -> void: "Visuals/CharacterRig/AnimationPlayer" ) as AnimationPlayer assert(character_animation_player.has_animation(&"retract_sit")) - var placement_deadline: int = Time.get_ticks_msec() + 5000 + # The host chooses and publishes a clear target after both RV manifests have + # settled. Waiting for that authoritative teleport also coordinates the + # animation-observation window between these two independent processes. + var placement_deadline: int = Time.get_ticks_msec() + 10000 while ( Time.get_ticks_msec() < placement_deadline - and player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) - > 0.25 + and player.global_position.distance_to(exterior_spawn.origin) <= 1.0 ): await physics_frame - assert(player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) <= 0.25) - assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) + assert(player.global_position.distance_to(exterior_spawn.origin) > 1.0) + for _frame: int in 2: + await physics_frame + var expected_position: Vector3 = player.global_position + var expected_direction: Vector3 = player.get_facing_direction() player.set_casting_visual() assert( @@ -241,7 +288,10 @@ func _run_client() -> void: assert(player.is_sitting()) for _frame: int in 15: await physics_frame + assert(fishing_spot.state == FishingSpotType.FishingState.READY) + assert(fishing_spot.get("_active_player") == null) fishing_spot.call("_begin_aiming", player) + assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST) assert(player.is_movement_enabled()) assert( int(player.get("_fishing_visual_phase")) @@ -250,6 +300,7 @@ func _run_client() -> void: var cast_charge: float = 0.0 var target: Vector3 = Vector3.ZERO var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position") + var target_diagnostics: Array[String] = [] for charge_step: int in range(1, 21): var candidate_charge := float(charge_step) / 20.0 fishing_spot.set("_cast_charge", candidate_charge) @@ -258,14 +309,33 @@ func _run_client() -> void: var candidate_surface := fishing_spot.get( "_aim_surface_sample" ) as FishingSurfaceSample + target_diagnostics.append( + "charge=%.2f surface=%s path=%s target=%s" % [ + candidate_charge, + candidate_surface.is_fishable(), + fishing_spot.get("_cast_path_is_clear"), + candidate_target, + ] + ) if ( candidate_surface.is_fishable() - and fishing_spot.is_cast_path_clear(cast_origin, candidate_target) + and bool(fishing_spot.get("_cast_path_is_clear")) ): cast_charge = candidate_charge target = candidate_target break - assert(cast_charge > 0.0, "no clear fishable test target was found") + assert( + cast_charge > 0.0, + ( + "no clear fishable test target was found; position=%s facing=%s " + + "expected=%s diagnostics=%s" + ) % [ + player.global_position, + player.get_facing_direction(), + expected_direction, + target_diagnostics, + ], + ) var evidence: Dictionary = fishing_spot.call("_build_network_evidence") fishing_spot.set("state", FishingSpotType.FishingState.CASTING) player.set_movement_enabled(false) @@ -388,3 +458,79 @@ func _create_initialized_main() -> Node: await process_frame assert(bool(main.get("_application_initialized"))) return main + + +func _wait_for_home( + network_home: NetworkHomeService, + fingerprint: String, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + await process_frame + var manifests: Dictionary = network_home.get( + "_manifests_by_fingerprint" + ) + if manifests.has(fingerprint): + return true + return false + + +func _find_clear_fishing_setup(main: Node, player: Player) -> Dictionary: + var world := main.get_node("TestWorld") as TestWorld + var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType + var regions: Array[FishableWaterRegion] = world.get_fishable_water_regions() + for region: FishableWaterRegion in regions: + var body := region.get_parent() as WaterBodyAuthoring + if body == null or body.surface_size.x <= 0.0 or body.surface_size.y <= 0.0: + continue + var center: Vector3 = region.global_position + var half_size: Vector2 = body.surface_size * 0.5 + var candidate_offsets: Array[Vector3] = [ + Vector3(half_size.x + 0.8, 0.0, 0.0), + Vector3(-half_size.x - 0.8, 0.0, 0.0), + Vector3(0.0, 0.0, half_size.y + 0.8), + Vector3(0.0, 0.0, -half_size.y - 0.8), + ] + for offset: Vector3 in candidate_offsets: + var position := Vector3( + center.x + offset.x, + region.get_surface_height() + 1.44, + center.z + offset.z, + ) + var direction: Vector3 = center - position + direction.y = 0.0 + direction = direction.normalized() + player.global_position = position + var visuals := player.get_node("Visuals") as Node3D + visuals.global_rotation.y = atan2(-direction.x, -direction.z) + for _frame: int in 2: + await physics_frame + var cast_origin: Vector3 = player.get_cast_origin_position() + var launch_position: Vector3 = player.get_fishing_rod_tip().global_position + for charge_step: int in range(1, 21): + var cast_charge := float(charge_step) / 20.0 + var cast_distance := lerpf( + fishing_spot.minimum_cast_distance, + fishing_spot.maximum_cast_distance, + cast_charge, + ) + var query_position := cast_origin + direction * cast_distance + var sample: FishingSurfaceSample = ( + fishing_spot.resolve_fishing_surface( + query_position, cast_origin.y + ) + ) + var target: Vector3 = sample.get_bobber_position( + fishing_spot.solid_bobber_clearance + ) + if ( + sample.is_fishable() + and fishing_spot.is_cast_path_clear( + launch_position, target + ) + ): + return { + "position": player.global_position, + "direction": direction, + } + return {} diff --git a/tests/late_join_multiplayer_validation.gd b/tests/late_join_multiplayer_validation.gd index 3813486..6ea544f 100644 --- a/tests/late_join_multiplayer_validation.gd +++ b/tests/late_join_multiplayer_validation.gd @@ -90,11 +90,12 @@ func _run_first_client() -> void: 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"))) + # Slot zero begins selected with the starter rod. Replacing that slot does + # not emit a selection change, so use the next slot to exercise the same + # equipped-item path a player uses in normal gameplay. + assert(player.hotbar.assign_item(1, &"crab_net")) + assert(player.hotbar.select_slot(1)) + assert(await _wait_for_local_net(main, player)) assert(player.begin_animation_action(ACTIVE_ACTION)) await create_timer(0.5).timeout assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION) @@ -185,6 +186,29 @@ func _wait_for_registry_size(session: NetworkSession, expected: int) -> bool: return false +func _wait_for_local_net(main: Node, player: Player) -> bool: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + # A joined client can finish its world transition one frame after the + # hotbar selection signal. Refresh through the production path until the + # ready fishing state accepts the equipped item. + main.call("_refresh_active_hotbar_item") + await process_frame + if bool(player.get("_active_item_is_net")): + return true + var fishing_spot: Node = main.get_node("%FishingSpot") + print( + "net equip wait timed out: ", + { + "fishing_state": int(fishing_spot.get("state")), + "selected_slot": player.hotbar.get_selected_slot(), + "selected_item": String(player.hotbar.get_selected_item_id()), + "owns_net": player.bag.owns_item(&"crab_net"), + }, + ) + return false + + func _history_contains(service: NetworkChatService, body: String) -> bool: return service.get_history().any( func(message: Dictionary) -> bool: diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index 7073bea..05a668d 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -593,15 +593,22 @@ 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( + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, NetworkProtocol.DEFAULT_WORLD_SEED, - )) - assert(session.start_private_host(TEST_PORT)) + true, + ))) + assert(bool(main.call( + "_prepare_host_world", + WorldLayout.STARTER_ISLAND, + NetworkProtocol.DEFAULT_WORLD_SEED, + ))) assert(save_manager.initialize_new_game( NetworkProtocol.DEFAULT_WORLD_SEED, WorldLayout.STARTER_ISLAND, )) + assert(session.start_private_host(TEST_PORT)) main.call("_enter_gameplay") for _frame: int in 4: await physics_frame @@ -613,31 +620,40 @@ func _run_host() -> void: # to settle before emitting the one-shot locomotion/action sequence. await create_timer(3.0).timeout var player := main.get("_player") as Player + var world := main.get_node("TestWorld") as TestWorld player.configure_network_remote(true) - player.apply_authoritative_network_input(_movement_input(1, true)) - await create_timer(2.5).timeout - player.apply_authoritative_network_input(_movement_input(2, false)) - await create_timer(2.5).timeout - player.apply_authoritative_network_input( - _movement_input(3, false, true, true) + await _reset_locomotion_test_position(player, session, world) + var input_sequence: int = 1 + input_sequence = await _sustain_locomotion_input( + player, input_sequence, 1.5, true ) - await create_timer(2.0).timeout - player.apply_authoritative_network_input( - _movement_input(4, false, false, true) + await _reset_locomotion_test_position(player, session, world) + input_sequence = await _sustain_locomotion_input( + player, input_sequence, 1.5, false ) - await create_timer(2.0).timeout - player.apply_authoritative_network_input( - _movement_input(5, false, false, true, &"draw", 1) + await _reset_locomotion_test_position(player, session, world) + input_sequence = await _sustain_locomotion_input( + player, input_sequence, 1.5, false, true, true ) + input_sequence = await _sustain_locomotion_input( + player, input_sequence, 1.5, false, false, true + ) + player.apply_authoritative_network_input( + _movement_input(input_sequence, false, false, true, &"draw", 1) + ) + input_sequence += 1 await create_timer(1.0).timeout player.apply_authoritative_network_input( - _movement_input(6, false, false, true, &"strike", 2, true) + _movement_input( + input_sequence, false, false, true, &"strike", 2, true + ) ) + input_sequence += 1 await create_timer(2.0).timeout player.apply_authoritative_network_input( - _movement_input(7, false, false, false, &"", 3) + _movement_input(input_sequence, false, false, false, &"", 3) ) - var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + var disconnect_deadline: int = Time.get_ticks_msec() + 15000 while ( Time.get_ticks_msec() < disconnect_deadline and session.is_authenticated_peer(remote_peer_id) @@ -686,12 +702,27 @@ func _run_client() -> void: var saw_net_strike_paused: bool = false var saw_animation_advance: bool = false var saw_dust: bool = false + var observed_animations: Dictionary = {} + var observed_locomotion_states: Dictionary = {} + var maximum_sneak_speed: float = 0.0 var previous_animation: StringName = &"" var previous_animation_position: float = -1.0 - var observation_deadline: int = Time.get_ticks_msec() + 18000 + var observation_deadline: int = Time.get_ticks_msec() + 22000 while Time.get_ticks_msec() < observation_deadline: await process_frame var current_animation: StringName = animation_player.current_animation + observed_animations[current_animation] = true + var target_locomotion_state: int = int(host_avatar.get( + "_network_target_locomotion_state" + )) + observed_locomotion_states[target_locomotion_state] = true + if target_locomotion_state == Player.LocomotionState.SNEAKING: + maximum_sneak_speed = maxf( + maximum_sneak_speed, + Vector2( + host_avatar.velocity.x, host_avatar.velocity.z + ).length(), + ) saw_running = saw_running or current_animation.begins_with("running") saw_walking = saw_walking or current_animation.begins_with("walking") saw_sneaking = saw_sneaking or current_animation == &"sneaking" @@ -732,15 +763,44 @@ func _run_client() -> void: and saw_dust ): break - assert(saw_running) - assert(saw_walking) - assert(saw_sneaking) - assert(saw_idle_sneak) - assert(saw_net_draw) - assert(saw_net_strike) - assert(saw_net_strike_paused) - assert(saw_animation_advance) - assert(saw_dust) + var missing_observations: Array[String] = [] + if not saw_running: + missing_observations.append("running") + if not saw_walking: + missing_observations.append("walking") + if not saw_sneaking: + missing_observations.append("sneaking") + if not saw_idle_sneak: + missing_observations.append("idle_sneak") + if not saw_net_draw: + missing_observations.append("net_draw") + if not saw_net_strike: + missing_observations.append("net_strike") + if not saw_net_strike_paused: + missing_observations.append("net_strike_paused") + if not saw_animation_advance: + missing_observations.append("animation_advance") + if not saw_dust: + missing_observations.append("sprint_dust") + if not missing_observations.is_empty(): + push_error( + ( + "Missing movement observations %s; observed animations: %s; " + + "locomotion states: %s; maximum sneak speed: %.3f" + ) % [ + missing_observations, + observed_animations.keys(), + observed_locomotion_states.keys(), + maximum_sneak_speed, + ] + ) + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit(1) + return print("Movement multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() @@ -778,6 +838,39 @@ func _movement_input( } +func _sustain_locomotion_input( + player: Player, + starting_sequence: int, + duration_seconds: float, + sprinting: bool, + moving: bool = true, + sneaking: bool = false, +) -> int: + var sequence: int = starting_sequence + var deadline: int = ( + Time.get_ticks_msec() + roundi(duration_seconds * 1000.0) + ) + while Time.get_ticks_msec() < deadline: + player.apply_authoritative_network_input( + _movement_input(sequence, sprinting, moving, sneaking) + ) + sequence += 1 + await create_timer(0.1).timeout + return sequence + + +func _reset_locomotion_test_position( + player: Player, + session: NetworkSession, + world: TestWorld, +) -> void: + player.velocity = Vector3.ZERO + player.global_transform = world.get_player_spawn_transform() + session.publish_authoritative_teleport(1) + for _frame: int in 4: + await physics_frame + + func _create_initialized_main() -> Node: root.size = Vector2i(1280, 720) var main: Node = MainScene.instantiate() diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index d0508b2..f79700e 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -27,10 +27,12 @@ func _run_host() -> void: 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(bool(main.call( + "_prepare_host_world", WorldLayout.STARTER_ISLAND, 1 + ))) var save_manager := main.get("_save_manager") as PlayerSaveManager - assert(save_manager.initialize_new_game()) + assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND)) + assert(session.start_private_host(TEST_PORT)) main.call("_enter_gameplay") await physics_frame await physics_frame @@ -62,6 +64,40 @@ func _run_host() -> void: remote_peer_id ) assert(remote_record != null) + var network_home := main.get_node( + "%NetworkHomeService" + ) as NetworkHomeService + var home_world := main.get_node("%HomeWorldService") as HomeWorldService + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + assert(await _wait_for_home( + network_home, remote_record.identity_fingerprint + )) + var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) + assert(remote_avatar != null) + var exterior_spawn: Transform3D = home_world.get_exterior_spawn_transform( + remote_record.identity_fingerprint + ) + var exterior_deadline: int = Time.get_ticks_msec() + 5000 + while ( + Time.get_ticks_msec() < exterior_deadline + and remote_avatar.global_position.distance_to(exterior_spawn.origin) > 0.25 + ): + await physics_frame + assert(remote_avatar.global_position.distance_to(exterior_spawn.origin) <= 0.25) + var canvas_state: Dictionary = service.get_canvas_state(canvas_id) + var canvas_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector( + canvas_state["origin"] + ) + remote_avatar.global_position = Vector3( + canvas_origin.x + 1.0, + player.global_position.y, + canvas_origin.z, + ) + session.publish_authoritative_teleport(remote_peer_id) + for _frame: int in 4: + await physics_frame assert(session.peer_supports_capability( remote_peer_id, SurfaceDrawingProtocol.CAPABILITY )) @@ -152,6 +188,9 @@ func _run_host() -> void: ): await process_frame assert(service.get_canvas_ids().size() == 1) + # Keep the second stamp observable on the client before the ban removes all + # artwork carrying that participant fingerprint. + await create_timer(1.0).timeout var player_list := main.get_node( "%NetworkPlayerListService" ) as NetworkPlayerListService @@ -221,6 +260,20 @@ func _run_client() -> void: var canvas_id: String = service.get_canvas_ids()[0] var snapshot: Dictionary = service.get_canvas_state(canvas_id) assert(str(_find_cell(snapshot, 7, 7).get("color_id", "")) == "coral") + var canvas_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector( + snapshot["origin"] + ) + var placement_deadline: int = Time.get_ticks_msec() + 10000 + while ( + Time.get_ticks_msec() < placement_deadline + and player.global_position.distance_to(canvas_origin) + > NetworkSurfaceDrawingService.MAX_DRAW_DISTANCE + 2.0 + ): + await physics_frame + assert( + player.global_position.distance_to(canvas_origin) + <= NetworkSurfaceDrawingService.MAX_DRAW_DISTANCE + 2.0 + ) assert(service.request_cell_edits(canvas_id, [{ "x": 7, "y": 7, @@ -381,3 +434,18 @@ func _create_initialized_main() -> Node: await process_frame assert(bool(main.get("_application_initialized"))) return main + + +func _wait_for_home( + network_home: NetworkHomeService, + fingerprint: String, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + await process_frame + var manifests: Dictionary = network_home.get( + "_manifests_by_fingerprint" + ) + if manifests.has(fingerprint): + return true + return false From 826f62c3526e0f01d88a4de10a35a55042085b7c Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 30 Aug 2026 23:53:07 -0400 Subject: [PATCH 09/14] Prepare private v0.20.1-alpha patron release --- docs/README-PLAYTEST.txt | 9 ++++++--- export_presets.cfg | 26 +++++++++++++------------- project.godot | 4 ++-- scripts/build_playtest.sh | 9 ++++++--- ui/title_screen.tscn | 2 +- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index 15e3698..d3f3432 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,8 +1,8 @@ straywild -v0.19.0-alpha -Alpha 0.19.0 +v0.20.1-alpha +Alpha 0.20.1 -Thank you for trying this early private playtest. +Thank you for supporting this private patron test release. straywild is an early multiplayer fishing game. This alpha includes local progression, player-hosted and dedicated multiplayer sessions, public room @@ -83,6 +83,8 @@ FEATURES TO TRY - Fishing, net gathering, and beach digging - Equipment and consumable use from the hotbar - Fishing Shop sell tray, wallet updates, and persistent upgrades +- Portable RV homes, multiplayer visits, RV storage, and the early decor + purchasing and placement prototype - Seeded generated worlds and the authored starter-island layout - Generated beaches, ponds, lakes, complete rivers, denser forests, varied elevation, and closed coastal cliffs @@ -117,5 +119,6 @@ KNOWN ALPHA LIMITATIONS networks may prevent some players from joining. - Multiplayer, dedicated-server deployment, UI, and balance are still alpha. - Some fish and character artwork remains temporary. +- RV exteriors and decor use temporary prototype geometry and presentation. - There is no installer or automatic updater. - Windows builds are unsigned. diff --git a/export_presets.cfg b/export_presets.cfg index 05b0918..ff588e2 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.19.0-alpha/windows-x86_64/straywild.exe" +export_path="builds/v0.20.1-alpha/windows-x86_64/straywild.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/straywild.ico" application/console_wrapper_icon="" application/icon_interpolation=0 -application/file_version="0.19.0.0" -application/product_version="0.19.0.0" +application/file_version="0.20.1.0" +application/product_version="0.20.1.0" application/company_name="Woofmeow" application/product_name="straywild" -application/file_description="straywild v0.19.0-alpha" +application/file_description="straywild v0.20.1-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.19.0-alpha/linux-arm64/straywild.arm64" +export_path="builds/v0.20.1-alpha/linux-arm64/straywild.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.19.0-alpha/macos/straywild.zip" +export_path="builds/v0.20.1-alpha/macos/straywild.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon_interpolation=0 -application/short_version="0.19.0" -application/version="0.19.0" +application/short_version="0.20.1" +application/version="0.20.1" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.19.0-alpha/server-linux-x86_64/straywildServer.x86_64" +export_path="builds/v0.20.1-alpha/server-linux-x86_64/straywildServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.19.0-alpha/android/straywild.apk" +export_path="builds/v0.20.1-alpha/android/straywild.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=190000 -version/name="v0.19.0-alpha" +version/code=200100 +version/name="v0.20.1-alpha" package/unique_name="io.woofmeow.netfishing" package/name="straywild" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.19.0-alpha/linux-x86_64/straywild.x86_64" +export_path="builds/v0.20.1-alpha/linux-x86_64/straywild.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index d202817..23fdd1e 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="straywild" -config/version="0.19.0-alpha" +config/version="0.20.1-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/straywild_256.png" @@ -260,7 +260,7 @@ alternate_fishing={ [network] -discovery/base_url="https://discovery.straywild.io" +discovery/base_url="https://discovery.straywild.io/patron-test" [physics] diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 2347d3d..f1ff151 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.19.0-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.1-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" -readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.1-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.1-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then @@ -77,6 +77,9 @@ straywild code is licensed under GPL-3.0-or-later. Source repository: ${SOURCE_URL} Source revision at build time: ${SOURCE_COMMIT} +This private patron test release is distributed with an exact corresponding +source archive named straywild-v0.20.1-alpha-source.tar.gz. + If this package includes uncommitted development changes, it must not be publicly redistributed until the exact corresponding source is also made available. diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index d1fc210..852fd71 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -213,7 +213,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.19.0-alpha" +text = "v0.20.1-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From 9b96a3bf53a8b66b47f01caf8825bfce5374c03d Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 00:31:02 -0400 Subject: [PATCH 10/14] Replace starter RV placeholder with donated motorcoach --- docs/ATTRIBUTION.md | 6 ++- homes/assets/starter_motorcoach.glb | Bin 0 -> 189284 bytes homes/assets/starter_motorcoach.glb.import | 45 ++++++++++++++++++ homes/home_world_service.gd | 51 +++++++++++++++------ 4 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 homes/assets/starter_motorcoach.glb create mode 100644 homes/assets/starter_motorcoach.glb.import diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index c235601..d8676f0 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -142,8 +142,10 @@ environment and UI artwork, item artwork, finished fish and creature artwork, and character customization pattern and channel-map artwork. Voyager's incorporated original work includes 3D assets, the provisional fish placeholder artwork, the title and dusk music identified above, and the robot -animalese tones. Specific contributor exceptions are recorded below and in the -in-game credits. +animalese tones. The current starter motorcoach exterior was supplied by a +contributing artist who released its copyright to the project; the original +and cleaned source files are retained in the owners' source-work archive. +Specific contributor exceptions are recorded below and in the in-game credits. These credits intentionally describe work by creator and contribution family instead of maintaining a second file-by-file asset catalog. Runtime resources diff --git a/homes/assets/starter_motorcoach.glb b/homes/assets/starter_motorcoach.glb new file mode 100644 index 0000000000000000000000000000000000000000..5b0d0225da3af0dc3978f6b0bb19562b571ae8e3 GIT binary patch literal 189284 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 Y$FNH{19#B{>47_%9L|Qb!+zoa0dB5ZD*ylh literal 0 HcmV?d00001 diff --git a/homes/assets/starter_motorcoach.glb.import b/homes/assets/starter_motorcoach.glb.import new file mode 100644 index 0000000..5cba35b --- /dev/null +++ b/homes/assets/starter_motorcoach.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bhph22caipcj6" +path="res://.godot/imported/starter_motorcoach.glb-153681ef1e622a556fba4df576dc6f84.scn" + +[deps] + +source_file="res://homes/assets/starter_motorcoach.glb" +dest_files=["res://.godot/imported/starter_motorcoach.glb-153681ef1e622a556fba4df576dc6f84.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd index 4e1b422..baec913 100644 --- a/homes/home_world_service.gd +++ b/homes/home_world_service.gd @@ -6,10 +6,13 @@ const NetworkHomeProtocolType = preload( ) const PlayerHomeStateType = preload("res://homes/player_home_state.gd") const DecorCatalogType = preload("res://homes/decor_catalog.gd") +const STARTER_MOTORCOACH_SCENE: PackedScene = preload( + "res://homes/assets/starter_motorcoach.glb" +) const EXTERIOR_BODY_SIZE := Vector3(5.6, 2.5, 2.35) -const EXTERIOR_DOOR_LOCAL := Vector3(-1.45, 0.85, 1.48) -const EXTERIOR_SPAWN_LOCAL := Vector3(-1.45, 0.25, 2.45) +const EXTERIOR_DOOR_LOCAL := Vector3(-0.2, 0.85, 1.48) +const EXTERIOR_SPAWN_LOCAL := Vector3(-0.2, 0.25, 2.45) const EXTERIOR_INTERACTION_DISTANCE: float = 2.7 const INTERIOR_EXIT_DISTANCE: float = 2.1 const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0) @@ -819,9 +822,18 @@ func _build_exterior( root.name = "RV_%s" % fingerprint.left(8) _exteriors_root.add_child(root) root.global_transform = world_transform - _add_box( - root, "Body", EXTERIOR_BODY_SIZE, Vector3(0.0, 1.35, 0.0), - Color("d8d2bd"), true, + if not _dedicated_runtime: + var motorcoach: Node3D = STARTER_MOTORCOACH_SCENE.instantiate() as Node3D + if motorcoach != null: + motorcoach.name = "StarterMotorcoach" + root.add_child(motorcoach) + else: + push_error("The starter motorcoach exterior could not be instantiated.") + _add_box_collision( + root, + "Body", + EXTERIOR_BODY_SIZE, + Vector3(0.0, 1.35, 0.0), ) root.visible = not _dedicated_runtime return root @@ -986,19 +998,28 @@ func _add_box( mesh_instance.position = position parent.add_child(mesh_instance) if with_collision: - var body := StaticBody3D.new() - body.name = "%sCollision" % node_name - body.position = position - var collision := CollisionShape3D.new() - var shape := BoxShape3D.new() - shape.size = size - collision.shape = shape - body.add_child(collision) - parent.add_child(body) - return body + return _add_box_collision(parent, node_name, size, position) return null +func _add_box_collision( + parent: Node3D, + node_name: String, + size: Vector3, + position: Vector3, +) -> StaticBody3D: + var body := StaticBody3D.new() + body.name = "%sCollision" % node_name + body.position = position + var collision := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = size + collision.shape = shape + body.add_child(collision) + parent.add_child(body) + return body + + func _spawn_decor_poof( owner_fingerprint: String, instance_id: String, From ee992d9f0c822856a0f8e619a521896110b5b301 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 00:52:31 -0400 Subject: [PATCH 11/14] Restore public discovery default after patron build --- project.godot | 2 +- scripts/build_portmaster.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/project.godot b/project.godot index 23fdd1e..8e751aa 100644 --- a/project.godot +++ b/project.godot @@ -260,7 +260,7 @@ alternate_fishing={ [network] -discovery/base_url="https://discovery.straywild.io/patron-test" +discovery/base_url="https://discovery.straywild.io" [physics] diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh index a40e0cd..2d9dcbb 100755 --- a/scripts/build_portmaster.sh +++ b/scripts/build_portmaster.sh @@ -236,7 +236,7 @@ unzip -tq "${ARCHIVE}" >/dev/null readonly TOP_LEVELS="$( unzip -Z1 "${ARCHIVE}" | cut -d/ -f1 | sort -u )" -readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' straywild.sh straywild)" +readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' straywild straywild.sh)" if [[ "${TOP_LEVELS}" != "${EXPECTED_TOP_LEVELS}" ]]; then echo "Unexpected PortMaster archive roots:" >&2 printf '%s\n' "${TOP_LEVELS}" >&2 From 6578d3e8701fa38d0c91bdacf8543ca4e9cd1ec6 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 05:48:36 -0400 Subject: [PATCH 12/14] Add transactional RV decor synchronization --- homes/home_decor_controller.gd | 58 +++- homes/home_world_service.gd | 15 +- main/main.gd | 31 +- network/network_home_protocol.gd | 14 +- network/network_home_service.gd | 492 +++++++++++++++++++++++++++++-- network/network_session.gd | 70 +++++ player/player.gd | 31 ++ 7 files changed, 663 insertions(+), 48 deletions(-) diff --git a/homes/home_decor_controller.gd b/homes/home_decor_controller.gd index 1a97a5d..8f9aa5f 100644 --- a/homes/home_decor_controller.gd +++ b/homes/home_decor_controller.gd @@ -47,6 +47,12 @@ func setup( _on_local_space_changed ): _network_home.local_space_changed.connect(_on_local_space_changed) + if not _network_home.local_manifest_rejected.is_connected( + _on_local_manifest_rejected + ): + _network_home.local_manifest_rejected.connect( + _on_local_manifest_rejected + ) if not _home_state.changed.is_connected(_on_home_changed): _home_state.changed.connect(_on_home_changed) if not _toolbar.placement_mode_requested.is_connected( @@ -84,7 +90,13 @@ func _input(event: InputEvent) -> void: and pointer_button.button_index == MOUSE_BUTTON_LEFT and not pointer_button.pressed ): - _finish_drag() + # A gameplay menu can take ownership between the press and release. Do + # not let that release finalize decor behind the menu; preserve the + # original transform instead. + if _can_decorate(): + _finish_drag() + else: + _cancel_drag() get_viewport().set_input_as_handled() @@ -120,14 +132,17 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: if _drag_instance_id.is_empty(): return + # Menus and other gameplay input handoffs must cancel an active drag before + # evaluating a held/released pointer. This prevents a same-frame TAB press + # followed by left-button release from committing a move behind the UI. + if not _can_decorate(): + _cancel_drag() + return if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): _finish_drag() return if not _drag_target_valid: return - if not _can_decorate(): - _cancel_drag() - return var current: Dictionary = _home_world.get_decor_preview_transform( _drag_owner_fingerprint, _drag_instance_id ) @@ -146,6 +161,9 @@ func _process(delta: float) -> void: func _begin_drag(screen_position: Vector2) -> bool: + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return false var owner_fingerprint: String = ( _network_home.get_local_home_owner_fingerprint() ) @@ -331,22 +349,34 @@ static func _placement_position(placement: Dictionary) -> Vector3: func _place_carried_decor(decor_id: StringName) -> void: + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return var owner_fingerprint: String = ( _network_home.get_local_home_owner_fingerprint() ) - var forward: Vector3 = -_player.global_basis.z - forward.y = 0.0 - forward = forward.normalized() + # RV transitions keep the gameplay root axis-aligned so its transform can be + # synchronized independently from the presentation. Use the visual facing + # direction instead; otherwise carried decor is always placed along world -Z + # instead of in front of the character the player sees. + var forward: Vector3 = _player.get_facing_direction() var global_position: Vector3 = _player.global_position + forward * 1.45 var local_position: Vector3 = _home_world.interior_global_to_local( owner_fingerprint, global_position ) + var placement_yaw: float = atan2(-forward.x, -forward.z) var instance_id: String = _network_home.place_local_decor( - decor_id, local_position, _player.global_rotation.y, _mode + decor_id, local_position, placement_yaw, _mode ) if instance_id.is_empty(): _toolbar.report_status("make room in front of you to place this decor") return + # A local host can reject its own optimistic manifest synchronously. Do not + # claim success after that rollback or leave the player looking for decor that + # was never accepted into the room. + if _home_state.get_placement(instance_id).is_empty(): + _toolbar.report_status("decor placement was not accepted") + return _select_instance(instance_id) _toolbar.report_status("decor placed") @@ -366,6 +396,9 @@ func _select_instance(instance_id: String) -> void: func _rotate_selected() -> void: if not _can_decorate() or _selected_instance_id.is_empty(): return + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return var placement: Dictionary = _home_state.get_placement( _selected_instance_id ) @@ -392,6 +425,9 @@ func _rotate_selected() -> void: func _store_selected() -> void: if _selected_instance_id.is_empty(): return + if _network_home.has_pending_local_decor_mutation(): + _toolbar.report_status("waiting for the previous decor update") + return if _network_home.remove_local_decor(_selected_instance_id): _clear_selection() _toolbar.report_status("decor packed into storage") @@ -418,6 +454,12 @@ func _on_local_space_changed( _refresh_toolbar() +func _on_local_manifest_rejected(message: String) -> void: + _clear_selection() + if _toolbar != null and _toolbar.visible: + _toolbar.report_status(message) + + func _on_home_changed() -> void: if ( not _selected_instance_id.is_empty() diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd index baec913..fd02d93 100644 --- a/homes/home_world_service.gd +++ b/homes/home_world_service.gd @@ -17,6 +17,7 @@ const EXTERIOR_INTERACTION_DISTANCE: float = 2.7 const INTERIOR_EXIT_DISTANCE: float = 2.1 const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0) const INTERIOR_SPACING: float = 14.0 +const INTERIOR_MAX_SPAWN_SLOTS: int = 64 const INTERIOR_FLOOR_SIZE := Vector3(8.0, 0.18, 4.5) const INTERIOR_WALL_HEIGHT: float = 2.7 const MIN_EXTERIOR_TRIANGLE_AREA: float = 1.0 @@ -461,13 +462,17 @@ func get_interior_spawn_transform( visitor_offset: int = 0, ) -> Transform3D: var anchor: Vector3 = get_interior_anchor(owner_fingerprint) - var result := Transform3D(Basis.IDENTITY, anchor) - result.origin += Vector3( - -1.0 + float(posmod(visitor_offset, 4)) * 0.65, + # `visitor_offset` is allocated by the host as a compact room-local slot. + # Keep it bounded as a last line of defense if a future caller accidentally + # supplies an engine peer ID instead of a slot. + var visitor_slot: int = posmod(visitor_offset, INTERIOR_MAX_SPAWN_SLOTS) + # Build the origin as a value rather than mutating Transform3D.origin. + var spawn_origin: Vector3 = anchor + Vector3( + -1.0 + float(posmod(visitor_slot, 4)) * 0.65, 0.25, - 0.4 + float(floori(float(visitor_offset) / 4.0)) * 0.55, + 0.4 + float(floori(float(visitor_slot) / 4.0)) * 0.55, ) - return result + return Transform3D(Basis.IDENTITY, spawn_origin) func get_interior_anchor(owner_fingerprint: String) -> Vector3: diff --git a/main/main.gd b/main/main.gd index 8335c69..00d2b40 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1731,12 +1731,14 @@ func _process(delta: float) -> void: func _apply_runtime_settings(settings: PlayerSettingsType) -> void: if settings == null: return - var requested_window_mode: DisplayServer.WindowMode = ( - DisplayServer.WINDOW_MODE_FULLSCREEN - if settings.fullscreen_enabled - else DisplayServer.WINDOW_MODE_WINDOWED + var current_window_mode: DisplayServer.WindowMode = ( + DisplayServer.window_get_mode() ) - if DisplayServer.window_get_mode() != requested_window_mode: + var requested_window_mode: DisplayServer.WindowMode = resolve_window_mode( + settings.fullscreen_enabled, + current_window_mode, + ) + if current_window_mode != requested_window_mode: DisplayServer.window_set_mode(requested_window_mode) _apply_world_pixelation(settings.world_pixel_size) _ui_pixelation.set_pixel_size(settings.ui_pixel_size) @@ -1763,6 +1765,25 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: ) +static func resolve_window_mode( + fullscreen_enabled: bool, + current_mode: DisplayServer.WindowMode, +) -> DisplayServer.WindowMode: + var is_fullscreen: bool = current_mode in [ + DisplayServer.WINDOW_MODE_FULLSCREEN, + DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN, + ] + if fullscreen_enabled: + return ( + current_mode + if is_fullscreen + else DisplayServer.WINDOW_MODE_FULLSCREEN + ) + if is_fullscreen: + return DisplayServer.WINDOW_MODE_WINDOWED + return current_mode + + func _apply_world_pixelation(pixel_size: int) -> void: var root_viewport: Viewport = get_viewport() root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST diff --git a/network/network_home_protocol.gd b/network/network_home_protocol.gd index 62b8488..96756e9 100644 --- a/network/network_home_protocol.gd +++ b/network/network_home_protocol.gd @@ -7,6 +7,7 @@ const CAPABILITY: StringName = &"portable_home_v1" const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_MESSAGE_LENGTH: int = 160 +const MAX_TRANSITION_COORDINATE: float = 10000.0 const WORLD_SPACE: StringName = &"world" enum TransitionAction { @@ -143,7 +144,7 @@ static func transform_to_array(value: Transform3D) -> Array[float]: static func array_to_transform(value: Variant) -> Transform3D: - if not _valid_transform(value): + if not validate_transition_transform(value): return Transform3D() var fields: Array = value return Transform3D( @@ -156,6 +157,17 @@ static func array_to_transform(value: Variant) -> Transform3D: ) +static func validate_transition_transform(value: Variant) -> bool: + if not _valid_transform(value): + return false + var fields: Array = value + return ( + absf(float(fields[9])) <= MAX_TRANSITION_COORDINATE + and absf(float(fields[10])) <= MAX_TRANSITION_COORDINATE + and absf(float(fields[11])) <= MAX_TRANSITION_COORDINATE + ) + + static func _valid_transform(value: Variant) -> bool: if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12: return false diff --git a/network/network_home_service.gd b/network/network_home_service.gd index df4fcde..33fa6ee 100644 --- a/network/network_home_service.gd +++ b/network/network_home_service.gd @@ -5,12 +5,15 @@ const NetworkHomeProtocolType = preload( "res://network/network_home_protocol.gd" ) const PlayerHomeStateType = preload("res://homes/player_home_state.gd") +const DecorCatalogType = preload("res://homes/decor_catalog.gd") +const OwnedItemType = preload("res://items/owned_item.gd") signal local_transition_finished(accepted: bool, message: String) signal local_space_changed(space_id: StringName, owner_fingerprint: String) signal local_manifest_rejected(message: String) const MAX_MANIFESTS: int = 128 +const MAX_PENDING_LOCAL_DECOR_TRANSACTIONS: int = 16 var _session: NetworkSession var _spawn_service: PlayerSpawnService @@ -24,9 +27,12 @@ var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {} var _fingerprint_by_peer: Dictionary[int, String] = {} var _authorized_owned_counts: Dictionary[int, Dictionary] = {} var _authorization_result_owner: Dictionary[String, int] = {} +var _interior_slots_by_space: Dictionary[StringName, Dictionary] = {} var _next_interior_index: int = 0 var _suppress_local_submission: bool = false var _publish_queued: bool = false +var _last_submitted_local_home_revision: int = -1 +var _pending_local_decor_transactions: Dictionary[int, Dictionary] = {} func setup( @@ -77,6 +83,10 @@ func can_local_decorate() -> bool: ) +func has_pending_local_decor_mutation() -> bool: + return not _pending_local_decor_transactions.is_empty() + + func get_authoritative_owned_count( peer_id: int, decor_id: StringName, @@ -101,6 +111,7 @@ func place_local_decor( ) -> String: if ( not can_local_decorate() + or has_pending_local_decor_mutation() or _bag == null or _bag.get_quantity(decor_id) <= 0 or _world_service == null @@ -123,6 +134,10 @@ func place_local_decor( ) if normalized.is_empty(): return "" + var transaction: Dictionary = _capture_local_decor_transaction() + if transaction.is_empty(): + return "" + _suppress_local_submission = true var instance_id: String = _local_home.place_decor( decor_id, normalized["position"], @@ -130,17 +145,22 @@ func place_local_decor( mode, ) if instance_id.is_empty(): + _suppress_local_submission = false return "" if not _bag.remove_item(decor_id, 1): _local_home.remove_placement(instance_id) + _suppress_local_submission = false + return "" + _suppress_local_submission = false + if not _commit_local_decor_transaction(transaction): return "" - _save_manager.save_if_dirty() return instance_id func remove_local_decor(instance_id: String) -> bool: if ( not can_local_decorate() + or has_pending_local_decor_mutation() or _bag == null or _inventory_layout == null or _hotbar == null @@ -149,6 +169,9 @@ func remove_local_decor(instance_id: String) -> bool: var placement: Dictionary = _local_home.get_placement(instance_id) if placement.is_empty(): return false + var transaction: Dictionary = _capture_local_decor_transaction() + if transaction.is_empty(): + return false var decor_id := StringName(str(placement["decor_id"])) var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id) if target_hotbar_slot < 0: @@ -174,14 +197,20 @@ func remove_local_decor(instance_id: String) -> bool: _inventory_layout.cancel_prepared_item_placement(decor_id) returned_to_inventory = _bag.add_item_to_storage(decor_id, 1) if not returned_to_inventory: + if prepared_hotbar_placement: + _inventory_layout.cancel_prepared_item_placement(decor_id) return false if target_hotbar_slot >= 0: _hotbar.assign_item(target_hotbar_slot, decor_id) _hotbar.select_slot(target_hotbar_slot) + _suppress_local_submission = true if not _local_home.remove_placement(instance_id): - _bag.remove_item(decor_id, 1) + _suppress_local_submission = false + _restore_local_decor_transaction(transaction) + return false + _suppress_local_submission = false + if not _commit_local_decor_transaction(transaction): return false - _save_manager.save_if_dirty() return true @@ -191,19 +220,28 @@ func update_local_decor( yaw_radians: float, mode: PlayerHomeStateType.PlacementMode, ) -> bool: + if has_pending_local_decor_mutation(): + return false var normalized: Dictionary = get_valid_local_decor_transform( instance_id, position, yaw_radians, mode ) if normalized.is_empty(): return false + var transaction: Dictionary = _capture_local_decor_transaction() + if transaction.is_empty(): + return false + _suppress_local_submission = true if not _local_home.update_placement( instance_id, normalized["position"], float(normalized["yaw"]), mode, ): + _suppress_local_submission = false + return false + _suppress_local_submission = false + if not _commit_local_decor_transaction(transaction): return false - _save_manager.save_if_dirty() return true @@ -337,7 +375,9 @@ func refresh_world_presentations() -> void: ) -func publish_local_home() -> bool: +func publish_local_home( + allow_pending_decor_transaction: bool = false, +) -> bool: if ( _session == null or not _session.is_gameplay_session_active() @@ -345,11 +385,24 @@ func publish_local_home() -> bool: or _local_home == null ): return false + # A purchase can update the owned decor count while an optimistic placement + # is waiting on the host. Hold that follow-up manifest until the placement is + # accepted or rolled back; otherwise it can carry the still-unverified + # placement and turn one rejection into a second stale rejection. + if ( + not allow_pending_decor_transaction + and has_pending_local_decor_mutation() + ): + _publish_queued = true + return true if _session.is_host() and ( _world_service == null or not _world_service.is_world_ready() ): return false var home_data: Dictionary = _local_home.to_save_data() + var revision: int = int(home_data.get("revision", -1)) + if revision >= 0 and revision == _last_submitted_local_home_revision: + return true var request: Dictionary = { "request_id": _new_id("home"), "session_id": _session.get_session_id(), @@ -361,6 +414,7 @@ func publish_local_home() -> bool: "portable_home_manifest", NetworkHomeProtocolType.manifest_signature_fields(request), ) + _last_submitted_local_home_revision = revision if _session.is_host(): _process_manifest_request(_session.get_local_peer_id(), request) else: @@ -445,6 +499,15 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void: _manifests_by_fingerprint[fingerprint] = manifest _fingerprint_by_peer[peer_id] = fingerprint _consume_owned_authorizations(peer_id, home_data) + if peer_id == _session.get_local_peer_id(): + _discard_local_decor_transactions_through( + int(home_data.get("revision", -1)) + ) + if _last_submitted_local_home_revision == int( + home_data.get("revision", -1) + ): + _last_submitted_local_home_revision = -1 + _flush_queued_local_home_publish() _broadcast_manifest(manifest) if is_first_manifest: _spawn_peer_at_exterior(peer_id, fingerprint) @@ -588,6 +651,16 @@ func receive_home_manifest(data: Dictionary) -> void: var fingerprint: String = str(data["owner_fingerprint"]) _manifests_by_fingerprint[fingerprint] = data.duplicate(true) _fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint + if fingerprint == _session.get_local_identity_fingerprint(): + var home: Dictionary = data.get("home", {}) + _discard_local_decor_transactions_through( + int(home.get("revision", -1)) + ) + if _last_submitted_local_home_revision == int( + home.get("revision", -1) + ): + _last_submitted_local_home_revision = -1 + _flush_queued_local_home_publish() if _world_service != null and _world_service.is_world_ready(): _world_service.apply_manifest(data) @@ -613,20 +686,304 @@ func receive_home_manifest_rejection( func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void: - if ( - canonical.is_empty() - or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical) - ): + _last_submitted_local_home_revision = -1 + var canonical_home: Dictionary = canonical.get("home", {}) + var canonical_revision: int = int(canonical_home.get("revision", -1)) + var transaction: Dictionary = _get_local_decor_rejection_transaction( + canonical_revision + ) + if canonical.is_empty() or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical): + if not transaction.is_empty(): + _restore_local_decor_transaction(transaction) + _pending_local_decor_transactions.clear() + if _save_manager != null and not _save_manager.save_if_dirty(): + push_warning("Unable to save the restored RV decor state.") + _flush_queued_local_home_publish() local_manifest_rejected.emit(message) return + # A shop result can be accepted locally while its own home manifest is still + # in flight. A delayed rejection for an earlier placement must not erase that + # accepted purchase just because the host's canonical manifest predates it. + var locally_owned_decor: Dictionary[StringName, int] = ( + _capture_local_owned_decor_counts() + ) + # Only carry ownership forward across an actual pending decor-action + # rejection. A standalone rejected manifest must remain authoritative rather + # than repeatedly re-submitting an unverified owned-count increase. + var has_owned_decor_increase: bool = ( + not transaction.is_empty() + and _has_local_owned_decor_increase( + locally_owned_decor, + canonical_home, + ) + ) _suppress_local_submission = true - _local_home.restore_from_save_data(canonical["home"]) + var restored: bool = _local_home.restore_from_save_data(canonical_home) + var owned_decor_restored: bool = ( + restored + and ( + not has_owned_decor_increase + or _restore_local_owned_decor_increases(locally_owned_decor) + ) + ) + var reconciled: bool = ( + restored + and owned_decor_restored + and _reconcile_local_decor_inventory(transaction) + ) _suppress_local_submission = false - _save_manager.save_if_dirty() + _discard_local_decor_transactions_after(canonical_revision) + if not reconciled: + push_error("Unable to reconcile RV decor after a host rejection.") + elif _save_manager != null and not _save_manager.save_if_dirty(): + push_warning("Unable to save the restored RV decor state.") receive_home_manifest(canonical) + if reconciled and has_owned_decor_increase: + # Submit the preserved purchase as a fresh revision after applying the + # canonical placement rollback. This is deliberately separate from the + # rejected action, so the host can validate the already-authorized count. + if not publish_local_home(): + push_warning("Unable to publish the restored RV decor ownership.") local_manifest_rejected.emit(message) +func _capture_local_decor_transaction() -> Dictionary: + if ( + _local_home == null + or _bag == null + or _inventory_layout == null + or _hotbar == null + ): + return {} + var saved_items: Array[OwnedItemType] = [] + for item: OwnedItemType in _bag.get_all_items(): + saved_items.append(item.duplicate_record()) + return { + "home": _local_home.to_save_data(), + "bag_items": saved_items, + "layout": _inventory_layout.to_save_data(), + "hotbar_slots": _hotbar.get_slots(), + "hotbar_fish_slots": _hotbar.get_fish_slots(), + "selected_hotbar_slot": _hotbar.get_selected_slot(), + } + + +func _capture_local_owned_decor_counts() -> Dictionary[StringName, int]: + var counts: Dictionary[StringName, int] = {} + if _local_home == null: + return counts + for decor_id: StringName in DecorCatalogType.get_product_ids(): + counts[decor_id] = _local_home.get_owned_count(decor_id) + return counts + + +func _has_local_owned_decor_increase( + local_counts: Dictionary[StringName, int], + canonical_home: Dictionary, +) -> bool: + var canonical_owned: Dictionary = canonical_home.get("owned_decor", {}) + for decor_id: StringName in DecorCatalogType.get_product_ids(): + if int(local_counts.get(decor_id, 0)) > int( + canonical_owned.get(String(decor_id), 0) + ): + return true + return false + + +func _restore_local_owned_decor_increases( + local_counts: Dictionary[StringName, int], +) -> bool: + if _local_home == null: + return false + for decor_id: StringName in DecorCatalogType.get_product_ids(): + var local_count: int = int(local_counts.get(decor_id, 0)) + var canonical_count: int = _local_home.get_owned_count(decor_id) + while canonical_count < local_count: + if not _local_home.add_owned_decor(decor_id, 1): + return false + canonical_count += 1 + return true + + +func _commit_local_decor_transaction(transaction: Dictionary) -> bool: + if _save_manager == null or not _save_manager.save_if_dirty(): + _restore_local_decor_transaction(transaction) + return false + var revision: int = _local_home.get_revision() + _pending_local_decor_transactions[revision] = transaction.duplicate(true) + _trim_pending_local_decor_transactions() + if publish_local_home(true): + return true + _pending_local_decor_transactions.erase(revision) + _restore_local_decor_transaction(transaction) + _flush_queued_local_home_publish() + # The optimistic mutation was already written before we attempted to publish + # it. If the session ended or could not accept the manifest, immediately + # persist the restored state so a restart cannot resurrect the rejected + # placement or consume its decor item. + if _save_manager != null and not _save_manager.save_if_dirty(): + push_warning("Unable to save the restored RV decor state.") + return false + + +func _restore_local_decor_transaction(transaction: Dictionary) -> bool: + if ( + transaction.is_empty() + or _local_home == null + or _bag == null + or _inventory_layout == null + or _hotbar == null + ): + return false + var home_data: Variant = transaction.get("home", {}) + var layout_data: Variant = transaction.get("layout", {}) + var stored_items: Variant = transaction.get("bag_items", []) + var stored_slots: Variant = transaction.get("hotbar_slots", []) + var stored_fish_slots: Variant = transaction.get("hotbar_fish_slots", []) + if ( + typeof(home_data) != TYPE_DICTIONARY + or typeof(layout_data) != TYPE_DICTIONARY + or typeof(stored_items) != TYPE_ARRAY + or typeof(stored_slots) != TYPE_ARRAY + or typeof(stored_fish_slots) != TYPE_ARRAY + ): + return false + var items: Array[OwnedItemType] = [] + for value: Variant in stored_items as Array: + if not value is OwnedItemType: + return false + items.append((value as OwnedItemType).duplicate_record()) + var slots: Array[StringName] = [] + for value: Variant in stored_slots as Array: + slots.append(StringName(str(value))) + var fish_slots: Array[StringName] = [] + for value: Variant in stored_fish_slots as Array: + fish_slots.append(StringName(str(value))) + var was_suppressed: bool = _suppress_local_submission + _suppress_local_submission = true + var home_restored: bool = _local_home.restore_from_save_data( + home_data as Dictionary + ) + var bag_restored: bool = _bag.replace_all_items(items) + var layout_restored: bool = _inventory_layout.restore_from_save_data( + layout_data as Dictionary + ) + var hotbar_restored: bool = _hotbar.replace_state( + slots, + int(transaction.get("selected_hotbar_slot", 0)), + fish_slots, + false, + ) + _suppress_local_submission = was_suppressed + return home_restored and bag_restored and layout_restored and hotbar_restored + + +func _reconcile_local_decor_inventory(transaction: Dictionary) -> bool: + if _bag == null or _local_home == null: + return false + var preferred_slots: Dictionary[StringName, int] = ( + _decor_hotbar_slots_from_transaction(transaction) + ) + for decor_id: StringName in DecorCatalogType.get_product_ids(): + var target_quantity: int = _local_home.get_available_count(decor_id) + var current_quantity: int = _bag.get_quantity(decor_id) + if current_quantity > target_quantity: + if not _bag.remove_item( + decor_id, current_quantity - target_quantity + ): + return false + continue + while current_quantity < target_quantity: + var preferred_slot: int = int( + preferred_slots.get(decor_id, -1) + ) + if not _restore_decor_item_to_bag(decor_id, preferred_slot): + return false + current_quantity += 1 + return true + + +func _decor_hotbar_slots_from_transaction( + transaction: Dictionary, +) -> Dictionary[StringName, int]: + var result: Dictionary[StringName, int] = {} + var stored_slots: Variant = transaction.get("hotbar_slots", []) + if typeof(stored_slots) != TYPE_ARRAY: + return result + for slot_index: int in range((stored_slots as Array).size()): + var decor_id := StringName(str((stored_slots as Array)[slot_index])) + if DecorCatalogType.has_product(decor_id): + result[decor_id] = slot_index + return result + + +func _restore_decor_item_to_bag( + decor_id: StringName, + preferred_hotbar_slot: int, +) -> bool: + if _bag == null: + return false + var prepared_slot: int = -1 + if ( + _inventory_layout != null + and _hotbar != null + and _inventory_layout.get_container( + PlayerInventoryLayout.EntryKind.ITEM, decor_id + ) < 0 + ): + var candidate_slot: int = preferred_hotbar_slot + if candidate_slot < 0: + candidate_slot = _hotbar.find_first_empty_slot() + if candidate_slot >= 0 and _inventory_layout.prepare_new_item_placement( + decor_id, + PlayerInventoryLayout.InventoryContainer.HOTBAR, + candidate_slot, + ): + prepared_slot = candidate_slot + var restored: bool = _bag.add_item(decor_id, 1) + if not restored: + if prepared_slot >= 0: + _inventory_layout.cancel_prepared_item_placement(decor_id) + if _bag.get_owned_item(decor_id) == null: + restored = _bag.add_item_to_storage(decor_id, 1) + if restored and prepared_slot >= 0: + _hotbar.assign_item(prepared_slot, decor_id) + _hotbar.select_slot(prepared_slot) + return restored + + +func _get_local_decor_rejection_transaction( + canonical_revision: int, +) -> Dictionary: + var revisions: Array[int] = [] + revisions.assign(_pending_local_decor_transactions.keys()) + revisions.sort() + for revision: int in revisions: + if canonical_revision < 0 or revision > canonical_revision: + return _pending_local_decor_transactions[revision].duplicate(true) + return {} + + +func _discard_local_decor_transactions_through(revision: int) -> void: + for pending_revision: int in _pending_local_decor_transactions.keys(): + if pending_revision <= revision: + _pending_local_decor_transactions.erase(pending_revision) + + +func _discard_local_decor_transactions_after(revision: int) -> void: + for pending_revision: int in _pending_local_decor_transactions.keys(): + if pending_revision > revision: + _pending_local_decor_transactions.erase(pending_revision) + + +func _trim_pending_local_decor_transactions() -> void: + var revisions: Array[int] = [] + revisions.assign(_pending_local_decor_transactions.keys()) + revisions.sort() + while revisions.size() > MAX_PENDING_LOCAL_DECOR_TRANSACTIONS: + _pending_local_decor_transactions.erase(revisions.pop_front()) + + func _submit_transition(action: int, owner_fingerprint: String) -> bool: var request: Dictionary = { "request_id": _new_id("home_transition"), @@ -697,12 +1054,18 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void: ): _send_transition_rejection(peer_id, "You cannot enter that RV.") return + var interior_space: StringName = ( + NetworkHomeProtocolType.make_space_id(owner_fingerprint) + ) + var visitor_slot: int = _reserve_interior_slot( + interior_space, peer_id + ) _apply_host_transition( peer_id, - NetworkHomeProtocolType.make_space_id(owner_fingerprint), + interior_space, owner_fingerprint, _world_service.get_interior_spawn_transform( - owner_fingerprint, peer_id + owner_fingerprint, visitor_slot ), ) return @@ -748,18 +1111,40 @@ func _apply_host_transition( owner_fingerprint: String, transform: Transform3D, ) -> void: + var avatar: Player = _spawn_service.get_avatar(peer_id) + var movement_sequence: int = 0 + if avatar != null: + movement_sequence = int( + avatar.make_network_snapshot(peer_id).get( + "acknowledged_input", 0 + ) + ) + var transform_data: Array[float] = ( + NetworkHomeProtocolType.transform_to_array(transform) + ) + if not NetworkHomeProtocolType.validate_transition_transform( + transform_data + ): + push_error("Refused an out-of-bounds RV transition transform.") + return + var previous_space: StringName = _session.get_peer_space(peer_id) + if ( + String(previous_space).begins_with("rv:") + and previous_space != space_id + ): + _release_interior_slot(previous_space, peer_id) var result: Dictionary = { "peer_id": peer_id, "space_id": String(space_id), "owner_fingerprint": owner_fingerprint, - "transform": NetworkHomeProtocolType.transform_to_array(transform), + "transform": transform_data, + "movement_sequence": movement_sequence, } _apply_transition_result(result) for recipient_id: int in _session.get_authenticated_peer_ids(): if recipient_id == 1: continue receive_home_transition.rpc_id(recipient_id, result) - _session.publish_authoritative_teleport(peer_id) @rpc( @@ -770,35 +1155,40 @@ func receive_home_transition(data: Dictionary) -> void: if ( typeof(data.get("peer_id")) != TYPE_INT or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(data.get("movement_sequence", 0)) != TYPE_INT + or int(data.get("movement_sequence", 0)) < 0 or not NetworkIdentityCrypto.valid_fingerprint( data.get("owner_fingerprint") ) + or not NetworkHomeProtocolType.validate_transition_transform( + data.get("transform") + ) ): return - var transform: Transform3D = NetworkHomeProtocolType.array_to_transform( - data.get("transform") - ) - if transform == Transform3D(): - return _apply_transition_result(data) func _apply_transition_result(data: Dictionary) -> void: var peer_id: int = int(data["peer_id"]) var space_id := StringName(str(data["space_id"])) + if not NetworkHomeProtocolType.validate_transition_transform( + data.get("transform") + ): + return + var target_transform: Transform3D = ( + NetworkHomeProtocolType.array_to_transform(data["transform"]) + ) if not _session.set_peer_space(peer_id, space_id): return - var avatar: Player = _spawn_service.get_avatar(peer_id) - if avatar != null: - avatar.global_transform = NetworkHomeProtocolType.array_to_transform( - data["transform"] - ) - avatar.velocity = Vector3.ZERO + if not _session.apply_authoritative_space_transition( + peer_id, + target_transform, + int(data.get("movement_sequence", 0)), + ): + return if peer_id == _session.get_local_peer_id(): _world_service.set_local_space(space_id) - local_space_changed.emit( - space_id, str(data["owner_fingerprint"]) - ) + local_space_changed.emit(space_id, str(data["owner_fingerprint"])) local_transition_finished.emit(true, "") _refresh_peer_visibility() @@ -840,12 +1230,20 @@ func _on_local_home_changed() -> void: func _publish_queued_home() -> void: + if has_pending_local_decor_mutation(): + return _publish_queued = false if _save_manager != null: _save_manager.save_if_dirty() publish_local_home() +func _flush_queued_local_home_publish() -> void: + if not _publish_queued or has_pending_local_decor_mutation(): + return + call_deferred("_publish_queued_home") + + func _on_join_authenticated() -> void: publish_local_home() @@ -880,6 +1278,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: func _on_peer_removed(peer_id: int) -> void: + _release_peer_interior_slots(peer_id) var fingerprint: String = _fingerprint_by_peer.get(peer_id, "") _fingerprint_by_peer.erase(peer_id) _authorized_owned_counts.erase(peer_id) @@ -937,6 +1336,10 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _fingerprint_by_peer.clear() _authorized_owned_counts.clear() _authorization_result_owner.clear() + _publish_queued = false + _last_submitted_local_home_revision = -1 + _pending_local_decor_transactions.clear() + _interior_slots_by_space.clear() _next_interior_index = 0 _world_service.clear_presentations() _world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE) @@ -950,6 +1353,37 @@ func _fingerprint_for_peer(peer_id: int) -> String: return record.identity_fingerprint if record != null else "" +func _reserve_interior_slot(space_id: StringName, peer_id: int) -> int: + var slots: Dictionary = _interior_slots_by_space.get(space_id, {}) + if slots.has(peer_id): + return int(slots[peer_id]) + var used_slots: Dictionary[int, bool] = {} + for value: Variant in slots.values(): + used_slots[int(value)] = true + var slot: int = 0 + while used_slots.has(slot): + slot += 1 + slots[peer_id] = slot + _interior_slots_by_space[space_id] = slots + return slot + + +func _release_interior_slot(space_id: StringName, peer_id: int) -> void: + var slots: Dictionary = _interior_slots_by_space.get(space_id, {}) + if slots.is_empty(): + return + slots.erase(peer_id) + if slots.is_empty(): + _interior_slots_by_space.erase(space_id) + else: + _interior_slots_by_space[space_id] = slots + + +func _release_peer_interior_slots(peer_id: int) -> void: + for space_id: StringName in _interior_slots_by_space.keys(): + _release_interior_slot(space_id, peer_id) + + func _new_id(prefix: String) -> String: return "%s:%s" % [ prefix, Crypto.new().generate_random_bytes(16).hex_encode(), diff --git a/network/network_session.gd b/network/network_session.gd index 8add90f..66d5101 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -28,6 +28,7 @@ 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 SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE: float = 12.0 const MOVEMENT_FLAG_JUMP: int = 1 << 0 const MOVEMENT_FLAG_SPRINT: int = 1 << 1 @@ -124,6 +125,9 @@ var _pending_movement_inputs: Array[Dictionary] = [] var _snapshot_accumulator: float = 0.0 var _movement_snapshot_tick: int = 0 var _last_local_snapshot_received_msec: int = 0 +var _local_space_transition_guard_active: bool = false +var _local_space_transition_guard_position: Vector3 = Vector3.ZERO +var _local_space_transition_minimum_ack: 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] = {} @@ -703,6 +707,40 @@ func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool: return get_peer_space(first_peer_id) == get_peer_space(second_peer_id) +func apply_authoritative_space_transition( + peer_id: int, + target_transform: Transform3D, + acknowledged_input: int, +) -> bool: + if not target_transform.origin.is_finite(): + return false + var local_peer_id: int = get_local_peer_id() + var local_transition: bool = peer_id == local_peer_id + var avatar: Player = _spawn_service.get_avatar(peer_id) + # Remote home transitions can arrive before their player-spawn message. Keep + # the new space assignment so the later spawn inherits correct visibility; + # only a missing local avatar is a transition failure. + if avatar == null: + return not local_transition + avatar.apply_network_space_transition(target_transform, local_transition) + if ( + local_transition + and state == State.JOINED_CLIENT + ): + # Movement snapshots use another ENet channel and a pre-transition audit + # can therefore arrive after the reliable home transition. Clear local + # prediction and reject those old-world snapshots until the host publishes + # its first audit from the new space. + _pending_movement_inputs.clear() + _last_local_snapshot_received_msec = 0 + _last_input_state_hash = 0 + _idle_input_accumulator = IDLE_INPUT_INTERVAL + _local_space_transition_guard_active = true + _local_space_transition_guard_position = target_transform.origin + _local_space_transition_minimum_ack = maxi(acknowledged_input, 0) + return true + + func get_peer_rtt_ms(peer_id: int) -> int: if peer_id == 1: return 0 @@ -2569,6 +2607,8 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: if avatar == null: continue if peer_id == local_peer_id: + if _reject_stale_space_transition_snapshot(snapshot): + continue _discard_acknowledged_movement_inputs( int(snapshot.get("acknowledged_input", 0)) ) @@ -2586,6 +2626,33 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) +func _reject_stale_space_transition_snapshot(snapshot: Dictionary) -> bool: + if not _local_space_transition_guard_active: + return false + var position_values: Array = snapshot.get("position", []) + if position_values.size() != 3: + return true + var position := Vector3( + float(position_values[0]), + float(position_values[1]), + float(position_values[2]), + ) + var acknowledged_input: int = int( + snapshot.get("acknowledged_input", 0) + ) + if acknowledged_input < _local_space_transition_minimum_ack: + return true + if acknowledged_input > _local_space_transition_minimum_ack: + _local_space_transition_guard_active = false + return false + if position.distance_to(_local_space_transition_guard_position) > ( + SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE + ): + return true + _local_space_transition_guard_active = false + return false + + func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: while ( not _pending_movement_inputs.is_empty() @@ -2914,6 +2981,9 @@ func _teardown_peer() -> void: _snapshot_accumulator = 0.0 _movement_snapshot_tick = 0 _last_local_snapshot_received_msec = 0 + _local_space_transition_guard_active = false + _local_space_transition_guard_position = Vector3.ZERO + _local_space_transition_minimum_ack = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/player.gd b/player/player.gd index 30807be..e36f92b 100644 --- a/player/player.gd +++ b/player/player.gd @@ -2093,6 +2093,37 @@ func reset_network_movement_state() -> void: _local_prediction_largest_error = 0.0 +func apply_network_space_transition( + target_transform: Transform3D, + align_local_camera: bool, +) -> void: + if not target_transform.origin.is_finite(): + return + # A room transition is a discontinuity, not ordinary movement. Preserve the + # host's input acknowledgement while clearing every motion, animation, and + # reconciliation state that could pull the avatar toward the previous space. + var acknowledged_input: int = _last_network_input_sequence + reset_network_movement_state() + _last_network_input_sequence = acknowledged_input + _apply_network_casting(false) + velocity = Vector3.ZERO + global_transform = Transform3D(Basis.IDENTITY, target_transform.origin) + var target_yaw: float = target_transform.basis.get_euler().y + _visuals.rotation.y = target_yaw + _network_camera_yaw = target_yaw + _network_target_position = global_position + _network_target_velocity = Vector3.ZERO + _network_target_visual_yaw = target_yaw + _network_snapshot_age = 0.0 + _network_snapshot_ready = true + _authoritative_movement_history.clear() + if align_local_camera: + _set_camera_dragging(false) + if _free_camera_active: + _set_free_camera_active(false) + _camera_yaw.rotation.y = target_yaw + + func capture_network_input(sequence: int) -> Dictionary: if _sitting_intent_pending and _sitting_intent_sequence < 0: _sitting_intent_sequence = sequence From 3be79fa3fccbd8c1c373919c7e900af00c13ffac Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 05:48:47 -0400 Subject: [PATCH 13/14] Normalize cursor handoff across gameplay menus --- network/network_surface_drawing_service.gd | 26 ++++++++++++++++++++-- ui/decor_shop.gd | 10 ++++++++- ui/fishing_shop.gd | 15 ++++++++++--- ui/player_menu.gd | 19 +++++++++++++--- ui/player_storage.gd | 11 ++++++++- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd index c322e5b..485dfaa 100644 --- a/network/network_surface_drawing_service.gd +++ b/network/network_surface_drawing_service.gd @@ -250,7 +250,11 @@ func activate( _pointer_screen_position = _clamped_pointer_position( initial_pointer_position ) - _prior_mouse_mode = Input.mouse_mode + # Art-kit activation can occur while the prior right-click camera drag is + # still held. Stop that drag before recording a return mode, then restore + # normal camera availability so right-click continues to work while drawing. + _stop_camera_drag_for_art_kit() + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _update_aim() _refresh_stencil_visibility() @@ -266,6 +270,10 @@ func deactivate() -> void: _eraser_mode = false _clear_armed_guide_action(false) _camera_look_active = false + # Do not leave a drag captured when drawing is closed by a menu, hotbar + # change, or session transition. The next camera drag must start on a fresh + # physical right-click press. + _stop_camera_drag_for_art_kit() _reset_stroke() _selected_canvas_id = "" _hovered_canvas_id = "" @@ -273,10 +281,24 @@ func deactivate() -> void: _aim_hit.clear() _hide_previews() _refresh_stencil_visibility() - Input.mouse_mode = _prior_mouse_mode + Input.mouse_mode = _get_restorable_mouse_mode(_prior_mouse_mode) _emit_hud_state("") +func _stop_camera_drag_for_art_kit() -> void: + if _local_player == null or not is_instance_valid(_local_player): + return + var camera_input_enabled: bool = _local_player.is_camera_input_enabled() + _local_player.set_camera_input_enabled(false) + _local_player.set_camera_input_enabled(camera_input_enabled) + + +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode + + func is_active() -> bool: return _active diff --git a/ui/decor_shop.gd b/ui/decor_shop.gd index 9292806..d05d9eb 100644 --- a/ui/decor_shop.gd +++ b/ui/decor_shop.gd @@ -82,9 +82,11 @@ func open_shop() -> bool: return false _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_enabled = _player.is_camera_input_enabled() - _prior_mouse_mode = Input.mouse_mode _player.set_movement_enabled(false) + # Release any active right-click drag first. The shop must not remember the + # drag's transient captured mode because release may happen inside the UI. _player.set_camera_input_enabled(false) + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _transaction_pending = ( @@ -133,6 +135,12 @@ func close_for_session_end() -> void: close_shop(false) +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode + + func _unhandled_input(event: InputEvent) -> void: if not visible or not event.is_action_pressed("ui_cancel"): return diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index ad8247c..234a1c1 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -654,11 +654,14 @@ func open_shop() -> bool: ) _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_enabled = _player.is_camera_input_enabled() - _prior_mouse_mode = Input.mouse_mode + _player.set_movement_enabled(false) + # Releasing an active camera drag before the snapshot prevents the close + # path from restoring a captured pointer after its matching button release + # has been consumed by the shop. + _player.set_camera_input_enabled(false) + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) _snapshot_stored = true _mouse_snapshot_stored = true - _player.set_movement_enabled(false) - _player.set_camera_input_enabled(false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _set_feedback("") @@ -1897,3 +1900,9 @@ func _apply_mouse_close_policy(reason: CloseReason) -> void: CloseReason.TEARDOWN: pass _mouse_snapshot_stored = false + + +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 2f9f96f..9b15913 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -1559,11 +1559,14 @@ func open_menu() -> void: _cancel_page_tween() _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_input_enabled = _player.is_camera_input_enabled() - _prior_mouse_mode = Input.mouse_mode + _player.set_movement_enabled(false) + # Ending an active right-click camera drag restores its ordinary pointer + # mode. Snapshot after that handoff so this menu never restores the drag's + # transient captured mode when it closes. + _player.set_camera_input_enabled(false) + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) _control_snapshot_stored = true _mouse_snapshot_stored = true - _player.set_movement_enabled(false) - _player.set_camera_input_enabled(false) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE visible = true @@ -1775,6 +1778,16 @@ func _apply_mouse_close_policy(reason: CloseReason) -> void: _mouse_snapshot_stored = false +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + # Mouse capture belongs to an in-progress camera drag, never to the player + # menu itself. A held right button can be released while this menu owns + # input, so restoring CAPTURED here would leave the pointer hidden without + # an active drag to release it. + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode + + func _on_bite_activated() -> void: if visible: close_menu(CloseReason.BITE_STARTED) diff --git a/ui/player_storage.gd b/ui/player_storage.gd index 1f553ea..83817c5 100644 --- a/ui/player_storage.gd +++ b/ui/player_storage.gd @@ -109,9 +109,12 @@ func _open_storage( _decorate_button.visible = home_access and allow_decorating _prior_movement_enabled = _player.is_movement_enabled() _prior_camera_enabled = _player.is_camera_input_enabled() - _prior_mouse_mode = Input.mouse_mode _player.set_movement_enabled(false) + # End an in-progress right-click camera drag before taking the snapshot. + # A held button can be released while this page owns input, so captured mode + # is never a valid mouse mode to restore when the page closes. _player.set_camera_input_enabled(false) + _prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _feedback.text = "select an item to move it · drag to choose a slot" @@ -153,6 +156,12 @@ func close_for_session_end() -> void: close_storage(false) +func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode: + if mouse_mode == Input.MOUSE_MODE_CAPTURED: + return Input.MOUSE_MODE_VISIBLE + return mouse_mode + + func consume_escape() -> bool: if not visible: return false From 014c064fae7592a8fe0e4107d177a22729c0a0e2 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 31 Aug 2026 06:06:09 -0400 Subject: [PATCH 14/14] Prepare private v0.20.2-alpha test build --- docs/README-PLAYTEST.txt | 6 +++--- export_presets.cfg | 26 +++++++++++++------------- project.godot | 4 ++-- scripts/build_playtest.sh | 10 +++++----- ui/title_screen.tscn | 2 +- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index d3f3432..f187182 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,8 +1,8 @@ straywild -v0.20.1-alpha -Alpha 0.20.1 +v0.20.2-alpha +Alpha 0.20.2 -Thank you for supporting this private patron test release. +Thank you for supporting this private testing release. straywild is an early multiplayer fishing game. This alpha includes local progression, player-hosted and dedicated multiplayer sessions, public room diff --git a/export_presets.cfg b/export_presets.cfg index ff588e2..a49f01f 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.1-alpha/windows-x86_64/straywild.exe" +export_path="builds/v0.20.2-alpha/windows-x86_64/straywild.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/straywild.ico" application/console_wrapper_icon="" application/icon_interpolation=0 -application/file_version="0.20.1.0" -application/product_version="0.20.1.0" +application/file_version="0.20.2.0" +application/product_version="0.20.2.0" application/company_name="Woofmeow" application/product_name="straywild" -application/file_description="straywild v0.20.1-alpha" +application/file_description="straywild v0.20.2-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.1-alpha/linux-arm64/straywild.arm64" +export_path="builds/v0.20.2-alpha/linux-arm64/straywild.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.1-alpha/macos/straywild.zip" +export_path="builds/v0.20.2-alpha/macos/straywild.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon_interpolation=0 -application/short_version="0.20.1" -application/version="0.20.1" +application/short_version="0.20.2" +application/version="0.20.2" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.20.1-alpha/server-linux-x86_64/straywildServer.x86_64" +export_path="builds/v0.20.2-alpha/server-linux-x86_64/straywildServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.1-alpha/android/straywild.apk" +export_path="builds/v0.20.2-alpha/android/straywild.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=200100 -version/name="v0.20.1-alpha" +version/code=200200 +version/name="v0.20.2-alpha" package/unique_name="io.woofmeow.netfishing" package/name="straywild" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.20.1-alpha/linux-x86_64/straywild.x86_64" +export_path="builds/v0.20.2-alpha/linux-x86_64/straywild.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index 8e751aa..5bcbb4d 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="straywild" -config/version="0.20.1-alpha" +config/version="0.20.2-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/straywild_256.png" @@ -260,7 +260,7 @@ alternate_fishing={ [network] -discovery/base_url="https://discovery.straywild.io" +discovery/base_url="https://discovery.straywild.io/patron-test" [physics] diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index f1ff151..d54ba70 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.1-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.2-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" -readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.1-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.1-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then @@ -77,8 +77,8 @@ straywild code is licensed under GPL-3.0-or-later. Source repository: ${SOURCE_URL} Source revision at build time: ${SOURCE_COMMIT} -This private patron test release is distributed with an exact corresponding -source archive named straywild-v0.20.1-alpha-source.tar.gz. +This private testing release is distributed with an exact corresponding +source archive named straywild-v0.20.2-alpha-source.tar.gz. If this package includes uncommitted development changes, it must not be publicly redistributed until the exact corresponding source is also made diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 852fd71..22f00f2 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -213,7 +213,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.20.1-alpha" +text = "v0.20.2-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]