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 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:
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 (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_DRAW
as 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.
The OpenGLGridLayer
class has a single setTile()
method. It is like the previous setGridMesh()
method of the OpenGLLayer
class.
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.
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.
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.
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()
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 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.
In the next post, we'll start to work on text rendering.