I'm just starting with Ruby and decided to do two different implementations of Conway's Game of Life. In this first one I'm not using cells
as a separate class and instead just track everything as a location on the board, using a state pattern I guess.
Anyway, I want this code to display good Object-Oriented programming basics as well as construction via test-driven development, using rspec.
Please critique away and tell me if it's under/over engineered for the problem, not extensible enough or too extended, as well as if my TDD progression was worthwhile, did I do enough or too few tests, or maybe not the right tests.
board.rb:
class Board
attr_accessor :width,:height
def initialize(cells=[], width = 3, height = 3)
@cells = cells
@height = height
@width = width
end
def live_cells
@cells
end
def is_alive?(x,y)
@cells.include?([x,y])
end
def neighbors_to(x,y)
@cells.select do |cell_x, cell_y|
dx = (x-cell_x).abs
dy = (y-cell_y).abs
#If the tuple x,y is a neighbor of that cell then the following is true
dx <= 1 and dy <= 1 and not (dx == 0 and dy == 0)
end.length
#We only care how many there are and do not care which cells in particular
end
def evolve
survivors = []
#Scan the entire board (may be able to be more clever here but you need to check each dead cell for ressurection)
@width.times do |x|
@height.times do |y|
survivors << [x,y] if should_survive?(x,y) or should_ressurect?(x,y)
end
end
#Populating the new board from survivors allows reference to the current generation while populating the next generation
Board.new(survivors, self.width,self.height)
end
def should_survive?(x,y)
(neighbors_to(x,y) == 2 or neighbors_to(x,y) == 3) and is_alive?(x,y)
end
def should_ressurect?(x,y)
neighbors_to(x,y) == 3 and not is_alive?(x,y)
end
def print_board
puts "Current Generation"
puts
@width.times do |x|
@height.times do |y|
print "#{is_alive?(x,y) ? '#' : '-'}"
end
puts
end
puts
end
end
board_spec.rb:
require 'spec_helper'
describe Board do
context "#new" do
it "should be created empty if passed empty array" do
Board.new([]).live_cells.should be_empty
end
it "should be created empty if passed nothing at all" do
Board.new
end
it "should contain the cells created with it" do
Board.new([[1,1],[2,2]]).live_cells.should be ==[[1,1],[2,2]]
end
end
context "#evolve" do
it "should evolve an empty board if empty" do
next_gen = next_generation_for([])
next_gen.should be_empty
end
it "should kill a lonely cell (< 2 neighbors)" do
next_gen = next_generation_for([[1,1]])
next_gen.should be_empty
end
it "should keep a cell with 2 neighbors alive" do
#pending("We need to count neighbors")
next_gen = next_generation_for([[1,1],[1,2],[1,0]])
next_gen.should include([1,1])
end
it "should keep a cell with 3 neighbors alive" do
#pending("We need to count neighbors")
next_gen = next_generation_for([[1,1],[1,2],[1,0],[0,0]])
next_gen.should include([1,1])
end
it "should revive a dead cell with 3 neighbors (reproduction)" do
#pending("Dimensions of the board to find dead cells")
next_gen = next_generation_for([[1,0],[0,1],[2,1]])
next_gen.should include([1,1])
end
it "should kill any cell with more than 3 neighbors" do
next_gen = next_generation_for([[0,1],[2,1],[1,2],[1,0],[1,1]])
next_gen.should_not include([1,1])
end
end
def next_generation_for(seed,width = 3, height = 3)
board = Board.new(seed, width, height)
board.evolve.live_cells
end
it "should be able to handle the blinker formation 10 times" do
seed = [[1,2],[1,1],[1,0]]
n = 0
while n<=10
next_gen = next_generation_for(seed)
next_gen.should satisfy{|cells| cells =~[[0,1],[1,1],[2,1]] or [[1,2],[1,1],[1,0]]}
seed = next_gen
n+=1
end
end
it "should be able to handle the block formation 10 times" do
seed = [[1,1],[2,1],[2,2],[1,2]]
n = 0
while n <= 10
next_gen = next_generation_for(seed)
next_gen.should =~ [[1,1],[2,1],[2,2],[1,2]]
seed = next_gen
n+=1
end
end
it "should be able to handle the beehive formation 10 times" do
seed = [[1,0],[2,0],[3,1],[2,2],[1,2],[0,1]]
n = 0
while n <= 10
next_gen = next_generation_for(seed,4,4)
next_gen.should =~ [[1,0],[2,0],[3,1],[2,2],[1,2],[0,1]]
seed = next_gen
n+=1
end
end
it "should be able to print the current board" do
board = Board.new([[1,1],[0,1],[0,2]],5,5)
board.print_board
end
end
describe Board, "counting neighbors" do
it "should find zero neighbors to a solitary cell" do
board = Board.new([[1,1]])
board.neighbors_to(1,1).should == 0
end
it "should find one neighbor to a single cell" do
board = Board.new([[1,1]])
board.neighbors_to(0,0).should == 1
board.neighbors_to(0,1).should == 1
board.neighbors_to(0,2).should == 1
board.neighbors_to(1,2).should == 1
board.neighbors_to(2,2).should == 1
board.neighbors_to(2,1).should == 1
board.neighbors_to(2,0).should == 1
board.neighbors_to(1,0).should == 1
end
it "should find two neighbors to cells if needed" do
board = Board.new([[0,1],[2,2]])
board.neighbors_to(1,1).should == 2
board.neighbors_to(1,2).should == 2
end
it "should find three neighbors to cells if needed" do
board = Board.new([[0,1],[2,2],[1,2]])
board.neighbors_to(1,1).should == 3
board.neighbors_to(2,2).should == 1
end
end
spec_helper
just has a pointer to require board.rb. I'm trying to get into this practice for larger programs so adding new classes won't require me to edit individual spec files. I also have a file, run_game.rb, that simply allows me to choose different patterns and iterates the game.
Some things that stand out to me:
- There is very little user fault tolerance, i.e. I can give it cells on a board that's too small and it runs just fine, it just never displays cells off the board. Is it worth the extra complication to solve such problems, i.e. make it more durable to a user or is this structure enough to communicate a foundation understanding of OO programming?
- Is there repetition in the
live_cells
method? I could just useattr_accessor :cells
but that's far less readable. - Would it be a "better" implementation from an OO programming standpoint to have
cells
as a class as well?
x-y
, alwaysx - y
. Conversly,x = -y
, neverx = - y
. \$\endgroup\$