Learning objectives
In this code along, we will build on what we learned in the previous code along about two-dimensional arrays to improve on our visualization of a Game of Life board. We will first write some code to read a board from a file to prevent having to manually enter cells. We will then learn how to use some existing code for drawing to store an image corresponding to a Game of Life board.
Visualizing a single board will prepare us to draw a collection of boards in the next code along, when we will implement the Game of Life and visualize a collection of boards over multiple generations as an animated GIF.
Setup
To complete this code along and the following code along, you will need starter code that will help us draw objects and generate images.
First, you will need to install a package called "llgcode/draw2d"
that we will use for drawing. To install this code into your Go directory, download this code as a .zip file here, and then expand the folder. You will see an llgcode
folder that contains draw2d
as a directory. In your go/src directory, if you do not already have a github.com
folder, then create one. Then, add llgcode
to this folder as a subdirectory.
Next, draw2d
requires a couple of “dependencies” of other code projects including functions that it calls.
golang/freetype
, which can be downloaded here. Expand the file, and then move thegolang
folder intogo/src/github.com
alongsidellgcode
.golang.org/x/image
, which can be downloaded here. Expand the file, and then move thegolang.org
folder intogo/src
.
We provide starter code as a single download gameOfLifeStarterCode.zip
. Download this code here, and then expand the folder. You should see three directories. Move each of these folders to your go/src
directory.
gameOfLife
: will contain the engine for implementing the Game of Life.canvas
: communicates withdraw2d
to allow us to draw automata and produce images.gifhelper
: code for producing animated GIFs.
We will not need the code contained in gifhelper
until the next code along, when we will be generating an animated GIF for multiple generations of the Game of Life. We will explain the contents of canvas
later in this code along when it comes time to draw a board.
As for gameOfLife
, this directory has the following structure:
boards
: a directory containing.csv
files that represent different initial Game of Life board states (more on this directory shortly).output
: an initially empty directory that will contain images and GIFs that we draw.datatypes.go
: a file that will contain type declarations (more to come shortly).functions.go
: a file that will contain functions that we will write in the next code along for implementing the Game of Life.io.go
: a file that will contain code for reading in a Game of Life board from file.drawing.go
: a file that will contain code for drawing a Game of Life board to a file.main.go
: a file that will containfunc main()
, where we will call functions to read in Game of Life boards and then draw them.
Code along summary
Declaring a GameBoard type
We already know that we conceptualize a Game of Life board as a two-dimensional slice of boolean variables, in which true
denotes that a cell is alive, and false
denotes that it is dead. When implementing the Game of Life in the next code along, we will need to store such a board for each of a number of generations. The result of the simulation will therefore have type []([][]bool)
, which is a three-dimensional slice of boolean variables.
Do not worry if you find concept this tricky to conceptualize. Rather, think about the end result of the Game of Life as providing us with a slice of game boards, where each game board is a two-dimensional slice of boolean variables.
Fortunately, Go provides a way to implement this abstraction. In datatypes.go
, we will add the following type declaration that establishes a new GameBoard
type.
// GameBoard is a two-dimensional slice of boolean variables // representing a single generation of a Game of Life board. type GameBoard [][]bool
Once we have added this code, we can use GameBoard
in place of [][]bool
whenever we are referring to a Game of Life board. As a result, the Game of Life simulation that we will complete in the next code along will produce a variable of type []GameBoard
, a slice of GameBoard
objects.
We will say much more about the transition toward abstracting variables as objects in our next chapter. For now, let’s learn how to read in a GameBoard
from file and then draw it.
Reading in a Game of Life board from file
In the previous code along, we entered the cells of a Game of Life board manually, an approach that will not work for larger boards. Instead, let’s write a function to read a Game of Life board from file. Note that the boards
directory contains three comma-separated files.
dinnerTable.csv
: a period-12 oscillator that we encountered in the main text.rPentomino.csv
: the five-cell R Pentomino pattern that produces surprisingly complex behavior, including the production of six gliders.gospergun.csv
: the “Gosper gun” that manufactures gliders at a constant rate.
We reproduce the animations of these three initial patterns below in order from left to right according to the above order.
Open and examine one of the files in boards
. Each row of the file contains a single line of comma-separated integers representing a row of the board, with alive cells represented by 1 and dead cells represented by 0.
We will write a function ReadBoardFromFile()
in io.go
that takes as input a string filename
, read in a file from the location specified in filename
, and then return a GameBoard
representing the automaton specified in the file according to the abovementioned format.
At the top of io.go
, we need to import three packages:
log
: for logging errors;os
: for opening files;strings
: for manipulating data parsed from files.
package main import ( "log" // needed for error reporting "os" // needed for reading in from file "strings" // needed for manipulating data parsed from file )
First, we read in the file using os.ReadFile()
, which will return two things: a slice of byte
variables containing all characters in the file, which we will call fileContents
, and an error variable that we call err
. As in previous code alongs on file parsing, we will ensure that there were no issues in reading the file by passing err
into a Check()
subroutine.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // to fill in } // Check takes as input a variable of error type. // If the variable has any value other than nil, it panics. func Check(e error) { if e != nil { log.Fatal(e) } }
Next, we convert the fileContents
slice into a string giantString
representing the entire contents of the file. We then use strings.Split()
to split this string into a slice of strings lines
, where each element of lines
is a string corresponding to a single line of the file.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) lines := strings.Split(giantString, "\n") // to fill in } // Check takes as input a variable of error type. // If the variable has any value other than nil, it panics. func Check(e error) { if e != nil { log.Fatal(e) } }
We now know that len(lines)
, the number of lines in the file, is equal to the number of rows in our GameBoard
, and so we can go ahead and declare this GameBoard
, which we will call board
. Because of our type declaration, instead of declaring board := make([][]bool, len(lines))
, we will replace [][]bool
with GameBoard
.
We will also add a return board
statement to the bottom of the function, leaving the middle of the function to be filled in.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) lines := strings.Split(giantString, "\n") // we know how many rows the GameBoard will have board := make(GameBoard, len(lines)) // to fill in return board }
We now range over all the lines of the file, separating out elements of each line using strings.Split()
into a slice of strings currentLine
every time we see a comma. The resulting slice of strings lineElements
contains a collection of values that are "0"
or "1"
; these strings correspond to a single element of the current row.
In the spirit of modularity and avoiding unnecessary nested loops, we will set the values of the current row of board
by passing lineElements
into a subroutine, which we call SetRowValues()
.
// ReadBoardFromFile takes a filename as a string and reads in the data provided // in this file, returning a game board. func ReadBoardFromFile(filename string) GameBoard { // try reading in the file fileContents, err := os.ReadFile(filename) Check(err) // parse out the file contents as a slice of strings, one line per element giantString := string(fileContents) lines := strings.Split(giantString, "\n") // we know how many rows the GameBoard will have board := make(GameBoard, len(lines)) // next, parse the data on each line and add to the GameBoard for i, currentLine := range lines { lineElements := strings.Split(currentLine, ",") // use a subroutine to set the values of the current row board[i] = SetRowValues(lineElements) } return board }
Now that we have written ReadBoardFromFile()
, we will write the SetRowValues()
subroutine. This function takes as input a string representing a line of a comma-separated Game of Life file in boards
, and it returns a slice of boolean values corresponding to parsing the line.
SetRowValues()
first makes a slice currentRow
of boolean variables having length equal to the number of elements in lineElements
. It then ranges over lineElements
, setting the j-th value of currentRow
equal to false
if the corresponding value of lineElements
is "0"
, and true
if the corresponding value of lineElements
is "1"
.
// SetRowValues takes as input a slice of strings, // where each element is either "0" or "1". // It returns a slice of boolean values of the same length, // encoring "0" as false and "1" as true. func SetRowValues(lineElements []string) []bool { currentRow := make([]bool, len(lineElements)) //range through the current line elements and set values for j, val := range lineElements { if val == "0" { currentRow[j] = false // technically this is not necessary } else if val == "1" { currentRow[j] = true } else { log.Fatal("Error: invalid entry in board file.") } } return currentRow }
STOP: Technically, settingcurrentRow[j]
equal tofalse
when the current value oflineElements
is equal to"0"
is not necessary. Why?
Understanding canvas.go: our engine for drawing
Now that we can read in a Game of Life board, we would like to draw it. First, we will learn a little bit about how to use programming in order to make an example drawing of a face.
Go provides a comprehensive library for drawing via the draw2d
directory that we asked you to install as part of the setup for this code along. This directory is not the easiest for a beginner to use directly, which is why we provided some code in the canvas
folder, which contains a collection of functions that call code from draw2d
so that we can simply use the functions in canvas
.
Note: Special thanks to Carl Kingsford and Hannah Kim for putting together thecanvas
package.
In particular, the canvas
package allows us to conceptualize a “canvas” object. We will say much more about working with “object-oriented” programming in a future chapter, but for now, think of a canvas as a rectangular window with a specified width and height, much like the screen that you are reading this on. The width and height of the canvas are measured in pixels, where a pixel is a single (small) point in the image that will be colored with a single color.
In the RGB color model, every rectangular pixel on a computer screen emits a single color formed as a mixture of differing amounts of the three primary colors of light: red, green, and blue (hence the acronym “RGB”). The intensity of each primary color in a pixel is expressed as an integer between 0 and 255, inclusively, with larger integers corresponding to greater intensities.
A few colors are shown in the figure below along with their RGB equivalents; for example, magenta corresponds to equal parts red and blue. Note that a color like (128, 0, 0) contains only red but appears duskier than (256, 0, 0) because the red in that pixel is less intense.
The functions from canvas.go
that we will need in this code along are listed below. We will say more about these functions as they are needed.
CreateNewCanvas()
: creates a rectangularcanvas
object with a given width and heightMakeColor()
: creates a new colorSetFillColor()
: sets the fill colorClear()
: fill the entire canvas with the fill colorFill()
: fill all shapes that have been drawn with the fill colorClearRect()
: draw a rectangle at given coordinates and fill it with the current fill colorCircle()
: draw a circle at given coordinatesGetImage()
: obtain the image corresponding to the currentcanvas
.
Let’s draw a face
In main.go
, instead of putting our code directly into func main()
, we will call a function DrawFace()
; this function will not take any inputs or return anything, but it will contain code for drawing our image.
func main() { fmt.Println("Coding the Game of Life!") DrawFace() }
We will next implement DrawFace()
in drawing.go
. The first thing that we will do is to create a canvas
object, which we will call c
. To do so, we will call CreateNewCanvas()
, which takes as input two integers w
and h
and returns a canvas
object that is w
pixels wide and h
pixels tall. In this case, the canvas will be 1000 pixels wide and 2000 pixels tall.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // to fill in }
Next, let’s make the entire canvas black. First, we will declare a variable black
(which has type color
) by calling canvas.MakeColor()
. This function takes three integer inputs between 0 and 255 corresponding to the RGB notation of the color that is returned.
Black is formed by the absence of any light, and so (as the table above illustrates) its RGB format is (0, 0, 0). Therefore, we declare our color variable by using the notation black := canvas.MakeColor(0, 0, 0)
. We will then set the fill color by calling c.SetFillColor(black)
, and fill the canvas with this fill color by calling c.Clear()
.
Note: The notationc.Foo()
for a functionFoo()
involving a canvas objectc
is new up to this point in the course. This type of function is called a “method”, and we will say much more about methods soon when we discuss object-oriented programming in greater detail.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() // to fill in }
Let’s take a look at the image associated with the canvas. To do so, we will call the function c.SaveToPNG()
from canvas.go
, which takes as input a file name as a string and saves an image associated with c
to this file; we will give this function the string "output/fun.png"
.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
After saving your files, navigate into go/src/gameOfLife
from the command line, compile your code by executing the command go build
, and run your code by executing either ./gameOfLife
(Mac) or gameOfLife.exe
(Windows). You should see the black rectangle shown below appear as fun.png
in your go/src/gameOfLife/output
directory.
We now are ready to add the head, but we first need to understand the
coordinate system. In the Cartesian plane that we work with in mathematics, increasing x-coordinates extend to the right, and increasing y-coordinates extend upward (see figure below, left). However, graphics uses a different standard that dates to the foundation of computing. Early computers drew an image to the screen starting in the top left corner of the screen and extending right and down. As a result, many graphics packages (canvas
draw2d
included) still use the standard of viewing the top left corner of a window as an “origin”, with increasing x-coordinates extending to the right, and increasing y-coordinates extending downward (see figure below, right).
We will draw the head as a white circle. We will first declare a color called white
; just as combining red, green, and blue light yields white light, our color variable will constitute the maximum amount of red, green, and blue. We then will set the fill color of c
to our newly declared color.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() white := canvas.MakeColor(255, 255, 255) c.SetFillColor(white) // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
We now will call the Circle()
function from the canvas
package. Remember when your geometry teacher told you that a circle is defined by its center and its radius? I know that you don’t, but it’s true nevertheless. When it comes to programming, Circle()
takes three float64
arguments: the x- and y- coordinates of the center, followed by the radius.
We want the head to lie in the top part of the rectangle, halfway across from left to right. Because the canvas is 1000 pixels wide, we know that the x-coordinate of the circle’s center should be 500. In order to have the same amount of space on the top, left, and right, let’s make its y-coordinate 500 as well; that is, the circle’s center will be 500 pixels from the top. As for the radius, let’s set it to be 200 pixels.
After calling c.Circle(500, 500, 200)
, we will call c.Fill()
to fill the circle that we have drawn with the current fill color, white.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() white := canvas.MakeColor(255, 255, 255) c.SetFillColor(white) // make face c.Circle(500, 500, 200) c.Fill() // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
When we compile and run our code, we obtain the white circle below.
Let’s next add a nose, which we will draw as a small black circle, whose center will be slightly below the center of the head. First, we will set the fill color to black. The nose’s x-coordinate will therefore be the same as that of the head, but the y-coordinate will be a little bit larger at 550 pixels. As for the radius, let’s make it ten pixels.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() white := canvas.MakeColor(255, 255, 255) c.SetFillColor(white) // make face c.Circle(500, 500, 200) c.Fill() // make nose c.SetFillColor(black) c.Circle(500, 550, 10) c.Fill() // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
When we compile and run our code, we see the figure shown below.
Next, we will add two eyes, which are once again circles. The eyes will fall slightly above the center of the face, and so we will make their y-coordinates equal to 475. Let’s move them to 75 pixels left and right of the center line, so that their x-coordinates will be 425 and 575. We will set their radii equal to 15 to make the eyes a little bigger than the nose.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() white := canvas.MakeColor(255, 255, 255) c.SetFillColor(white) // make face c.Circle(500, 500, 200) c.Fill() // make nose c.SetFillColor(black) c.Circle(500, 550, 10) c.Fill() // make eyes c.Circle(425, 475, 15) c.Circle(575, 475, 15) c.Fill() // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
Our updated face is shown in the figure below and is starting to take shape as a landmark achievement of Western art.
Finally, we will make a mouth, which gives us the opportunity to make a new color, red. This color will be formed by taking the maximum amount of the red variable and no green or blue, and so we define our red
variable using MakeColor(255, 0, 0)
.
We will also make the mouth rectangular, which will allow us to use the function c.ClearRect()
. This function takes four float64
parameters; the first two correspond to the minimum x- and y-coordinates of the rectangle, and the last two correspond to the maximum x- and y-coordinates of the rectangle. We will make the rectangle 200 pixels wide and 20 pixels tall, and so the x-coordinates will range from 400 to 600, and the y-coordinates will range from 600 to 620. We do not need to call c.Fill()
after calling c.ClearRect()
, since this function will fill the rectangle automatically.
// DrawFace takes no inputs and returns nothing. // It uses the canvas package to draw a face to a file // in the output folder. func DrawFace() { // create the canvas c := canvas.CreateNewCanvas(1000, 2000) // fill canvas as black black := canvas.MakeColor(0, 0, 0) c.SetFillColor(black) c.Clear() white := canvas.MakeColor(255, 255, 255) c.SetFillColor(white) // make face c.Circle(500, 500, 200) c.Fill() // make nose c.SetFillColor(black) c.Circle(500, 550, 10) c.Fill() // make eyes c.Circle(425, 475, 15) c.Circle(575, 475, 15) c.Fill() // make mouth red := canvas.MakeColor(255, 0, 0) c.SetFillColor(red) c.ClearRect(400, 600, 600, 620) // save the image associated with the canvas c.SaveToPNG("output/fun.png") }
Compiling and running our code produces our final face as shown below.
Note: Feel free to continue editing our face. Add a body, some arms, or change colors however you like to get the hang of working withcanvas.go
. And feel free to post your drawings in our Discord!
Drawing a Game of Life board
In drawing.go
, let’s write a function DrawGameBoard()
that takes as input a GameBoard
object board
in addition to an integer cellWidth
. This function will create a canvas object in which each cell is assigned a square that is cellWidth
pixels wide and tall, and then return an image
object associated with this canvas so that we will be able to render it.
We will first import two packages: canvas
(for drawing shapes), and Go’s built-in image
package (for exporting to image objects).
package main import ( "canvas" // for drawing shapes "image" // for exporting our drawing to an image )
Next, we will set the height of the canvas (in pixels) equal to the number of rows in board
times cellWidth
, and the width of the canvas equal to the number of columns in board
times cellWidth
. We will determine the number of rows and columns in board
using two subroutines, which we will discuss after writing DrawGameBoard()
.
Then, we will call canvas.CreateNewCanvas()
from the canvas
package to create a new canvas object c
that is width
pixels wide and height
pixels tall.
At the end of our function, we will eventually call c.GetImage()
from canvas
to return the image
object associated with our canvas.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) //to fill in return c.GetImage() }
Next, we will declare two color variables, one for a dark gray color (RGB: (60, 60, 60)) and one for white (RGB: (255, 255, 255)). After doing so, we will call c.SetFillColor()
to set the fill color to dark gray, followed by c.Clear()
to fill the entire canvas with this fill color.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // to fill in return c.GetImage() }
We are now ready to range over every cell in board
and color a given cell white if it is alive. We don’t need to take any action if the cell is dead, since the color of dead cells will be the same as the background color.
Although we are ardent followers of modularity, in this particular case, we don’t mind using a nested for loop to ranging over board is in order, since our code will be short. After ranging i
over the rows of board
and j
over the columns of board
, we will draw a rectangle at the position on the canvas corresponding to board[i][j]
. The question is precisely where on the canvas we should draw this rectangle.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // fill in colored squares for i := range board { for j := range board[i] { //only draw the cell if it's alive, since it would equal background color if board[i][j] == true { c.SetFillColor(white) // draw the cell at the appropriate location. But where? } } } return c.GetImage() }
Let us recall for a moment about how we conceptualize arrays. As shown in the figure below on the left, the rows of an array extend downward from the top left, and the columns of the array extend rightward from the top left. This is similar to the standard graphics approach (reproduced below on the right), except that the rows increase in proportion to y-coordinates, and the columns increase in proportion to x-coordinates. Therefore, although we are tempted to think that an element a[r][c]
of an array will be drawn at a position (x, y) where x depends on r
and y depends on c
, the opposite is true: x depends on c
, and y depends on r
.
We now can say that to draw the rectangle associated with board[i][j], the minimum x-value will be the product of j
, the column index, and cellWidth
; the minimum y-value will be the product of i
, the row index, and cellWidth
. Because we are drawing a square, the maximum x- and y-values will be equal to the minimum such values plus cellWidth
. We are now ready to finish DrawGameBoard()
.
// DrawGameBoard takes as input a (rectangular) GameBoard and an integer cellWidth. // It returns an Image object corresponding to the GameBoard, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoard(board GameBoard, cellWidth int) image.Image { // create a canvas object of the appropriate width and height (in pixels) height := CountRows(board) * cellWidth width := CountColumns(board) * cellWidth c := canvas.CreateNewCanvas(width, height) // make two colors: dark gray and white darkGray := canvas.MakeColor(60, 60, 60) white := canvas.MakeColor(255, 255, 255) // set the entire board as dark gray c.SetFillColor(darkGray) c.Clear() // fill in colored squares for i := range board { for j := range board[i] { //only draw the cell if it's alive, since it would equal background color if board[i][j] == true { c.SetFillColor(white) x := j * cellWidth y := i * cellWidth c.ClearRect(x, y, x+cellWidth, y+cellWidth) } } } return c.GetImage() }
Two seemingly simple subroutines, and an introduction to assertion functions
We now just need to write the subroutines CountRows()
and CountColumns()
in functions.go
, and then we will be ready to write some code in func main()
to read in a Game of Life board using ReadBoardFromFile()
and draw it using DrawGameBoard()
.
We can count the number of rows in board
by accessing len(board)
, and we can count the number of columns in board by accessing the length of the first row, len(board[0])
. In CountColumns()
, we make sure to check that board doesn’t have zero rows, so that when we access board[0]
, we know that it exists.
STOP: What issue do you see that might arise with these two functions?
// CountRows takes as input a GameBoard // and returns the number of rows in the board func CountRows(board GameBoard) int { return len(board) } // CountColumns takes as input a rectangular GameBoard and // returns the number of columns in the board. func CountColumns(board GameBoard) int { if len(board) == 0 { panic("Error: no rows in GameBoard.") } // give # of elements in 0-th row return len(board[0]) }
Although CountRows()
always correctly the number of rows in a two-dimensional slice, recall from our creation of a triangular slice in the previous code along that a two-dimensional slice does not necessarily have the same number of elements in each row.
We could make our code more robust in two ways. We could return the maximum length of any row of board
. However, the strategy that we will take is to revise CountColumns()
so that it only returns a value if board
is rectangular. To do so, at the beginning of CountColumns()
, we will call a subroutine AssertRectangular()
, which takes board
as input. This assertion function does not return anything, but it panics if board
has no rows, or if when we range over the rows of board
, any row has length that is not equal to the length of the initial row of board
.
Note: Halting the program is a strict decision to make for a non-rectangular board. In practice, we might make different design choices, such as providing a message to the user.
// CountColumns takes as input a GameBoard and returns the number of columns // in the board, assuming that the board is rectangular. func CountColumns(board GameBoard) int { // assert that we have a rectangular board AssertRectangular(board) // we can assume board is rectangular now // number of columns is the number of elements in 0-th row return len(board[0]) } // AssertRectangular takes as input a GameBoard. // If returns nothing. If the board has no rows, or is not rectangular, it panics. func AssertRectangular(board GameBoard) { if len(board) == 0 { panic("Error: no rows in GameBoard.") } firstRowLength := len(board[0]) //range over rows and make sure that they have the same length as first row for row := 1; row < len(board); row++ { if len(board[row]) != firstRowLength { panic("Error: GameBoard is not rectangular.") } } }
Running our code, and improving our automaton visualization
In main.go
, we will add the following import statements:
import ( "image/png" // for drawing an image to PNG "os" // for opening a file that will hold the image )
In func main()
, we will first read a board from file. Let’s read the simplest board, the R pentomino, which is stored in boards/rPentomino.csv
. We will then set our cellWidth
parameter to 20 pixels, and call DrawGameBoard()
on rPentomino
and cellWidth
.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) }
The image of the canvas that we generated is stored in an image
object called img
, which we want to write to a file to view. First, we declare a string filename
, which we will set to be equal to "output/rPentomino.png"
, so that our image will be written into the output
directory.
We then create a file f
in this location, check if there was an error, and defer closing the file to the end of func main()
.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) filename := "output/rPentomino.png" f, err := os.Create(filename) Check(err) defer f.Close() }
Finally, we call the Encode()
function from the png
package with inputs f
and img
to encode the image into the file, checking once again whether this process produced an error.
func main() { rPentomino := ReadBoardFromFile("boards/rPentomino.csv") cellWidth := 20 img := DrawGameBoard(rPentomino, cellWidth) filename := "output/rPentomino.png" f, err := os.Create(filename) Check(err) defer f.Close() err = png.Encode(f, img) Check(err) }
Save your code, compile, and run. You should see an rPentomino.png
file appear in the output
folder, shown in the figure below.
Let’s add a little bit of flavor to our automaton drawing. First, let’s draw the cells as circles by calling c.Circle()
. To do so, recall the innermost block of DrawGameBoard()
, reproduced below.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) x := j * cellWidth y := i * cellWidth c.ClearRect(x, y, x+cellWidth, y+cellWidth) }
If board[i][j]
is alive, then this code draws a white square whose top-left corner is (x
, y
) and whose bottom-right corner is (x+cellWidth
, y+cellWidth
). If we are going to draw a circle in this same location, then the center’s circle should move to the center of the cell by adding float64(cellWidth)/2
to each of x
and y
. We are now ready to replace the innermost ready to replace the innermost if statement in DrawGameBoard()
with the following code.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) // set coordinates of the cell's top left corner x := float64(j*cellWidth) + float64(cellWidth)/2 y := float64(i*cellWidth) + float64(cellWidth)/2 c.Circle(x, y, float64(cellWidth)/2) c.Fill() }
After compiling and running our code once again, we obtain the illustration below. The circles are nice, but the cells touch, which will make the cells tricky to view when we transition to animations.
Let’s reduce the radius of the circle by a bit by setting a scaling factor that we can multiply by the radius of the circle, adjusting the central if block of DrawGameBoard()
as follows.
//only draw the cell if it's alive if board[i][j] == true { c.SetFillColor(white) x := j * cellWidth y := i * cellWidth scalingFactor := 0.8 // to make circle smaller c.Circle(float64(x), float64(y), scalingFactor*float64(cellWidth)/2) c.Fill() }
Compiling and running our code this time produces the following image.
Now that DrawGameBoard()
is improved, we are ready to draw more complicated boards. For example, compile and run your code after changing func main()
as follows to draw the dinner table oscillator, which is shown in the figure below on the left.
func main() { dinnerTable := ReadBoardFromFile("boards/dinnerTable.csv") cellWidth := 20 img := DrawGameBoard(dinnerTable, cellWidth) filename := "output/dinnerTable.png" f, err := os.Create(filename) Check(err) defer f.Close() err = png.Encode(f, img) Check(err) }
STOP: Compile and run your code once again after reading in the Gosper gun (gospergun.csv
) to draw the Gosper gun shown in the figure below on the right.
Drawing multiple Game of Life boards
Now that we have written a function to draw a single board, we will also write a DrawGameBoards()
function in drawing.go
that takes a slice of GameBoard
objects boards
and an integer cellWidth
as input and that returns a slice of image.Image
objects corresponding to calling DrawGameBoard()
on each GameBoard
in boards
. This function will come in handy in the next code along, when we will write a function returning a slice of GameBoard
variables as a result of simulating the Game of Life over multiple generations, and after calling DrawGameBoards()
on this slice, we will generate an animated GIF showing the changes in the automaton over time.
// DrawGameBoards takes as input a slice of (rectangular) GameBoards and an integer cellWidth. // It returns a slice of Image objects corresponding to each of these GameBoards, // where each cell in the image has width and height equal to cellWidth. func DrawGameBoards(boards []GameBoard, cellWidth int) []image.Image { numGenerations := len(boards) imageList := make([]image.Image, numGenerations) for i := range boards { imageList[i] = DrawGameBoard(boards[i], cellWidth) } return imageList }