theseus 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Rakefile +1 -24
- data/bin/theseus +2 -259
- data/lib/theseus/algorithms/base.rb +31 -0
- data/lib/theseus/algorithms/kruskal.rb +81 -0
- data/lib/theseus/algorithms/prim.rb +81 -0
- data/lib/theseus/algorithms/recursive_backtracker.rb +80 -0
- data/lib/theseus/cli.rb +377 -0
- data/lib/theseus/formatters/png.rb +13 -45
- data/lib/theseus/maze.rb +74 -103
- data/lib/theseus/version.rb +2 -2
- data/test/maze_test.rb +9 -9
- metadata +48 -58
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'theseus/algorithms/base'
|
2
|
+
|
3
|
+
module Theseus
|
4
|
+
module Algorithms
|
5
|
+
# The recursive backtracking algorithm is a quick, flexible algorithm
|
6
|
+
# for generating mazes. It tends to produce mazes with fewer dead-ends
|
7
|
+
# than algorithms like Kruskal's or Prim's.
|
8
|
+
class RecursiveBacktracker < Base
|
9
|
+
# The x-coordinate that the generation algorithm will consider next.
|
10
|
+
attr_reader :x
|
11
|
+
|
12
|
+
# The y-coordinate that the generation algorithm will consider next.
|
13
|
+
attr_reader :y
|
14
|
+
|
15
|
+
def initialize(maze, options={}) #:nodoc:
|
16
|
+
super
|
17
|
+
|
18
|
+
loop do
|
19
|
+
@y = rand(@maze.height)
|
20
|
+
@x = rand(@maze.row_length(@y))
|
21
|
+
break if @maze.valid?(@x, @y)
|
22
|
+
end
|
23
|
+
|
24
|
+
@tries = @maze.potential_exits_at(@x, @y).shuffle
|
25
|
+
@stack = []
|
26
|
+
end
|
27
|
+
|
28
|
+
def do_step #:nodoc:
|
29
|
+
direction = next_direction or return false
|
30
|
+
nx, ny = @maze.move(@x, @y, direction)
|
31
|
+
|
32
|
+
@maze.apply_move_at(@x, @y, direction)
|
33
|
+
|
34
|
+
# if (nx,ny) is already visited, then we're weaving (moving either over
|
35
|
+
# or under the existing passage).
|
36
|
+
nx, ny, direction = @maze.perform_weave(@x, @y, nx, ny, direction) if @maze[nx, ny] != 0
|
37
|
+
|
38
|
+
@maze.apply_move_at(nx, ny, @maze.opposite(direction))
|
39
|
+
|
40
|
+
@stack.push([@x, @y, @tries])
|
41
|
+
@tries = @maze.potential_exits_at(nx, ny).shuffle
|
42
|
+
@tries.push direction if @tries.include?(direction) unless rand(100) < @maze.randomness
|
43
|
+
@x, @y = nx, ny
|
44
|
+
|
45
|
+
return true
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# Returns the next direction that ought to be attempted by the recursive
|
51
|
+
# backtracker. This will also handle the backtracking. If there are no
|
52
|
+
# more directions to attempt, and the stack is empty, this will return +nil+.
|
53
|
+
def next_direction #:nodoc:
|
54
|
+
loop do
|
55
|
+
direction = @tries.pop
|
56
|
+
nx, ny = @maze.move(@x, @y, direction)
|
57
|
+
|
58
|
+
if @maze.valid?(nx, ny) && (@maze[@x, @y] & (direction | (direction << Maze::UNDER_SHIFT)) == 0)
|
59
|
+
if @maze[nx, ny] == 0
|
60
|
+
return direction
|
61
|
+
elsif !@maze.dead?(@maze[nx, ny]) && @maze.weave > 0 && rand(100) < @maze.weave
|
62
|
+
# see if we can weave over/under the cell at (nx,ny)
|
63
|
+
return direction if @maze.weave_allowed?(@x, @y, nx, ny, direction)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
while @tries.empty?
|
68
|
+
if @stack.empty?
|
69
|
+
@pending = false
|
70
|
+
return nil
|
71
|
+
else
|
72
|
+
@x, @y, @tries = @stack.pop
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/lib/theseus/cli.rb
ADDED
@@ -0,0 +1,377 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'theseus'
|
3
|
+
require 'theseus/formatters/png'
|
4
|
+
|
5
|
+
require 'theseus/algorithms/recursive_backtracker'
|
6
|
+
require 'theseus/algorithms/kruskal'
|
7
|
+
require 'theseus/algorithms/prim'
|
8
|
+
|
9
|
+
module Theseus
|
10
|
+
class CLI
|
11
|
+
TYPE_MAP = {
|
12
|
+
"ortho" => Theseus::OrthogonalMaze,
|
13
|
+
"delta" => Theseus::DeltaMaze,
|
14
|
+
"sigma" => Theseus::SigmaMaze,
|
15
|
+
"upsilon" => Theseus::UpsilonMaze,
|
16
|
+
}
|
17
|
+
|
18
|
+
ALGO_MAP = {
|
19
|
+
"backtrack" => Theseus::Algorithms::RecursiveBacktracker,
|
20
|
+
"kruskal" => Theseus::Algorithms::Kruskal,
|
21
|
+
"prim" => Theseus::Algorithms::Prim,
|
22
|
+
}
|
23
|
+
|
24
|
+
def self.run(*args)
|
25
|
+
new(*args).run
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_accessor :animate
|
29
|
+
attr_accessor :delay
|
30
|
+
attr_accessor :output
|
31
|
+
attr_accessor :sparse
|
32
|
+
attr_accessor :unicursal
|
33
|
+
attr_accessor :type
|
34
|
+
attr_accessor :format
|
35
|
+
attr_accessor :solution
|
36
|
+
|
37
|
+
attr_reader :maze_opts
|
38
|
+
attr_reader :png_opts
|
39
|
+
|
40
|
+
def initialize(*args)
|
41
|
+
args.flatten!
|
42
|
+
|
43
|
+
@animate = false
|
44
|
+
@delay = 50
|
45
|
+
@output = "maze"
|
46
|
+
@sparse = 0
|
47
|
+
@unicursal = false
|
48
|
+
@type = "ortho"
|
49
|
+
@format = :ascii
|
50
|
+
@solution = nil
|
51
|
+
|
52
|
+
@png_opts = Theseus::Formatters::PNG::DEFAULTS.dup
|
53
|
+
@maze_opts = { mask: nil, width: nil, height: nil,
|
54
|
+
randomness: 50, weave: 0, symmetry: :none, braid: 0, wrap: :none,
|
55
|
+
entrance: nil, exit: nil, algorithm: ALGO_MAP["backtrack"] }
|
56
|
+
|
57
|
+
option_parser.parse!(args)
|
58
|
+
|
59
|
+
if args.any?
|
60
|
+
abort "extra arguments detected: #{args.inspect}"
|
61
|
+
end
|
62
|
+
|
63
|
+
normalize_settings!
|
64
|
+
end
|
65
|
+
|
66
|
+
def run
|
67
|
+
if @animate
|
68
|
+
run_animation
|
69
|
+
else
|
70
|
+
run_static
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def run_animation
|
77
|
+
if format == :ascii
|
78
|
+
run_ascii_animation
|
79
|
+
else
|
80
|
+
run_png_animation
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def clear_screen
|
85
|
+
print "\e[2J"
|
86
|
+
end
|
87
|
+
|
88
|
+
def cursor_home
|
89
|
+
print "\e[H"
|
90
|
+
end
|
91
|
+
|
92
|
+
def show_maze
|
93
|
+
cursor_home
|
94
|
+
puts @maze.to_s(:mode => :unicode)
|
95
|
+
end
|
96
|
+
|
97
|
+
def run_ascii_animation
|
98
|
+
clear_screen
|
99
|
+
|
100
|
+
@maze.generate! do
|
101
|
+
show_maze
|
102
|
+
sleep(@delay)
|
103
|
+
end
|
104
|
+
|
105
|
+
show_maze
|
106
|
+
end
|
107
|
+
|
108
|
+
def write_frame(step, options={})
|
109
|
+
f = "%s-%04d.png" % [@output, step]
|
110
|
+
step += 1
|
111
|
+
File.open(f, "w") { |io| io.write(@maze.to(:png, @png_opts.merge(options))) }
|
112
|
+
print "."
|
113
|
+
end
|
114
|
+
|
115
|
+
def run_png_animation
|
116
|
+
step = 0
|
117
|
+
@maze.generate! do
|
118
|
+
write_frame(step)
|
119
|
+
step += 1
|
120
|
+
end
|
121
|
+
|
122
|
+
write_frame(step)
|
123
|
+
step += 1
|
124
|
+
|
125
|
+
if @solution
|
126
|
+
solver = @maze.new_solver(type: @solution)
|
127
|
+
|
128
|
+
while solver.step
|
129
|
+
path = solver.to_path(color: @png_opts[:solution_color])
|
130
|
+
write_frame(step, paths: [path])
|
131
|
+
step += 1
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
puts
|
136
|
+
puts "done, %d frames written to %s-*.png" % [step, @output]
|
137
|
+
end
|
138
|
+
|
139
|
+
def run_static
|
140
|
+
@maze.generate!
|
141
|
+
@sparse.times { @maze.sparsify! }
|
142
|
+
|
143
|
+
if @unicursal
|
144
|
+
enter_at = @unicursal_entrance || [-1,0]
|
145
|
+
if enter_at[0] > 0 && enter_at[0] < width*2
|
146
|
+
exit_at = [enter_at[0]+1, enter_at[1]]
|
147
|
+
else
|
148
|
+
exit_at = [enter_at[0], enter_at[1]+1]
|
149
|
+
end
|
150
|
+
@maze = @maze.to_unicursal(entrance: enter_at, exit: exit_at)
|
151
|
+
end
|
152
|
+
|
153
|
+
if @format == :ascii
|
154
|
+
puts @maze.to_s(:mode => :unicode)
|
155
|
+
else
|
156
|
+
@png_opts[:solution] = @solution
|
157
|
+
File.open(@output + ".png", "w") { |io| io.write(@maze.to(:png, @png_opts)) }
|
158
|
+
puts "maze written to #{@output}.png"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def normalize_settings!
|
163
|
+
# default width to height, and vice-versa
|
164
|
+
@maze_opts[:width] ||= @maze_opts[:height]
|
165
|
+
@maze_opts[:height] ||= @maze_opts[:width]
|
166
|
+
|
167
|
+
if @maze_opts[:mask].nil? && (@maze_opts[:width].nil? || @maze_opts[:height].nil?)
|
168
|
+
warn "You must specify either a mask (-m) or the maze dimensions(-w or -H)."
|
169
|
+
abort "Try --help for a full list of options."
|
170
|
+
end
|
171
|
+
|
172
|
+
if @animate
|
173
|
+
abort "sparse cannot be used for animated mazes" if @sparse > 0
|
174
|
+
abort "cannot animate unicursal mazes" if @unicursal
|
175
|
+
|
176
|
+
if @format != :ascii
|
177
|
+
@png_opts[:background] = ChunkyPNG::Color.from_hex(@png_opts[:background]) unless Fixnum === @png_opts[:background]
|
178
|
+
|
179
|
+
if @png_opts[:background] & 0xFF != 0xFF
|
180
|
+
warn "if you intend to make a movie out of the frames from the animation,"
|
181
|
+
warn "it is HIGHLY RECOMMENDED that you use a fully opaque background color."
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# convert delay to a fraction of a second
|
186
|
+
@delay = @delay / 1000.0
|
187
|
+
end
|
188
|
+
|
189
|
+
if @solution
|
190
|
+
abort "cannot display solution in ascii mode" if @format == :ascii
|
191
|
+
end
|
192
|
+
|
193
|
+
if @unicursal
|
194
|
+
@unicursal_entrance = @maze_opts.delete(:entrance)
|
195
|
+
@maze_opts[:entrance] = [0,0]
|
196
|
+
@maze_opts[:exit] = [0,0]
|
197
|
+
end
|
198
|
+
|
199
|
+
@maze_opts[:mask] ||= Theseus::TransparentMask.new(@maze_opts[:width], @maze_opts[:height])
|
200
|
+
@maze_opts[:width] ||= @maze_opts[:mask].width
|
201
|
+
@maze_opts[:height] ||= @maze_opts[:mask].height
|
202
|
+
@maze = TYPE_MAP[@type].new(@maze_opts)
|
203
|
+
|
204
|
+
if @unicursal && !@maze.respond_to?(:to_unicursal)
|
205
|
+
abort "#{@type} mazes do not support the -u (unicursal) option"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def option_parser
|
210
|
+
OptionParser.new do |opts|
|
211
|
+
setup_required_options(opts)
|
212
|
+
setup_output_options(opts)
|
213
|
+
setup_maze_options(opts)
|
214
|
+
setup_formatting_options(opts)
|
215
|
+
setup_misc_options(opts)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def setup_required_options(opts)
|
220
|
+
opts.separator ""
|
221
|
+
opts.separator "Required options:"
|
222
|
+
|
223
|
+
opts.on("-w", "--width N", Integer, "width of the maze (default 20, or mask width)") do |w|
|
224
|
+
@maze_opts[:width] = w
|
225
|
+
end
|
226
|
+
|
227
|
+
opts.on("-H", "--height N", Integer, "height of the maze (default 20 or mask height)") do |h|
|
228
|
+
@maze_opts[:height] = h
|
229
|
+
end
|
230
|
+
|
231
|
+
opts.on("-m", "--mask FILE", "png file to use as mask") do |m|
|
232
|
+
case m
|
233
|
+
when /^triangle:(\d+)$/ then @maze_opts[:mask] = Theseus::TriangleMask.new($1.to_i)
|
234
|
+
else @maze_opts[:mask] = Theseus::Mask.from_png(m)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
def setup_output_options(opts)
|
240
|
+
opts.separator ""
|
241
|
+
opts.separator "Output options:"
|
242
|
+
|
243
|
+
opts.on("-a", "--[no-]animate", "emit frames for each step") do |v|
|
244
|
+
@animate = v
|
245
|
+
end
|
246
|
+
|
247
|
+
opts.on("-D", "--delay N", Integer, "time to wait between animation frames, in ms, default is #{@delay}") do |d|
|
248
|
+
@delay = d
|
249
|
+
end
|
250
|
+
|
251
|
+
opts.on("-o", "--output FILE", "where to save the file(s) (for png only)") do |f|
|
252
|
+
@output = f
|
253
|
+
end
|
254
|
+
|
255
|
+
opts.on("-f", "--format FMT", "png, ascii (default #{@format})") do |f|
|
256
|
+
@format = f.to_sym
|
257
|
+
end
|
258
|
+
|
259
|
+
opts.on("-V", "--solve [METHOD]", "whether to display the solution of the maze.", "METHOD is either `backtracker' (the default) or `astar'") do |s|
|
260
|
+
@solution = (s || :backtracker).to_sym
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def setup_maze_options(opts)
|
265
|
+
opts.separator ""
|
266
|
+
opts.separator "Maze options:"
|
267
|
+
|
268
|
+
opts.on("-s", "--seed N", Integer, "random seed to use") do |s|
|
269
|
+
srand(s)
|
270
|
+
end
|
271
|
+
|
272
|
+
opts.on("-A", "--algorithm NAME", "the algorithm to use to generate the maze.",
|
273
|
+
"may be any of #{ALGO_MAP.keys.sort.join(",")}.",
|
274
|
+
"defaults to `backtrack'.") do |a|
|
275
|
+
@maze_opts[:algorithm] = ALGO_MAP[a] or abort "unknown algorithm `#{a}'"
|
276
|
+
end
|
277
|
+
|
278
|
+
opts.on("-t", "--type TYPE", "#{TYPE_MAP.keys.sort.join(",")} (default: #{@type})") do |t|
|
279
|
+
@type = t
|
280
|
+
end
|
281
|
+
|
282
|
+
opts.on("-u", "--[no-]unicursal", "generate a unicursal maze (results in 2x size)") do |u|
|
283
|
+
@unicursal = u
|
284
|
+
end
|
285
|
+
|
286
|
+
opts.on("-y", "--symmetry TYPE", "one of none,x,y,xy,radial (default is '#{@maze_opts[:symmetry]}')") do |s|
|
287
|
+
@maze_opts[:symmetry] = s.to_sym
|
288
|
+
end
|
289
|
+
|
290
|
+
opts.on("-e", "--weave N", Integer, "0-100, chance of a passage to go over/under another (default #{@maze_opts[:weave]})") do |v|
|
291
|
+
@maze_opts[:weave] = v
|
292
|
+
end
|
293
|
+
|
294
|
+
opts.on("-r", "--random N", Integer, "0-100, randomness of maze (default #{@maze_opts[:randomness]})") do |r|
|
295
|
+
@maze_opts[:randomness] = r
|
296
|
+
end
|
297
|
+
|
298
|
+
opts.on("-S", "--sparse N", Integer, "how sparse to make the maze (default #{@sparse})") do |s|
|
299
|
+
@sparse = s
|
300
|
+
end
|
301
|
+
|
302
|
+
opts.on("-d", "--braid N", Integer, "0-100, percentage of deadends to remove (default #{maze_opts[:braid]})") do |b|
|
303
|
+
@maze_opts[:braid] = b
|
304
|
+
end
|
305
|
+
|
306
|
+
opts.on("-R", "--wrap axis", "none,x,y,xy (default #{@maze_opts[:wrap]})") do |w|
|
307
|
+
@maze_opts[:wrap] = w.to_sym
|
308
|
+
end
|
309
|
+
|
310
|
+
opts.on("-E", "--enter [X,Y]", "the entrance of the maze (default -1,0)") do |s|
|
311
|
+
@maze_opts[:entrance] = s.split(/,/).map { |v| v.to_i }
|
312
|
+
end
|
313
|
+
|
314
|
+
opts.on("-X", "--exit [X,Y]", "the exit of the maze (default width,height-1)") do |s|
|
315
|
+
@maze_opts[:exit] = s.split(/,/).map { |v| v.to_i }
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def setup_formatting_options(opts)
|
320
|
+
opts.separator ""
|
321
|
+
opts.separator "Formatting options:"
|
322
|
+
|
323
|
+
opts.on("-B", "--background COLOR", "rgba hex background color for maze (default %08X)" % @png_opts[:background]) do |c|
|
324
|
+
@png_opts[:background] = c
|
325
|
+
end
|
326
|
+
|
327
|
+
opts.on("-C", "--cellcolor COLOR", "rgba hex cell color for maze (default %08X)" % @png_opts[:cell_color]) do |c|
|
328
|
+
@png_opts[:cell_color] = c
|
329
|
+
end
|
330
|
+
|
331
|
+
opts.on("-L", "--wallcolor COLOR", "rgba hex wall color for maze (default %08X)" % @png_opts[:wall_color]) do |c|
|
332
|
+
@png_opts[:wall_color] = c
|
333
|
+
end
|
334
|
+
|
335
|
+
opts.on("-U", "--solutioncolor COLOR", "rgba hex color for the answer path (default %08X)" % @png_opts[:solution_color]) do |c|
|
336
|
+
@png_opts[:solution_color] = c
|
337
|
+
end
|
338
|
+
|
339
|
+
opts.on("-c", "--cell N", Integer, "size of each cell (default #{@png_opts[:cell_size]})") do |c|
|
340
|
+
@png_opts[:cell_size] = c
|
341
|
+
end
|
342
|
+
|
343
|
+
opts.on("-b", "--border N", Integer, "border padding around outside (default #{@png_opts[:outer_padding]})") do |c|
|
344
|
+
@png_opts[:outer_padding] = c
|
345
|
+
end
|
346
|
+
|
347
|
+
opts.on("-p", "--padding N", Integer, "padding around cell (default #{@png_opts[:cell_padding]})") do |c|
|
348
|
+
@png_opts[:cell_padding] = c
|
349
|
+
end
|
350
|
+
|
351
|
+
opts.on("-W", "--wall N", Integer, "thickness of walls (default #{@png_opts[:wall_width]})") do |c|
|
352
|
+
@png_opts[:wall_width] = c
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def setup_misc_options(opts)
|
357
|
+
opts.separator ""
|
358
|
+
opts.separator "Other options:"
|
359
|
+
|
360
|
+
opts.on_tail("-v", "--version", "display the Theseus version and exit") do
|
361
|
+
maze = Theseus::OrthogonalMaze.generate(width: 20, height: 4)
|
362
|
+
s = maze.to_s(mode: :lines).strip
|
363
|
+
print s.gsub(/^/, " ").sub(/^\s*/, "theseus --")
|
364
|
+
|
365
|
+
require 'theseus/version'
|
366
|
+
puts "--> v#{Theseus::Version::STRING}"
|
367
|
+
puts "a maze generator, renderer, and solver by Jamis Buck <jamis@jamisbuck.org>"
|
368
|
+
exit
|
369
|
+
end
|
370
|
+
|
371
|
+
opts.on_tail("-h", "--help", "this helpful list of options") do
|
372
|
+
puts opts
|
373
|
+
exit
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|