diff --git a/src/builder.rs b/src/builder.rs index 169c7ea..3a7c798 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,7 +1,4 @@ -use crate::{ - generator::{generate_backtracking, GeneratorType}, - HexMaze, -}; +use crate::{GeneratorType, HexMaze}; use hexx::Hex; use thiserror::Error; @@ -186,21 +183,14 @@ impl MazeBuilder { } if !maze.is_empty() { - self.generate_maze(&mut maze); + self.generator_type + .generate(&mut maze, self.start_position, self.seed); } Ok(maze) } - - fn generate_maze(&self, maze: &mut HexMaze) { - match self.generator_type { - GeneratorType::RecursiveBacktracking => { - generate_backtracking(maze, self.start_position, self.seed); - } - } - } } -fn create_hex_maze(radius: u16) -> HexMaze { +pub(crate) fn create_hex_maze(radius: u16) -> HexMaze { let mut maze = HexMaze::new(); let radius = i32::from(radius); diff --git a/src/generator.rs b/src/generator.rs deleted file mode 100644 index 082787a..0000000 --- a/src/generator.rs +++ /dev/null @@ -1,55 +0,0 @@ -#[cfg(feature = "bevy_reflect")] -use bevy::prelude::*; -use hexx::{EdgeDirection, Hex}; -use rand::{rngs::StdRng, seq::SliceRandom, thread_rng, Rng, RngCore, SeedableRng}; -use std::collections::HashSet; - -use crate::HexMaze; - -#[allow(clippy::module_name_repetitions)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "bevy_reflect", derive(Reflect))] -#[cfg_attr(feature = "bevy", derive(Component))] -#[cfg_attr(feature = "bevy", reflect(Component))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum GeneratorType { - #[default] - RecursiveBacktracking, -} - -pub fn generate_backtracking(maze: &mut HexMaze, start_pos: Option, seed: Option) { - if maze.is_empty() { - return; - } - - let start = start_pos.unwrap_or(Hex::ZERO); - - let mut visited = HashSet::new(); - - let mut rng: Box = seed.map_or_else( - || Box::new(thread_rng()) as Box, - |seed| Box::new(StdRng::seed_from_u64(seed)) as Box, - ); - - recursive_backtrack(maze, start, &mut visited, &mut rng); -} - -fn recursive_backtrack( - maze: &mut HexMaze, - current: Hex, - visited: &mut HashSet, - rng: &mut R, -) { - visited.insert(current); - let mut directions = EdgeDirection::ALL_DIRECTIONS; - directions.shuffle(rng); - - for direction in directions { - let neighbor = current + direction; - if maze.get_tile(&neighbor).is_some() && !visited.contains(&neighbor) { - maze.remove_tile_wall(¤t, direction); - maze.remove_tile_wall(&neighbor, direction.const_neg()); - recursive_backtrack(maze, neighbor, visited, rng); - } - } -} diff --git a/src/generator/backtrack.rs b/src/generator/backtrack.rs new file mode 100644 index 0000000..e59c045 --- /dev/null +++ b/src/generator/backtrack.rs @@ -0,0 +1,111 @@ +use crate::HexMaze; +use hexx::{EdgeDirection, Hex}; +use rand::{rngs::StdRng, seq::SliceRandom, thread_rng, Rng, RngCore, SeedableRng}; +use std::collections::HashSet; + +pub(super) fn generate_backtracking(maze: &mut HexMaze, start_pos: Option, seed: Option) { + if maze.is_empty() { + return; + } + + let start = start_pos.unwrap_or(Hex::ZERO); + + let mut visited = HashSet::new(); + + let mut rng: Box = seed.map_or_else( + || Box::new(thread_rng()) as Box, + |seed| Box::new(StdRng::seed_from_u64(seed)) as Box, + ); + + recursive_backtrack(maze, start, &mut visited, &mut rng); +} + +fn recursive_backtrack( + maze: &mut HexMaze, + current: Hex, + visited: &mut HashSet, + rng: &mut R, +) { + visited.insert(current); + let mut directions = EdgeDirection::ALL_DIRECTIONS; + directions.shuffle(rng); + + for direction in directions { + let neighbor = current + direction; + if maze.get_tile(&neighbor).is_some() && !visited.contains(&neighbor) { + maze.remove_tile_wall(¤t, direction); + maze.remove_tile_wall(&neighbor, direction.const_neg()); + recursive_backtrack(maze, neighbor, visited, rng); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::builder::create_hex_maze; + use rstest::rstest; + + #[rstest] + #[case(Hex::ZERO)] + #[case(Hex::new(1, -1))] + #[case(Hex::new(-2, 2))] + fn recursive_backtrack_start_visited(#[case] start: Hex) { + let mut maze = create_hex_maze(3); + let mut rng = StdRng::seed_from_u64(12345); + let mut visited = HashSet::new(); + + recursive_backtrack(&mut maze, start, &mut visited, &mut rng); + + assert!(visited.contains(&start), "Start position should be visited"); + } + + #[rstest] + #[case(Hex::ZERO)] + #[case(Hex::new(1, -1))] + #[case(Hex::new(-2, 2))] + fn recursive_backtrack_walls_removed(#[case] start: Hex) { + let mut maze = create_hex_maze(3); + let mut rng = StdRng::seed_from_u64(12345); + let mut visited = HashSet::new(); + + recursive_backtrack(&mut maze, start, &mut visited, &mut rng); + + for &pos in maze.keys() { + let walls = maze.get_walls(&pos).unwrap(); + assert!( + walls.count() < 6, + "At least one wall should be removed for each tile" + ); + } + } + + #[rstest] + #[case(Hex::ZERO)] + #[case(Hex::new(1, -1))] + #[case(Hex::new(-2, 2))] + fn recursive_backtrack_connectivity(#[case] start: Hex) { + let mut maze = create_hex_maze(3); + let mut rng = StdRng::seed_from_u64(12345); + let mut visited = HashSet::new(); + + recursive_backtrack(&mut maze, start, &mut visited, &mut rng); + + let mut to_visit = vec![start]; + let mut connected = HashSet::new(); + while let Some(current) = to_visit.pop() { + if !connected.insert(current) { + continue; + } + for dir in EdgeDirection::ALL_DIRECTIONS { + let neighbor = current + dir; + if let Some(walls) = maze.get_walls(¤t) { + if !walls.contains(dir) && maze.get_tile(&neighbor).is_some() { + to_visit.push(neighbor); + } + } + } + } + assert_eq!(connected.len(), maze.len(), "All tiles should be connected"); + } +} diff --git a/src/generator/mod.rs b/src/generator/mod.rs new file mode 100644 index 0000000..771af0c --- /dev/null +++ b/src/generator/mod.rs @@ -0,0 +1,24 @@ +mod backtrack; +use crate::HexMaze; +use backtrack::generate_backtracking; +#[cfg(feature = "bevy_reflect")] +use bevy::prelude::*; +use hexx::Hex; + +#[allow(clippy::module_name_repetitions)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "bevy_reflect", derive(Reflect))] +#[cfg_attr(feature = "bevy", derive(Component))] +#[cfg_attr(feature = "bevy", reflect(Component))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GeneratorType { + #[default] + RecursiveBacktracking, +} +impl GeneratorType { + pub fn generate(&self, maze: &mut HexMaze, start_pos: Option, seed: Option) { + match self { + Self::RecursiveBacktracking => generate_backtracking(maze, start_pos, seed), + } + } +} diff --git a/tests/generator.rs b/tests/generator.rs new file mode 100644 index 0000000..b823266 --- /dev/null +++ b/tests/generator.rs @@ -0,0 +1,66 @@ +use hexlab::prelude::*; +use rstest::rstest; + +#[rstest] +#[case(GeneratorType::RecursiveBacktracking, None, None)] +#[case(GeneratorType::RecursiveBacktracking, Some(Hex::new(1, -1)), None)] +#[case(GeneratorType::RecursiveBacktracking, None, Some(12345))] +fn generator_type( + #[case] generator: GeneratorType, + #[case] start_pos: Option, + #[case] seed: Option, +) { + let mut maze = HexMaze::new(); + for q in -3..=3 { + for r in -3..=3 { + let hex = Hex::new(q, r); + if hex.length() <= 3 { + maze.add_tile(hex); + } + } + } + let initial_size = maze.len(); + + generator.generate(&mut maze, start_pos, seed); + + assert_eq!(maze.len(), initial_size, "Maze size should not change"); + + // Check maze connectivity + let start = start_pos.unwrap_or(Hex::ZERO); + let mut to_visit = vec![start]; + let mut visited = std::collections::HashSet::new(); + while let Some(current) = to_visit.pop() { + if !visited.insert(current) { + continue; + } + for dir in EdgeDirection::ALL_DIRECTIONS { + let neighbor = current + dir; + if let Some(walls) = maze.get_walls(¤t) { + if !walls.contains(dir) && maze.get_tile(&neighbor).is_some() { + to_visit.push(neighbor); + } + } + } + } + assert_eq!(visited.len(), maze.len(), "All tiles should be connected"); + + // Check that each tile has at least one open wall + for &pos in maze.keys() { + let walls = maze.get_walls(&pos).unwrap(); + assert!( + walls.count() < 6, + "Tile at {:?} should have at least one open wall", + pos + ); + } +} + +#[test] +fn test_empty_maze() { + let mut maze = HexMaze::new(); + GeneratorType::RecursiveBacktracking.generate(&mut maze, None, None); + assert!( + maze.is_empty(), + "Empty maze should remain empty after generation" + ); +}