feat: pathfinding for ghosts, add debug rendering of paths

This commit is contained in:
2025-08-11 15:25:39 -05:00
parent ad2ec35bfb
commit d9c8f97903
4 changed files with 216 additions and 0 deletions

View File

@@ -5,6 +5,7 @@
//! a traverser and display directional animated textures. //! a traverser and display directional animated textures.
use glam::Vec2; use glam::Vec2;
use pathfinding::prelude::dijkstra;
use rand::prelude::*; use rand::prelude::*;
use smallvec::SmallVec; 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) 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<Vec<NodeId>> {
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::<Vec<_>>()
},
|&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. /// Renders the ghost at its current position.
/// ///
/// Draws the appropriate directional sprite based on the ghost's /// Draws the appropriate directional sprite based on the ghost's

View File

@@ -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. /// Renders Pac-Man to the canvas.
/// ///
/// Calculates screen position, determines if Pac-Man is stopped, /// Calculates screen position, determines if Pac-Man is stopped,

View File

@@ -173,12 +173,65 @@ impl Game {
if self.debug_mode { if self.debug_mode {
self.map self.map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos); .debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
self.render_pathfinding_debug(canvas)?;
} }
self.draw_hud(canvas)?; self.draw_hud(canvas)?;
canvas.present(); canvas.present();
Ok(()) 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<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> 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<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> { fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
let lives = 3; let lives = 3;
let score_text = format!("{:02}", self.score); let score_text = format!("{:02}", self.score);

117
tests/pathfinding.rs Normal file
View File

@@ -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");
}