PacDroid: from scratch to fun
In this tutorial, I'm going to explain how to write a game from scratch with libGDX. I will make reference to my tutorials “How to make games” and “Introduction to libGDX”, so I suppose that you have read them.
I chose to do a clone of Pac-Man, a game released in 1980 where a voracious yellow circle has to eat all food dots in a maze while avoiding four ghosts trying to kill it. Four “energizers” in each corner allow Pac-Man to reverse the situation and make the ghosts edible for a short duration. The original game looked like this:
Note: This tutorial and the project to download contain copyrighted materials that I'm allowed to use for educational purposes. Don't reuse them in a published work. It's illegal.
Preparation
Before writing the first line of code, I need to create a few images: a droid, four ghosts and a maze. This is quickly done; I will spend later more time on the artwork. These first drawings should help define the right size of each element and, of course, allow to build our first prototype. At this stage, all sizes depend on an important decision to take: how will PacDroid be controlled? If I decide to use a touchpad, I will have less space on screen for the maze and other graphic elements. I know that the maze will take a lot of space, so I prefer to use only the accelerometer. Maybe later I will implement a transparent touchpad for the gamers uncomfortable with the accelerometer and for the (very rare) devices without this sensor.
Here are my first drawings:
PacDroid is going to be animated so I need to draw two frames: mouth opened and mouth closed, and my frames should face the four directions.
In the original version, the ghosts’ eyes are also animated. That won’t be the case here. It’s a tutorial, not a real game, so I avoid overloading it.
For the maze, I decide to make a tiled map, as in the original version. I construct a tile set by drawing over a screenshot, and then import it in Tiled to create the first level:
The properties panel on the right is oversized to make it readable.
doorX and doorY are the coordinates of the left tile of the exit of the ghosts house, and startX and startY are the coordinates of the starting position of PacDroid. By storing these properties in the map file, I separate the work done on levels from the code (if I worked in a company with a level designer, he/she could decide freely where PacDroid starts, where to place the ghosts’ house, etc.; nothing is hardcoded).
The tile size is 37 pixels (width and height) and the map size is 28 tiles x 31. So my map has a width = 28 * 37 = 1036 pixels and a height = 31 * 37 = 1174 pixels. These dimensions will have to be slightly scaled down on a WXGA tablet (1280x800), and more importantly on an ordinary WVGA phone (800x480) but in both cases the quality should not suffer.
I have to decide how to draw my food dots: directly in the map editor, or with sprites rendered in real time. The first solution has an important disadvantage (it’s complicated to change the appearance of dots and to set effects on them), but the second has an even greater drawback: it would require hundreds of sprite and an array to memorize the initial locations and the eaten dots. I don’t want to bother with that so I decide to add the food dots and the energizers directly on the map. Because of this choice, the energizers (the big circle in the top left corner of the image below) won't flash as in the original version. Note that I could have done an exception for the four energizers, but I want to process all tiles with the same method in this tutorial.
After placing the dots, I count them and I add a property Goal to my level. PacDroid must eat “Goal” dots to go to the next level.
I pack all my images, except the tile set, in a texture atlas with the texture packer.
It's time to write the game logic. As Pac-Man is a well-known game and there are plenty of web sites about it, I don't really need to formalize the concept and write the rules. I can read them here if needed. But I still write the pseudo-code that I will insert in the code as comments.
Example:
B4X:
If the droid collides with a ghost then
If the ghost can be eaten then
The player gets extra points
The earned points are shown above the eaten ghost
The ghost appearance changes
The ghost returns to the house (new mode = Return)
Else
The droid has been caught -> the game is paused
The droid death is animated
The death music is played
The number of lives is decreased
If no more lives then
Game over
Else
The actors come back to their starting position
End if
End if
End if
First lines of code: the skeleton
The first thing I write is the declaration of libGDX in Globals and in the Activity events:
B4X:
Sub Globals
Dim lGdx As LibGDX
Dim GL As lgGL
End Sub
Sub Activity_Create(FirstTime As Boolean)
Dim Config As lgConfiguration
'Enables the accelerometer
Config.useAccelerometer = True
'Disables the compass
Config.useCompass = False
'Forces the device to stay on
Config.useWakelock = True
'Limits the number of simultaneous sounds
Config.maxSimultaneousSounds = 4
'Creates the libGDX surface
lGdx.Initialize2(Config, "LG")
End Sub
Sub Activity_Resume
'Informs libGDX of Resume events
If lGdx.IsInitialized Then lGdx.Resume
End Sub
Sub Activity_Pause (UserClosed As Boolean)
'Informs libGDX of Pause events
If lGdx.IsInitialized Then lGdx.Pause
End Sub
I force the device to stay on by creating a wake lock because of the accelerometer; there will be no activity on the touch screen during the game.
I know that my game does not need a lot of simultaneous sounds, so I limit them to 4.
The next step is to write the events of the libGDX life-cycle, then to think to the organization of my code. As I explained in the first tutorial, it’s better to create a class for each element: clsDroid, clsGhost and clsMaze. And because a scene graph will make my life easier, I declare a lgScn2DStage in Globals and in the Create event of libGdx. I don’t forget to dispose it in the Dispose event. I add a main lgScn2DTable as advised in the second tutorial and a capture listener to the stage so that I can filter all the input events before they reach the Scene2D actors. Now my LG_Create event contains:
B4X:
Sub LG_Create
'Initializes the stage
Stage.Initialize("")
'Adds a capture listener to the stage
Dim IL As lgScn2DInputListener
IL.Initialize("ST")
Stage.AddCaptureListener(IL)
'Creates the main table
Table.Initialize("")
Table.FillParent = True
Stage.AddActor(Table)
End Sub
I add the code in the Render event to perform the actions of actors and to draw the actors:
B4X:
Sub LG_Render
'Clears the screen
GL.glClearColor(0, 0, 0, 1)
GL.glClear(GL.GL10_COLOR_BUFFER_BIT)
'Applies the actions to actors
Stage.Act
'Draws the actors
Stage.Draw
End Sub
I could have gone faster by using the _TemplateScene2DTable.b4a which contains already all this code.
As I have a lot of things to do before drawing my actors, I insert in the Render event a call to another sub: “LG_Update”. I will place all the game logic in this sub.
Ok, it’s time to add some flesh to the skeleton. I’m going to convert my pseudo-code in Basic in a specific order: first the loading code, second the drawing code, third the game logic. I want to see quickly how my actors are rendered and whether the chosen layout (with the score and highscore above, the maze in the middle and the lives below) adapts well to different screen sizes.
Loading and initialization
The loading is done in the Create event without an Asset Manager because I don’t have many resources or big ones that I should wait for. Some resources are loaded and initialized directly in the event or with the Initialize function of the corresponding actor. While writing these Initialize functions, I realize that it would be easier to position my two moving actors (droid and ghost) with the 0, 0 coordinates in the bottom left corner of the maze. As I know that actors placed in a group use the local coordinates system of their parent, I create a lgScn2DGroup that I place in the middle cell of my main table, where the maze takes place, and I will add my actors to this group when needed. At the beginning, my moving actors are in the backstage because I want to display a title over an empty maze.
The score and highscore labels are two lgScn2DLabel, each one in a cell. The score cell is expanded to push the highscore label against the right side. The maze cell, in the next row, has to occupy all the space available, so I call Expand and set Colspan to 2 (otherwise my cell couldn’t be larger than the score cell).
While I create the bottom cell to show the lives, I find that it would be a good idea to add another table in this cell so that I can draw lives in separate cells of this new table. To decrease visually the number of lives, I will just have to remove the last cell of the table.
For the life image, I will reuse the second frame of PacDroid (open mouth).
To load the maze, I initialize a lgMapTmxMapLoader. Then I size the tiles so as the whole maze fits into the screen and no space is lost. To do my computations, I need to know the size of the map and the size of a tile. I read them in the properties of the map:
B4X:
MapWidth = Maps.Properties.Get("width")
MapHeight = Maps.Properties.Get("height")
Dim TileWidth As Int = Maps.Properties.Get("tilewidth")
Dim TileHeight As Int = Maps.Properties.Get("tileheight")
Dim RatioW As Float = Width / (TileWidth * MapWidth)
Dim RatioH As Float = (Height - (GutterSize * 2)) / (TileHeight * MapHeight)
If RatioW < RatioH Then
MapRenderer.Initialize4(Maps, RatioW, SpriteBatch)
TileSize = MapRenderer.UnitScale * TileWidth
Else
MapRenderer.Initialize4(Maps, RatioH, SpriteBatch)
TileSize = MapRenderer.UnitScale * TileHeight
End If
Let’s load the PacDroid frames now.
I did not create the frames facing to the left and to the top. It’s easy to create them at runtime by flipping the other frames:
B4X:
argRightAnim = Atlas.FindRegions2("right")
argDownAnim = Atlas.FindRegions2("down")
For i = 0 To 1
argLeftAnim(i).Initialize(argRightAnim(i))
argLeftAnim(i).Flip(True, False)
argUpAnim(i).Initialize(argDownAnim(i))
argUpAnim(i).Flip(False, True)
Next
My frames have to be scaled to the current resolution. To ensure they are scaled properly for the current tile size, I compute the ratio between the tile size and the frame size and I use it as the scale. I ensure also that the origin is in the middle of the image (so the scaling will be done around this point). Note that my frames are two times bigger than a tile, but will be centered on one tile. It’s different from the original version.
B4X:
Actor.ScaleX = Main.Maze.TileSize / argRightAnim(0).OriginalWidth * 2
Actor.ScaleY = Main.Maze.TileSize / argRightAnim(0).OriginalHeight * 2
Actor.OriginX = argRightAnim(0).OriginalWidth / 2
Actor.OriginY = argRightAnim(0).OriginalHeight / 2
To create the ghosts, I retrieve their image from the atlas as a sprite, scale it with the same scale as PacDroid, then create an image actor. The image actor expects a drawable, so I convert the sprite:
B4X:
Dim sdwGhost As lgScn2DSpriteDrawable
sdwGhost.Initialize2(sprGhost)
Actor.InitializeWithDrawable(sdwGhost, "")
All my resources are now loaded and initialized, let’s draw them.
Rendering
To position properly my moving actors, I prefer to use coordinates in tiles rather than in pixels. If the coordinate value is an integer, the actor is over one tile. If the value is a float, the actor is across two tiles. In all cases, it has to end its move on a tile, so with integer values. To convert between pixels and tiles, I write a set of functions. Example:
B4X:
Public Sub FromTileToPixel_X(TileX As Int) As Int
Return Round((TileX - 0.5) * TileSize)
End Sub
Public Sub FromTileToPixel_Y(TileY As Int) As Int
Return Round((TileY - 0.5) * TileSize)
End Sub
Public Sub FromTileToPixel_InvertedY(TileY As Int) As Int
Return Round((MapHeight - 1.5 - TileY) * TileSize)
End Sub
All the rendering is done by the SpriteBatch of the stage. When Stage.Draw is called, it raises the Draw event of my group of actors, in the middle cell. I have nothing to do in this event apart calling the specific Draw function of the maze (it won’t be called automatically because I did not declare explicitly the maze as a Scene2D actor). I disable the blending before drawing because the maze has no transparent tile and thus no blending is needed. It’s just an optimization; it’s not required.
To avoid creating multiple tiled maps, I put each different design of the maze (each level) in a different layer of the map. That’s why, in the Draw function of the maze, I render only the layer which corresponds to the current level:
B4X:
Public Sub Draw(Camera As lgOrthographicCamera)
MapRenderer.SetCameraView(Camera)
MapRenderer.RenderTileLayer(CurrentLayer)
End Sub
The ghosts are automatically rendered because they are image actors. PacDroid, on the contrary, is a basic actor, so I have to render it myself. The main reason for this is because it is animated. I have to select the appropriate frame to render and this frame must be positioned exactly. When I packed the frames of PacDroid in the atlas, I chose the option to remove their whitespace, so now they have different sizes. Fortunately, the atlas kept track of the amount of space removed and returns this information in the offset property of the lgTextureAtlasRegion:
B4X:
SpriteBatch.DrawRegion3(Frame, _
Actor.X + (Frame.OffsetX * Actor.ScaleX) - Actor.OriginX + Main.Maze.TileSize, _
Actor.Y + (Frame.OffsetY * Actor.ScaleY) - Actor.OriginY + Main.Maze.TileSize, _
Actor.OriginX, Actor.OriginY, Frame.RegionWidth, Frame.RegionHeight, _
Actor.ScaleX, Actor.ScaleY, 0)
The lgAnimation class used to animate PacDroid is fairly simple to use. It is initialized with two parameters: the array of two frames corresponding to the current movement direction and the duration between each frame, which is 300ms in this game:
B4X:
Anim.Initialize(FrameDuration, argRightAnim)
B4X:
Dim Frame As lgTextureAtlasRegion = Anim.GetKeyFrame2(StateTime, True)
StateTime = StateTime + Main.DeltaTime
After placing my actors for a first rendering, here are the results on a Nexus 7:
We have now something that looks like a game but it does not react to the user input and the actors do not move. I’m going to bring life to all this. I begin by PacDroid.
......
Last edited: