NS-23: fix bump departure — stepped pick + goal-only steer

Ground pick: step the ray through steep walkable hits (0.09 < dot UP < 0.64)
so a view that grazes bump sides still resolves to flat floor beyond; walls
(dot ~0) stop the chain.

Player: drop NavigationAgent3D waypoint following; steer only toward the
server goal in xz so plateau paths are not dominated by vertical nav
segments. move_and_slide handles slopes and obstacles.
pull/23/head
VinPropane 2026-04-05 01:07:02 -04:00
parent 725bef1133
commit 05d7916cc7
2 changed files with 52 additions and 40 deletions

View File

@ -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 ~3945° (dot UP ~0.710.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:

View File

@ -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()