theseus 1.0.2 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|