Last time I showed you the functions necessary for Life — for John Conway’s game of Life, that is. We ended up with a set of functions you can use to generate frames of a Life session.
This time I’ll show you an object-oriented version (a Life class) along with some other tweaks to make things look nicer.
I’ll assume you’ve read the previous post and jump right in. The ultimate goal here is a class that lets us do this:
life = ConwayLife((640,480), (5,5), fpath=ImgFrmsPath) life.go(2000, percentage=0.18) #
The first parameter is the image size to create; the second parameter is the cell size. Both are in pixels. Only the image size is required. The default cell size is (4,4).
fpath parameter provides a directory to put the images in.
go() method kicks off the frame-generation process. It requires a count of how many frames to generate. The optional
percentage parameter controls how many cells are created to initialize the game grid (the default is 0.5, 50%).
ConwayLife class does all the heavy lifting.
Here’s what first part of that class:
class ConwayLife (object): def __init__ (self, dims, cell=(4,4), **kwargs): self.dims = dims self.cell = cell self.fname = 'life-%05d.png' # kwarg self.fpath = BasePath # kwarg self.fmode = 'PNG' # kwarg self.cols = self.dims//self.cell self.rows = self.dims//self.cell self.buf1 = None self.buf2 = None self.colors = ConwayLife.colorlist(LifeColors) def go (self, cycles=20, percentage=0.5): # Clear buffers to zero... self.buf1 = [[0 for c in range(self.cols)] for r in range(self.rows)] self.buf2 = [[0 for c in range(self.cols)] for r in range(self.rows)] # Initialize buffers with some life... for r in range(self.rows): for c in range(self.cols): self.buf1[r][c] = (1 if random() < percentage else 0) # Cycle the life... for n in range(cycles): self._life_cycle() self._save_frame(n) #
These methods create and use the class. The
go() method is the only public method. The other methods all implement the Life machinery.
One note: for horizontal space reasons, the
**kwargs parameter represents the three properties marked below with “kwarg” — in reality, the
fmode, parameters and defaults are explicitly called out in the signature.
Next we have the second-level methods,
The former executes one cycle of Life; the latter generates an image frame of the results of that cycle:
def _life_cycle (self): # For each row and column... for r in range(self.rows): for c in range(self.cols): # Do the neighbor counts... self._count_neighbors(r,c) # For each row and column... for r in range(self.rows): for c in range(self.cols): # Apply the counts per the rules... self._apply_rule(r,c) def _save_frame (self, frm_nbr): filename = path.join(self.fpath, self.fname % frm_nbr) dims = (self.cols*self.cell, self.rows*self.cell) # Create image... im = Image.new('RGB', dims, (255,255,255)) draw = ImageDraw.Draw(im) # For each row... for y in range(self.rows): # For each column... for x in range(self.cols): # Get the cell value and color... v = self.buf1[y][x] c = self.colors(v) # Calculate cell's ULC... x0 = x * self.cell y0 = y * self.cell # Draw the cell... for yy in range(self.cell): for xx in range(self.cell): draw.point((x0+xx,y0+yy), fill=c) # Save the image... im.save(filename, self.fmode) return filename #
_life_cycle() method is essentially the same as before. It first scans the entire grid doing a neighbor count for each cell. Then it scans the board again to update the cells (via the
_save_frame() method is also basically the same as last time. It scans the grid and draws the appropriate block (dead or alive) in the image. As you’ll see, we’re tracking how old cells have lived, and we use that age to determine what color to make the cell.
(Please refer to the Pillow documentation for details on the
Now we’re down to the third-level methods that actually do the work of the Life cycle,
The functions do pretty much what their names suggest:
def _count_neighbors (self, row, col): total = 0 left = (self.cols-1) if col == 0 else (col-1) right = (col+1) if col < (self.cols-1) else 0 top = (self.rows-1) if row == 0 else (row-1) bot = (row+1) if row < (self.rows-1) else 0 # Top row... total += (1 if (0 < self.buf1[top][left] ) else 0) total += (1 if (0 < self.buf1[top][col] ) else 0) total += (1 if (0 < self.buf1[top][right]) else 0) # Middle row... total += (1 if (0 < self.buf1[row][left] ) else 0) total += (1 if (0 < self.buf1[row][right]) else 0) # Bottom row... total += (1 if (0 < self.buf1[bot][left] ) else 0) total += (1 if (0 < self.buf1[bot][col] ) else 0) total += (1 if (0 < self.buf1[bot][right]) else 0) # Save total in count(r,c)... self.buf2[row][col] = total return total def _apply_rule (self, row, col): age = self.buf1[row][col] # cell age non = self.buf2[row][col] # number of neighbors self.buf1[row][col] = self._rule(age, non) def _rule (self, age, neighbors): if 0 < age: # Cell is alive (needs 2 or 3 to survive)... return ((age+1) if (neighbors in [2,3]) else 0) # Cell is dead (needs 3 to spawn)... return (1 if neighbors == 3 else 0) #
_count_neighbors() method calculates the surrounding rows and columns as before, but this time we can’t just use the cell value. Previously a cell was either dead (value = zero) or alive (value = one). We could count neighbors just by adding the values of the neighbors.
The cell value is the age of the cell. Zero is still dead, but one or more is alive (with the value saying how many cycles the cell has lived). Therefore our count of a neighbor cell has to explicitly be 0 or 1 depending on whether the cell tests dead or alive.
As before, we store the count in the matching cell of the other buffer.
_apply_rule() method starts the same in grabbing the call’s age and neighbor count, but then it defers to the
_rule() method to actually apply the rule. The intent is to easily allow alternate rule sets.
_rule() method is the same rules we saw before. A living cell needs 2 or 3 neighbors to keep living. A dead cell comes to life if it has exactly 3 neighbors.
That just leaves the code for determining what color to make the cells. The whole point of keeping track of their age is change the color as they get older.
The general idea is that a new born cell starts off with a bright color (like white) and then that color changes and fades as the cell ages. This makes the resulting animation emphasize new cell activity.
I’m still playing around with colorizing, but here’s the current code:
colorlist class (which is inside the
ConwayLife class) just converts an input list of tuples into a function that returns a certain color given a cell age.
LifeColors = [ ( 1, (0x00,0x00,0x00)), # Dead cell ( 1, (0xff,0xff,0xff)), # Born cell ( 2, (0xff,0xff,0xbf)), # Live cell aging... ( 3, (0xff,0xff,0x7f)), ( 4, (0xff,0xff,0x3f)), ( 5, (0xff,0xff,0x00)), ( 6, (0xbf,0xff,0x00)), ( 7, (0x7f,0xff,0x00)), ( 8, (0x3f,0xff,0x00)), ( 9, (0x00,0xff,0x00)), (10, (0x00,0xbf,0x3f)), (20, (0x00,0x7f,0x7f)), ( 0, (0x00,0x3f,0x7f)), ] class colorlist (object): def __init__ (self, list_of_colors): self._cs =  cc = 0 for entry in list_of_colors: self._cs.append((cc, entry)) cc += entry def __call__ (self, age): t = [c for c in self._cs if c <= age] return t[-1] def __len__ (self): return len(self._cs) def __getitem__ (self, ix): return self._cs[ix] #
LifeColors list of tuples is the default input, but users can provide their own. (And I can easily experiment.) Each tuple consists of a number and a color. The number determines how many cycles a cell should be this color.
The order of the list determines the order of colors. The very first entry is the dead cell color. The rest apply to living cells in the list order. The very last entry is returned once the cell ages beyond the penultimate entry.
And that’s it, a parameterized ConwayLife class that you can use to generate image frames of a Life session.
RIP John Horton Conway!
(He actually kinda hated that this is what he’s most known for, but it is a fascinating thing, Life. Complexity from simple rules. Awesome!)