Design Patterns and Video Games

OpenGL 2D Facade (12): Characters

In this post, I add a new type of layer dedicated to characters. These layers can draw tiles anywhere and updates their OpenGL data every time we render a frame.

This post is part of the OpenGL 2D Facade series

Objective

To check that the new layer type works fine, I propose to move a character in the level using the arrow keys:

There are no collision checks; the character can go anywhere, as long as it is inside the world. Also, note that the view follows the character.

Facade improvement

I extend the facade to introduce different layer types:

OpenGL 2D Facade: Grid and Character Layers

There is still a Layer abstract class that contains features shared by all layers. Note that there is no more the setGridMesh()method specific to a grid layer. I also rename setTexture() to setTileset() to get a more generic API. I guess that it is clear now that, in the specific case of OpenGL, we store tilesets in textures. In other cases, for instance using Pygame for rendering, we could store tilesets in Pygame surfaces.

The two new abstract classes GridLayer and CharactersLayer are the interfaces for grid layers (what we previously done) and character layers.

The API of the CharactersLayer is the following one:

OpenGL implementation

We base both implementations of the layer classes (OpenGLGridLayer and CharactersLayer) on an OpenGL mesh. It is mostly as before, with a Vertex Array Object (VAO) and its Vertex Buffer Objects (VBO). We gather all the related code into the GLMesh class, moved from the previous OpenGLLayer class.

The setData() has a new argument dynamic. If set to True, we create VBOs with the GL_DYNAMIC_DRAW flag; otherwise, the flag is GL_STATIC_DRAWas before. For instance, for the vertices buffer:

# Static/Dynamic flag
if dynamic:
    flags = GL_DYNAMIC_DRAW
else:
    flags = GL_STATIC_DRAW

# Copy vertices data to GPU
glBindBuffer(GL_ARRAY_BUFFER, vertexVboId)
vertices = np.ascontiguousarray(vertices.flatten())
glBufferData(GL_ARRAY_BUFFER, 4 * len(vertices), vertices, flags)
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, None)

GL_DYNAMIC_DRAW and GL_STATIC_DRAW flags are only optimization hints. Rendering is fine even if you choose the wrong one and can be a bit slower in some cases.

OpenGLGridLayer class

The OpenGLGridLayer class has a single setTile() method. It is like the previous setGridMesh() method of the OpenGLLayer class.

OpenGLCharactersLayer class

Set character count

The setCharacterCount() method of the OpenGLCharactersLayer class initiliazes the mesh:

def setCharacterCount(self, count: int):
    if self.__vertices is not None:
        raise RuntimeError("Update of characters count is not supported")

    self.__vertices = np.empty([count, 4, 2], dtype=np.float32)
    self.__uvMap = np.empty([count, 4, 2], dtype=np.float32)
    self.__faces = np.empty([count, 4], dtype=np.uint)
    faceCount = 0
    for charIndex in range(count):
        self.setCharacterLocation(charIndex, 0, 0)
        self.setCharacterTile(charIndex, self.__transparentTile[0], self.__transparentTile[1])

        self.__faces[charIndex, 0] = faceCount * 4
        self.__faces[charIndex, 1] = faceCount * 4 + 1
        self.__faces[charIndex, 2] = faceCount * 4 + 2
        self.__faces[charIndex, 3] = faceCount * 4 + 3
        faceCount += 1

    self.setData(self.__vertices, self.__faces, self.__uvMap, dynamic=True)

We save the mesh (vertices, faces, and uvMap) in attributes and change it in other methods.

The loop (lines 9-17) initializes each quad of the mesh. Line 10 puts the character at the top-left corner of the level (coordinates 0,0). Line 11 assigns a transparent tile to characters: consequently, after initialization, all characters are invisible. Lines 13-16 add a new set of four vertex indices for the quad.

Note: We set the transparentTile attribute in the setTileSet() method as the bottom right tile of the tileset (assumed as fully transparent):

def setTileset(self, fileName: str, tileWidth: int, tileHeight: int):
    super(OpenGLCharactersLayer, self).setTileset(fileName, tileWidth, tileHeight)
    self.__transparentTile = (
        self.textureWidth // self.tileWidth - 1,
        self.textureHeight // self.tileHeight - 1
    )

Note: we call the setData() method with dynamic=True. It is to tell OpenGL that we update our mesh regularly. It is only an optimization hint; if you forgot it, the rendering works fine.

Set character location

The setCharacterLocation() method defines the location of a character:

def setCharacterLocation(self, charIndex: int, x: float, y: float):
    assert 0 <= charIndex < self.__vertices.shape[0]

    spriteScreenX1 = -1 + x * self.screenTileWidth
    spriteScreenY1 = 1 - y * self.screenTileHeight
    spriteScreenX2 = spriteScreenX1 + self.screenTileWidth
    spriteScreenY2 = spriteScreenY1 - self.screenTileHeight
    self.__vertices[charIndex, 0] = [spriteScreenX1, spriteScreenY2]
    self.__vertices[charIndex, 1] = [spriteScreenX1, spriteScreenY1]
    self.__vertices[charIndex, 2] = [spriteScreenX2, spriteScreenY1]
    self.__vertices[charIndex, 3] = [spriteScreenX2, spriteScreenY2]

You can recognize the "magic formula" we previously created.

Set character tile

The setCharacterTile() method defines the tile of a character:

def setCharacterTile(self, charIndex: int, tileX: float, tileY: float):
    assert 0 <= charIndex < self.__vertices.shape[0]

    spriteTextureX1 = tileX * self.textureTileWidth
    spriteTextureY1 = tileY * self.textureTileHeight
    spriteTextureX2 = spriteTextureX1 + self.textureTileWidth
    spriteTextureY2 = spriteTextureY1 + self.textureTileHeight
    self.__uvMap[charIndex, 0] = [spriteTextureX1, spriteTextureY2]
    self.__uvMap[charIndex, 1] = [spriteTextureX1, spriteTextureY1]
    self.__uvMap[charIndex, 2] = [spriteTextureX2, spriteTextureY1]
    self.__uvMap[charIndex, 3] = [spriteTextureX2, spriteTextureY2]

You can also recognize the "magic formula" for the case of the texture.

Update OpenGL mesh data

The draw() method sends the mesh data to OpenGL every time we call it. Then the super method renders the mesh as usual:

def draw(self):
    self.updateData(self.__vertices, self.__faces, self.__uvMap)
    super(OpenGLCharactersLayer, self).draw()

Main program

Create the characters layer

At the beginning of the program, we create the characters layer:

charsLayer = guiFacade.createCharactersLayer()
charsLayer.setTileset("characters.png", 32, 32)
charsLayer.setCharacterCount(1)
charsLayer.setCharacterTile(0, 1, 0)
characterX = 13.0
characterY = 8.0
charsLayer.setCharacterLocation(0, characterX, characterY)
characterSpeed = 1.0 / 2

We create only one character, but you can try to create more: don't forget to set a tile; otherwise, it will be transparent!

Main game loop

The main game loop moves the character and adjusts the view to ensure that he/she is always visible:

guiFacade.init()
translationX = 0.0
translationY = 0.0
minTranslationX = 0
minTranslationY = 0
viewWidth = guiFacade.screenWidth / groundTileWidth
viewHeight = guiFacade.screenHeight / groundTileHeight
maxTranslationX = levelWidth - viewWidth
maxTranslationY = levelHeight - viewHeight
while not guiFacade.closingRequested:
    # Update inputs state
    guiFacade.updateInputs()

    # Keyboard key single press
    keyboard = guiFacade.keyboard
    for keyEvent in keyboard.keyEvents:
        if keyEvent.type == KeyEvent.KEYDOWN:
            if keyEvent.key == Keyboard.K_ESCAPE:
                guiFacade.closingRequested = True
                break
    keyboard.clearKeyEvents()

    # Keyboard key multi/continuous press
    if keyboard.isKeyPressed(Keyboard.K_LEFT):
        characterX -= characterSpeed
        charsLayer.setCharacterTile(0, 4, 0)
    if keyboard.isKeyPressed(Keyboard.K_RIGHT):
        characterX += characterSpeed
        charsLayer.setCharacterTile(0, 7, 0)
    if keyboard.isKeyPressed(Keyboard.K_UP):
        characterY -= characterSpeed
        charsLayer.setCharacterTile(0, 10, 0)
    if keyboard.isKeyPressed(Keyboard.K_DOWN):
        characterY += characterSpeed
        charsLayer.setCharacterTile(0, 1, 0)

    if characterX <= 0:
        characterX = 0
    if characterY <= 0:
        characterY = 0
    if characterX > levelWidth - 1:
        characterX = levelWidth - 1
    if characterY > levelHeight - 1:
        characterY = levelHeight - 1

    charsLayer.setCharacterLocation(0, characterX, characterY)

    # View
    if characterX < translationX + 10:
        translationX = characterX - 10
    if characterY < translationY + 10:
        translationY = characterY - 10
    if characterX > translationX + viewWidth - 10:
        translationX = characterX - viewWidth + 10
    if characterY > translationY + viewHeight - 10:
        translationY = characterY - viewHeight + 10

    if translationX < minTranslationX:
        translationX = minTranslationX
    if translationY < minTranslationY:
        translationY = minTranslationY
    if translationX >= maxTranslationX:
        translationX = maxTranslationX
    if translationY >= maxTranslationY:
        translationY = maxTranslationY

    guiFacade.setTranslation(translationX, translationY)

    # Render scene
    guiFacade.render()

Lines 2-9 initialize variables to handle the view.

Lines 15-21 stops the loop if the player presses the escape key.

Lines 24-46 updates the character location (in characterX and characterY variables) depending on arrow keys. They ensure that the location is correct and that the character does not go outside the level. They update the tile according to the character orientation using the setCharacterTile() method.

Lines 49-67 updates the view. It is as before, except that we set the view according to the location of the character. We don't update the view every time, but only when the character is close to a screen edge.

Final program

Download code and assets

In the next post, we'll start to work on text rendering.