diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd index 1309339..700961d 100644 --- a/homes/home_world_service.gd +++ b/homes/home_world_service.gd @@ -40,9 +40,13 @@ const INTERIOR_MAX_SPAWN_SLOTS: int = 64 const TENT_INTERIOR_SIZE := Vector2(4.0, 4.0) const STARTER_RV_INTERIOR_SIZE := Vector2(4.0, 6.0) const STARTER_RV_EXIT_LOCAL_X: float = 1.45 +const STARTER_RV_EXIT_LOCAL_Z: float = -1.0 const INTERIOR_EXIT_LOCAL_Y: float = 0.35 const INTERIOR_EXIT_DOORWAY_HALF_WIDTH: float = 0.48 const INTERIOR_EXIT_TRIGGER_DEPTH: float = 0.45 +const INTERIOR_EXIT_MIN_LOCAL_Y: float = -0.25 +const INTERIOR_EXIT_MAX_LOCAL_Y: float = 2.25 +const INTERIOR_VOID_FALL_DEPTH: float = 1.0 const INTERIOR_SHELL_COLLISION_META: StringName = ( &"home_interior_shell_collision" ) @@ -605,12 +609,36 @@ func is_avatar_near_interior_exit( func has_avatar_crossed_interior_exit_threshold( avatar: Player, owner_fingerprint: String, + previous_global_position: Vector3 = Vector3.INF, ) -> bool: if avatar == null: return false - var local_position: Vector3 = interior_global_to_local( - owner_fingerprint, avatar.global_position + return has_crossed_interior_exit_threshold( + owner_fingerprint, + previous_global_position, + avatar.global_position, ) + + +func has_crossed_interior_exit_threshold( + owner_fingerprint: String, + previous_global_position: Vector3, + current_global_position: Vector3, +) -> bool: + if ( + owner_fingerprint.is_empty() + or not current_global_position.is_finite() + or not _presentations_by_fingerprint.has(owner_fingerprint) + ): + return false + var local_position: Vector3 = interior_global_to_local( + owner_fingerprint, current_global_position + ) + var previous_local_position: Vector3 = Vector3.INF + if previous_global_position.is_finite(): + previous_local_position = interior_global_to_local( + owner_fingerprint, previous_global_position + ) for exit_position: Vector3 in get_interior_exit_positions( owner_fingerprint ): @@ -626,15 +654,76 @@ func has_avatar_crossed_interior_exit_threshold( if ( outward_distance >= 0.0 and outward_distance <= INTERIOR_EXIT_TRIGGER_DEPTH - and absf(local_position.z - threshold_position.z) - <= INTERIOR_EXIT_DOORWAY_HALF_WIDTH - and local_position.y >= -0.25 - and local_position.y <= 2.25 + and _is_inside_interior_exit_aperture( + local_position, threshold_position, exit_direction + ) + ): + return true + # Checking only the current 0.45 m strip lets a low render frame or a + # fast airborne step tunnel completely beyond it. Test the full movement + # segment against the threshold plane so walking, sprinting, and jumping + # all cross the same authored doorway reliably. + if not previous_local_position.is_finite(): + continue + var previous_outward_distance: float = ( + (previous_local_position - threshold_position).dot( + exit_direction + ) + ) + if previous_outward_distance >= 0.0 or outward_distance < 0.0: + continue + var movement_outward: float = ( + outward_distance - previous_outward_distance + ) + if movement_outward <= 0.000001: + continue + var crossing_weight: float = clampf( + -previous_outward_distance / movement_outward, + 0.0, + 1.0, + ) + var crossing_position: Vector3 = previous_local_position.lerp( + local_position, crossing_weight + ) + if _is_inside_interior_exit_aperture( + crossing_position, threshold_position, exit_direction ): return true return false +func is_avatar_below_interior_floor( + avatar: Player, + owner_fingerprint: String, +) -> bool: + if ( + avatar == null + or owner_fingerprint.is_empty() + or not _presentations_by_fingerprint.has(owner_fingerprint) + ): + return false + return interior_global_to_local( + owner_fingerprint, avatar.global_position + ).y < -INTERIOR_VOID_FALL_DEPTH + + +func _is_inside_interior_exit_aperture( + local_position: Vector3, + threshold_position: Vector3, + exit_direction: Vector3, +) -> bool: + var doorway_side_direction := Vector3( + -exit_direction.z, 0.0, exit_direction.x + ) + return ( + absf((local_position - threshold_position).dot( + doorway_side_direction + )) <= INTERIOR_EXIT_DOORWAY_HALF_WIDTH + and local_position.y >= INTERIOR_EXIT_MIN_LOCAL_Y + and local_position.y <= INTERIOR_EXIT_MAX_LOCAL_Y + ) + + func get_interior_exit_direction(owner_fingerprint: String) -> Vector3: return ( Vector3.LEFT @@ -655,10 +744,14 @@ func get_interior_exit_positions( if _home_tier(owner_fingerprint) >= PlayerHomeStateType.STARTER_RV_TIER: return [ anchor + Vector3( - -STARTER_RV_EXIT_LOCAL_X, INTERIOR_EXIT_LOCAL_Y, 0.0 + -STARTER_RV_EXIT_LOCAL_X, + INTERIOR_EXIT_LOCAL_Y, + STARTER_RV_EXIT_LOCAL_Z, ), anchor + Vector3( - STARTER_RV_EXIT_LOCAL_X, INTERIOR_EXIT_LOCAL_Y, 0.0 + STARTER_RV_EXIT_LOCAL_X, + INTERIOR_EXIT_LOCAL_Y, + STARTER_RV_EXIT_LOCAL_Z, ), ] return [ diff --git a/main/main.gd b/main/main.gd index 6cacbca..936f5ca 100644 --- a/main/main.gd +++ b/main/main.gd @@ -308,6 +308,8 @@ var _home_exit_transition_active: bool = false var _home_exit_waiting_for_network: bool = false var _home_exit_start_position: Vector3 = Vector3.ZERO var _home_exit_walk_tween: Tween +var _home_exit_previous_position: Vector3 = Vector3.INF +var _home_exit_tracked_owner: String = "" var _home_entry_transition_active: bool = false var _home_entry_walk_tween: Tween var _observed_cancel_input_frame: int = -1 @@ -1951,7 +1953,10 @@ func _set_gameplay_active(active: bool) -> void: _player.set_camera_active(active) _fishing_spot.set_gameplay_input_enabled(active) _gathering_controller.set_gameplay_input_enabled(active) - _water_recovery.set_recovery_enabled(active) + _water_recovery.set_recovery_enabled( + active + and not String(_network_home.get_local_space()).begins_with("rv:") + ) _game_ui.set_gameplay_ui_enabled(active) _save_manager.set_autosave_enabled(active) if active: @@ -3037,6 +3042,14 @@ func _on_local_home_space_changed( owner_fingerprint: String, ) -> void: var inside_rv: bool = String(space_id).begins_with("rv:") + # Interior rooms live above and away from the playable world. Their isolated + # coordinates can still overlap broad world-water trigger volumes, so world + # recovery must never claim an interior fall and respawn the player without + # also changing their authoritative home space. + _water_recovery.set_recovery_enabled(_gameplay_started and not inside_rv) + _reset_home_exit_position_tracking( + owner_fingerprint if inside_rv else "" + ) _world_time_visuals.set_local_home_interior_active(inside_rv) _rain_ambience.set_suppressed(inside_rv) _rain_puddles.set_suppressed(inside_rv) @@ -3092,9 +3105,25 @@ func _cancel_home_entry_transition() -> void: _player.set_local_input_suppressed(HOME_ENTRY_INPUT_OWNER, false) _player.set_camera_input_suppressed(HOME_ENTRY_INPUT_OWNER, false) _home_entry_transition_active = false + _reset_home_exit_position_tracking( + _network_home.get_local_home_owner_fingerprint() + ) func _maybe_begin_home_exit_transition() -> void: + var owner_fingerprint: String = ( + _network_home.get_local_home_owner_fingerprint() + ) + if owner_fingerprint.is_empty(): + _reset_home_exit_position_tracking() + return + var current_position: Vector3 = _player.global_position + if ( + _home_exit_tracked_owner != owner_fingerprint + or not _home_exit_previous_position.is_finite() + ): + _home_exit_tracked_owner = owner_fingerprint + _home_exit_previous_position = current_position if ( _home_exit_transition_active or _home_entry_transition_active @@ -3106,21 +3135,36 @@ func _maybe_begin_home_exit_transition() -> void: or _game_ui.get_decor_shop().visible or _game_ui.get_rv_upgrade_shop().visible ): + _home_exit_previous_position = current_position return - var owner_fingerprint: String = ( - _network_home.get_local_home_owner_fingerprint() - ) - if ( - owner_fingerprint.is_empty() - or not _home_world.has_avatar_crossed_interior_exit_threshold( + var previous_position: Vector3 = _home_exit_previous_position + _home_exit_previous_position = current_position + # A collision/import regression must not leave an occupant falling into the + # world-water plane while their authoritative space remains the RV. Use the + # normal fade and host-authoritative exit as a last-resort room recovery. + if _home_world.is_avatar_below_interior_floor( + _player, owner_fingerprint + ): + _begin_home_exit_transition(owner_fingerprint, true) + return + if not _home_world.has_avatar_crossed_interior_exit_threshold( _player, owner_fingerprint, - ) - ): + previous_position, + ): return _begin_home_exit_transition(owner_fingerprint) +func _reset_home_exit_position_tracking(owner_fingerprint: String = "") -> void: + _home_exit_tracked_owner = owner_fingerprint + _home_exit_previous_position = ( + _player.global_position + if not owner_fingerprint.is_empty() and is_instance_valid(_player) + else Vector3.INF + ) + + func _begin_home_exit_transition( owner_fingerprint: String, skip_walk: bool = false, @@ -3242,6 +3286,9 @@ func _cancel_home_exit_transition(reset_fade: bool) -> void: _home_exit_transition_active = false _home_exit_waiting_for_network = false _home_exit_start_position = Vector3.ZERO + _reset_home_exit_position_tracking( + _network_home.get_local_home_owner_fingerprint() + ) if is_instance_valid(_game_ui): _game_ui.set_gameplay_input_locked(false) _game_ui.set_rv_storage_button_visible( diff --git a/tests/home_spawn_multiplayer_validation.gd b/tests/home_spawn_multiplayer_validation.gd index d76533c..7f60723 100644 --- a/tests/home_spawn_multiplayer_validation.gd +++ b/tests/home_spawn_multiplayer_validation.gd @@ -81,20 +81,62 @@ func _run_host() -> void: session.get_local_peer_id(), upgrade_result_id, true ) assert(rv_exits[0].x < rv_exits[1].x) + var interior_anchor: Vector3 = home_world.get_interior_anchor( + host_fingerprint + ) for exit_position: Vector3 in rv_exits: - host_avatar.global_position = ( - exit_position - + home_world.get_interior_exit_direction_near( + var exit_direction: Vector3 = ( + home_world.get_interior_exit_direction_near( host_fingerprint, exit_position - ) * 0.05 + ) ) - assert(home_world.has_avatar_crossed_interior_exit_threshold( + var airborne_inside: Vector3 = ( + exit_position - exit_direction * 0.8 + Vector3.UP * 0.55 + ) + var airborne_beyond_trigger: Vector3 = ( + exit_position + + exit_direction + * (HomeWorldService.INTERIOR_EXIT_TRIGGER_DEPTH + 0.8) + + Vector3.UP * 0.75 + ) + host_avatar.global_position = airborne_beyond_trigger + # The endpoint is deliberately beyond the narrow legacy trigger strip. + # Only swept crossing detection can recognize this airborne frame. + assert(not home_world.has_avatar_crossed_interior_exit_threshold( host_avatar, host_fingerprint )) + assert(home_world.has_avatar_crossed_interior_exit_threshold( + host_avatar, host_fingerprint, airborne_inside + )) + var wall_crossing: Vector3 = airborne_beyond_trigger + Vector3( + 0.0, + 0.0, + HomeWorldService.INTERIOR_EXIT_DOORWAY_HALF_WIDTH + 0.5, + ) + host_avatar.global_position = wall_crossing + assert(not home_world.has_avatar_crossed_interior_exit_threshold( + host_avatar, + host_fingerprint, + airborne_inside + Vector3( + 0.0, + 0.0, + HomeWorldService.INTERIOR_EXIT_DOORWAY_HALF_WIDTH + 0.5, + ), + )) host_avatar.global_position = rv_exits[0] + Vector3(0.0, 0.0, 1.1) assert(not home_world.has_avatar_crossed_interior_exit_threshold( host_avatar, host_fingerprint )) + host_avatar.global_position = interior_anchor + Vector3( + 0.0, -HomeWorldService.INTERIOR_VOID_FALL_DEPTH - 0.1, 0.0 + ) + assert(home_world.is_avatar_below_interior_floor( + host_avatar, host_fingerprint + )) + host_avatar.global_position = interior_anchor + assert(not home_world.is_avatar_below_interior_floor( + host_avatar, host_fingerprint + )) var presentations: Dictionary = home_world.get( "_presentations_by_fingerprint" ) @@ -111,9 +153,79 @@ func _run_host() -> void: var wall_shape := wall_collision.get_child(0) as CollisionShape3D assert(wall_shape != null) assert((wall_shape.shape as ConcavePolygonShape3D).backface_collision) - host_avatar.global_position = host_spawn.origin + for door_index: int in rv_exits.size(): + var door_name: String = ( + "WallWestDoor" if door_index == 0 else "WallEastDoor" + ) + var door_mesh := host_interior.find_child( + door_name, true, false + ) as MeshInstance3D + assert(door_mesh != null and door_mesh.mesh != null) + var authored_door_center: Vector3 = ( + door_mesh.global_transform * door_mesh.get_aabb().get_center() + ) + assert(absf(authored_door_center.z - rv_exits[door_index].z) < 0.05) + var rv_host_spawn: Transform3D = home_world.get_exterior_spawn_transform( + host_fingerprint + ) + host_avatar.global_position = rv_host_spawn.origin assert(network_home.request_enter(host_fingerprint)) - await physics_frame + assert(await _wait_for_home_entry( + main, network_home, host_fingerprint + )) + var water_recovery := main.get_node( + "%WaterRecovery" + ) as WaterRecoveryController + assert(not water_recovery.is_recovery_enabled()) + for exit_position: Vector3 in rv_exits: + var exit_direction: Vector3 = ( + home_world.get_interior_exit_direction_near( + host_fingerprint, exit_position + ) + ) + var airborne_inside: Vector3 = ( + exit_position - exit_direction * 0.8 + Vector3.UP * 0.55 + ) + var airborne_outside: Vector3 = ( + exit_position + + exit_direction + * (HomeWorldService.INTERIOR_EXIT_TRIGGER_DEPTH + 0.8) + + Vector3.UP * 0.75 + ) + main.set("_home_exit_tracked_owner", host_fingerprint) + main.set("_home_exit_previous_position", airborne_inside) + host_avatar.global_position = airborne_outside + host_avatar.velocity = exit_direction * host_avatar.sprint_speed + host_avatar.velocity.y = 2.0 + main.call("_maybe_begin_home_exit_transition") + assert(bool(main.get("_home_exit_transition_active"))) + assert(not water_recovery.is_recovery_active()) + assert(await _wait_for_home_exit(main, network_home)) + assert(water_recovery.is_recovery_enabled()) + # Fade-in gives gravity time to settle the capsule from the authored + # 0.25 m spawn lift. The horizontal authoritative target remains exact. + assert(_same_door_horizontal_position( + host_avatar.global_position, rv_host_spawn.origin + )) + assert(absf( + host_avatar.global_position.y - rv_host_spawn.origin.y + ) < 0.6) + assert(network_home.request_enter(host_fingerprint)) + assert(await _wait_for_home_entry( + main, network_home, host_fingerprint + )) + assert(not water_recovery.is_recovery_enabled()) + # If imported collision ever regresses, falling beneath an active room uses + # the same authoritative home exit instead of starting world-water recovery. + host_avatar.global_position = interior_anchor + Vector3( + 0.0, -HomeWorldService.INTERIOR_VOID_FALL_DEPTH - 0.1, 0.0 + ) + main.call("_maybe_begin_home_exit_transition") + assert(bool(main.get("_home_exit_transition_active"))) + assert(not water_recovery.is_recovery_active()) + main.call("_cancel_home_exit_transition", true) + host_avatar.global_position = interior_anchor + main.call("_reset_home_exit_position_tracking", host_fingerprint) var remote_peer_id: int = await _wait_for_remote_peer(session) assert(remote_peer_id > 1) @@ -153,6 +265,7 @@ func _run_host() -> void: ): await process_frame assert(network_home.get_local_home_owner_fingerprint().is_empty()) + assert(water_recovery.is_recovery_enabled()) var visibility_deadline: int = Time.get_ticks_msec() + 1000 while ( Time.get_ticks_msec() < visibility_deadline @@ -263,6 +376,37 @@ func _wait_for_home( return false +func _wait_for_home_entry( + main: Node, + network_home: NetworkHomeService, + fingerprint: String, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 5000 + while Time.get_ticks_msec() < deadline: + await process_frame + if ( + network_home.get_local_home_owner_fingerprint() == fingerprint + and not bool(main.get("_home_entry_transition_active")) + ): + return true + return false + + +func _wait_for_home_exit( + main: Node, + network_home: NetworkHomeService, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 5000 + while Time.get_ticks_msec() < deadline: + await process_frame + if ( + network_home.get_local_home_owner_fingerprint().is_empty() + and not bool(main.get("_home_exit_transition_active")) + ): + return true + return false + + func _faces_away_from_home( avatar: Player, home_world: HomeWorldService, @@ -292,12 +436,17 @@ func _wait_for_remote_peer(session: NetworkSession) -> int: func _same_door_position(first: Vector3, second: Vector3) -> bool: return ( - Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z)) - < 0.08 + _same_door_horizontal_position(first, second) and absf(first.y - second.y) < 0.3 ) +func _same_door_horizontal_position(first: Vector3, second: Vector3) -> bool: + return Vector2(first.x, first.z).distance_to( + Vector2(second.x, second.z) + ) < 0.08 + + func _cleanup(main: Node, session: NetworkSession) -> void: session.disconnect_session("") main.queue_free() diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index 4e190d8..445f858 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -137,6 +137,10 @@ func is_recovery_active() -> bool: return state != RecoveryState.IDLE +func is_recovery_enabled() -> bool: + return _recovery_enabled + + func _exit_tree() -> void: _abort_active_recovery(true)