diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd index 3dee890..3322ed7 100644 --- a/client/scripts/ground_pick.gd +++ b/client/scripts/ground_pick.gd @@ -1,8 +1,8 @@ extends Node3D ## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. -## NS-19: reject vertical faces (pedestal). Bump slopes ~39–45° (dot UP ~0.71–0.78). -## 0.82 rejected slope-first picks when leaving plateau; use 0.64. Walls stay ~0. +## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance +## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau. ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal target_chosen(world: Vector3) @@ -10,6 +10,15 @@ signal target_chosen(world: Vector3) ## Minimum `hit_normal.dot(Vector3.UP)` to accept a pick (0 = vertical wall, 1 = flat floor). const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.64 +## Nudge past a steep walkable triangle so the ray can hit ground behind (meters along view ray). +const STEEP_PICK_THROUGH: float = 0.14 + +## Max ray segments (steep walkable → advance) before giving up. +const MAX_PICK_SEGMENTS: int = 24 + +## Below this dot, treat as wall — do not step through (pedestal sides stay non-pickable). +const WALL_NORMAL_DOT_CUTOFF: float = 0.09 + ## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` ## in `_ready`. var fallback_camera: Camera3D @@ -29,27 +38,45 @@ func _try_pick(screen_pos: Vector2) -> void: if cam == null: return - var origin: Vector3 = cam.project_ray_origin(screen_pos) var ray_dir: Vector3 = cam.project_ray_normal(screen_pos) - var to: Vector3 = origin + ray_dir * 2000.0 - var query := PhysicsRayQueryParameters3D.create(origin, to) - query.collision_mask = 1 + if ray_dir.length_squared() < 1e-12: + return + ray_dir = ray_dir.normalized() + var from: Vector3 = cam.project_ray_origin(screen_pos) var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var hit: Dictionary = space.intersect_ray(query) - if hit.is_empty(): - return - var collider: Variant = hit.get("collider") - if not _collider_is_walkable(collider): - return - var hit_normal: Variant = hit.get("normal", Vector3.ZERO) - if hit_normal is not Vector3: - return - var nrm: Vector3 = (hit_normal as Vector3).normalized() - if nrm.dot(Vector3.UP) < MIN_WALKABLE_NORMAL_DOT_UP: - return - var hit_pos: Variant = hit.get("position") - if hit_pos is Vector3: - target_chosen.emit(hit_pos as Vector3) + const RAY_LEN: float = 2000.0 + var chosen: Vector3 = Vector3.ZERO + var have_chosen: bool = false + + for _i in range(MAX_PICK_SEGMENTS): + var to: Vector3 = from + ray_dir * RAY_LEN + var query := PhysicsRayQueryParameters3D.create(from, to) + query.collision_mask = 1 + var hit: Dictionary = space.intersect_ray(query) + if hit.is_empty(): + break + if not _collider_is_walkable(hit.get("collider")): + break + var hit_normal: Variant = hit.get("normal", Vector3.ZERO) + if hit_normal is not Vector3: + break + var nrm: Vector3 = (hit_normal as Vector3).normalized() + var ndot: float = nrm.dot(Vector3.UP) + if ndot >= MIN_WALKABLE_NORMAL_DOT_UP: + var pos_var: Variant = hit.get("position") + if pos_var is Vector3: + chosen = pos_var as Vector3 + have_chosen = true + break + if ndot <= WALL_NORMAL_DOT_CUTOFF: + break + var hp_var: Variant = hit.get("position") + if hp_var is not Vector3: + break + from = (hp_var as Vector3) + ray_dir * STEEP_PICK_THROUGH + + if have_chosen: + target_chosen.emit(chosen) func _collider_is_walkable(collider: Variant) -> bool: diff --git a/client/scripts/player.gd b/client/scripts/player.gd index d921752..30b687d 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -10,7 +10,6 @@ const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 -const DIRECT_APPROACH_RADIUS: float = 0.85 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -85,22 +84,8 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - var nav_map: RID = _nav_agent.get_navigation_map() - if NavigationServer3D.map_get_iteration_id(nav_map) == 0: - _steer_toward_world_point(_auth_walk_goal) - move_and_slide() - return - - if horiz_dist <= DIRECT_APPROACH_RADIUS: - _steer_toward_world_point(_auth_walk_goal) - else: - var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next_h: Vector3 = Vector3( - next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z - ) - if to_next_h.length_squared() > 0.0025: - _steer_toward_world_point(next_path_position) - else: - _steer_toward_world_point(_auth_walk_goal) - + # Do not follow NavigationAgent3D waypoints: from the bump plateau the first segment often + # drops straight down the mesh, and the agent map can disagree with presentation. Steer only + # toward the server goal in xz; move_and_slide + floor handles slopes and obstacles. + _steer_toward_world_point(_auth_walk_goal) move_and_slide()