feat: add more code examples

This commit is contained in:
2025-01-03 00:24:47 +02:00
parent c4bbd7c88c
commit 84b95fac6a
14 changed files with 654 additions and 245 deletions

View File

@@ -0,0 +1,176 @@
/// 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, generation algorithm,
/// and starting position.
///
/// # 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");
/// ```
#[derive(Default)]
pub struct MazeBuilder {
radius: Option<u16>,
seed: Option<u64>,
generator_type: GeneratorType,
start_position: Option<Hex>,
}
impl MazeBuilder {
/// Creates a new [`MazeBuilder`] instance with default settings.
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets the radius for the hexagonal maze.
///
/// The radius determines the size of the maze, specifically the number of tiles
/// from the center (0,0) to the edge of the hexagon, not including the center tile.
/// For example, a radius of 3 would create a maze with 3 tiles from center to edge,
/// resulting in a total diameter of 7 tiles (3 + 1 + 3).
///
/// # Arguments
///
/// - `radius` - The number of tiles from the center to the edge of the hexagon.
#[inline]
#[must_use]
pub const fn with_radius(mut self, radius: u16) -> Self {
self.radius = Some(radius);
self
}
/// Sets the random seed for maze generation.
///
/// Using the same seed will produce identical mazes, allowing for reproducible results.
///
/// # 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
}
/// Sets the starting position for maze generation.
///
/// # Arguments
///
/// - `pos` - The hexagonal coordinates for the starting position.
#[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<Maze, 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(&start_pos).is_none() {
return Err(MazeBuilderError::InvalidStartPosition(start_pos));
}
}
if !maze.is_empty() {
self.generator_type.generate(
&mut maze,
self.start_position,
self.seed,
);
}
Ok(maze)
}
}

View File

@@ -1,35 +0,0 @@
pub fn generate_backtracking(
maze: &mut HexMaze,
start_pos: Option<Hex>,
seed: Option<u64>,
) {
if maze.is_empty() {
return;
}
let start = start_pos.unwrap_or(Hex::ZERO);
let mut visited = HashSet::new();
let mut rng: Box<dyn RngCore> = seed.map_or_else(
|| Box::new(thread_rng()) as Box<dyn RngCore>,
|seed| Box::new(ChaCha8Rng::seed_from_u64(seed)) as Box<dyn RngCore>,
);
recursive_backtrack(maze, start, &mut visited, &mut rng);
}
fn recursive_backtrack<R: Rng>(
maze: &mut HexMaze,
current: Hex,
visited: &mut HashSet<Hex>,
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(&current, direction);
maze.remove_tile_wall(&neighbor, direction.const_neg());
recursive_backtrack(maze, neighbor, visited, rng);
}
}
}

View File

@@ -1,32 +0,0 @@
#[derive(Default)]
pub struct MazeBuilder {
radius: Option<u32>,
seed: Option<u64>,
generator_type: GeneratorType,
start_position: Option<Hex>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum GeneratorType {
#[default]
RecursiveBacktracking,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HexMaze(HashMap<Hex, HexTile>);
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "bevy", derive(Reflect, Component))]
#[cfg_attr(feature = "bevy", reflect(Component))]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HexTile {
pub(crate) pos: Hex,
pub(crate) walls: Walls,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "bevy", derive(Reflect, Component))]
#[cfg_attr(feature = "bevy", reflect(Component))]
pub struct Walls(u8);

197
assets/code/hexlab/walls.rs Normal file
View File

@@ -0,0 +1,197 @@
/// A bit-flag representation of walls in a hexagonal tile.
///
/// `Walls` uses an efficient bit-flag system to track the presence or absence of walls
/// along each edge of a hexagonal tile. Each of the six possible walls is represented
/// by a single bit in an 8-bit integer, allowing for fast operations and minimal memory usage.
///
/// # Examples
///
/// Creating and manipulating walls:
/// ```
/// use hexlab::prelude::*;
///
/// // Create a hexagon with all walls
/// let walls = Walls::new();
/// assert!(walls.is_enclosed());
///
/// // Create a hexagon with no walls
/// let mut walls = Walls::empty();
/// assert!(walls.is_empty());
///
/// // Add specific walls
/// walls.insert(EdgeDirection::FLAT_NORTH);
/// walls.insert(EdgeDirection::FLAT_SOUTH);
/// assert_eq!(walls.count(), 2);
/// ```
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
#[cfg_attr(feature = "bevy", derive(Component))]
#[cfg_attr(feature = "bevy", reflect(Component))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Walls(u8);
impl Walls {
/// Insert a wall in the specified direction.
///
/// # Arguments
///
/// - `direction` - The direction in which to insert the wall.
///
/// # Returns
///
/// Returns `true` if a wall was present, `false` otherwise.
///
/// # Examples
///
/// ```
/// use hexlab::prelude::*;
///
/// let mut walls = Walls::empty();
/// assert_eq!(walls.count(), 0);
///
/// assert!(!walls.insert(1));
/// assert_eq!(walls.count(), 1);
///
/// assert!(walls.insert(1));
/// assert_eq!(walls.count(), 1);
///
/// assert!(!walls.insert(EdgeDirection::FLAT_NORTH));
/// assert_eq!(walls.count(), 2);
/// ```
#[inline]
pub fn insert<T>(&mut self, direction: T) -> bool
where
T: Into<Self>,
{
let mask = direction.into().0;
let was_present = self.0 & mask != 0;
self.0 |= mask;
was_present
}
/// Removes a wall in the specified direction.
///
/// # Arguments
///
/// - `direction` - The direction from which to remove the wall.
///
/// # Returns
///
/// Returns `true` if a wall was present and removed, `false` otherwise.
///
/// # Examples
///
/// ```
/// use hexlab::prelude::*;
///
/// let mut walls = Walls::new();
///
/// assert!(walls.remove(1));
/// assert_eq!(walls.count(), 5);
///
/// assert!(!walls.remove(1));
/// assert_eq!(walls.count(), 5);
///
/// assert!(walls.remove(EdgeDirection::FLAT_NORTH));
/// assert_eq!(walls.count(), 4);
/// ```
#[inline]
pub fn remove<T>(&mut self, direction: T) -> bool
where
T: Into<Self>,
{
let mask = direction.into().0;
let was_present = self.0 & mask != 0;
self.0 &= !mask;
was_present
}
/// Checks if there is a wall in the specified direction.
///
/// # Arguments
///
/// - `other` - The direction to check for a wall.
///
/// # Examples
///
/// ```
/// use hexlab::prelude::*;
///
/// let mut walls = Walls::empty();
/// walls.insert(EdgeDirection::FLAT_NORTH);
///
/// assert!(walls.contains(EdgeDirection::FLAT_NORTH));
/// assert!(!walls.contains(EdgeDirection::FLAT_SOUTH));
/// ```
#[inline]
pub fn contains<T>(&self, direction: T) -> bool
where
T: Into<Self>,
{
self.0 & direction.into().0 != 0
}
/// Toggles a wall in the specified direction.
///
/// If a wall exists in the given direction, it will be removed.
/// If no wall exists, one will be added.
///
/// # Arguments
///
/// - `direction` - The direction in which to toggle the wall.
///
/// # Returns
///
/// The previous state (`true` if a wall was present before toggling, `false` otherwise).
///
/// # Examples
///
/// ```
/// use hexlab::prelude::*;
///
/// let mut walls = Walls::empty();
///
/// assert!(!walls.toggle(0));
/// assert_eq!(walls.count(), 1);
///
/// assert!(walls.toggle(0));
/// assert_eq!(walls.count(), 0);
/// ```
pub fn toggle<T>(&mut self, direction: T) -> bool
where
T: Into<Self> + Copy,
{
let mask = direction.into().0;
let was_present = self.0 & mask != 0;
self.0 ^= mask;
was_present
}
}
impl From<EdgeDirection> for Walls {
fn from(value: EdgeDirection) -> Self {
Self(1 << value.index())
}
}
impl From<u8> for Walls {
fn from(value: u8) -> Self {
Self(1 << value)
}
}
impl FromIterator<EdgeDirection> for Walls {
fn from_iter<T: IntoIterator<Item = EdgeDirection>>(iter: T) -> Self {
let mut walls = 0u8;
for direction in iter {
walls |= 1 << direction.index();
}
Self(walls)
}
}
impl<const N: usize> From<[EdgeDirection; N]> for Walls {
fn from(value: [EdgeDirection; N]) -> Self {
value.into_iter().collect()
}
}

View File

@@ -0,0 +1,69 @@
/// Move floor entities to their target Y positions based on movement speed
///
/// # Behavior
/// - Calculates movement distance based on player speed and delta time
/// - Moves floors towards their target Y position
/// - Removes FloorYTarget component when floor reaches destination
pub fn move_floors(
mut commands: Commands,
mut maze_query: Query<
(Entity, &mut Transform, &FloorYTarget),
(With<HexMaze>, With<FloorYTarget>),
>,
player_query: Query<&MovementSpeed, With<Player>>,
time: Res<Time>,
) {
let speed = player_query.get_single().map_or(100., |s| s.0);
let movement_distance = speed * time.delta_secs();
for (entity, mut transform, movement_state) in maze_query.iter_mut() {
let delta = movement_state.0 - transform.translation.y;
if delta.abs() > MOVEMENT_THRESHOLD {
let movement = delta.signum() * movement_distance.min(delta.abs());
transform.translation.y += movement;
} else {
transform.translation.y = movement_state.0; // snap to final position
commands.entity(entity).remove::<FloorYTarget>();
}
}
}
/// Handle floor transition events by setting up floor movement targets
///
/// # Behavior
/// - Checks if any floors are currently moving
/// - Processes floor transition events
/// - Sets target Y positions for all maze entities
/// - Updates current and next floor designations
pub fn handle_floor_transition_events(
mut commands: Commands,
mut maze_query: Query<
(Entity, &Transform, Option<&FloorYTarget>),
With<HexMaze>
>,
current_query: Query<Entity, With<CurrentFloor>>,
next_query: Query<Entity, With<NextFloor>>,
mut event_reader: EventReader<TransitionFloor>,
) {
let is_moving = maze_query
.iter()
.any(|(_, _, movement_state)| movement_state.is_some());
if is_moving {
return;
}
for event in event_reader.read() {
let direction = event.into();
let Some(current_entity) = current_query.get_single().ok() else {
continue;
};
let Some(next_entity) = next_query.get_single().ok() else {
continue;
};
for (entity, transforms, movement_state) in maze_query.iter_mut() {
let target_y = (FLOOR_Y_OFFSET as f32).mul_add(direction, transforms.translation.y);
if movement_state.is_none() {
commands.entity(entity).insert(FloorYTarget(target_y));
}
}
update_current_next_floor(&mut commands, current_entity, next_entity);
break;
}

View File

@@ -1,88 +1,159 @@
pub(super) fn setup(
/// Spawns a new maze floor in response to a SpawnMaze trigger event
pub(super) fn spawn_maze(
trigger: Trigger<SpawnMaze>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
config: Res<MazeConfig>,
layout: Res<Layout>,
maze_query: Query<(Entity, &Floor, &Maze)>,
global_config: Res<GlobalMazeConfig>,
) {
let maze = MazeBuilder::new()
.with_radius(config.radius)
.with_seed(0)
.with_generator(GeneratorType::RecursiveBacktracking)
.build()
.expect("Something went wrong while creating maze");
let SpawnMaze { floor, config } = trigger.event();
let assets = create_base_assets(&mut meshes, &mut materials, &config);
commands
if maze_query.iter().any(|(_, f, _)| f.0 == *floor) {
warn!("Floor {} already exists, skipping creation", floor);
return;
}
let maze = match generate_maze(config) {
Ok(m) => m,
Err(e) => {
error!("Failed to generate maze for floor {floor}: {:?}", e);
return;
}
};
// Calculate vertical offset based on floor number
let y_offset = match *floor {
1 => 0, // Ground/Initial floor (floor 1) is at y=0
_ => FLOOR_Y_OFFSET, // Other floors are offset vertically
} as f32;
let entity = commands
.spawn((
Name::new("Floor"),
SpatialBundle {
transform: Transform::from_translation(Vec3::ZERO),
..default()
},
Name::new(format!("Floor {}", floor)),
HexMaze,
maze.clone(),
Floor(*floor),
config.clone(),
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
Visibility::Visible,
))
.with_children(|parent| {
for tile in maze.values() {
spawn_single_hex_tile(
parent,
&assets,
tile,
&layout.0,
config.height,
)
}
});
.insert_if(CurrentFloor, || *floor == 1) // Only floor 1 gets CurrentFloor
.insert_if(NextFloor, || *floor != 1) // All other floors get NextFloor
.id();
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
spawn_maze_tiles(
&mut commands,
entity,
&maze,
&assets,
config,
&global_config,
);
}
fn spawn_single_hex_tile(
/// Spawns all tiles for a maze as children of the parent maze entity
pub fn spawn_maze_tiles(
commands: &mut Commands,
parent_entity: Entity,
maze: &Maze,
assets: &MazeAssets,
maze_config: &MazeConfig,
global_config: &GlobalMazeConfig,
) {
commands.entity(parent_entity).with_children(|parent| {
for tile in maze.values() {
spawn_single_hex_tile(
parent,
assets,
tile,
maze_config,
global_config,
);
}
});
}
/// Spawns a single hexagonal tile with appropriate transforms and materials
pub(super) fn spawn_single_hex_tile(
parent: &mut ChildBuilder,
assets: &MazeAssets,
tile: &HexTile,
layout: &HexLayout,
hex_height: f32,
maze_config: &MazeConfig,
global_config: &GlobalMazeConfig,
) {
dbg!(tile);
let world_pos = tile.to_vec3(layout);
let rotation = match layout.orientation {
let world_pos = tile.to_vec3(&maze_config.layout);
let rotation = match maze_config.layout.orientation {
// Pointy hexagons don't need additional rotation (0 degrees)
HexOrientation::Pointy => Quat::from_rotation_y(0.0),
HexOrientation::Flat => Quat::from_rotation_y(FRAC_PI_6), // 30 degrees rotation
// Flat-top hexagons need 30 degrees (pi/6) rotation around Y axis
HexOrientation::Flat => Quat::from_rotation_y(FRAC_PI_6),
};
// Select material based on tile position: start, end, or default
let material = match tile.pos() {
pos if pos == maze_config.start_pos => assets
.custom_materials
.get(&RosePine::Pine)
.cloned()
.unwrap_or_default(),
pos if pos == maze_config.end_pos => assets
.custom_materials
.get(&RosePine::Love)
.cloned()
.unwrap_or_default(),
_ => assets.hex_material.clone(),
};
parent
.spawn((
Name::new(format!("Hex {}", tile.to_string())),
PbrBundle {
mesh: assets.hex_mesh.clone(),
material: assets.hex_material.clone(),
transform: Transform::from_translation(world_pos)
.with_rotation(rotation),
..default()
},
Name::new(format!("Hex {}", tile)),
Tile,
Mesh3d(assets.hex_mesh.clone()),
MeshMaterial3d(material),
Transform::from_translation(world_pos).with_rotation(rotation),
))
.with_children(|parent| {
spawn_walls(parent, assets, hex_height / 2., &tile.walls())
spawn_walls(parent, assets, tile.walls(), global_config)
});
}
/// Spawns walls around a hexagonal tile based on the walls configuration
fn spawn_walls(
parent: &mut ChildBuilder,
assets: &MazeAssets,
y_offset: f32,
walls: &Walls,
global_config: &GlobalMazeConfig,
) {
// Base rotation for wall alignment (90 degrees counter-clockwise)
let z_rotation = Quat::from_rotation_z(-FRAC_PI_2);
let y_offset = global_config.height / 2.;
for i in 0..6 {
if !walls.contains(i) {
continue;
}
// Calculate the angle for this wall
// FRAC_PI_3 = 60 deg
// Negative because going clockwise
// i * 60 produces: 0, 60, 120, 180, 240, 300
let wall_angle = -FRAC_PI_3 * i as f32;
let x_offset = (HEX_SIZE - WALL_SIZE) * f32::cos(wall_angle);
let z_offset = (HEX_SIZE - WALL_SIZE) * f32::sin(wall_angle);
// cos(angle) gives x coordinate on unit circle
// sin(angle) gives z coordinate on unit circle
// Multiply by wall_offset to get actual distance from center
let x_offset = global_config.wall_offset() * f32::cos(wall_angle);
let z_offset = global_config.wall_offset() * f32::sin(wall_angle);
// x: distance along x-axis from center
// y: vertical offset from center
// z: distance along z-axis from center
let pos = Vec3::new(x_offset, y_offset, z_offset);
// 1. Rotate around x-axis to align wall with angle
// 2. Add FRAC_PI_2 (90) to make wall perpendicular to angle
let x_rotation = Quat::from_rotation_x(wall_angle + FRAC_PI_2);
let final_rotation = z_rotation * x_rotation;
@@ -90,52 +161,18 @@ fn spawn_walls(
}
}
/// Spawns a single wall segment with the specified rotation and position
fn spawn_single_wall(
parent: &mut ChildBuilder,
asstets: &MazeAssets,
assets: &MazeAssets,
rotation: Quat,
offset: Vec3,
) {
parent.spawn((
Name::new("Wall"),
PbrBundle {
mesh: asstets.wall_mesh.clone(),
material: asstets.wall_material.clone(),
transform: Transform::from_translation(offset)
.with_rotation(rotation),
..default()
},
Wall,
Mesh3d(assets.wall_mesh.clone()),
MeshMaterial3d(assets.wall_material.clone()),
Transform::from_translation(offset).with_rotation(rotation),
));
}
fn create_base_assets(
meshes: &mut ResMut<Assets<Mesh>>,
materials: &mut ResMut<Assets<StandardMaterial>>,
config: &Res<MazeConfig>,
) -> MazeAssets {
MazeAssets {
hex_mesh: meshes.add(generate_hex_mesh(HEX_SIZE, config.height)),
wall_mesh: meshes.add(generate_square_mesh(HEX_SIZE)),
hex_material: materials.add(white_material()),
wall_material: materials.add(Color::BLACK),
}
}
fn generate_hex_mesh(radius: f32, depth: f32) -> Mesh {
let hexagon = RegularPolygon {
sides: 6,
circumcircle: Circle::new(radius),
};
let prism_shape = Extrusion::new(hexagon, depth);
let rotation = Quat::from_rotation_x(FRAC_PI_2);
Mesh::from(prism_shape).rotated_by(rotation)
}
fn generate_square_mesh(depth: f32) -> Mesh {
let square = Rectangle::new(WALL_SIZE, WALL_SIZE);
let rectangular_prism = Extrusion::new(square, depth);
let rotation = Quat::from_rotation_x(FRAC_PI_2);
Mesh::from(rectangular_prism).rotated_by(rotation)
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB