15 Circuits

In this chapter we are going to add some circuit like objects to the game. Before we start talking about the code, let’s look at the idea behind how this system will work.

We’re going to have three types of new blocks: head, tail, and cable. The tail block will always follow the head block and the head block will move to any adjacent cable blocks. Be careful though, if you have too many head blocks next to a cable block it will die out.

Track New Block Types - Part 1

First copy the third tutorial “Show Current Block” to a new file with your initials at the end:

cp 03_show_current_block.py 15_circuits_TVR.py

We need to reference a new texture file with the circuit textures. Go to the line reading TEXTURE_PATH = 'numbered_textures.png' and change it to read TEXTURE_PATH = 'story_textures.png'. Also go to the line reading def tex_coord(x, y, n=4): and change it to say n=8. This is because the new texture file is larger and has more textures in it, so we need to tell the program how many pieces to break the file into.

Now we need to add the three new block types. The head blocks will be of type ELECH, the tail blocks will be ELECT, and the cable blocks will simply be called CABLE. Delete the numbered stones defined in NUMSTONE from the third tutorial. Delete the below section of code (around line 80).

NUMSTONE = []
NUMSTONE.append(tex_coords((3, 0), (3, 0), (3, 0)))
NUMSTONE.append(tex_coords((3, 1), (3, 1), (3, 1)))
NUMSTONE.append(tex_coords((0, 2), (0, 2), (0, 2)))
NUMSTONE.append(tex_coords((1, 2), (1, 2), (1, 2)))
NUMSTONE.append(tex_coords((2, 2), (2, 2), (2, 2)))
NUMSTONE.append(tex_coords((3, 2), (3, 2), (3, 2)))
NUMSTONE.append(tex_coords((0, 3), (0, 3), (0, 3)))
NUMSTONE.append(tex_coords((1, 3), (1, 3), (1, 3)))
NUMSTONE.append(tex_coords((2, 3), (2, 3), (2, 3)))
NUMSTONE.append(tex_coords((3, 3), (3, 3), (3, 3)))

And then add the below code.

CABLE = tex_coords((1, 5), (1, 5), (1, 5))
ELECH = tex_coords((2, 5), (2, 5), (2, 5))
ELECT = tex_coords((0, 5), (0, 5), (0, 5))

After all the above changes are made the code should read as follows from line 52 to line 85

def tex_coord(x, y, n=8):
    """ Return the bounding vertices of the texture square.

    """
    m = 1.0 / n
    dx = x * m
    dy = y * m
    return dx, dy, dx + m, dy, dx + m, dy + m, dx, dy + m


def tex_coords(top, bottom, side):
    """ Return a list of the texture squares for the top, bottom and side.

    """
    top = tex_coord(*top)
    bottom = tex_coord(*bottom)
    side = tex_coord(*side)
    result = []
    result.extend(top)
    result.extend(bottom)
    result.extend(side * 4)
    return result


TEXTURE_PATH = 'story_textures.png'

GRASS = tex_coords((1, 0), (0, 1), (0, 0))
SAND = tex_coords((1, 1), (1, 1), (1, 1))
BRICK = tex_coords((2, 0), (2, 0), (2, 0))
STONE = tex_coords((2, 1), (2, 1), (2, 1))
CABLE = tex_coords((1, 5), (1, 5), (1, 5))
ELECH = tex_coords((2, 5), (2, 5), (2, 5))
ELECT = tex_coords((0, 5), (0, 5), (0, 5))

Now go down to the Model class __init__ function and delete the below section.

for i in range(0, 10):
    # x axis
    self.add_block((i, -2, 0), NUMSTONE[i], immediate=False)
    self.add_block((-i, -2, 0), NUMSTONE[i], immediate=False)

    # y axis
    self.add_block((0, -2 + i, 0), NUMSTONE[i], immediate=False)
    self.add_block((0, -2 + -i, 0), NUMSTONE[i], immediate=False)

    # z axis
    self.add_block((0, -2, i), NUMSTONE[i], immediate=False)
    self.add_block((0, -2, -i), NUMSTONE[i], immediate=False)

This removes the numbered axes from the world.

Circuit blocks are going to be special interactive blocks, so let’s track them separately. We usually track blocks with the world dictionary which maps (x, y, z) positions of blocks to the type/texture of that block. Let’s add another dictionary called circuit that acts just like world, but only stores circuit blocks.

Just below where self.world is declared, add a definition for self.circuit. This section should read as below:

    def __init__(self):

        # A Batch is a collection of vertex lists for batched rendering.
        self.batch = pyglet.graphics.Batch()

        # A TextureGroup manages an OpenGL texture.
        self.group = TextureGroup(image.load(TEXTURE_PATH).get_texture())

        # A mapping from position to the texture of the block at that position.
        # This defines all the blocks that are currently in the world.
        self.world = {}

        self.circuit = {}

        # Same mapping as `world` but only contains blocks that are shown.
        self.shown = {}

        # Mapping from position to a pyglet `VertextList` for all shown blocks.
        self._shown = {}

        # Mapping from sector to a list of positions inside that sector.
        self.sectors = {}

        # Simple function queue implementation. The queue is populated with
        # _show_block() and _hide_block() calls
        self.queue = deque()

        self._initialize()

We want to make sure blocks are added to the circuit dictionary only when they are circuit related. There are two functions used to add and remove blocks: add_block and remove_block. We want to add a section to add_block that says “if the block is a circuit type, add it to self.circuit”. Similarly we then need to add a statement in remove_block that says “if the block is of a circuit type, remove it from self.circuit”. Change these two functions in the Model class to read as below.

    def add_block(self, position, texture, immediate=True):
        """ Add a block with the given `texture` and `position` to the world.

        Parameters
        ----------
        position : tuple of len 3
            The (x, y, z) position of the block to add.
        texture : list of len 3
            The coordinates of the texture squares. Use `tex_coords()` to
            generate.
        immediate : bool
            Whether or not to draw the block immediately.

        """
        if position in self.world:
            self.remove_block(position, immediate)
        self.world[position] = texture
        self.sectors.setdefault(sectorize(position), []).append(position)
        if immediate:
            if self.exposed(position):
                self.show_block(position)
            self.check_neighbors(position)
        if texture in [ELECH, CABLE, ELECT]:
            self.circuit[position] = texture
    def remove_block(self, position, immediate=True):
        """ Remove the block at the given `position`.

        Parameters
        ----------
        position : tuple of len 3
            The (x, y, z) position of the block to remove.
        immediate : bool
            Whether or not to immediately remove block from canvas.

        """
        del self.world[position]
        self.sectors[sectorize(position)].remove(position)
        if immediate:
            if position in self.shown:
                self.hide_block(position)
            self.check_neighbors(position)
        if position in self.circuit:
            del self.circuit[position]

Recall how you can press a number key to select which type of block you want to build. We want to be able to do this with circuit blocks. Go to the line reading self.inventory = [BRICK, GRASS, SAND] (around line number 464) and add the three new types of blocks to the list on the right side of the equal sign. It should then say self.inventory = [BRICK, GRASS, SAND, CABLE, ELECH, ELECT].

Next go to draw_label. We will need to specify what to display when these new types of blocks are selected. We do this by defining the value of blockSelectedString. Change this function to read as follows.

    def draw_label(self):
        """ Draw the label in the top left of the screen. Label includes
            the current block selection.

        """
        # determines the current block selected
        if (self.block == BRICK):
            blockSelectedString = "Brick"
        elif (self.block == GRASS):
            blockSelectedString = "Grass"
        elif (self.block == SAND):
            blockSelectedString = "Sand"
        elif (self.block == ELECH):
            blockSelectedString = "ELECH"
        elif (self.block == ELECT):
            blockSelectedString = "ELECT"
        elif (self.block == CABLE):
            blockSelectedString = "Cable"

        x, y, z = self.position
        self.label.text = '%02d (%.2f, %.2f, %.2f) %d / %d Block: %s' % (
            pyglet.clock.get_fps(), x, y, z,
            len(self.model._shown), len(self.model.world),
            blockSelectedString)
        self.label.draw()

Execute the code and now you will be able to add these new types of blocks and see what type of block you have selected at the top of the screen.

Run the Circuits - Part 2

In this section we will add the code to make the circuits work. As is our customer first copy the circuit tutorial part 1 to a new file with your initials at the end:

cp 15_circuits_part_1.py 15_circuits_part_2_TVR.py

We are going to break this into two parts: identifying what changes need to be made at each iteration to move the circuit by one block and then actually making those changes.

If a cable block is next to two or fewer head blocks, it needs to become a head block. All head blocks will become tail blocks and all tail blocks will become cable. Basically we know what happens to head and tail blocks, but we need to search around head blocks. Let’s create a function that returns the position of all surrounding blocks.

Just after the function hit_test in the Model class add the following:

    def neighbor(self, position):
        x, y, z = position
        local = set()
        for dx in [-1, 0, 1]:
            for dy in [-1, 0, 1]:
                for dz in [-1, 0, 1]:
                    key = (x + dx, y + dy, z + dz)
                    local.add(key)
        return(local)

Basically this function adds and subtracts one from each coordinate and returns the position of the specified block and all the surrounding blocks as a list.

Below this, add another function called circuit_change. This function will actually update the circuit and make it move.

    def circuit_change(self):
        count_h = {}
        # track what contained blocks should change to
        to_cable = set()
        to_elect = set()
        to_elech = set()
        elech_safe = set()
        # iterate through all circuit blocks
        for position in self.circuit:
            # if haven't checked this block before
            # initialize count to zero
            if position not in count_h:
                count_h[position] = 0
            # all tail blocks will become cable
            if self.circuit[position] is ELECT:
                to_cable.add(position)
            # if it is a head block
            elif self.circuit[position] is ELECH:
                local = self.neighbor(position)
                # iterate through all surrounding blocks
                for pos in local:
                    # if surrounding block is cable
                    if pos in self.circuit and self.circuit[pos] is CABLE:
                        # make sure count is initialized
                        if pos not in count_h:
                            count_h[pos] = 0
                        # add one to count
                        count_h[pos] += 1
                        # if < 2 surrounding head blocks
                        # it will become a head block
                        if count_h[pos] <= 2:
                            to_elech.add(pos)
                        # otherwise make sure it doesn't change
                        elif pos in to_elech:
                            to_elech.remove(pos)
                # the head block will become a tail block
                to_elect.add(position)
        # go through and make the identified changes
        for position in to_elect:
            self.add_block(position, ELECT)
        for position in to_elech:
            self.add_block(position, ELECH)
        for position in to_cable:
            self.add_block(position, CABLE)

This code looks long, but this is just due to the large number of comments scattered through the code. Commenting is a important practice in programming that many people ignore. By commenting your code, it becomes easier for others to follow your logic and sometimes it even reminds yourself of what you did and why. Let’s briefly go through the method circuit_change together.

The variables to_cable, to_elech, and to_elect are sets: groups of values without any duplicates. These are used to store what will happen to the contained blocks. For example, blocks in the set to_cable will become cable blocks after this function runs.

First we go through all the blocks stored in self.circuit. count_h stores the number of surrounding ELECH blocks for the given position. We need to make sure each checked block has a count and we always start at zero. If the specified block is a tail block, it will become cable. If we find a block of type ELECH, we need to check the surrounding blocks, update their count_h value, and if that value is less than or equal to two, that cable block will be added to to_elech to become a head block. The checked ELECH block must then become a tail block. Finally the three for loops at the end of the function make the identified changes.

Now jump down to on_key_press and add a new elif case that calls circuit_change when the C key is pressed. That is add the following.

elif symbol == key.C:
    self.model.circuit_change()

Now if you layout circuit blocks and add a head block followed by a tail block, you can move the head and tail blocks by pressing C repeatedly.

_images/circuits.GIF