This is a simple Game of Life implementation, with one neat gimmick: You can specify the rules that the simulator uses to decide whether cells should live or die. Congrats, God!
I'd especially like tips on:
- Idiomaticity: As always, I abuse language features, and I'd like help doing that.
- Efficiency: After a certain point, it takes a while to print out the next generation -- whether this is from figuring it out or printing it, I'm not sure. Any tips to speed up both phases are appreciated.
- Edge cases: I tested a few common patterns (spaceships, bombs, etc.) in various rules, but I don't know enough about cellular automata to make sure it works in every scenario. I can't imagine why it wouldn't, though.
- Prettiness: The output is kinda clunky. I'm open to improvement in the format of that as well.
game_of_life.rb
class Hash
def boundaries
return [[0, 0], [0, 0]] if self.empty?
x_bounds = self.keys.inject([self.keys[0][0]] * 2) { |(min, max), (x, _)| [min, x, max].minmax }
y_bounds = self.keys.inject([self.keys[0][1]] * 2) { |(min, max), (_, y)| [min, y, max].minmax }
[[x_bounds[0], y_bounds[0]], [x_bounds[1], y_bounds[1]]]
end
def hmap(&block)
Hash[map { |k, v| block.call(k, v) }]
end
end
class Array
def neighbors
range = (-1..1).to_a
range.product(range)
.reject { |item| item == [0,0] }
.map do |loc|
loc.zip(self).map { |arr| arr.inject(:+) }
end
end
def count
self.each_with_object(Hash.new 0) do |item, count|
count[item] += 1
end
end
end
def iterate(board, rules)
board if board.empty?
board.keys.map(&:neighbors).flatten(1).count
.hmap { |loc, count| [loc, [count, board[loc]]] }
.hmap do |loc, (count, alive)|
if rules[alive ? :s : :b].include?(count)
alive = true
else
alive = false
end
[loc, alive]
end.reject { |_, alive| !alive }
end
def draw_board(board)
puts 'No living cells!' if board.empty?
top_left, bot_right = board.boundaries
top_left[1].upto(bot_right[1]) do |y|
top_left[0].upto(bot_right[0]) do |x|
print (board[[x, y]] ? 'X' : ' ')
end
puts
end
end
generations = Integer(ARGV.shift)
rules = Hash[ARGV.shift.split('/').map(&:chars).map { |arr| [arr.shift.downcase.to_sym, arr.map(&:to_i)] }]
board = Hash[
File.open(ARGV.shift) { |file| file.readlines.map { |line| line.chomp.chars } }
.map.with_index { |row, y| row.map.with_index { |cell, x| [[x, y], cell != ' '] } }
.flatten(1)
.reject { |(_, data)| !data }
]
board.default = false
def pretty(list, conjunction)
if list.length == 1
list[0]
elsif list.length == 2
"#{list[0]} #{conjunction} #{list[1]}"
else
"#{list[0...-1].join(', ')}, #{conjunction} #{list[-1]}"
end
end
puts 'Rules:'
puts "* Cells stay alive with #{pretty(rules[:s], 'or')} neighbors"
puts "* Cells are born with #{pretty(rules[:b], 'or')} neighbors"
puts '* In all other cases, cells die or stay dead.'
puts '***'
(0..generations).each do |round|
puts "Generation #{round}:"
draw_board(board)
board = iterate(board, rules)
end
The syntax is as follows:
ruby game_of_life.rb <generations> <rules> <starting map>
where generations
is a positive integer, rules
is a string in the format of B3/S23
; the B
side represents when cells go from dead to alive and the S
side represents when they stay alive between generations. starting map
is the name of a file which contains Gen 0; spaces represent dead cells, any other (printable, non-newline) character represents a living one.
To simulate 50 generations of this pattern in Conway's Game of Life:
t
bo x
op txt
save that pattern to a file called map.txt
in the same directory as game_of_life.rb
, then run this command:
ruby game_of_life.rb 50 B3/S23 map.txt
The output will be long, but should end with:
Generation 50:
XX
XX
X
X X
XX
block.call
because I'm more used to Java-esque programming (i.e. functional interfaces) and passing a block as a parameter, then calling it, is more comfortable than using a magic function. \$\endgroup\$