From d9c8f97903d15bc4229f61af12855170df37303e Mon Sep 17 00:00:00 2001 From: Xevion Date: Mon, 11 Aug 2025 15:25:39 -0500 Subject: [PATCH] feat: pathfinding for ghosts, add debug rendering of paths --- src/entity/ghost.rs | 35 +++++++++++++ src/entity/pacman.rs | 11 ++++ src/game.rs | 53 ++++++++++++++++++++ tests/pathfinding.rs | 117 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 tests/pathfinding.rs diff --git a/src/entity/ghost.rs b/src/entity/ghost.rs index 24b551a..43a5c5e 100644 --- a/src/entity/ghost.rs +++ b/src/entity/ghost.rs @@ -5,6 +5,7 @@ //! a traverser and display directional animated textures. use glam::Vec2; +use pathfinding::prelude::dijkstra; use rand::prelude::*; use smallvec::SmallVec; @@ -175,6 +176,40 @@ impl Ghost { Vec2::new(pos.x + BOARD_PIXEL_OFFSET.x as f32, pos.y + BOARD_PIXEL_OFFSET.y as f32) } + /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. + /// + /// Returns a vector of NodeIds representing the path, or None if no path exists. + /// The path includes the current node and the target node. + pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> Option> { + let start_node = self.traverser.position.from_node_id(); + + // Use Dijkstra's algorithm to find the shortest path + let result = dijkstra( + &start_node, + |&node_id| { + // Get all edges from the current node + graph.adjacency_list[node_id] + .edges() + .filter(|edge| can_ghost_traverse(*edge)) + .map(|edge| (edge.target, (edge.distance * 100.0) as u32)) + .collect::>() + }, + |&node_id| node_id == target, + ); + + result.map(|(path, _cost)| path) + } + + /// Returns the ghost's color for debug rendering. + pub fn debug_color(&self) -> sdl2::pixels::Color { + match self.ghost_type { + GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red + GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink + GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan + GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange + } + } + /// Renders the ghost at its current position. /// /// Draws the appropriate directional sprite based on the ghost's diff --git a/src/entity/pacman.rs b/src/entity/pacman.rs index ac707e2..5e280b9 100644 --- a/src/entity/pacman.rs +++ b/src/entity/pacman.rs @@ -111,6 +111,17 @@ impl Pacman { } } + /// Returns the current node ID that Pac-Man is at or moving towards. + /// + /// If Pac-Man is at a node, returns that node ID. + /// If Pac-Man is between nodes, returns the node it's moving towards. + pub fn current_node_id(&self) -> NodeId { + match self.traverser.position { + Position::AtNode(node_id) => node_id, + Position::BetweenNodes { to, .. } => to, + } + } + /// Renders Pac-Man to the canvas. /// /// Calculates screen position, determines if Pac-Man is stopped, diff --git a/src/game.rs b/src/game.rs index 8e1e9f8..bc12f00 100644 --- a/src/game.rs +++ b/src/game.rs @@ -173,12 +173,65 @@ impl Game { if self.debug_mode { self.map .debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos); + self.render_pathfinding_debug(canvas)?; } self.draw_hud(canvas)?; canvas.present(); Ok(()) } + /// Renders pathfinding debug lines from each ghost to Pac-Man. + /// + /// Each ghost's path is drawn in its respective color with a small offset + /// to prevent overlapping lines. + fn render_pathfinding_debug(&self, canvas: &mut Canvas) -> Result<()> { + let pacman_node = self.pacman.current_node_id(); + + for (i, ghost) in self.ghosts.iter().enumerate() { + if let Some(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) { + if path.len() < 2 { + continue; // Skip if path is too short + } + + // Set the ghost's color + canvas.set_draw_color(ghost.debug_color()); + + // Calculate offset based on ghost index to prevent overlapping lines + let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0 + + // Calculate a consistent offset direction for the entire path + let first_node = self.map.graph.get_node(path[0]).unwrap(); + let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap(); + let first_pos = first_node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); + let last_pos = last_node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); + + // Use the overall direction from start to end to determine the perpendicular offset + let overall_dir = (last_pos - first_pos).normalize(); + let perp_dir = glam::Vec2::new(-overall_dir.y, overall_dir.x); + + // Calculate offset positions for all nodes using the same perpendicular direction + let mut offset_positions = Vec::new(); + for &node_id in &path { + let node = self.map.graph.get_node(node_id).unwrap(); + let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); + offset_positions.push(pos + perp_dir * offset); + } + + // Draw lines between the offset positions + for window in offset_positions.windows(2) { + canvas + .draw_line( + (window[0].x as i32, window[0].y as i32), + (window[1].x as i32, window[1].y as i32), + ) + .map_err(anyhow::Error::msg)?; + } + } + } + + Ok(()) + } + fn draw_hud(&mut self, canvas: &mut Canvas) -> Result<()> { let lives = 3; let score_text = format!("{:02}", self.score); diff --git a/tests/pathfinding.rs b/tests/pathfinding.rs new file mode 100644 index 0000000..050ab40 --- /dev/null +++ b/tests/pathfinding.rs @@ -0,0 +1,117 @@ +use pacman::entity::direction::Direction; +use pacman::entity::ghost::{Ghost, GhostType}; +use pacman::entity::graph::{Graph, Node}; +use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; +use std::collections::HashMap; + +fn create_test_atlas() -> SpriteAtlas { + let mut frames = HashMap::new(); + let directions = ["up", "down", "left", "right"]; + let ghost_types = ["blinky", "pinky", "inky", "clyde"]; + + for ghost_type in &ghost_types { + for (i, dir) in directions.iter().enumerate() { + frames.insert( + format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"), + MapperFrame { + x: i as u16 * 16, + y: 0, + width: 16, + height: 16, + }, + ); + frames.insert( + format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"), + MapperFrame { + x: i as u16 * 16, + y: 16, + width: 16, + height: 16, + }, + ); + } + } + + let mapper = AtlasMapper { frames }; + let dummy_texture = unsafe { std::mem::zeroed() }; + SpriteAtlas::new(dummy_texture, mapper) +} + +#[test] +fn test_ghost_pathfinding() { + // Create a simple test graph + let mut graph = Graph::new(); + + // Add nodes in a simple line: 0 -> 1 -> 2 + let node0 = graph.add_node(Node { + position: glam::Vec2::new(0.0, 0.0), + }); + let node1 = graph.add_node(Node { + position: glam::Vec2::new(10.0, 0.0), + }); + let node2 = graph.add_node(Node { + position: glam::Vec2::new(20.0, 0.0), + }); + + // Connect the nodes + graph.connect(node0, node1, false, None, Direction::Right).unwrap(); + graph.connect(node1, node2, false, None, Direction::Right).unwrap(); + + // Create a test atlas for the ghost + let atlas = create_test_atlas(); + + // Create a ghost at node 0 + let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); + + // Test pathfinding from node 0 to node 2 + let path = ghost.calculate_path_to_target(&graph, node2); + + assert!(path.is_some()); + let path = path.unwrap(); + assert_eq!(path, vec![node0, node1, node2]); +} + +#[test] +fn test_ghost_pathfinding_no_path() { + // Create a test graph with disconnected components + let mut graph = Graph::new(); + + let node0 = graph.add_node(Node { + position: glam::Vec2::new(0.0, 0.0), + }); + let node1 = graph.add_node(Node { + position: glam::Vec2::new(10.0, 0.0), + }); + + // Don't connect the nodes + let atlas = create_test_atlas(); + let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); + + // Test pathfinding when no path exists + let path = ghost.calculate_path_to_target(&graph, node1); + + assert!(path.is_none()); +} + +#[test] +fn test_ghost_debug_colors() { + let atlas = create_test_atlas(); + let mut graph = Graph::new(); + let node = graph.add_node(Node { + position: glam::Vec2::new(0.0, 0.0), + }); + + let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas); + let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas); + let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas); + let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas); + + // Test that each ghost has a different debug color + let colors = std::collections::HashSet::from([ + blinky.debug_color(), + pinky.debug_color(), + inky.debug_color(), + clyde.debug_color(), + ]); + assert_eq!(colors.len(), 4, "All ghost colors should be unique"); +}