This commit is contained in:
RochesterX
2026-04-04 20:11:56 -04:00
parent 11b6b0d058
commit b00e5d4090
8 changed files with 97 additions and 11 deletions

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
### Project Title: A* SANDBOX
### Name: Trevor Maze
### OU Email: trevormaze@oakland.edu
## Overview:
A* Sandbox is an interactive visualization for the A* graph traversal algorithm, frequently used in video games for pathfinding. My implementation simplifies the arbitrary graph to a grid, where every node is connected to the eight around it (movement weights being equal to sqrt(2) for the diagonals and 1 for the cardinal directions).
## Instructions:
A* Sandbox is written in Swift. To compile the code, [Swift must be installed](https://www.swift.org/install/). Once installed, the code can be run with ``swift run`` or by running the generated executable ``./.build/debug/A-Star``. The latter method can be used to include an initfile (such as the provided test files) and optionally specify ``--nointeract`` to disable the interactive environment and display the raw output of the algorithm.
If Swift cannot be installed, I have included binaries in the ``Binaries/`` folder that can be run via the terminal.
> [!NOTE]
> __Windows users:__ This project was developed on a Unix-based system. While support for Windows terminals has been tested, the experience is noticeably degraded. I recommend using a Linux or macOS system to run this project.
## Video:
## AI Usage Declaration:
I used AI for assistance with implementing cross-platform terminal input. Everything related to "terminal raw mode" and the readKey() function were created based on AI-generated solutions, tailored for my project's needs.
__No AI-generated code was used in the implementation of the core algorithm (the pathfind() function).__

View File

@@ -58,10 +58,14 @@ Example:
aStar.display(path, on: grid)
// If noInteract, print full path
print("\nFull path: ")
for vector in path {
let index = path.firstIndex(of: vector)!
print("(\(vector.x), \(vector.y))\(index != path.count - 1 ? (index % 5 == 4 ? "" : "") : "")\(index % 5 == 4 ? "\n" : "")", terminator: "")
if path.count == 2 {
print("\n\(Color.Red)No non-trivial path found.\(Color.Default)")
} else {
print("\nFull path: ")
for vector in path {
let index = path.firstIndex(of: vector)!
print("(\(vector.x), \(vector.y))\(index != path.count - 1 ? (index % 5 == 4 ? "" : "\t") : "")\(index % 5 == 4 ? "\n" : "")", terminator: "")
}
}
print()
@@ -125,21 +129,30 @@ Example:
var directions: [Bool] = Array(repeating: true, count: 8)
// The main algorithm function
func pathfind(grid: [[Int]], start: Vector2, goal: Vector2) -> [Vector2] {
// For keeping track of yet-unexplored relevant nodes
var openSet: [Vector2] = [start]
// For keeping track of all the nodes previously traversed to reconstruct the optimal path
var cameFrom: Dictionary<Vector2, Vector2> = [:]
// A map keeping track of the G-score (actual cost from start to a given node) for every node on the grid
var gScore: Dictionary<Vector2, Float> = [:]
gScore[start] = 0
// A map keeping track of the F-score (estimated cost from any given node to the goal, composed of the actual score from the start to the node and the estimated score from there to the goal) for every node on the grid
var fScore: Dictionary<Vector2, Float> = [:]
fScore[start] = h(current: start, goal: goal)
// Loop until all relevant nodes have been explored [O(width * height) worst-case]
while openSet.count != 0 {
// Get the next relevant node with the lowest F-score (preditced total cost) [O(width * height) worst-case]
var current: Vector2 = getLowest(openSet, map: fScore)
// If this node is the goal...
if current == goal {
// ...reconstruct the path taken by going back through the nodes previously chosen and reversing, returning the resulting path
var totalPath: [Vector2] = [current]
while cameFrom.keys.contains(current) {
current = cameFrom[current]!
@@ -149,8 +162,10 @@ Example:
return totalPath
}
// Otherwise, remove the current node from the set of unexplored nodes
openSet.remove(at: openSet.firstIndex(of: current)!)
// Since A* technically works on graphs, not grids, we construct all the "edges" that need checking
let neighbors: [Vector2] = [
Vector2(x: current.x - 1, y: current.y - 1),
Vector2(x: current.x, y: current.y - 1),
@@ -164,29 +179,32 @@ Example:
Vector2(x: current.x + 1, y: current.y + 1),
]
// Loop through each of these neighbors...
for i: Int in 0..<neighbors.count {
// Enforce direction whitelist
// ...remove it if that direction is turned off...
if !directions[i] {
continue
}
// ...ignore it if it's outside the bounds of the grid...
let neighbor: Vector2 = neighbors[i]
if neighbor.x >= grid[0].count ||
neighbor.x < 0 ||
neighbor.y >= grid.count ||
neighbor.y < 0 {
continue
}
if neighbor.x >= grid[0].count || neighbor.x < 0 || neighbor.y >= grid.count || neighbor.y < 0 {
continue
}
// ...disregard it if it's a wall (represented by a 1)...
if grid[neighbor.y][neighbor.x] == 1 {
continue
}
// ...and calculate it's G-score (true score it took to get to the current node, plus the actual weight of the edge from current to neighbor)
let tentative_gScore: Float = gScore[current]! + d(current: current, neighbor: neighbor)
// If this G-score sets a new record for this node (meaning it's on a lower scoring, more optimal path), update all the maps to reflect this discovery
if tentative_gScore < gScore[neighbor, default: Float.infinity] {
cameFrom[neighbor] = current
gScore[neighbor] = tentative_gScore
fScore[neighbor] = tentative_gScore + h(current: neighbor, goal: goal)
// Worst-case O(width * height) lookup
if !openSet.contains(neighbor) {
openSet.append(neighbor)
}
@@ -194,15 +212,18 @@ Example:
}
}
// If the goal was never found, return a "path" with just the start and goal, which indicates to the rest of the program that a non-trivial path was not found
return [start, goal]
}
// The heuristic function (which has to NEVER overestimate the real cost, otherwise A* may traverse a nonoptimal path); here using octile distance, where the goal must be reached by traversing nodes and taking a diagonal path involves a cost of sqrt(2)
func h(current: Vector2, goal: Vector2) -> Float {
let x = Float(abs(current.x - goal.x))
let y = Float(abs(current.y - goal.y))
return abs(x - y) + 1.4142 * min(x, y)
}
// The true weight of the edge from current to neighbor (just it's distance on the cartesian plane)
func d(current: Vector2, neighbor: Vector2) -> Float {
if current.x == neighbor.x || current.y == neighbor.y {
return 1
@@ -210,6 +231,7 @@ Example:
return 1.4142
}
// A naive algorithm to get the node with the lowest value in the map [O(map.keys.count) worst-case]
func getLowest(_ set: [Vector2], map: Dictionary<Vector2, Float>) -> Vector2 {
var tentativeLowest = set[0]
for item in set {

View File

@@ -0,0 +1,8 @@
...........#.....F....
.......S...#...####...
...........#..........
...........#..........
...........#..........
...........#..........
......................
......................

View File

@@ -0,0 +1,8 @@
..F#..........
.###.########.
.#.........#..
.#..#......#.#
.#.........#..
.###.......##.
.#.........#..
...#.......#.S

12
Tests/Test3-ValidPathLong Normal file
View File

@@ -0,0 +1,12 @@
..........F.
............
............
............
###########.
............
............
.###########
............
............
............
...........S

View File

@@ -0,0 +1,7 @@
..............
.###..........
.#S#..........
.###..........
...........F..
..............
..............

View File

@@ -0,0 +1,6 @@
.##...#.............
....#########...#...
.###..#.....#.......
...#.....#####...#..
..S#.....##......#F.
.......#..#.#....#..

View File

@@ -0,0 +1 @@
.SF.