Files

681 lines
23 KiB
Swift
Raw Permalink Normal View History

2026-04-04 15:35:28 -04:00
import Foundation
2026-03-31 18:42:04 -04:00
#if os(macOS)
import Darwin
#elseif os(Linux)
import Glibc
#elseif os(Windows)
import ucrt
#endif
2026-03-31 09:59:05 -04:00
@main
struct A_Star {
static func main() {
2026-04-04 15:35:28 -04:00
var initfilePath = "-"
var noInteract = false
if CommandLine.arguments.count == 2 {
initfilePath = CommandLine.arguments[1]
} else if CommandLine.arguments.count == 3 && CommandLine.arguments[2] == "--nointeract" {
initfilePath = CommandLine.arguments[1]
noInteract = true
} else if (CommandLine.arguments.count != 1) {
print("""
Improper syntax.
Usage:
binary-path [initfile-path [--nointeract]]
Example:
./A-Star Tests/Test1 --nointeract
""")
return
}
2026-03-31 18:42:04 -04:00
2026-04-04 15:35:28 -04:00
var grid: [[Int]] = [[]]
var start: Vector2 = Vector2(x: -1, y: -1)
var goal: Vector2 = Vector2(x: -1, y: -1)
2026-03-31 18:42:04 -04:00
2026-04-04 15:35:28 -04:00
parseInitfile(initfilePath, grid: &grid, start: &start, goal: &goal)
2026-03-31 18:42:04 -04:00
2026-04-04 15:35:28 -04:00
// Hide cursor
2026-03-31 18:42:04 -04:00
print("\u{1B}[?25l")
Terminal.enableRawMode()
var cursorPosition = Vector2(x: 0, y: 0)
defer {
2026-04-04 15:35:28 -04:00
quit(clear: !noInteract)
2026-03-31 18:42:04 -04:00
}
var aStar = A_Star()
var path = aStar.pathfind(grid: grid, start: start, goal: goal)
2026-04-04 15:35:28 -04:00
if noInteract {
aStar.display(path, on: grid)
// If noInteract, print full path
2026-04-04 20:11:56 -04:00
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: "")
}
2026-04-04 15:35:28 -04:00
}
print()
quit(clear: false)
}
2026-03-31 18:42:04 -04:00
while true {
aStar.display(path, on: grid, cursorPosition: cursorPosition)
let key = Terminal.readKey()
switch key {
case .Left:
cursorPosition += Vector2(x: -1, y: 0)
case .Right:
cursorPosition += Vector2(x: 1, y: 0)
case .Up:
cursorPosition += Vector2(x: 0, y: -1)
case .Down:
cursorPosition += Vector2(x: 0, y: 1)
default:
switch key {
2026-04-01 11:11:43 -04:00
case .Quit:
2026-04-04 15:35:28 -04:00
quit(clear: true)
2026-03-31 18:42:04 -04:00
case .Start:
start = cursorPosition
case .Finish:
goal = cursorPosition
case .Wall:
if cursorPosition != goal && cursorPosition != start {
grid[cursorPosition.y][cursorPosition.x] = grid[cursorPosition.y][cursorPosition.x] == 1 ? 0 : 1
}
case .NW:
aStar.directions[0] = !aStar.directions[0]
case .N:
aStar.directions[1] = !aStar.directions[1]
case .NE:
aStar.directions[2] = !aStar.directions[2]
case .W:
aStar.directions[3] = !aStar.directions[3]
case .E:
aStar.directions[4] = !aStar.directions[4]
case .SW:
aStar.directions[5] = !aStar.directions[5]
case .S:
aStar.directions[6] = !aStar.directions[6]
case .SE:
aStar.directions[7] = !aStar.directions[7]
default:
break
}
path = aStar.pathfind(grid: grid, start: start, goal: goal)
continue
}
if cursorPosition.x < 0 { cursorPosition.x = 0 }
if cursorPosition.y < 0 { cursorPosition.y = 0 }
if cursorPosition.x > grid[0].count - 1 { cursorPosition.x = grid[0].count - 1 }
if cursorPosition.y > grid.count - 1 { cursorPosition.y = grid.count - 1 }
}
2026-03-31 09:59:05 -04:00
}
2026-03-31 18:42:04 -04:00
var directions: [Bool] = Array(repeating: true, count: 8)
2026-04-04 20:11:56 -04:00
// The main algorithm function
2026-03-31 18:42:04 -04:00
func pathfind(grid: [[Int]], start: Vector2, goal: Vector2) -> [Vector2] {
2026-04-04 20:11:56 -04:00
// For keeping track of yet-unexplored relevant nodes
2026-03-31 09:59:05 -04:00
var openSet: [Vector2] = [start]
2026-04-04 20:11:56 -04:00
// For keeping track of all the nodes previously traversed to reconstruct the optimal path
2026-03-31 09:59:05 -04:00
var cameFrom: Dictionary<Vector2, Vector2> = [:]
2026-04-04 20:11:56 -04:00
// A map keeping track of the G-score (actual cost from start to a given node) for every node on the grid
2026-03-31 18:42:04 -04:00
var gScore: Dictionary<Vector2, Float> = [:]
2026-03-31 09:59:05 -04:00
gScore[start] = 0
2026-04-04 20:11:56 -04:00
// 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
2026-03-31 18:42:04 -04:00
var fScore: Dictionary<Vector2, Float> = [:]
fScore[start] = h(current: start, goal: goal)
2026-03-31 09:59:05 -04:00
2026-04-04 20:11:56 -04:00
// Loop until all relevant nodes have been explored [O(width * height) worst-case]
2026-03-31 09:59:05 -04:00
while openSet.count != 0 {
2026-04-04 20:11:56 -04:00
// Get the next relevant node with the lowest F-score (preditced total cost) [O(width * height) worst-case]
2026-03-31 18:42:04 -04:00
var current: Vector2 = getLowest(openSet, map: fScore)
2026-04-04 20:11:56 -04:00
// If this node is the goal...
2026-03-31 18:42:04 -04:00
if current == goal {
2026-04-04 20:11:56 -04:00
// ...reconstruct the path taken by going back through the nodes previously chosen and reversing, returning the resulting path
2026-03-31 18:42:04 -04:00
var totalPath: [Vector2] = [current]
while cameFrom.keys.contains(current) {
current = cameFrom[current]!
totalPath.append(current)
}
totalPath.reverse()
return totalPath
}
2026-04-04 20:11:56 -04:00
// Otherwise, remove the current node from the set of unexplored nodes
2026-03-31 18:42:04 -04:00
openSet.remove(at: openSet.firstIndex(of: current)!)
2026-04-04 20:11:56 -04:00
// Since A* technically works on graphs, not grids, we construct all the "edges" that need checking
2026-03-31 18:42:04 -04:00
let neighbors: [Vector2] = [
Vector2(x: current.x - 1, y: current.y - 1),
Vector2(x: current.x, y: current.y - 1),
Vector2(x: current.x + 1, y: current.y - 1),
Vector2(x: current.x - 1, y: current.y),
Vector2(x: current.x + 1, y: current.y),
Vector2(x: current.x - 1, y: current.y + 1),
Vector2(x: current.x, y: current.y + 1),
Vector2(x: current.x + 1, y: current.y + 1),
]
2026-04-04 20:11:56 -04:00
// Loop through each of these neighbors...
2026-03-31 18:42:04 -04:00
for i: Int in 0..<neighbors.count {
2026-04-04 20:11:56 -04:00
// ...remove it if that direction is turned off...
2026-03-31 18:42:04 -04:00
if !directions[i] {
continue
}
2026-04-04 20:11:56 -04:00
// ...ignore it if it's outside the bounds of the grid...
2026-03-31 18:42:04 -04:00
let neighbor: Vector2 = neighbors[i]
2026-04-04 20:11:56 -04:00
if neighbor.x >= grid[0].count || neighbor.x < 0 || neighbor.y >= grid.count || neighbor.y < 0 {
continue
}
2026-03-31 18:42:04 -04:00
2026-04-04 20:11:56 -04:00
// ...disregard it if it's a wall (represented by a 1)...
2026-03-31 18:42:04 -04:00
if grid[neighbor.y][neighbor.x] == 1 {
continue
}
2026-04-04 20:11:56 -04:00
// ...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)
2026-03-31 18:42:04 -04:00
let tentative_gScore: Float = gScore[current]! + d(current: current, neighbor: neighbor)
2026-04-04 20:11:56 -04:00
// 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
2026-03-31 18:42:04 -04:00
if tentative_gScore < gScore[neighbor, default: Float.infinity] {
cameFrom[neighbor] = current
gScore[neighbor] = tentative_gScore
fScore[neighbor] = tentative_gScore + h(current: neighbor, goal: goal)
2026-04-04 20:11:56 -04:00
// Worst-case O(width * height) lookup
2026-03-31 18:42:04 -04:00
if !openSet.contains(neighbor) {
openSet.append(neighbor)
}
}
}
2026-03-31 09:59:05 -04:00
}
2026-03-31 18:42:04 -04:00
2026-04-04 20:11:56 -04:00
// 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
2026-03-31 18:42:04 -04:00
return [start, goal]
2026-03-31 09:59:05 -04:00
}
2026-04-04 20:11:56 -04:00
// 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)
2026-03-31 18:42:04 -04:00
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)
2026-03-31 09:59:05 -04:00
}
2026-04-04 20:11:56 -04:00
// The true weight of the edge from current to neighbor (just it's distance on the cartesian plane)
2026-03-31 18:42:04 -04:00
func d(current: Vector2, neighbor: Vector2) -> Float {
if current.x == neighbor.x || current.y == neighbor.y {
return 1
}
return 1.4142
}
2026-04-04 20:11:56 -04:00
// A naive algorithm to get the node with the lowest value in the map [O(map.keys.count) worst-case]
2026-03-31 18:42:04 -04:00
func getLowest(_ set: [Vector2], map: Dictionary<Vector2, Float>) -> Vector2 {
var tentativeLowest = set[0]
for item in set {
if map[item]! < map[tentativeLowest]! {
tentativeLowest = item
}
}
return tentativeLowest
}
2026-04-04 15:35:28 -04:00
func display(_ path: [Vector2], on: [[Int]], cursorPosition: Vector2 = Vector2(x: -1, y: -1)) {
2026-03-31 18:42:04 -04:00
let draw: Dictionary<String, String> = [
"nw": "",
"ne": "",
"sw": "",
"se": "",
"n": "",
"e": "",
"s": "",
"w": "",
"start": "S",
"goal": "F",
"path": "*",
"empty": "·",
"wall": "",
"clear": "\u{1B}[2J",
"clearLine": "\u{1B}[2K",
"home": "\u{1B}[H",
"hide": "\u{1B}[?25l",
"show": "\u{1B}[?25h",
]
var output: String = ""
output += draw["nw"] ?? "?"
2026-04-01 11:11:43 -04:00
output += String(repeating: draw["n"] ?? "?", count: on[0].count + 2)
2026-03-31 18:42:04 -04:00
output += draw["ne"] ?? "?"
output += "\n"
for y: Int in 0..<on.count {
2026-04-01 11:11:43 -04:00
output += "\(draw["w"] ?? "?") "
2026-03-31 18:42:04 -04:00
for x: Int in 0..<on[0].count {
output += cursorPosition == Vector2(x: x, y: y) ? "\u{1B}[7m" : "\u{1B}[27m"
let position: Vector2 = Vector2(x: x, y: y)
if position == path.first {
2026-04-01 11:11:43 -04:00
output += "\(Color.Yellow)\(draw["start"] ?? "?")\(Color.Default)"
2026-03-31 18:42:04 -04:00
continue
}
if position == path.last {
2026-04-01 11:11:43 -04:00
output += "\(Color.Cyan)\(draw["goal"] ?? "?")\(Color.Default)"
2026-03-31 18:42:04 -04:00
continue
}
if path.contains(position) {
let nodeIndex: Int = path.firstIndex(of: position) ?? 0
let positionAfter = path[nodeIndex + 1]
let deltaX = positionAfter.x - position.x
let deltaY = positionAfter.y - position.y
var arrow = ""
switch (deltaX, deltaY) {
case (-1, -1):
arrow = ""
case (0, -1):
arrow = ""
case (1, -1):
arrow = ""
case (-1, 0):
arrow = ""
case (1, 0):
arrow = ""
case (-1, 1):
arrow = ""
case (0, 1):
arrow = ""
case (1, 1):
arrow = ""
default:
arrow = "?"
}
2026-04-01 11:11:43 -04:00
output += "\(Color.Green)\(arrow)\(Color.Default)"
2026-03-31 18:42:04 -04:00
continue
}
output += on[y][x] == 1 ? draw["wall"] ?? "?" : draw["empty"] ?? "?"
}
output += "\u{1B}[27m"
2026-04-01 11:11:43 -04:00
output += " \(draw["e"] ?? "?")"
2026-03-31 18:42:04 -04:00
output += "\n"
}
output += draw["sw"] ?? "?"
2026-04-01 11:11:43 -04:00
output += String(repeating: draw["s"] ?? "?", count: on[0].count + 2)
2026-03-31 18:42:04 -04:00
output += draw["se"] ?? "?"
output += "\n\n"
2026-04-01 11:11:43 -04:00
output += draw["nw"] ?? "?"
output += String(repeating: draw["n"] ?? "?", count: 7)
output += draw["ne"] ?? "?"
output += "\n\(draw["w"] ?? "?")"
2026-03-31 18:42:04 -04:00
output += directions[0] ? "" : " "
output += directions[1] ? "" : " "
output += directions[2] ? "" : " "
2026-04-01 11:11:43 -04:00
output += " \(draw["e"] ?? "?") Path length: \(path.count <= 2 ? "\(Color.Red)Not found\(Color.Default)" : "\(Color.Green)\(String(path.count - 1))\(Color.Default)")\n\(draw["w"] ?? "?")"
2026-03-31 18:42:04 -04:00
output += directions[3] ? "" : " "
output += " "
output += directions[4] ? "" : " "
2026-04-01 11:11:43 -04:00
output += " \(draw["e"] ?? "?")\n\(draw["w"] ?? "?")"
2026-03-31 18:42:04 -04:00
output += directions[5] ? "" : " "
output += directions[6] ? "" : " "
output += directions[7] ? "" : " "
2026-04-01 11:11:43 -04:00
output += " \(draw["e"] ?? "?") Press [Q] to quit\n"
output += draw["sw"] ?? "?"
output += String(repeating: draw["s"] ?? "?", count: 7)
output += draw["se"] ?? "?"
2026-03-31 18:42:04 -04:00
print(draw["clear"] ?? "?", terminator: "")
print(draw["home"] ?? "?", terminator: "")
print(output)
}
2026-04-04 15:35:28 -04:00
static func parseInitfile(_ initfilePath: String, grid: inout [[Int]], start: inout Vector2, goal: inout Vector2) {
if initfilePath != "-" {
do {
let fileURL = URL(fileURLWithPath: initfilePath)
let text = try String(contentsOf: fileURL, encoding: .utf8)
var starts = 0
var goals = 0
grid = text.split(whereSeparator: \.isNewline).enumerated().map({ y, line in
return line.enumerated().map({ x, char in
if char == "S" {
starts += 1
start = Vector2(x: x, y: y)
}
else if char == "F" {
goals += 1
goal = Vector2(x: x, y: y)
}
return char == "#" ? 1 : 0
})
})
if starts == 0 {
throw InitfileError.missingStart
} else if goals == 0 {
throw InitfileError.missingGoal
}
if starts != 1 {
throw InitfileError.multipleStarts
} else if goals != 1 {
throw InitfileError.multipleGoals
}
if grid.count == 0 || grid[0].count == 0 { throw InitfileError.emptyFile }
for row in grid {
if row.count != grid[0].count {
throw InitfileError.unevenRowLengths
}
}
return
} catch InitfileError.missingStart {
print("No start symbol \"S\" found in initfile.\nPress enter to continue...")
_ = readLine()
} catch InitfileError.missingGoal {
print("No goal symbol \"F\" found in initfile.\nPress enter to continue...")
_ = readLine()
} catch InitfileError.multipleStarts {
print("Multiple start symbols (\"S\") found in initfile.\nPress enter to continue...")
_ = readLine()
} catch InitfileError.multipleGoals {
print("Multiple goal symbols (\"F\") found in initfile.\nPress enter to continue...")
_ = readLine()
} catch InitfileError.emptyFile {
print("Initfile is empty.\nPress enter to continue...")
_ = readLine()
} catch InitfileError.unevenRowLengths {
print("Uneven row lengths in initfile.\nPress enter to continue...")
_ = readLine()
} catch {
print("Error processing initfile.\nPress enter to continue...")
_ = readLine()
}
}
// Clear screen and go home
print("\u{1B}[2J", terminator: "")
print("\u{1B}[H", terminator: "")
// From https://patorjk.com/software/taag
print(#"""
$$$$$$\ $$$$$$\ $$\ $$\
$$ __$$\ $$\$$\ $$ __$$\ $$ |$$ |
$$ / $$ | \$$$ | $$ / \__| $$$$$$\ $$$$$$$\ $$$$$$$ |$$$$$$$\ $$$$$$\ $$\ $$\
$$$$$$$$ |$$$$$$$\ \$$$$$$\ \____$$\ $$ __$$\ $$ __$$ |$$ __$$\ $$ __$$\ \$$\ $$ |
$$ __$$ |\_$$$ __| \____$$\ $$$$$$$ |$$ | $$ |$$ / $$ |$$ | $$ |$$ / $$ | \$$$$ /
$$ | $$ | $$ $$\ $$\ $$ |$$ __$$ |$$ | $$ |$$ | $$ |$$ | $$ |$$ | $$ | $$ $$<
$$ | $$ | \__\__| \$$$$$$ |\$$$$$$$ |$$ | $$ |\$$$$$$$ |$$$$$$$ |\$$$$$$ |$$ /\$$\
\__| \__| \______/ \_______|\__| \__| \_______|\_______/ \______/ \__/ \__|
Welcome to the A* Sandbox, an interactive terminal-based visualizer for the A* graph traversal
algorithm. You can enter a width and height for the grid, then use the following controls to
experiment with the algorithm. The optimal path will refresh every time you make a change to
the grid.
Controls:
- Cursor movement: WASD, HJKL, or Arrow Keys
- Place wall: Space
- Place start: [
- Place goal: ]
- Restrict valid movement options: number keys (directions based on number pad)
"""#)
print("Enter the grid width: ", terminator: "")
let width: Int = Int(readLine() ?? "") ?? 16
print("Enter the grid height: ", terminator: "")
let height: Int = Int(readLine() ?? "") ?? 8
grid = Array(repeating: Array(repeating: 0, count: width), count: height)
start = Vector2(x: 0, y: 0)
goal = Vector2(x: width - 1, y: 0)
}
static func noUI(_ path: String) {
do {
let fileURL = URL(fileURLWithPath: path)
let text = try String(contentsOf: fileURL, encoding: .utf8)
var start: Vector2 = Vector2(x: 0, y: 0)
var goal: Vector2 = Vector2(x: 0, y: 0)
let grid = text.split(whereSeparator: \.isNewline).enumerated().map({ y, line in
return line.enumerated().map({ x, char in
if char == "S" { start = Vector2(x: x, y: y) }
else if char == "F" { goal = Vector2(x: x, y: y) }
return char == "#" ? 1 : 0
})
})
let aStar = A_Star()
let path = aStar.pathfind(grid: grid, start: start, goal: goal)
aStar.display(path, on: grid)
} catch {
print("Error processing input file.")
}
}
2026-03-31 18:42:04 -04:00
static func clamp(_ value: Int, low: Int, high: Int) -> Int {
if value < low {
return low
}
if value > high {
return high
}
return value
}
}
enum Key {
case Up
case Down
case Left
case Right
case Wall
case Start
case Finish
case NW
case N
case NE
case E
case SE
case S
case SW
case W
2026-04-01 11:11:43 -04:00
case Quit
2026-03-31 18:42:04 -04:00
case Unknown
}
struct Terminal {
2026-03-31 19:58:45 -04:00
#if os(macOS) || os(Linux)
2026-03-31 18:42:04 -04:00
nonisolated(unsafe) static var originalTerm = termios()
2026-03-31 19:58:45 -04:00
#endif
2026-03-31 18:42:04 -04:00
static func readKey() -> Key {
var byte: UInt8 = 0
#if os(Windows)
let ch = _getch()
if ch == 224 || ch == 0 {
let nextCh = _getch()
2026-03-31 19:58:45 -04:00
switch nextCh {
2026-03-31 18:42:04 -04:00
case 72: return .Up
2026-03-31 19:58:45 -04:00
case 80: return .Down
2026-03-31 18:42:04 -04:00
case 75: return .Left
case 77: return .Right
default: return .Unknown
}
}
byte = UInt8(ch)
#else
read(STDIN_FILENO, &byte, 1)
if byte == 27 {
var seq1: UInt8 = 0
var seq2: UInt8 = 0
read(STDIN_FILENO, &seq1, 1)
read(STDIN_FILENO, &seq2, 1)
if seq1 == 91 {
switch seq2 {
case 65: return .Up
case 66: return .Down
case 67: return .Right
case 68: return .Left
default: return .Unknown
}
}
}
#endif
let character = String(UnicodeScalar(byte))
switch character {
case "w":
return .Up
case "k":
return .Up
case "a":
return .Left
case "h":
return .Left
case "s":
return .Down
case "j":
return .Down
case "d":
return .Right
case "l":
return .Right
case " ":
return .Wall
case "[":
return .Start
case "]":
return .Finish
case "7":
return .NW
case "8":
return .N
case "9":
return .NE
case "4":
return .W
case "6":
return .E
case "1":
return .SW
case "2":
return .S
case "3":
return .SE
2026-04-01 11:11:43 -04:00
case "q":
return .Quit
2026-03-31 18:42:04 -04:00
default:
return .Unknown
}
}
static func enableRawMode() {
#if os(macOS) || os(Linux)
tcgetattr(STDIN_FILENO, &originalTerm)
var rawTerm = originalTerm
rawTerm.c_lflag &= ~tcflag_t(ICANON | ECHO)
tcsetattr(STDIN_FILENO, TCSANOW, &rawTerm)
2026-04-04 15:35:28 -04:00
signal(SIGINT, quitHandler)
2026-03-31 18:42:04 -04:00
#endif
}
static func disableRawMode() {
#if os(macOS) || os(Linux)
tcsetattr(STDIN_FILENO, TCSANOW, &originalTerm)
#endif
2026-03-31 09:59:05 -04:00
}
}
2026-04-04 15:35:28 -04:00
func quitHandler(_ signal: Int32) {
quit(clear: true)
}
func quit(clear: Bool = true) {
2026-03-31 18:42:04 -04:00
Terminal.disableRawMode()
2026-04-04 15:35:28 -04:00
2026-03-31 18:42:04 -04:00
// Show cursor
print("\u{1B}[?25h", terminator: "")
2026-04-04 15:35:28 -04:00
// Clear + return home
if clear {
print("\u{1B}[2J", terminator: "")
print("\u{1B}[H", terminator: "")
}
2026-03-31 18:42:04 -04:00
exit(0)
}
2026-03-31 09:59:05 -04:00
struct Vector2: Hashable {
var x: Int
var y: Int
static func == (lhs: Vector2, rhs: Vector2) -> Bool {
return lhs.x == rhs.x && lhs.y == rhs.y
}
2026-03-31 18:42:04 -04:00
static func += (lhs: inout Vector2, rhs: Vector2) {
lhs = Vector2(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
2026-03-31 09:59:05 -04:00
func hash(into hasher: inout Hasher) {
hasher.combine(x)
hasher.combine(y)
}
}
2026-04-01 11:11:43 -04:00
struct Color {
static let Black = "\u{1B}[90m"
static let Red = "\u{1B}[91m"
static let Green = "\u{1B}[92m"
static let Yellow = "\u{1B}[93m"
static let Blue = "\u{1B}[94m"
static let Magenta = "\u{1B}[95m"
static let Cyan = "\u{1B}[96m"
static let White = "\u{1B}[97m"
static let Default = "\u{1B}[39m"
}
2026-04-04 15:35:28 -04:00
enum InitfileError: Error {
case emptyFile
case missingStart
case missingGoal
case multipleStarts
case multipleGoals
case unevenRowLengths
}