# Maze generator, version 1
# Copyright (C) 2024 Foxarc
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
extends Node
var language = TranslationServer.get_loaded_locales().find(TranslationServer.get_locale().left(-3))
# For every case size, / 1 2 3 4 5 6 7 8 9
var max_cases = [0, [57,27], [28,13], [19,9], [14,6], [11,5], [9,4], [8,3], [7,3], [6,3]]
var offset = [0, [0,0], [10,10], [0,0], [10,30], [20,20], [30,30], [10,60], [10,30], [30,0]]
var render_cells = []
var time_spent = 0
var generating = false
var JS_animation = []
func clear_cells():
for i in range(render_cells.size()):
for j in range(render_cells[i].size()):
render_cells[i][j].queue_free()
render_cells.clear()
func rerender_cells(new_x,new_y):
if $"Export".visible:
$"Export".visible = false
clear_cells()
$"Progress".visible = false
var border = StyleBoxFlat.new()
border.bg_color = Color("303030")
border.border_color = Color("ffffff")
border.border_width_top = 1
border.border_width_right = 1
border.border_width_bottom = 1
border.border_width_left = 1
for x in range(new_x):
render_cells.append([])
for y in range(new_y):
render_cells[x].append(Panel.new())
render_cells[x][y].name = str(x) + " " + str(y)
render_cells[x][y].add_theme_stylebox_override("panel",border)
render_cells[x][y].size.x = 20*$"case_size".value
render_cells[x][y].size.y = 20*$"case_size".value
render_cells[x][y].position.x = render_cells[x][y].size.x*x + offset[$"case_size".value][0]
render_cells[x][y].position.y = render_cells[x][y].size.y*y + offset[$"case_size".value][1]
$"maze".add_child(render_cells[x][y])
func update_cell(x,y,state):
var update = render_cells[x][y].get_theme_stylebox("panel").duplicate()
var cell_type = {0:Color("303030"),1:Color("ff0000"),2:Color("00ff00"),3:Color("ffffff"),4:Color("800080"),5:Color("0000ff")}
update.bg_color = cell_type[state]
render_cells[x][y].add_theme_stylebox_override("panel",update)
if Vector2(x,y) == get_tooltip_position():
update_tooltip(-1,-1)
func get_cell_state(x,y):
if x < 0 or x > render_cells.size()-1 or y < 0 or y > render_cells[x].size()-1:
return -1
var state = render_cells[x][y].get_theme_stylebox("panel").bg_color
# 5: Visual only, 3-way intersection
# 4: Visual only, openable wall cell
# 3: Visual only, available cell
# 2: Visited cell
# 1: Unopenable wall cell
# 0: Free cell
var cell_type = {Color("303030"):0,Color("ff0000"):1,Color("00ff00"):2,Color("ffffff"):3,Color("800080"):4,Color("0000ff"):5}
return cell_type[state]
func get_tooltip_position():
return Vector2(int($"Tooltip/position/x".text) - 1,int($"Tooltip/position/y".text) - 1)
func update_tooltip(x,y):
var update_only = (x == -1 and y == -1)
var inside_maze_renderer = (x >= $"maze".position.x and x < $"maze".size.x+$"maze".position.x) and (y >= $"maze".position.y and y < $"maze".size.y+$"maze".position.y)
var relative_position = Vector2(x - $"maze".position.x - offset[$"case_size".value][0] , y - $"maze".position.y - offset[$"case_size".value][1])
var inside_case = (relative_position.x > 0 and relative_position.y > 0) and (relative_position.x < $"maze".size.x - offset[$"case_size".value][0]*2 and relative_position.y < $"maze".size.y - offset[$"case_size".value][1]*2)
if update_only or inside_maze_renderer:
if update_only or inside_case:
if !$"Tooltip".visible:
$"Tooltip".visible = true
var case
if !update_only:
$"Tooltip".position = Vector2(x,y)
case = (relative_position/(20*$"case_size".value)).floor()
$"Tooltip/position/x".text = str(case.x + 1)
$"Tooltip/position/y".text = str(case.y + 1)
else:
case = Vector2(int($"Tooltip/position/x".text) - 1,int($"Tooltip/position/y".text) - 1)
var resize = ["CASE_FREE", "CASE_CLOSED", "CASE_VISITED", "CASE_AVAILABLE", "CASE_WALL", "CASE_INTERSECTION"]
var state = get_cell_state(case.x,case.y)
if state == -1:
$"Tooltip".size.x = 86
$"Tooltip/case_type".size.x = 73
$"Tooltip/case_type".size.y = 24
$"Tooltip/case_type".text = ""
else:
$"Tooltip/case_type".size.y = 43
$"Tooltip/case_type".text = resize[state]
$"Tooltip".size.x = max(86,$"Tooltip/case_type".get_content_width() + 4*2)
$"Tooltip/case_type".size.x = $"Tooltip".size.x
if x+$"Tooltip".size.x > $"maze".position.x+$"maze".size.x:
$"Tooltip".position.x -= $"Tooltip".size.x
if y+$"Tooltip".size.y > $"maze".position.y+$"maze".size.y:
$"Tooltip".position.y -= $"Tooltip".size.y
time_spent = Time.get_ticks_msec()
elif $"Tooltip".visible:
$"Tooltip".visible = false
time_spent = 0
elif $"Tooltip".visible:
$"Tooltip".visible = false
time_spent = 0
return true
func update_progress(taken:float,remaining:float):
$"Progress/bar".size.x = (taken/remaining)*1152
$"Progress/count".position.x = 1110 - $"Progress/text".get_content_width() - 3
$"Progress/count".text = str(taken) + "/" + str(remaining)
func get_save_path(file_format,callback):
var path = FileDialog.new()
path.name = "SAVE"
path.file_mode = FileDialog.FILE_MODE_SAVE_FILE
path.access = FileDialog.ACCESS_FILESYSTEM
path.filters = PackedStringArray(["*." + file_format])
path.use_native_dialog = true
path.file_selected.connect(callback)
path.visible = true
add_child(path)
func write_save_path(file_format,path,content):
var shortest = 2 + file_format.length() + 1
# Werewolves are on your way
if path.length() < shortest or\
# For know-it-all users
path.right(shortest-1) == "\\." + file_format or\
# For normal users
path.right(shortest-2) != "." + file_format:
path += "." + file_format
var file = FileAccess.open(path,FileAccess.WRITE)
file.store_string(content)
func maze_to_CSV():
get_save_path("csv",save_CSV)
func maze_to_JS():
get_save_path("html",save_JS)
func save_CSV(path):
var CSV = ""
for y in range(render_cells[0].size()):
for x in range(render_cells.size()):
CSV += str(get_cell_state(x,y)) + ","
CSV = CSV.left(-1) + "\n"
write_save_path("csv",path,CSV)
func save_JS(path):
var JS = '''
'''
var JS_array = "["
JS_array += str(JS_animation[0]) + "," + str(JS_animation[1]) + ",["
for x in range(JS_animation[2].size()):
JS_array += "["
for y in range(JS_animation[2][x].size()):
JS_array += str(JS_animation[2][x][y]) + ","
JS_array = JS_array.left(-1) + "],"
JS_array = JS_array.left(-1) + "],"
for i in range(3,JS_animation.size()):
JS_array += str(JS_animation[i]) + ","
JS_array = JS_array.left(-1) + "]"
JS = JS.replace("JS_ANIMATION",JS_array)
write_save_path("html",path,JS)
func _on_surface_size_x_changed(value):
rerender_cells(value,$"surface_size_y".value)
func _on_surface_size_y_changed(value):
rerender_cells($"surface_size_x".value,value)
func _on_case_size_value_changed(value):
$"surface_size_x".max_value = max_cases[value][0]
$"surface_size_y".max_value = max_cases[value][1]
$"Limitations/x/max".text = str(max_cases[value][0])
$"Limitations/y/max".text = str(max_cases[value][1])
rerender_cells($"surface_size_x".value,$"surface_size_y".value)
func _on_record_toggled(toggled_on):
if (toggled_on):
$"Captions/Visualization box".visible = false
$"Captions/Visualization text".visible = false
else:
$"Captions/Visualization box".visible = true
$"Captions/Visualization text".visible = true
func _on_generate_pressed():
if $"Language hint".visible:
$"Language hint".visible = false
if $"Export".visible:
$"Export".visible = false
JS_animation = []
$"Progress".visible = false
if generating:
generating = false
$"Generate".text = "GENERATE"
return true
var generated = false
# For know-it-all users
if $"surface_size_x".value == 1:
for y in range(render_cells[0].size()):
update_cell(0,y,1)
generated = true
elif $"surface_size_y".value == 1:
for x in range(render_cells.size()):
update_cell(x,0,1)
generated = true
elif $"surface_size_x".value == 2 and $"surface_size_y".value == 2:
update_cell(0,0,1)
update_cell(1,0,1)
update_cell(0,1,1)
update_cell(1,1,1)
generated = true
elif $"surface_size_x".value == 2 or $"surface_size_y".value == 2:
for x in range($"surface_size_x".value):
for y in range(render_cells[x].size()):
update_cell(x,y,1)
generated = true
elif $"surface_size_x".value >= 100 or $"surface_size_y".value >= 100:
return "foxes will now attack u"
if !generated:
$"surface_size_x".editable = false
$"surface_size_y".editable = false
$"case_size".editable = false
$"Generate".text = "CANCEL"
$"record".disabled = true
if $"record".button_pressed:
generated = await generate_and_record(Vector2($"surface_size_x".value,$"surface_size_y".value),render_cells)
else:
var cells = await generate(Vector2($"surface_size_x".value,$"surface_size_y".value),$"case_size".value)
for x in range(render_cells.size()):
for y in range(render_cells[x].size()):
update_cell(x,y,cells[x][y])
generated = true
$"surface_size_x".editable = true
$"surface_size_y".editable = true
$"case_size".editable = true
$"Generate".text = "GENERATE"
$"record".disabled = false
if generated:
if !JS_animation.size():
$"Export/JS".disabled = true
$"Export/JS".text = "NO_JS"
else:
$"Export/JS".disabled = false
$"Export/JS".text = "EXPORT_JS"
$"Export".visible = true
func _ready():
$"Captions/Visualization box".size.x = min(137 + $"Captions/3-way intersection text".get_content_width() + 3,293)
$"Captions/Visualization text".position.x = $"Captions/Visualization box".position.x + $"Captions/Visualization box".size.x + 2
$"Captions/Visualization text".size.x = min($"Captions/Visualization text".get_content_width(),637)
rerender_cells($"surface_size_x".value,$"surface_size_y".value)
func _input(event):
if event is InputEventMouseMotion:
update_tooltip(event.position.x,event.position.y)
func _process(_delta):
if time_spent:
if Time.get_ticks_msec() > time_spent + 1*1000:
if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
elif Input.get_mouse_mode() == Input.MOUSE_MODE_HIDDEN:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
elif Input.get_mouse_mode() == Input.MOUSE_MODE_HIDDEN:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if Input.is_action_just_pressed("language"):
language = (language + 1) % TranslationServer.get_loaded_locales().size()
TranslationServer.set_locale(TranslationServer.get_loaded_locales()[language])
$"Captions/Visualization box".size.x = min(137 + $"Captions/3-way intersection text".get_content_width() + 3,293)
$"Captions/Visualization text".position.x = $"Captions/Visualization box".position.x + $"Captions/Visualization box".size.x + 2
$"Captions/Visualization text".size.x = min($"Captions/Visualization text".get_content_width(),637)
$"Progress/count".position.x = 1110 - $"Progress/text".get_content_width() - 3
update_tooltip(-1,-1)
# Input: Surface size, Case size.
# Output: Maze.
func generate(floor_surface,size):
size = 1 # Not now for a version 1 of the maze generator.
var floor_size = floor_surface/size
# Recursive backtracker algorithm using stack for low-performance computers
#
# From Wikipedia (06/29/2024):
# Randomized depth-first search
# This algorithm, also known as the "recursive backtracker" algorithm, is a randomized version of the depth-first search algorithm.
#
# Frequently implemented with a stack, this approach is one of the simplest ways to generate a maze using a computer. Consider the space for a
# maze being a large grid of cells (like a large chess board), each cell starting with four walls. Starting from a random cell, the computer then
# selects a random neighbouring cell that has not yet been visited. The computer removes the wall between the two cells and marks the new cell as
# visited, and adds it to the stack to facilitate backtracking. The computer continues this process, with a cell that has no unvisited neighbours
# being considered a dead-end. When at a dead-end it backtracks through the path until it reaches a cell with an unvisited neighbour, continuing
# the path generation by visiting this new, unvisited cell (creating a new junction). This process continues until every cell has been visited,
# causing the computer to backtrack all the way back to the beginning cell. We can be sure every cell is visited.
#
# As given above this algorithm involves deep recursion which may cause stack overflow issues on some computer architectures. The algorithm can be
# rearranged into a loop by storing backtracking information in the maze itself. This also provides a quick way to display a solution, by starting
# at any given point and backtracking to the beginning.
#
# Mazes generated with a depth-first search have a low branching factor and contain many long corridors, because the algorithm explores as far as
# possible along each branch before backtracking.
#
# Iterative implementation (with stack)
#
# A disadvantage of the first approach is a large depth of recursion – in the worst case,
# the routine may need to recur on every cell of the area being processed, which may exceed the maximum recursion stack depth in many environments.
# As a solution, the same backtracking method can be implemented with an explicit stack, which is usually allowed to grow much bigger with no harm.
#
# 1. Choose the initial cell, mark it as visited and push it to the stack
# 2. While the stack is not empty
# 1. Pop a cell from the stack and make it a current cell
# 2. If the current cell has any neighbours which have not been visited
# 1. Push the current cell to the stack
# 2. Choose one of the unvisited neighbours
# 1. Remove the wall between the current cell and the chosen cell
# 2. Mark the chosen cell as visited and push it to the stack
# 2D cells
var cells = []
# But first:
# Our game is going to have its own rules for making the maze. Recall there is a distance between walls.
# All algorithms will assume that the size of the walls around our cells doesn't matter at all. On the
# contrary, recall there is a size for the floor of the backrooms. If we didn't consider the size for
# the walls, we will find walls of a thickness of a paper and they will be out of touch for a backroom.
#
# Walls thickness must be considered: we do not need to make a new layer above the cells to mark the
# walls around the cells but we can reuse the existing cells to mark walls around them that is for
# each cell, there is exactly 8 cells of walls around it. It will make sense and won't cause a problem
# to any algorithm in existence as we can simply skip the wall cell.
# We will enclose the edges of the floor by walls.
# For each cell:
# - 0 means the cell is free
# - 1 means the cell is a wall
# - 2 means the cell was visited
for x in range(floor_size.x):
cells.append([])
cells[x].resize(floor_size.y)
cells[x].fill(0)
cells[x][0] = 1 # The first row will always be walls
for y in range(1,floor_size.y-1):
# Add walls on the edges of the floor
cells[0][y] = 1
cells[floor_size.x-1][y] = 1
for x in range(floor_size.x):
cells[x][floor_size.y-1] = 1 # We finish the last row with walls
# How many cells are really available? We need another calculation for that.
# Recall that each cell will have exactly 8 walls cells around it. Since there are 8 walls cells around a
# cell, that means a cell takes a cell above and under, left and right for walls.
#
# 2 more rules:
# 1. A cell is allowed to own the walls at the edges of the floor.
# 2. A new cell will share another cell's walls if there are existing walls created by this previous cell.
#
# As a result, the first cell will have 8 walls around it, the next ones 8-3 = 5 walls from by sharing the
# previous cell's walls, all the way up until the edge of the floor.
var free_x = int(1 + (floor_size.x - 3)/2) # First cell will take 3 cases for walls, the next ones 2.
var free_y = int(1 + (floor_size.y - 3)/2) # Ditto.
# Apply the instructions from Wikipedia:
var stack = []
# 1. Choose the initial cell, mark it as visited and push it to the stack
var cur = [2*(randi() % free_x) + 1,2*(randi() % free_y) + 1] # Start with the cells, add +1 to find the free cell
cells[cur[0]][cur[1]] = 2
stack.append(cur)
# 2. While the stack is not empty
while stack.size() != 0:
# 1. Pop a cell from the stack and make it a current cell
cur = stack.back()
stack.pop_back()
# 2. If the current cell has any neighbours which have not been visited
var sides = []
if cur[1]-2 > -1 and cells[cur[0]][cur[1]-2] == 0: # Up
sides.append([cur[0],cur[1]-2])
if cur[0]+2 < floor_size.x and cells[cur[0]+2][cur[1]] == 0: # Right
sides.append([cur[0]+2,cur[1]])
if cur[1]+2 < floor_size.y and cells[cur[0]][cur[1]+2] == 0: # Down
sides.append([cur[0],cur[1]+2])
if cur[0]-2 > -1 and cells[cur[0]-2][cur[1]] == 0: # Left
sides.append([cur[0]-2,cur[1]])
if sides.size():
# 1. Push the current cell to the stack
stack.append(cur)
# 2. Choose one of the unvisited neighbours
var next = sides[randi() % sides.size()]
# 1. Remove the wall between the current cell and the chosen cell
# 2. Mark the chosen cell as visited and push it to the stack
cells[ cur[0] + (next[0]-cur[0])/2 ][ cur[1] + (next[1]-cur[1])/2 ] = 2
cells[next[0]][next[1]] = 2
stack.append(next)
# When we are done with generating the maze, fill the remaining "free" cells with walls.
for x in range(cells.size()):
for y in range(cells[x].size()):
if cells[x][y] == 0:
cells[x][y] = 1
return cells
# Input: Surface size, Case size, Render cells.
func generate_and_record(floor_size,cells):
var progress_speed = (1/floor_size.x + 1/floor_size.y)
generating = true
for x in range(floor_size.x):
update_cell(x,0,1)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
for y in range(1,floor_size.y-1):
for x in range(floor_size.x):
update_cell(x,y,0)
update_cell(0,y,1)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
update_cell(floor_size.x-1,y,1)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
for x in range(floor_size.x):
update_cell(x,floor_size.y-1,1)
var free_x = int(1 + (floor_size.x - 3)/2)
var free_y = int(1 + (floor_size.y - 3)/2)
var free_cells = free_x*free_y
var progress = 0
$"Progress".visible = true
update_progress(progress,free_cells)
var stack = []
# Mark available cells for visualization
for x in range(free_x):
for y in range(free_y):
update_cell(2*x+1,2*y+1,3)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
# Mark walls cells for visualization
for x in range(1,cells.size()-1):
for y in range(1,cells[x].size()-1):
if y % 2 == 0:
update_cell(x,y,4)
elif 2*x < cells.size()-1 and y < cells[x].size()-1:
update_cell(2*x,y,4)
# Copy the untouched maze (preview) into JS animation
JS_animation = [floor_size.x,floor_size.y,[]]
for x in range(floor_size.x):
JS_animation[2].append([])
for y in range(floor_size.y):
JS_animation[2][x].append(get_cell_state(x,y))
var cur = [2*(randi() % free_x) + 1,2*(randi() % free_y) + 1]
update_cell(cur[0],cur[1],2)
progress += 1
update_progress(progress,free_cells)
JS_animation.append(cur[0])
JS_animation.append(cur[1])
JS_animation.append(2)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
stack.append(cur)
while stack.size() != 0:
cur = stack.back()
stack.pop_back()
var unchecked = [ [cur[0],cur[1]-2] , [cur[0]+2,cur[1]] , [cur[0],cur[1]+2] , [cur[0]-2,cur[1]] ] # Up, Right, Down, Left
var sides = []
for pos in unchecked:
if get_cell_state(pos[0],pos[1]) == 0 or get_cell_state(pos[0],pos[1]) == 3 or get_cell_state(pos[0],pos[1]) == 4:
sides.append(pos)
unchecked = [ [cur[0],cur[1]-1] , [cur[0]+1,cur[1]] , [cur[0],cur[1]+1] , [cur[0]-1,cur[1]] ]
var connected = []
for pos in unchecked:
if get_cell_state(pos[0],pos[1]) == 2 or get_cell_state(pos[0],pos[1]) == 5:
connected.append(pos)
if sides.size():
stack.append(cur)
# We consider an intersection to be a cell with 3 sides connected to the cell
if connected.size() == 2: # It is 2 as the algorithm always resumes from a +1 connected cell (2+1 = 3)
update_cell(cur[0],cur[1],5) # Mark the cell as intersection for render only
JS_animation.append(cur[0])
JS_animation.append(cur[1])
JS_animation.append(5)
var next = sides[randi() % sides.size()]
update_cell(cur[0] + (next[0]-cur[0])/2, cur[1] + (next[1]-cur[1])/2, 2)
JS_animation.append(cur[0] + (next[0]-cur[0])/2)
JS_animation.append(cur[1] + (next[1]-cur[1])/2)
JS_animation.append(2)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
update_cell(next[0],next[1],2)
progress += 1
update_progress(progress,free_cells)
JS_animation.append(next[0])
JS_animation.append(next[1])
JS_animation.append(2)
if !generating:
return false
await get_tree().create_timer(progress_speed).timeout
stack.append(next)
for x in range(cells.size()):
for y in range(cells[x].size()):
if get_cell_state(x,y) == 0 or get_cell_state(x,y) == 4:
update_cell(x,y,1)
JS_animation.append(-1)
JS_animation.append(-1)
JS_animation.append(-1)
generating = false
return true