Initial commit.

This commit is contained in:
2024-04-10 01:04:22 -04:00
commit a36add2344
12 changed files with 1347 additions and 0 deletions

269
maze/maze.go Normal file
View File

@ -0,0 +1,269 @@
package maze
import (
"image"
"image/color"
"image/draw"
"math/rand"
)
type cell uint8
const (
// cellRight is set if movement to the right is allowed.
cellRight cell = 1 << iota
// cellDown is set if movement down is allowed.
cellDown
)
type Maze struct {
Size image.Rectangle
Start, End image.Point
// the cells in the maze, starting at size.Min and going left-to-right, top-to-bottom.
cells []cell
}
// if cells at i and j belong to different neighborhoods, they will be merged
// and true will be returned. Otherwise, false will be returned.
func tryJoinNeighborhoods(neighborhood []*[]int, i, j int) bool {
if aN, bN := neighborhood[i], neighborhood[j]; aN != bN {
// prefer the larger set absorbing the smaller set.
if len(*aN) < len(*bN) {
aN, bN = bN, aN
}
// aN is now the larger set, and bN is the smaller set.
// append the smaller set to the larger set.
*aN = append(*aN, *bN...)
// update the membership of the smaller set to point to the larger set.
for _, member := range *bN {
neighborhood[member] = aN
}
return true
}
return false
}
// Make creates a new maze of the given size, using the given random number generator.
//
// If r is nil, the default random number generator will be used.
func Make(size image.Rectangle, r *rand.Rand) Maze {
if size.Dx() <= 0 || size.Dy() <= 0 {
panic("invalid maze size")
}
if r == nil {
// create a new random number generator if
// one wasn't provided.
r = rand.New(rand.NewSource(rand.Int63()))
}
w, h := size.Dx(), size.Dy()
neighborhoods := make([]*[]int, w*h)
cells := make([]cell, w*h)
// each cell begins initially disconnected, alone in its own set.
for i := range cells {
neighborhoods[i] = &[]int{i}
}
// create a list of all edges in the maze.
// there are (w-1)*h horizontal edges, and w*(h-1) vertical edges.
// the first (w-1)*h edges are horizontal, and the rest are vertical.
edges := make([]int, (w-1)*h+w*(h-1))
for i := range edges {
edges[i] = i
}
// shuffle the list of edges, this will be the order in which we attempt to join cells.
r.Shuffle(len(edges), func(i, j int) {
edges[i], edges[j] = edges[j], edges[i]
})
// repeatedly join cells until all cells are in the same set.
for _, edge := range edges {
if edge < (w-1)*h {
// join right
// we need to convert the edge index into coordinates, and then back into a cell index.
i := edge%(w-1) + edge/(w-1)*w
if tryJoinNeighborhoods(neighborhoods, i, i+1) {
cells[i] |= cellRight
} else {
// already joined, skip to the next edge.
continue
}
} else {
// join down
// we can mostly use the edge index unmodified, we just need to subtract the length of the horizontal edges.
i := edge - (w-1)*h
if tryJoinNeighborhoods(neighborhoods, i, i+w) {
cells[i] |= cellDown
} else {
// already joined, skip to the next edge.
continue
}
}
// if we reach this point, then two neighborhoods were joined. If all cells are in the same neighborhood,
// then we can stop trying to merge the remaining edges.
if len(*neighborhoods[0]) == w*h {
break
}
}
var start, end image.Point
// choose a start and end point on opposite sides of the maze.
switch r.Intn(2) {
case 0: // left/right
start = image.Pt(0, r.Intn(h)).Add(size.Min)
end = image.Pt(w-1, r.Intn(h)).Add(size.Min)
case 1: // top/bottom
start = image.Pt(r.Intn(w), 0).Add(size.Min)
end = image.Pt(r.Intn(w), h-1).Add(size.Min)
}
if r.Intn(2) == 0 {
// swap start and end points.
start, end = end, start
}
return Maze{
Size: size,
Start: start,
End: end,
cells: cells,
}
}
func (m Maze) coordToIndex(p image.Point) int {
return p.X - m.Size.Min.X + (p.Y-m.Size.Min.Y)*m.Size.Dx()
}
// Returns true if you can move to the right from the given point.
func (m Maze) Right(p image.Point) bool {
return p.In(m.Size) && m.cells[m.coordToIndex(p)]&cellRight != 0
}
// Returns true if you can move down from the given point.
func (m Maze) Down(p image.Point) bool {
return p.In(m.Size) && m.cells[m.coordToIndex(p)]&cellDown != 0
}
// Returns true if you can move to the left from the given point.
func (m Maze) Left(p image.Point) bool {
return m.Right(p.Sub(image.Pt(1, 0)))
}
// Returns true if you can move up from the given point.
func (m Maze) Up(p image.Point) bool {
return m.Down(p.Sub(image.Pt(0, 1)))
}
// Returns a drawing of the maze, mostly for debugging purposes.
// An optional path can be drawn on top of the maze.
func (m Maze) Draw(path []image.Point) *image.RGBA {
const cellSize = 16
const markerRadius = 4 // the radius of the start and end markers.
const wallRadius = 2 // half the thickness of the wall lines.
const pathRadius = 2 // half the thickness of the path line.
backgroundColor := image.NewUniform(color.Black)
wallColor := image.NewUniform(color.White)
startColor := image.NewUniform(color.RGBA{0, 255, 0, 255})
endColor := image.NewUniform(color.RGBA{255, 0, 0, 255})
pathColor := image.NewUniform(color.RGBA{0, 0, 255, 255})
leftWall := image.Rect(0, 0, 0, cellSize).Inset(-wallRadius)
topWall := image.Rect(0, 0, cellSize, 0).Inset(-wallRadius)
rightWall := leftWall.Add(image.Pt(cellSize, 0))
bottomWall := topWall.Add(image.Pt(0, cellSize))
marker := image.Rect(cellSize/2-markerRadius, cellSize/2-markerRadius, cellSize/2+markerRadius, cellSize/2+markerRadius)
img := image.NewRGBA(image.Rect(0, 0, m.Size.Dx()*cellSize+wallRadius*2, m.Size.Dy()*cellSize+wallRadius*2))
// fill the entire image with the background color.
draw.Draw(img, img.Bounds(), backgroundColor, image.Point{}, draw.Src)
for y := m.Size.Min.Y; y < m.Size.Max.Y; y++ {
for x := m.Size.Min.X; x < m.Size.Max.X; x++ {
p := image.Pt(x, y)
cellP := p.Sub(m.Size.Min).Mul(cellSize).Add(image.Pt(wallRadius, wallRadius))
draw := func(rect image.Rectangle, color *image.Uniform) {
draw.Draw(img, rect.Add(cellP), color, image.Point{}, draw.Src)
}
if p == m.Start {
draw(marker, startColor)
}
if p == m.End {
draw(marker, endColor)
}
if !m.Right(p) {
draw(rightWall, wallColor)
}
if !m.Down(p) {
draw(bottomWall, wallColor)
}
if !m.Left(p) {
draw(leftWall, wallColor)
}
if !m.Up(p) {
draw(topWall, wallColor)
}
}
}
if len(path) > 1 {
pathDrawOffset := image.Pt(cellSize/2+wallRadius, cellSize/2+wallRadius)
draw := func(i, j int) {
// fake drawing a line between the two points by treating them as points
// of a rectangle, and drawing that rectangle instead.
//
// this only works because we're assuming there are no diagonal movements.
rect := image.Rectangle{
path[i].Sub(m.Size.Min).Mul(cellSize),
path[j].Sub(m.Size.Min).Mul(cellSize),
}.Canon().Inset(-pathRadius).Add(pathDrawOffset)
draw.Draw(img, rect, pathColor, image.Point{}, draw.Src)
}
start := 0
// cheating by assuming adjacent points in the path are adjacent cells,
// and movements are only horizontal or vertical.
for i := 1; i < len(path); i++ {
if path[start].X == path[i].X || path[start].Y == path[i].Y {
// while points are on the same horizontal or vertical line,
// skip the intermediate points.
continue
}
// all the points before this one were on the same line, so draw them
// as a single line.
draw(start, i-1)
start = i - 1
}
// any remaining points we haven't drawn yet are also on the same line.
draw(start, len(path)-1)
}
return img
}

304
maze/problem.go Normal file
View File

@ -0,0 +1,304 @@
package maze
import (
"cmp"
"fmt"
"image"
)
type problemCellState uint8
const (
// zero value, for unvisited cells.
stateUnvisited problemCellState = iota
// we considered the cell and set its heuristic, but then the movement
// test failed, we don't know how to reach it still. distance is still uninitialized.
stateUnexplored
// this is the starting cell.
// distance is 0.
stateStart
// the cell was reached via the respective direction.
// distance is the number of cells traversed to reach this cell.
stateRight
stateDown
stateLeft
stateUp
// the number of directions, for array bounds.
// not used as a state itself.
stateLen
)
var stateStrings = [stateLen]string{
"unvisited",
"unexplored",
"start",
"right",
"down",
"left",
"up",
}
// lookup table to reverse the direction.
//
// used to backtrack from the end to the start once the maze is solved.
var dirReverse = [stateLen]problemCellState{
stateUnvisited, // stateUnvisited
stateUnexplored, // stateUnexplored
stateStart, // stateStart
stateLeft, // stateRight
stateUp, // stateDown
stateRight, // stateLeft
stateDown, // stateUp
}
// lookup table to advance in the given direction.
var dirAdvance = [stateLen]image.Point{
{0, 0}, // stateUnvisited
{0, 0}, // stateUnexplored
{0, 0}, // stateStart
{1, 0}, // stateRight
{0, 1}, // stateDown
{-1, 0}, // stateLeft
{0, -1}, // stateUp
}
// lookup table to offset a point during movement tests
// for left and up, we need to test the cell to the left or above.
var dirTestOffset = [stateLen]image.Point{
{0, 0}, // stateUnvisited
{0, 0}, // stateUnexplored
{0, 0}, // stateStart
{0, 0}, // stateRight
{0, 0}, // stateDown
{-1, 0}, // stateLeft
{0, -1}, // stateUp
}
func (d problemCellState) String() string {
if d < stateLen {
return stateStrings[d]
}
// The Stringer is the only case where we do bounds checking, the other functions can just panic.
return fmt.Sprintf("unknown(%d)", d)
}
type problemCell struct {
state problemCellState
// the number of cells traversed to reach this cell.
// only valid if state for stateStart, stateRight, stateDown, stateLeft, or stateUp.
distance uint
// the optimistic distance estimate to the end point.
// valid for any state except stateUnvisited.
heuristic uint
}
// A Problem is intended to be used by the Solve function to find a path through a maze.
//
// This is more of a demonstration of how to use the Solve function, a dedicated maze solving
// algorithm would more efficient. In particular, the solver manages a max heap for discarding
// states when at capacity, which a maze solver would not need - a maze has a finite number of cells,
// which for our purposes represent a state, and we'd never exceed (and thus need to keep track
// of and discard) that many states.
type Problem struct {
// the bounds of the maze.
size image.Rectangle
// a lookup table for the bounds that a cell can be transverse in.
//
// for the movement directions, this will be identical to size, but contracted by one cell.
// for example, legalPositions[stateRight] will have the rightmost column removed, as you can't
// move further right from the rightmost column.
//
// for other possible states, the rectangle will be empty (and realistically, never used).
legalPositions [stateLen]image.Rectangle
// the functions that test if movement is allowed in the given direction.
// this needs to be combined with dirTestOffset to get the correct cell to test.
testFuncs [stateLen]func(image.Point) bool
// the start and end points of the maze.
start, end image.Point
// the cells in the maze, starting at size.Min, going left to right, top to bottom.
cells []problemCell
}
// convert a coordinate to an index in the cells slice.
//
// it is assumed that c.In(p.size) is true, otherwise this
// will yield a wrong, and possibly out of bounds index.
func (p Problem) coordToIndex(c image.Point) int {
return (c.X - p.size.Min.X) + (c.Y-p.size.Min.Y)*p.size.Dx()
}
func absDelta(a, b int) uint {
if a > b {
return uint(a - b)
}
return uint(b - a)
}
// an optimistic distance estimate based on the Manhattan distance to the end point.
func (p Problem) heuristic(c image.Point) uint {
return absDelta(c.X, p.end.X) + absDelta(c.Y, p.end.Y)
}
// Initializes the maze search problem, and appends the start point to the given slice.
func (p *Problem) Initialize(out []image.Point) ([]image.Point, error) {
p.cells = make([]problemCell, p.size.Dx()*p.size.Dy())
p.cells[p.coordToIndex(p.start)] = problemCell{
state: stateStart,
heuristic: p.heuristic(p.end),
}
return append(out, p.start), nil
}
// Appends the next positions to consider from the given position.
func (p Problem) Next(c image.Point, out []image.Point) ([]image.Point, error) {
distance := p.cells[p.coordToIndex(c)].distance + 1
for _, d := range [...]problemCellState{stateRight, stateDown, stateLeft, stateUp} {
if !c.In(p.legalPositions[d]) {
// can't move in this direction, stop immediately.
continue
}
next := c.Add(dirAdvance[d])
nextIndex := p.coordToIndex(next)
switch {
case p.cells[nextIndex].state == stateUnvisited:
// calculate the heuristic for the first time and change its state to unexplored.
p.cells[nextIndex].heuristic = p.heuristic(next)
p.cells[nextIndex].state = stateUnexplored
fallthrough
case p.cells[nextIndex].state == stateUnexplored:
// similar to the unvisited case, but don't calculate the heuristic again.
fallthrough
// if the cell has been visited, but we found a shorter path to it, then
// update the direction and distance.
case distance < p.cells[nextIndex].distance:
// if we reach this point, then one of the above cases has occurred, and
// this is a cell we're interested in exploring further.
//
// test to make sure we can actually move there.
if p.testFuncs[d](c.Add(dirTestOffset[d])) {
p.cells[nextIndex].state = d
p.cells[nextIndex].distance = distance
out = append(out, next)
}
}
}
return out, nil
}
// Returns true if the given position is the end of the maze.
//
// We're also assuming that the point was returned by Initialize or Next,
// otherwise we wouldn't know how to reach the start position, and this
// wouldn't actually be solved.
func (p Problem) Solved(c image.Point) bool {
return c == p.end
}
// Converts the given position, the one that passed the Solved test above,
// into a list of points that form a path from the start to the end.
func (p Problem) Finish(c image.Point) ([]image.Point, error) {
if c != p.end {
return nil, fmt.Errorf("not at the end: %v", c)
}
switch p.cells[p.coordToIndex(c)].state {
case stateUnvisited, stateUnexplored:
return nil, fmt.Errorf("no path to the end")
}
length := p.cells[p.coordToIndex(c)].distance
path := make([]image.Point, length+1)
path[length] = c
for ; length > 0; length-- {
c = c.Add(dirAdvance[dirReverse[p.cells[p.coordToIndex(c)].state]])
path[length-1] = c
}
return path, nil
}
// This function would be used to release any resources associated with a given search state,
// but since a state for this problem is just a point, and all the actual state information
// is stored in the cells slice, there's nothing to do here.
func (p Problem) Discard(image.Point) {
// nop
}
// Returns true if state a should be explored before b.
func (p Problem) OptimisticLess(a, b image.Point) bool {
ca := p.cells[p.coordToIndex(a)]
cb := p.cells[p.coordToIndex(b)]
if r := cmp.Compare(ca.distance+ca.heuristic, cb.distance+cb.heuristic); r != 0 {
return r < 0
}
// If the estimated total path length for both states seems to be equal,
// prefer the one with the lower heuristic (closest to the end).
//
// since distance+heuristic for both states is equal, this conversely means that
// we prefer the one with the higher distance traveled so far.
return ca.heuristic < cb.heuristic
}
// Similar to OptimisticLess, but we scale the heuristic to penalize uncertainty
// about the actual remaining distance.
//
// Note that the solver uses this with a max heap to determine which states to prune when at capacity.
//
// This will typically be similar to OptimisticLess, but with a penalty for uncertainty.
//
// For our purposes, we're just going to use the same heuristic as OptimisticLess. Assuming that you give
// the solver a capacity equal to the number of cells in the maze, it won't need to prune any states.
func (p Problem) PessimisticLess(a, b image.Point) bool {
return p.OptimisticLess(a, b)
}
// NewMazeProblem creates a new maze problem with the given size, start and end points,
// and well as two functions that test if movement to the right and down is allowed from a given point.
func NewProblem(size image.Rectangle, start, end image.Point, rightTest func(image.Point) bool, downTest func(image.Point) bool) *Problem {
var legalPositions [stateLen]image.Rectangle
legalPositions[stateRight] = image.Rect(size.Min.X, size.Min.Y, size.Max.X-1, size.Max.Y)
legalPositions[stateDown] = image.Rect(size.Min.X, size.Min.Y, size.Max.X, size.Max.Y-1)
legalPositions[stateLeft] = image.Rect(size.Min.X+1, size.Min.Y, size.Max.X, size.Max.Y)
legalPositions[stateUp] = image.Rect(size.Min.X, size.Min.Y+1, size.Max.X, size.Max.Y)
var testFuncs [stateLen]func(image.Point) bool
testFuncs[stateRight] = rightTest
testFuncs[stateDown] = downTest
// note that the dirTestOffset lookup table has non-zero values for left and up,
// to compensate for the fact that we need to test the cell to the left or above.
testFuncs[stateLeft] = rightTest
testFuncs[stateUp] = downTest
return &Problem{
size: size,
legalPositions: legalPositions,
testFuncs: testFuncs,
start: start,
end: end,
}
}