Initial - and extremely ugly - version of Wilson's maze algorithm.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Manuel Friedli 2024-12-14 04:30:35 +01:00
parent cf405fbc98
commit cdfa1f4476
Signed by: manuel
GPG key ID: 41D08ABA75634DA1
8 changed files with 360 additions and 10 deletions

View file

@ -0,0 +1,19 @@
package ch.fritteli.maze.generator.algorithm;
import ch.fritteli.maze.generator.model.Maze;
import org.jetbrains.annotations.NotNull;
import java.util.Random;
public abstract class AbstractMazeGeneratorAlgorithm implements MazeGeneratorAlgorithm {
@NotNull
protected final Maze maze;
@NotNull
protected final Random random;
public AbstractMazeGeneratorAlgorithm(@NotNull final Maze maze) {
this.maze = maze;
this.random = new Random(maze.getRandomSeed());
}
}

View file

@ -0,0 +1,5 @@
package ch.fritteli.maze.generator.algorithm;
public interface MazeGeneratorAlgorithm {
void run();
}

View file

@ -10,20 +10,13 @@ import org.jetbrains.annotations.Nullable;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Random;
public class RandomDepthFirst {
@NotNull
private final Maze maze;
@NotNull
private final Random random;
public class RandomDepthFirst extends AbstractMazeGeneratorAlgorithm {
@NotNull
private final Deque<Position> positions = new LinkedList<>();
public RandomDepthFirst(@NotNull final Maze maze) {
this.maze = maze;
this.random = new Random(maze.getRandomSeed());
super(maze);
}
public void run() {

View file

@ -0,0 +1,297 @@
package ch.fritteli.maze.generator.algorithm;
import ch.fritteli.maze.generator.model.Direction;
import ch.fritteli.maze.generator.model.Maze;
import ch.fritteli.maze.generator.model.Position;
import ch.fritteli.maze.generator.model.Tile;
import io.vavr.Tuple2;
import io.vavr.collection.Iterator;
import io.vavr.collection.Seq;
import io.vavr.collection.Stream;
import io.vavr.collection.Vector;
import io.vavr.control.Option;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;
/**
* An implementation of <a href="https://en.wikipedia.org/wiki/Maze_generation_algorithm#Wilson's_algorithm">Wilson's Algorithm</a>.
*/
public class Wilson extends AbstractMazeGeneratorAlgorithm {
public Wilson(@NotNull final Maze maze) {
super(maze);
}
@Override
public void run() {
final MyMaze myMaze = new MyMaze(this.maze.getWidth(), this.maze.getHeight());
// 1. Initialization: pick random location, add to maze
final Position position = myMaze.getRandomAvailablePosition(this.random).get();
myMaze.setPartOfMaze(position);
// 2. while locations-not-in-maze exist, repeat:
final List<MyMaze.Path> pths = new ArrayList<>();
Position startPosition;
while ((startPosition = myMaze.getRandomAvailablePosition(this.random).getOrNull()) != null) {
final MyMaze.Path path = myMaze.createPath(startPosition);
while (true) {
final Position nextPosition = path.nextRandomPosition(this.random);
if (path.contains(nextPosition)) {
path.removeLoopUpTo(nextPosition);
} else {
path.append(nextPosition);
if (myMaze.isPartOfMaze(nextPosition)) {
myMaze.setPartOfMaze(path);
pths.add(path);
break;
}
}
}
}
applyToMaze(pths, maze);
solve(maze);
// 1. pick random location not in maze
// 2. perform random walk until you reach the maze (*)
// 3. add path to maze
// (*): random walk:
// 1. advance in random direction
// 2. if loop with current path is formed, remove loop, continue from last location before loop
}
private void applyToMaze(@NotNull final List<MyMaze.Path> pths, @NotNull final Maze maze) {
pths.forEach(path -> {
final Iterator<Direction> moves = Stream.ofAll(path.path)
.sliding(2)
.flatMap(poss -> poss.head().getDirectionTo(poss.get(1)));
Position position = path.path.getFirst();
while (moves.hasNext()) {
Tile tile = maze.getTileAt(position).get();
final Direction move = moves.next();
tile.digTo(move);
position = position.move(move);
maze.getTileAt(position).forEach(t -> t.digTo(move.invert()));
}
});
Direction direction = determineDirectionForDigging(maze.getStart());
Tile t = maze.getStartTile();
this.digTo(t, direction);
direction = determineDirectionForDigging(maze.getEnd());
t = maze.getEndTile();
this.digTo(t, direction);
}
@Nullable
private Direction determineDirectionForDigging(@NotNull final Position position) {
if (position.y() == 0) {
return Direction.TOP;
}
if (position.x() == 0) {
return Direction.LEFT;
}
if (position.y() == this.maze.getHeight() - 1) {
return Direction.BOTTOM;
}
if (position.x() == this.maze.getWidth() - 1) {
return Direction.RIGHT;
}
return null;
}
private void digTo(@NotNull final Tile tile, @NotNull final Direction direction) {
tile.enableDiggingToOrFrom(direction);
tile.digTo(direction);
}
private void solve(@NotNull final Maze maze) {
EnumSet<Direction>[][] remainingDirs = new EnumSet[maze.getWidth()][maze.getHeight()];
for (int x = 0; x < remainingDirs.length; x++) {
for (int y = 0; y < remainingDirs[x].length; y++) {
remainingDirs[x][y] = EnumSet.allOf(Direction.class);
}
}
Position p = maze.getStart();
final Direction direction = this.determineDirectionForDigging(p);
remainingDirs[p.x()][p.y()].remove(direction);
LinkedList<Position> solution = new LinkedList<>();
solution.add(p);
while (!p.equals(maze.getEnd())) {
final Tile tile = maze.getTileAt(p).get();
EnumSet<Direction> dirs = remainingDirs[p.x()][p.y()];
dirs.removeIf(tile::hasWallAt);
if (dirs.isEmpty()) {
solution.pop();
p = solution.peek();
} else {
final Direction nextDir = dirs.iterator().next();
final Position nextPos = p.move(nextDir);
solution.push(nextPos);
remainingDirs[p.x()][p.y()].remove(nextDir);
remainingDirs[nextPos.x()][nextPos.y()].remove(nextDir.invert());
p = nextPos;
}
}
solution.forEach(s -> maze.getTileAt(s).forEach(Tile::setSolution));
}
private static class MyMaze {
private final int width;
private final int height;
private final boolean[][] partOfMaze;
private final boolean[] completeColumns;
MyMaze(final int width, final int height) {
this.width = width;
this.height = height;
this.partOfMaze = new boolean[this.width][this.height];
for (int x = 0; x < this.width; x++) {
this.partOfMaze[x] = new boolean[this.height];
}
this.completeColumns = new boolean[this.width];
}
boolean isPartOfMaze(final int x, final int y) {
return this.partOfMaze[x][y];
}
boolean isPartOfMaze(@NotNull final Position position) {
return this.isPartOfMaze(position.x(), position.y());
}
void setPartOfMaze(final int x, final int y) {
this.partOfMaze[x][y] = true;
this.checkCompleteColumn(x);
}
void setPartOfMaze(@NotNull final Position position) {
this.setPartOfMaze(position.x(), position.y());
}
void setPartOfMaze(@NotNull final Path path) {
path.path.forEach(this::setPartOfMaze);
}
void checkCompleteColumn(final int x) {
if (this.completeColumns[x]) {
return;
}
for (int y = 0; y < this.height; y++) {
if (!this.isPartOfMaze(x, y)) {
return;
}
}
this.completeColumns[x] = true;
}
Option<Position> getRandomAvailablePosition(@NotNull final Random random) {
final Seq<Integer> allowedColumns = Vector.ofAll(this.completeColumns)
.zipWithIndex()
.reject(Tuple2::_1)
.map(Tuple2::_2);
if (allowedColumns.isEmpty()) {
return Option.none();
}
final int x = allowedColumns.get(random.nextInt(allowedColumns.size()));
final boolean[] column = partOfMaze[x];
final Seq<Integer> allowedRows = Vector.ofAll(column)
.zipWithIndex()
.reject(Tuple2::_1)
.map(Tuple2::_2);
if (allowedRows.isEmpty()) {
return Option.none();
}
final int y = allowedRows.get(random.nextInt(allowedRows.size()));
return Option.some(new Position(x, y));
}
public Path createPath(@NotNull final Position position) {
return new Path(position);
}
private class Path {
@NotNull
private final List<Position> path = new LinkedList<>();
Path(@NotNull final Position position) {
this.path.add(position);
}
@NotNull
Position nextRandomPosition(@NotNull final Random random) {
final Direction direction = this.getRandomDirectionFromLastPosition(random);
return this.path.getLast().move(direction);
}
boolean contains(@NotNull final Position position) {
return this.path.contains(position);
}
void removeLoopUpTo(@NotNull final Position position) {
while (!this.path.removeLast().equals(position)) {
}
this.path.add(position);
}
public void append(@NotNull final Position nextPosition) {
this.path.add(nextPosition);
}
@NotNull
private Direction getRandomDirectionFromLastPosition(@NotNull final Random random) {
final EnumSet<Direction> validDirections = this.getValidDirectionsFromLastPosition();
if (validDirections.isEmpty()) {
throw new IllegalStateException("WE MUST NOT GET HERE! analyze why it happened!!!");
}
if (validDirections.size() == 1) {
return validDirections.iterator().next();
}
final Direction[] directionArray = validDirections.toArray(Direction[]::new);
final int index = random.nextInt(directionArray.length);
return directionArray[index];
}
@NotNull
private EnumSet<Direction> getValidDirectionsFromLastPosition() {
final Position fromPosition = this.path.getLast();
final EnumSet<Direction> validDirections = EnumSet.allOf(Direction.class);
if (this.path.size() > 1) {
final Position prevP = this.path.get(this.path.size() - 2);
fromPosition.getDirectionTo(prevP)
.forEach(validDirections::remove);
}
boolean canLeft = fromPosition.x() > 0;
boolean canRight = fromPosition.x() < width - 1;
boolean canUp = fromPosition.y() > 0;
boolean canDown = fromPosition.y() < height - 1;
if (!canLeft) {
validDirections.remove(Direction.LEFT);
}
if (!canRight) {
validDirections.remove(Direction.RIGHT);
}
if (!canUp) {
validDirections.remove(Direction.TOP);
}
if (!canDown) {
validDirections.remove(Direction.BOTTOM);
}
return validDirections;
}
@Override
public String toString() {
return Stream.ofAll(this.path)
.map(position -> "(%s,%s)".formatted(position.x(), position.y()))
.mkString("Path[", "->", "]");
}
}
}
}

View file

@ -1,11 +1,14 @@
package ch.fritteli.maze.generator.model;
import org.jetbrains.annotations.NotNull;
public enum Direction {
TOP,
RIGHT,
BOTTOM,
LEFT;
@NotNull
public Direction invert() {
return switch (this) {
case TOP -> BOTTOM;

View file

@ -1,10 +1,12 @@
package ch.fritteli.maze.generator.model;
import io.vavr.control.Option;
import lombok.With;
import org.jetbrains.annotations.NotNull;
@With
public record Position(int x, int y) {
@NotNull
public Position move(@NotNull final Direction direction) {
return switch (direction) {
case BOTTOM -> this.withY(this.y + 1);
@ -13,4 +15,20 @@ public record Position(int x, int y) {
case TOP -> this.withY(this.y - 1);
};
}
@NotNull
public Option<Direction> getDirectionTo(@NotNull final Position position) {
final int xDiff = position.x - this.x;
final int yDiff = position.y - this.y;
return switch (xDiff) {
case -1 -> Option.when(yDiff == 0, Direction.LEFT);
case 0 -> switch (yDiff) {
case -1 -> Option.some(Direction.TOP);
case 1 -> Option.some(Direction.BOTTOM);
default -> Option.none();
};
case 1 -> Option.when(yDiff == 0, Direction.RIGHT);
default -> Option.none();
};
}
}

View file

@ -233,7 +233,7 @@ class Generator {
if (position.equals(previousPosition)) {
continue;
}
if (tileAtPosition.map(Tile::isSolution).getOrElse(false)) {
if (tileAtPosition.exists(Tile::isSolution)) {
return position;
}
}

View file

@ -0,0 +1,15 @@
package ch.fritteli.maze.generator.algorithm;
import ch.fritteli.maze.generator.model.Maze;
import ch.fritteli.maze.generator.renderer.text.TextRenderer;
import org.junit.jupiter.api.Test;
class WilsonTest {
@Test
void foo() {
final Maze maze = new Maze(50, 10, 0);
final Wilson wilson = new Wilson(maze);
wilson.run();
System.out.println(TextRenderer.newInstance().render(maze));
}
}