extends Node3D ## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. ## 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) ## 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 ## Nudge past an occluder body so the ray reaches walkable ground behind it (meters along view ray). const OCCLUDER_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 func _ready() -> void: set_process_input(true) func _input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: _try_pick(get_viewport().get_mouse_position()) func _try_pick(screen_pos: Vector2) -> void: var cam: Camera3D = get_viewport().get_camera_3d() if cam == null: cam = fallback_camera if cam == null: return var ray_dir: Vector3 = cam.project_ray_normal(screen_pos) 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 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 var hit_collider: Variant = hit.get("collider") if _collider_is_occluder(hit_collider): var occluder_pos: Variant = hit.get("position") if occluder_pos is not Vector3: break from = (occluder_pos as Vector3) + ray_dir * OCCLUDER_PICK_THROUGH continue if not _collider_is_walkable(hit_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: var n: Node = collider as Node while n: if n.is_in_group("walkable"): return true n = n.get_parent() return false func _collider_is_occluder(collider: Variant) -> bool: var n: Node = collider as Node while n: if n.is_in_group("occluder"): return true n = n.get_parent() return false