Skip to main content

Finally, this is the blog series ending. By the end of this blog, we will have finished our implementation of Conan’s Game of Life.

Let’s recap what we still have to do:

  • Introduce the propagation step that will actually provide the subsequent game of life steps.
  • Introduce some kind of menu with buttons that allow the user to clear the screen, create a random structure and start/stop the simulation

Propagation Step

We already saw the Python algorithm to calculate a step of the Game of Life in the Challenge: The Game of Life in Python. It is just a matter of adapting it to our current case.

Our colony object has the variable indices. We will create a method that takes this variable and updates it to contain the newly calculated state.

class Colony(pygame.rect.Rect):
    ...
    # Other methods and attributes
    ...

    def step(self):
        iter_states = np.vstack((
            np.zeros(self.cols, dtype=int),
            self.states,
            np.zeros(self.cols, dtype=int)
            ))
        iter_states = np.hstack((
            np.zeros((self.rows+2,1), dtype=int),
            iter_states,
            (np.zeros((self.rows+2,1), dtype=int))
            ))

        neighbours = self._neigh(self.indices, iter_states)

        self.states = np.where(
            (neighbours == 3) | (iter_states[1:self.rows+1, 1:self.cols+1] == 1) & (neighbours == 2),
            1, 0)

    def _neigh(self, indices, GoLArr):
        def neigh(element, GoLArr):
            row, column = element // self.cols, element % self.cols
            return np.sum(GoLArr[row:row+3, column:column+3]) - GoLArr[row+1,column+1]

        return np.vectorize(neigh, excluded=(1, ))(indices, GoLArr)

For details on how it works, you can check the previously mentioned blog.

Now, when will we call this method? The main loop is running at 60 frames per second, so we cannot run this at every frame, or the simulation would be too fast. Therefore, we must find a way to perform this step after a predetermined time. Luckily, PyGame offers a very convinient way of doing so. The following command will provide, in seconds, the amount of time that the current frame has been on screen and adds it to a previously initialized variable that will keep track of time.

dt += clock.tick(60) / 1000

So, we can add the following piece of code just before (or after, does not really matter) to control when to perform a colony step.

if dt >= 0.25:
            dt = 0
            if menu.state == 'simulating':
                colony.step()

We can see that, if 0.25 seconds have passed the variable is reset to 0 and, if the menu.state variable is simulating (we will see it afterwards), then the step method will be called and an iteration of the colony will be performed.

Menu

The only thing that we still need to do is to add some way for the user to play with the game. We will create a menu with three buttons

  • clear: To clear up everything and produce a blank screen
  • random: To create a random structure
  • play: To switch between simulating and not simulating.

Moreover, the menu will provide a way for the main loop to check if the game is in simulation mode or not, the menu.state attribute.

The Button Class

We create a Button class that will allow the Menu class to create buttons with some text, at a determined position and that respond to the user clicking on them. Let’s start with the __init__ method.

import pygame
from pygame.surface import Surface

from typing import Union

class Button(pygame.rect.Rect):

    def __init__(self,
                 left: int, top: int, width: int, heigth: int,
                 pad: int = 10, text: Union[None, str] = None,
                 color: str = 'white', pressed_color: str = 'light_gray'
                 ) -> None:
        super().__init__(left + pad, top + pad, width - 2*pad, heigth - 2*pad)
        self.text = text
        self.font = pygame.font.Font(size = 28) if text is not None else None
        self.pad = pad
        self.default_color = color
        self.pressed_color = pressed_color

We will simplye pass the position of the button as Rectangle with a padding variable. This padding variable will make sure that the button does not completely fill the space allowed to it and will make it look a bit better. It will also be able to take some text.

Then, for drawing the button to the screen, we create the following method

def draw(self, screen: Surface) -> None:
        if self.is_pressed():
            pygame.draw.rect(screen, self.pressed_color, self.inflate(-7, -7))
        else:
            pygame.draw.rect(screen, self.default_color, self)

        if self.text:
            text = self.font.render(self.text, True, 'black')

            width = self.centerx - text.get_width() // 2
            height = self.centery - text.get_height() // 2

            screen.blit(text, (width, height))

This method first checks if the button is being pressed (see below). If it is, it will draw itself compressed by 7 pixels on each side and with a different color than the default, otherwise, it will be drawn as default.

Then, if the button has some text, it will print it centered. Note that we first have to render the text into the text variable and then blit this text onto the screen.

Finally, the is_pressed method.

def is_pressed(self) -> bool:
    left_button, *_ = pygame.mouse.get_pressed()
    return left_button is True and \
        self.collidepoint(*pygame.mouse.get_pos())

First, it gets the state of the left mouse button (True for pressed and False for not pressed). Then if the left button is pressed, and it is colliding with the button itself, this method will return True, efectively changing the behaviour of the draw method.

The Menu Class

Finally, using the lower part of the screen, we will have the menu. Besides the three buttons, the menu will also contain a text that will show if the simulation is running or not.

import pygame
from pygame.surface import Surface

from objects.button import Button


class Menu(pygame.rect.Rect):

    def __init__(self, left: int, top: int, width: int, height: int) -> None:
        super().__init__(left, top, width, height)
        self.info = pygame.font.Font(size = 48)
        self.state = 'stopped'
        self.config = {
            'simulating': ('Running', 'dark green'),
            'stopped': ('Not running', 'red'),
        }

        self.button_area = pygame.rect.Rect(
            self.width // 3,
            self.top,
            self.right - self.width // 3,
            self.height
        )

        self.clear_button = Button(
            self.button_area.left,
            self.button_area.top,
            self.button_area.width // 3,
            self.button_area.height,
            text = 'Clear',
            color = '#E0E0E0',
            pressed_color='#CCFFCC'
        )

        self.random_button = Button(
            self.button_area.left + self.button_area.width // 3,
            self.button_area.top,
            self.button_area.width // 3,
            self.button_area.height,
            text = 'Random',
            color = '#E0E0E0',
            pressed_color='#CCFFCC'
        )

        self.play_button = Button(
            self.button_area.left + 2 * self.button_area.width // 3,
            self.button_area.top,
            self.button_area.width // 3,
            self.button_area.height,
            text = 'Play',
            color = '#E0E0E0',
            pressed_color='#CCFFCC'
        )

        self.buttons = (self.clear_button, self.random_button, self.play_button)

Although this seems like a long and hard init method, it is not actually that bad. First of all, it creates some variables that we will need on the calls to other methods.

Then it creates the self.button_area, which is the place that we will reserve for the three buttons. And then, it creates the three buttons calling the Button object with different parameters and positions.

Then, we will need two more methods, one for drawing the menu onto a screen in the other for changing the self.state variable from simulating to stopped.

def draw(self, screen: Surface) -> None:
    info, color = self.config[self.state]

    text = self.info.render(info, True, color)

    width = self.width // 6 - text.get_width() // 2
    height = self.centery - text.get_height() // 2

    screen.blit(text, (width, height))

    for button in self.buttons:
        button.draw(screen)

def change(self) -> None:
    self.state = 'simulating' if self.state != 'simulating' else 'stopped'

Note that in the draw method, besides calling the draw methods of the buttons, we also draw the informational text, taking the text and the color from the self.config dictionary and the self.state key.

Putting it all together

Finally, everything is finished. Running the main file will produce the following results available as a Youtube video.

https://www.youtube.com/watch?v=xYF5HctDvUY
Demo showing the end result of our implementation of Conway’s Game of Life.

All the code is available in this public GitHub repository. Feel free to browse it, fork it and adapt it to your needs. Tweaking and messing around is really the best way to learn Python and get better at it.

Auteur