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.
@@ -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
@@ -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