From cdfa1f4476d9eef3893bf0a403947f34b3532409 Mon Sep 17 00:00:00 2001 From: Manuel Friedli Date: Sat, 14 Dec 2024 04:30:35 +0100 Subject: [PATCH] Initial - and extremely ugly - version of Wilson's maze algorithm. --- .../AbstractMazeGeneratorAlgorithm.java | 19 ++ .../algorithm/MazeGeneratorAlgorithm.java | 5 + .../generator/algorithm/RandomDepthFirst.java | 11 +- .../maze/generator/algorithm/Wilson.java | 297 ++++++++++++++++++ .../maze/generator/model/Direction.java | 3 + .../maze/generator/model/Position.java | 18 ++ .../generator/renderer/pdf/Generator.java | 2 +- .../maze/generator/algorithm/WilsonTest.java | 15 + 8 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 src/main/java/ch/fritteli/maze/generator/algorithm/AbstractMazeGeneratorAlgorithm.java create mode 100644 src/main/java/ch/fritteli/maze/generator/algorithm/MazeGeneratorAlgorithm.java create mode 100644 src/main/java/ch/fritteli/maze/generator/algorithm/Wilson.java create mode 100644 src/test/java/ch/fritteli/maze/generator/algorithm/WilsonTest.java diff --git a/src/main/java/ch/fritteli/maze/generator/algorithm/AbstractMazeGeneratorAlgorithm.java b/src/main/java/ch/fritteli/maze/generator/algorithm/AbstractMazeGeneratorAlgorithm.java new file mode 100644 index 0000000..330f9e7 --- /dev/null +++ b/src/main/java/ch/fritteli/maze/generator/algorithm/AbstractMazeGeneratorAlgorithm.java @@ -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()); + } +} diff --git a/src/main/java/ch/fritteli/maze/generator/algorithm/MazeGeneratorAlgorithm.java b/src/main/java/ch/fritteli/maze/generator/algorithm/MazeGeneratorAlgorithm.java new file mode 100644 index 0000000..0cc091a --- /dev/null +++ b/src/main/java/ch/fritteli/maze/generator/algorithm/MazeGeneratorAlgorithm.java @@ -0,0 +1,5 @@ +package ch.fritteli.maze.generator.algorithm; + +public interface MazeGeneratorAlgorithm { + void run(); +} diff --git a/src/main/java/ch/fritteli/maze/generator/algorithm/RandomDepthFirst.java b/src/main/java/ch/fritteli/maze/generator/algorithm/RandomDepthFirst.java index a65b15e..21aa2d1 100644 --- a/src/main/java/ch/fritteli/maze/generator/algorithm/RandomDepthFirst.java +++ b/src/main/java/ch/fritteli/maze/generator/algorithm/RandomDepthFirst.java @@ -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 positions = new LinkedList<>(); public RandomDepthFirst(@NotNull final Maze maze) { - this.maze = maze; - this.random = new Random(maze.getRandomSeed()); + super(maze); } public void run() { diff --git a/src/main/java/ch/fritteli/maze/generator/algorithm/Wilson.java b/src/main/java/ch/fritteli/maze/generator/algorithm/Wilson.java new file mode 100644 index 0000000..86e4fd6 --- /dev/null +++ b/src/main/java/ch/fritteli/maze/generator/algorithm/Wilson.java @@ -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 Wilson's Algorithm. + */ +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 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 pths, @NotNull final Maze maze) { + pths.forEach(path -> { + final Iterator 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[][] 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 solution = new LinkedList<>(); + solution.add(p); + while (!p.equals(maze.getEnd())) { + final Tile tile = maze.getTileAt(p).get(); + EnumSet 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 getRandomAvailablePosition(@NotNull final Random random) { + final Seq 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 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 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 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 getValidDirectionsFromLastPosition() { + final Position fromPosition = this.path.getLast(); + final EnumSet 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[", "->", "]"); + } + } + } +} diff --git a/src/main/java/ch/fritteli/maze/generator/model/Direction.java b/src/main/java/ch/fritteli/maze/generator/model/Direction.java index e50ba6c..75ee1c4 100644 --- a/src/main/java/ch/fritteli/maze/generator/model/Direction.java +++ b/src/main/java/ch/fritteli/maze/generator/model/Direction.java @@ -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; diff --git a/src/main/java/ch/fritteli/maze/generator/model/Position.java b/src/main/java/ch/fritteli/maze/generator/model/Position.java index 4b91543..a6fa788 100644 --- a/src/main/java/ch/fritteli/maze/generator/model/Position.java +++ b/src/main/java/ch/fritteli/maze/generator/model/Position.java @@ -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 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(); + }; + } } diff --git a/src/main/java/ch/fritteli/maze/generator/renderer/pdf/Generator.java b/src/main/java/ch/fritteli/maze/generator/renderer/pdf/Generator.java index 0de27a9..547f3da 100644 --- a/src/main/java/ch/fritteli/maze/generator/renderer/pdf/Generator.java +++ b/src/main/java/ch/fritteli/maze/generator/renderer/pdf/Generator.java @@ -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; } } diff --git a/src/test/java/ch/fritteli/maze/generator/algorithm/WilsonTest.java b/src/test/java/ch/fritteli/maze/generator/algorithm/WilsonTest.java new file mode 100644 index 0000000..8e46ba5 --- /dev/null +++ b/src/test/java/ch/fritteli/maze/generator/algorithm/WilsonTest.java @@ -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)); + } +}