Path: blob/main/crates/bevy_input_focus/src/navigator.rs
9395 views
//! Functions used by navigators to determine where to go next.1use crate::directional_navigation::{AutoNavigationConfig, FocusableArea};2use bevy_ecs::prelude::*;3use bevy_math::{CompassOctant, Dir2, Rect, Vec2};45// We can't directly implement this for `bevy_ui` types here without circular dependencies,6// so we'll use a more generic approach with separate functions for different component sets.78/// Calculate 1D overlap between two ranges.9///10/// Returns a value between 0.0 (no overlap) and 1.0 (perfect overlap).11fn calculate_1d_overlap(12origin_pos: f32,13origin_size: f32,14candidate_pos: f32,15candidate_size: f32,16) -> f32 {17let origin_min = origin_pos - origin_size / 2.0;18let origin_max = origin_pos + origin_size / 2.0;19let cand_min = candidate_pos - candidate_size / 2.0;20let cand_max = candidate_pos + candidate_size / 2.0;2122let overlap = (origin_max.min(cand_max) - origin_min.max(cand_min)).max(0.0);23let max_overlap = origin_size.min(candidate_size);24if max_overlap > 0.0 {25overlap / max_overlap26} else {270.028}29}3031/// Calculate the overlap factor between two nodes in the perpendicular axis.32///33/// Returns a value between 0.0 (no overlap) and 1.0 (perfect overlap).34/// For diagonal directions, always returns 1.0.35fn calculate_overlap(36origin_pos: Vec2,37origin_size: Vec2,38candidate_pos: Vec2,39candidate_size: Vec2,40octant: CompassOctant,41) -> f32 {42match octant {43CompassOctant::North | CompassOctant::South => {44// Check horizontal overlap45calculate_1d_overlap(46origin_pos.x,47origin_size.x,48candidate_pos.x,49candidate_size.x,50)51}52CompassOctant::East | CompassOctant::West => {53// Check vertical overlap54calculate_1d_overlap(55origin_pos.y,56origin_size.y,57candidate_pos.y,58candidate_size.y,59)60}61// Diagonal directions don't require strict overlap62_ => 1.0,63}64}6566/// Score a candidate node for navigation in a given direction.67///68/// Lower score is better. Returns `f32::INFINITY` for unreachable nodes.69fn score_candidate(70origin_pos: Vec2,71origin_size: Vec2,72candidate_pos: Vec2,73candidate_size: Vec2,74octant: CompassOctant,75config: &AutoNavigationConfig,76) -> f32 {77// Get direction in mathematical coordinates, then flip Y for UI coordinates78let dir = Dir2::from(octant).as_vec2() * Vec2::new(1.0, -1.0);79let to_candidate = candidate_pos - origin_pos;8081// Check direction first82// Convert UI coordinates (Y+ = down) to mathematical coordinates (Y+ = up) by flipping Y83let origin_math = Vec2::new(origin_pos.x, -origin_pos.y);84let candidate_math = Vec2::new(candidate_pos.x, -candidate_pos.y);85if !octant.is_in_direction(origin_math, candidate_math) {86return f32::INFINITY;87}8889// Check overlap for cardinal directions90let overlap_factor = calculate_overlap(91origin_pos,92origin_size,93candidate_pos,94candidate_size,95octant,96);9798if overlap_factor < config.min_alignment_factor {99return f32::INFINITY;100}101102// Calculate distance between rectangle edges, not centers103let origin_rect = Rect::from_center_size(origin_pos, origin_size);104let candidate_rect = Rect::from_center_size(candidate_pos, candidate_size);105let dx = (candidate_rect.min.x - origin_rect.max.x)106.max(origin_rect.min.x - candidate_rect.max.x)107.max(0.0);108let dy = (candidate_rect.min.y - origin_rect.max.y)109.max(origin_rect.min.y - candidate_rect.max.y)110.max(0.0);111let distance = (dx * dx + dy * dy).sqrt();112113// Check max distance114if let Some(max_dist) = config.max_search_distance {115if distance > max_dist {116return f32::INFINITY;117}118}119120// Calculate alignment score using center-to-center direction121let center_distance = to_candidate.length();122let alignment = if center_distance > 0.0 {123to_candidate.normalize().dot(dir).max(0.0)124} else {1251.0126};127128// Combine distance and alignment129// Prefer aligned nodes by penalizing misalignment130let alignment_penalty = if config.prefer_aligned {131(1.0 - alignment) * distance * 2.0 // Misalignment scales with distance132} else {1330.0134};135136distance + alignment_penalty137}138139/// Finds the best entity to navigate to from the origin towards the given direction.140///141/// For details on what "best" means here, refer to [`AutoNavigationConfig`], which configures142/// how candidates are scored.143pub fn find_best_candidate(144origin: &FocusableArea,145direction: CompassOctant,146candidates: &[FocusableArea],147config: &AutoNavigationConfig,148) -> Option<Entity> {149// Find best candidate in this direction150let mut best_candidate = None;151let mut best_score = f32::INFINITY;152153for candidate in candidates {154// Skip self155if candidate.entity == origin.entity {156continue;157}158159// Score the candidate160let score = score_candidate(161origin.position,162origin.size,163candidate.position,164candidate.size,165direction,166config,167);168169if score < best_score {170best_score = score;171best_candidate = Some(candidate.entity);172}173}174175best_candidate176}177178#[cfg(test)]179mod tests {180use super::*;181182#[test]183fn test_is_in_direction() {184let origin = Vec2::new(100.0, 100.0);185186// Node to the north (mathematically up) should have larger Y187let north_node = Vec2::new(100.0, 150.0);188assert!(CompassOctant::North.is_in_direction(origin, north_node));189assert!(!CompassOctant::South.is_in_direction(origin, north_node));190191// Node to the south (mathematically down) should have smaller Y192let south_node = Vec2::new(100.0, 50.0);193assert!(CompassOctant::South.is_in_direction(origin, south_node));194assert!(!CompassOctant::North.is_in_direction(origin, south_node));195196// Node to the east should be in East direction197let east_node = Vec2::new(150.0, 100.0);198assert!(CompassOctant::East.is_in_direction(origin, east_node));199assert!(!CompassOctant::West.is_in_direction(origin, east_node));200201// Node to the northeast (mathematically up-right) should have larger Y, larger X202let ne_node = Vec2::new(150.0, 150.0);203assert!(CompassOctant::NorthEast.is_in_direction(origin, ne_node));204assert!(!CompassOctant::SouthWest.is_in_direction(origin, ne_node));205}206207#[test]208fn test_calculate_overlap_horizontal() {209let origin_pos = Vec2::new(100.0, 100.0);210let origin_size = Vec2::new(50.0, 50.0);211212// Fully overlapping node to the north213let north_pos = Vec2::new(100.0, 200.0);214let north_size = Vec2::new(50.0, 50.0);215let overlap = calculate_overlap(216origin_pos,217origin_size,218north_pos,219north_size,220CompassOctant::North,221);222assert_eq!(overlap, 1.0); // Full overlap223224// Partially overlapping node to the north225let north_pos = Vec2::new(110.0, 200.0);226let partial_overlap = calculate_overlap(227origin_pos,228origin_size,229north_pos,230north_size,231CompassOctant::North,232);233assert!(partial_overlap > 0.0 && partial_overlap < 1.0);234235// No overlap236let north_pos = Vec2::new(200.0, 200.0);237let no_overlap = calculate_overlap(238origin_pos,239origin_size,240north_pos,241north_size,242CompassOctant::North,243);244assert_eq!(no_overlap, 0.0);245}246247#[test]248fn test_score_candidate() {249let config = AutoNavigationConfig::default();250let origin_pos = Vec2::new(100.0, 100.0);251let origin_size = Vec2::new(50.0, 50.0);252253// Node directly to the north (up on screen = smaller Y)254let north_pos = Vec2::new(100.0, 0.0);255let north_size = Vec2::new(50.0, 50.0);256let north_score = score_candidate(257origin_pos,258origin_size,259north_pos,260north_size,261CompassOctant::North,262&config,263);264assert!(north_score < f32::INFINITY);265assert!(north_score < 150.0); // Should be close to the distance (100)266267// Node in opposite direction (should be unreachable)268let south_pos = Vec2::new(100.0, 200.0);269let south_size = Vec2::new(50.0, 50.0);270let invalid_score = score_candidate(271origin_pos,272origin_size,273south_pos,274south_size,275CompassOctant::North,276&config,277);278assert_eq!(invalid_score, f32::INFINITY);279280// Closer node should have better score than farther node281let close_pos = Vec2::new(100.0, 50.0);282let far_pos = Vec2::new(100.0, -100.0);283let close_score = score_candidate(284origin_pos,285origin_size,286close_pos,287north_size,288CompassOctant::North,289&config,290);291let far_score = score_candidate(292origin_pos,293origin_size,294far_pos,295north_size,296CompassOctant::North,297&config,298);299assert!(close_score < far_score);300}301}302303304