mirror of
https://github.com/kristoferssolo/hexlab.git
synced 2025-10-21 19:40:34 +00:00
404 lines
10 KiB
Rust
404 lines
10 KiB
Rust
use crate::{
|
|
generator::{generate_backtracking, GeneratorType},
|
|
HexMaze,
|
|
};
|
|
use hexx::Hex;
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum MazeBuilderError {
|
|
/// Occurs when attempting to build a maze without specifying a radius.
|
|
#[error("Radius must be specified to build a maze")]
|
|
NoRadius,
|
|
|
|
/// Occurs when the specified radius is too large.
|
|
#[error("Radius {0} is too large. Maximum allowed radius is {1}")]
|
|
RadiusTooLarge(u32, u32),
|
|
|
|
/// Occurs when the specified start position is outside the maze bounds.
|
|
#[error("Start position {0:?} is outside maze bounds")]
|
|
InvalidStartPosition(Hex),
|
|
|
|
/// Occurs when maze generation fails.
|
|
#[error("Failed to generate maze: {0}")]
|
|
GenerationError(String),
|
|
}
|
|
|
|
/**
|
|
A builder pattern for creating hexagonal mazes.
|
|
|
|
This struct provides a fluent interface for configuring and building hexagonal mazes.
|
|
It offers flexibility in specifying the maze size, random seed, and generation algorithm.
|
|
|
|
# Examples
|
|
|
|
Basic usage:
|
|
```
|
|
use hexlab::prelude::*;
|
|
|
|
let maze = MazeBuilder::new()
|
|
.with_radius(5)
|
|
.build()
|
|
.expect("Failed to create maze");
|
|
|
|
// A radius of 5 creates 61 hexagonal tiles
|
|
assert!(!maze.is_empty());
|
|
assert_eq!(maze.len(), 91);
|
|
```
|
|
|
|
Using a seed for reproducible results:
|
|
```
|
|
use hexlab::prelude::*;
|
|
|
|
let maze1 = MazeBuilder::new()
|
|
.with_radius(3)
|
|
.with_seed(12345)
|
|
.build()
|
|
.expect("Failed to create maze");
|
|
|
|
let maze2 = MazeBuilder::new()
|
|
.with_radius(3)
|
|
.with_seed(12345)
|
|
.build()
|
|
.expect("Failed to create maze");
|
|
|
|
// Same seed should produce identical mazes
|
|
assert_eq!(maze1.len(), maze2.len());
|
|
assert_eq!(maze1, maze2);
|
|
```
|
|
|
|
Specifying a custom generator:
|
|
```
|
|
use hexlab::prelude::*;
|
|
|
|
let maze = MazeBuilder::new()
|
|
.with_radius(7)
|
|
.with_generator(GeneratorType::RecursiveBacktracking)
|
|
.build()
|
|
.expect("Failed to create maze");
|
|
```
|
|
*/
|
|
#[allow(clippy::module_name_repetitions)]
|
|
#[derive(Default)]
|
|
pub struct MazeBuilder {
|
|
radius: Option<u32>,
|
|
seed: Option<u64>,
|
|
generator_type: GeneratorType,
|
|
start_position: Option<Hex>,
|
|
}
|
|
|
|
impl MazeBuilder {
|
|
/// Creates a new [`MazeBuilder`] instance.
|
|
#[inline]
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Sets the radius for the hexagonal maze.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `radius` - The size of the maze (number of tiles along one edge).
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn with_radius(mut self, radius: u32) -> Self {
|
|
self.radius = Some(radius);
|
|
self
|
|
}
|
|
|
|
/// Sets the random seed for maze generation.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `seed` - The random seed value.
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn with_seed(mut self, seed: u64) -> Self {
|
|
self.seed = Some(seed);
|
|
self
|
|
}
|
|
|
|
/// Sets the generator algorithm for maze creation.
|
|
///
|
|
/// Different generators may produce different maze patterns and characteristics.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `generator_type` - The maze generation algorithm to use.
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn with_generator(mut self, generator_type: GeneratorType) -> Self {
|
|
self.generator_type = generator_type;
|
|
self
|
|
}
|
|
|
|
#[inline]
|
|
#[must_use]
|
|
pub const fn with_start_position(mut self, pos: Hex) -> Self {
|
|
self.start_position = Some(pos);
|
|
self
|
|
}
|
|
|
|
/**
|
|
Builds the hexagonal maze based on the configured parameters.
|
|
|
|
# Errors
|
|
|
|
Returns [`MazeBuilderError::NoRadius`] if no radius is specified.
|
|
Returns [`MazeBuilderError::InvalidStartPosition`] if the start position is outside maze bounds.
|
|
|
|
# Examples
|
|
|
|
```
|
|
use hexlab::prelude::*;
|
|
|
|
// Should fail without radius
|
|
let result = MazeBuilder::new().build();
|
|
assert!(result.is_err());
|
|
|
|
// Should succeed with radius
|
|
let result = MazeBuilder::new()
|
|
.with_radius(3)
|
|
.build();
|
|
assert!(result.is_ok());
|
|
|
|
let maze = result.unwrap();
|
|
assert!(!maze.is_empty());
|
|
```
|
|
*/
|
|
pub fn build(self) -> Result<HexMaze, MazeBuilderError> {
|
|
let radius = self.radius.ok_or(MazeBuilderError::NoRadius)?;
|
|
let mut maze = create_hex_maze(radius);
|
|
|
|
if let Some(start_pos) = self.start_position {
|
|
if maze.get_tile(&start_pos).is_none() {
|
|
return Err(MazeBuilderError::InvalidStartPosition(start_pos));
|
|
}
|
|
}
|
|
|
|
if !maze.is_empty() {
|
|
self.generate_maze(&mut maze);
|
|
}
|
|
|
|
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: u32) -> HexMaze {
|
|
let mut maze = HexMaze::new();
|
|
let radius = i32::try_from(radius).unwrap_or(5);
|
|
|
|
for q in -radius..=radius {
|
|
let r1 = (-radius).max(-q - radius);
|
|
let r2 = radius.min(-q + radius);
|
|
for r in r1..=r2 {
|
|
let pos = Hex::new(q, r);
|
|
maze.add_tile(pos);
|
|
}
|
|
}
|
|
|
|
maze
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use hexx::EdgeDirection;
|
|
|
|
use super::*;
|
|
|
|
/// Helper function to count the number of tiles for a given radius
|
|
fn calculate_hex_tiles(radius: u32) -> usize {
|
|
let r = radius as i32;
|
|
(3 * r * r + 3 * r + 1) as usize
|
|
}
|
|
|
|
#[test]
|
|
fn new_builder() {
|
|
let builder = MazeBuilder::new();
|
|
assert!(builder.radius.is_none());
|
|
assert!(builder.seed.is_none());
|
|
assert!(builder.start_position.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn builder_with_radius() {
|
|
let radius = 5;
|
|
let maze = MazeBuilder::new().with_radius(radius).build().unwrap();
|
|
|
|
assert_eq!(maze.len(), calculate_hex_tiles(radius));
|
|
assert!(maze.get_tile(&Hex::ZERO).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn builder_without_radius() {
|
|
let maze = MazeBuilder::new().build();
|
|
assert!(matches!(maze, Err(MazeBuilderError::NoRadius)));
|
|
}
|
|
|
|
#[test]
|
|
fn builder_with_seed() {
|
|
let radius = 3;
|
|
let seed = 12345;
|
|
|
|
let maze1 = MazeBuilder::new()
|
|
.with_radius(radius)
|
|
.with_seed(seed)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let maze2 = MazeBuilder::new()
|
|
.with_radius(radius)
|
|
.with_seed(seed)
|
|
.build()
|
|
.unwrap();
|
|
|
|
// Same seed should produce identical mazes
|
|
assert_eq!(maze1, maze2);
|
|
}
|
|
|
|
#[test]
|
|
fn different_seeds_produce_different_mazes() {
|
|
let radius = 3;
|
|
|
|
let maze1 = MazeBuilder::new()
|
|
.with_radius(radius)
|
|
.with_seed(12345)
|
|
.build()
|
|
.unwrap();
|
|
|
|
let maze2 = MazeBuilder::new()
|
|
.with_radius(radius)
|
|
.with_seed(54321)
|
|
.build()
|
|
.unwrap();
|
|
|
|
// Different seeds should produce different mazes
|
|
assert_ne!(maze1, maze2);
|
|
}
|
|
|
|
#[test]
|
|
fn maze_connectivity() {
|
|
let radius = 3;
|
|
let maze = MazeBuilder::new().with_radius(radius).build().unwrap();
|
|
|
|
// Helper function to count accessible neighbors
|
|
fn count_accessible_neighbors(maze: &HexMaze, pos: Hex) -> usize {
|
|
EdgeDirection::ALL_DIRECTIONS
|
|
.iter()
|
|
.filter(|&&dir| {
|
|
let neighbor = pos + dir;
|
|
if let Some(walls) = maze.get_walls(&pos) {
|
|
!walls.contains(dir) && maze.get_tile(&neighbor).is_some()
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.count()
|
|
}
|
|
|
|
// Check that each tile has at least one connection
|
|
for &pos in maze.keys() {
|
|
let accessible_neighbors = count_accessible_neighbors(&maze, pos);
|
|
assert!(
|
|
accessible_neighbors > 0,
|
|
"Tile at {:?} has no accessible neighbors",
|
|
pos
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn start_position() {
|
|
let radius = 3;
|
|
let start_pos = Hex::new(1, 1);
|
|
|
|
let maze = MazeBuilder::new()
|
|
.with_radius(radius)
|
|
.with_start_position(start_pos)
|
|
.build()
|
|
.unwrap();
|
|
|
|
assert!(maze.get_tile(&start_pos).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_start_position() {
|
|
let maze = MazeBuilder::new()
|
|
.with_radius(3)
|
|
.with_start_position(Hex::new(10, 10))
|
|
.build();
|
|
|
|
assert!(matches!(
|
|
maze,
|
|
Err(MazeBuilderError::InvalidStartPosition(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn maze_boundaries() {
|
|
let radius = 3;
|
|
let maze = MazeBuilder::new().with_radius(radius).build().unwrap();
|
|
|
|
// Test that tiles exist within the radius
|
|
for q in -(radius as i32)..=(radius as i32) {
|
|
for r in -(radius as i32)..=(radius as i32) {
|
|
let pos = Hex::new(q, r);
|
|
if q.abs() + r.abs() <= radius as i32 {
|
|
assert!(
|
|
maze.get_tile(&pos).is_some(),
|
|
"Expected tile at {:?} to exist",
|
|
pos
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn different_radii() {
|
|
for radius in 1..=5 {
|
|
let maze = MazeBuilder::new().with_radius(radius).build().unwrap();
|
|
|
|
assert_eq!(
|
|
maze.len(),
|
|
calculate_hex_tiles(radius),
|
|
"Incorrect number of tiles for radius {}",
|
|
radius
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wall_consistency() {
|
|
let radius = 3;
|
|
let maze = MazeBuilder::new().with_radius(radius).build().unwrap();
|
|
|
|
// Check that if tile A has no wall to tile B,
|
|
// then tile B has no wall to tile A
|
|
for &pos in maze.keys() {
|
|
for &dir in &EdgeDirection::ALL_DIRECTIONS {
|
|
let neighbor = pos + dir;
|
|
if let (Some(walls), Some(neighbor_walls)) =
|
|
(maze.get_walls(&pos), maze.get_walls(&neighbor))
|
|
{
|
|
assert_eq!(
|
|
walls.contains(dir),
|
|
neighbor_walls.contains(dir.const_neg()),
|
|
"Wall inconsistency between {:?} and {:?}",
|
|
pos,
|
|
neighbor
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|