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
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.
I extend the facade to introduce different layer types:
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
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
CharactersLayer are the interfaces for grid layers (what we previously done) and character layers.
The API of the
CharactersLayer is the following one:
setCharacterCount()defines the maximum number of characters the layer can display. The facade user must call it first.
setCharacterLocation()sets the screen location of a character. Like the translation in the view, (x,y) are float coordinates where round values correspond to cells.
setCharacterTile()sets the tile of a character.
We base both implementations of the layer classes (
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
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_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 has a single
setTile() method. It is like the previous
setGridMesh() method of 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, self.__transparentTile) 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 (
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.
setCharacterLocation() method defines the location of a character:
def setCharacterLocation(self, charIndex: int, x: float, y: float): assert 0 <= charIndex < self.__vertices.shape 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.
setCharacterTile() method defines the tile of a character:
def setCharacterTile(self, charIndex: int, tileX: float, tileY: float): assert 0 <= charIndex < self.__vertices.shape 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.
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()
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!
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
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
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.
In the next post, we'll start to work on text rendering.