Completing the player's and the background's components

All the game objects are reliant upon or react to the player. For example, the aliens will spawn, chase and shoot relative to the player's position. Even the background will take its que for which way to scroll based on what the player is doing. Therefore, as mentioned previously it makes sense to get the player working first.

However, remember that using the Entity-Component pattern will mean that some of the components we code for the player will also be used when we implement some other game objects.

Tip

The author hopes the reader understands that if we hadn't coded the empty component classes before Transform and subsequently GameObject, that all these calls to the Transform class and the context within which these components work might have been harder to understand.

As we code all the player and background components I will make it clear what is new code and what we coded back in the Coding the player's and the background's empty component classes section.

The player's components

Remember that despite the heading of this section some of these components comprise non-player related game objects too. We are just coding these first because it makes sense to get the player working right away.

Completing the StdGraphicsComponent

We have two empty methods at the moment, initialize and draw. Let's add the code into the bodies starting with initialize. The new code (everything in the bodies of the methods) is highlighted.

Add the new code to the initialize method.

@Override
public void initialize(
   Context context, 
   ObjectSpec spec, 
   PointF objectSize){

   // Make a resource id out of the string of the file name
   int resID = context.getResources()
                .getIdentifier(spec.getBitmapName(),
                "drawable", 
                context.getPackageName());

   // Load the bitmap using the id
   mBitmap = BitmapFactory.decodeResource(
                context.getResources(), resID);

   // Resize the bitmap
   mBitmap = Bitmap
                .createScaledBitmap(mBitmap,
                             (int)objectSize.x,
                             (int)objectSize.y,
                             false);

   // Create a mirror image of the bitmap if needed
   Matrix matrix = new Matrix();
   matrix.setScale(-1, 1);
   mBitmapReversed = Bitmap.createBitmap(mBitmap,
               0, 0,
               mBitmap.getWidth(),
               mBitmap.getHeight(),
               matrix, true);
}

All the code we have seen several times before. As a reminder this is what is happening. The getResources.getIdentifier methods use the name of the bitmap to identify a graphics file from the drawable folder.

The identifier is then used by the decodeResource method to load the graphics into a Bitmap object.

Next, createScaledBitmap is used to scale the Bitmap to the correct size for the game object.

Finally, a reversed version of the Bitmap is created with another Bitmap. Now we can show any GameObject with a StdGraphicsComponent facing left or right.

Now add the highlighted code into the draw method of the StdGraphicsComponent class.

@Override
public void draw(Canvas canvas, 
                      Paint paint, 
                      Transform t) {
            
   if(t.getFacingRight())
        canvas.drawBitmap(mBitmap,
                     t.getLocation().x,
                     t.getLocation().y,
                     paint);

   else
         canvas.drawBitmap(mBitmapReversed,
                      t.getLocation().x,
                      t.getLocation().y,
                      paint);
}

The code in the draw method uses the Transform class's getFacingRight method to determine whether to draw the Bitmap that is facing right or left.

Completing the PlayerMovementComponent

This class is part of the solution that will bring the player's spaceship to life. The code in the move method uses the Transform to determine which way(s) the ship is heading and to move it accordingly. In a few pages time we will also code the PlayerInputComponent that will manipulate the Transform according to the player's screen touches. This is the class that responds to those manipulations.

Add the new highlighted code to the move method of the PlayerMovementComponent class.

@Override
public boolean move(long fps, Transform t,
                            Transform playerTransform){

   // How high is the screen?
   float screenHeight = t.getmScreenSize().y;
   // Where is the player?
   PointF location = t.getLocation();
   // How fast is it going
   float speed = t.getSpeed();
   // How tall is the ship
   float height = t.getObjectHeight();

   // Move the ship up or down if needed
   if(t.headingDown()){
           location.y += speed / fps;
   }
   else if(t.headingUp()){
           location.y -= speed / fps;
   }

   // Make sure the ship can't go off the screen
   if(location.y > screenHeight - height){
           location.y = screenHeight - height;
   }
   else if(location.y < 0){
           location.y = 0;
   }

   // Update the collider
   t.updateCollider();

   return true;
}

The first thing that happens in the move method is that some local variables are initialized from the Transform instance using the getter methods. As the variables are used more than once the code will be neater and fractionally faster than repeatedly calling the Transform class's getters again. At this point we have a variable to represent the screen height, the location of the object, speed of the object and the height of the object represented by the local variables, screenHeight, location, speed and height, respectively.

Next in the move method we use an if statement combined with an else if to determine if the ship is heading either up or down. Here it is again.

// Move the ship up or down if needed
if(t.headingDown()){
   location.y += speed / fps;
}
else if(t.headingUp()){
   location.y -= speed / fps;
}

If it is then the ship is moved either up or down by speed / fps. The next if and else if pair checks whether the ship has gone off the top or bottom of the screen.

// Make sure the ship can't go off the screen
if(location.y > screenHeight - height){
         location.y = screenHeight - height;
}
else if(location.y < 0){
        location.y = 0;
}

If it has location.y is changed to reflect either the lowest (screenHeight-height) or highest (0) point on the screen that the ship should be allowed at.

Note

Note that when I say highest and lowest this is slightly ambiguous. The highest point on the screen is represented by the lowest number (pixel position zero) and the lowest point on the screen is the higher number.

The final line of code calls the updateCollider method so that the collider is updated based on the new position of the ship.

Completing the PlayerSpawnComponent

This is a very simple component. This code executes whenever the GameObject's spawn method is called. Add the highlighted code.

@Override
public void spawn(Transform playerTransform, Transform t) {

   // Spawn in the centre of the screen
   t.setLocation(
                t.getmScreenSize().x/2, 
                t.getmScreenSize().y/2);

}

All that we need to do is put the ship in the middle of the screen with the setLocation method. The middle is calculated by dividing the height and width by two.

Completing the PlayerInputComponent

This class is fairly long but not too complicated when taken a bit at a time. First add the following code to the PlayerInputComponent and setTransform methods.

PlayerInputComponent(GameEngine ger) {
   ger.addObserver(this);
   mPLS = ger;
}

@Override
public void setTransform(Transform transform) {
   mTransform = transform;
}

The constructor receives a reference to the GameEngine which it uses to register as an Observer using the addObserver method. Now this class will receive touch details every time the player touches the screen.

In the setTransform method mTransform is a made a reference to the Transform of the GameObject. Remember that the GameObject class was passed a InputController reference by the factory (soon to code GameObjectFactory) class and uses that reference to call this method.

Now that mTransform is a reference to the actual Transform that is part of the GameObject that is the player's ship the handleInput method can use it to manipulate it. Remember we manipulate the Transform in handleInput and the PlayerMovementComponent responds to those manipulations. As another reminder it is the onTouchEvent method in GameEngine which calls handleInput because PlayerInputComponent is registered as an Observer.

Add the highlighted code in the handleInput method and then we will discuss it. Be sure to look at the parameters that are passed in because they are key to the discussion on how the method does its job.

// Required method of InputObserver 
// and is called from the onTouchEvent method
@Override
public void handleInput(MotionEvent event, 
                        GameState gameState, 
                        ArrayList<Rect> buttons) {               
   int i = event.getActionIndex();
   int x = (int) event.getX(i);
   int y = (int) event.getY(i);

   switch (event.getAction() & MotionEvent.ACTION_MASK) {

           case MotionEvent.ACTION_UP:
                if (buttons.get(HUD.UP).contains(x,y)
                || buttons.get(HUD.DOWN).contains(x,y)) {

            // Player has released either up or down
            mTransform.stopVertical();
           }
           break;

      case MotionEvent.ACTION_DOWN:
           if (buttons.get(HUD.UP).contains(x,y)) {
                  // Player has pressed up
                  mTransform.headUp();
           } else if (buttons.get(HUD.DOWN).contains(x,y)) {
                  // Player has pressed down
                  mTransform.headDown();
           } else if (buttons.get(HUD.FLIP).contains(x,y)) {
                  // Player has released the flip button
                  mTransform.flip();
           } else if (buttons.get(HUD.SHOOT).contains(x,y)) {
                  mPLS.spawnPlayerLaser(mTransform);
           }
           break;

      case MotionEvent.ACTION_POINTER_UP:
           if (buttons.get(HUD.UP).contains(x, y)
           || 
           buttons.get(HUD.DOWN).contains(x, y)) {

           // Player has released either up or down
           mTransform.stopVertical();
           }
           break;

      case MotionEvent.ACTION_POINTER_DOWN:
             if (buttons.get(HUD.UP).contains(x, y)) {
                   // Player has pressed up
                   mTransform.headUp();
             } else if (buttons.get(HUD.DOWN).contains(x, y)) {
                   // Player has pressed down
                   mTransform.headDown();
            } else if (buttons.get(HUD.FLIP).contains(x, y)) {
                         // Player has released the flip button
                         mTransform.flip();
            } else if (buttons.get(HUD.SHOOT).contains(x, y)) {
                       mPLS.spawnPlayerLaser(mTransform);
            }
            break;
   }
}

Let's break the internals of handleInput down into manageable chunks. First, we use the getActionIndex, getX and getY methods to determine the coordinates of the touch which triggered this method to be called. These values are now stored in the x and y variables.

int i = event.getActionIndex();
int x = (int) event.getX(i);
int y = (int) event.getY(i);

Now we enter a switch block which decides the action type. There are four case statements that we handle. This is new. Previously we have only handled two cases ACTION_UP and ACTION_DOWN. The difference is that it is possible that multiple fingers could be interacting at one time. Let's see how we can handle this and what the four case statements are.

switch (event.getAction() & MotionEvent.ACTION_MASK) {

}

The first is nothing new. ACTION_UP is handled and we are only interested in the up and down buttons being released. If the up or down button is released, then the stopVertical method is called and the next time the move method of the PlayerMovementComponent is called the ship will not be moved up or down.

case MotionEvent.ACTION_UP:
   if (buttons.get(HUD.UP).contains(x,y)
   || buttons.get(HUD.DOWN).contains(x,y)) {

         // Player has released either up or down
         mTransform.stopVertical();
   }
   break;

Next, we handle ACTION_DOWN and this case is slightly more extensive. We need to handle all the ship controls. Each of the ifelse blocks handle what happens when x and y are calculated to be inside a specific button. Take a close look next.

case MotionEvent.ACTION_DOWN:
   if (buttons.get(HUD.UP).contains(x,y)) {
         // Player has pressed up
         mTransform.headUp();
   } else if (buttons.get(HUD.DOWN).contains(x,y)) {
         // Player has pressed down
         mTransform.headDown();
   } else if (buttons.get(HUD.FLIP).contains(x,y)) {
         // Player has released the flip button
         mTransform.flip();
   } else if (buttons.get(HUD.SHOOT).contains(x,y)) {
         mPLS.spawnPlayerLaser(mTransform);
   }
   break;

When up is pressed the headUp method is called. When down is pressed the headDown method is called. When flip is pressed the flip method is called and when shoot is pressed mPLS is used to call the spawnPlayerLaser method on the GameEngine class.

If you look at the next two case statements, presented again next together they will look familiar. In fact, apart from the first line of code for each case - the case statement itself the code is identical to the earlier two case statements.

You will notice that instead of ACTION_UP and ACTION_DOWN we are now responding to the ACTION_POINTER_UP and ACTION_POINTER_DOWN. The explanation is simple. If the first finger to make contact with the screen causes an action to be triggered it is held by the MotionEvent object as an ACTION_UP or ACTION_DOWN. If it is the 2nd, 3rd, fourth, etc. then it is held as an ACTION_POINTER_UP or ACTION_POINTER_DOWN. This hasn't mattered in any of the earlier games and we have been able to avoid the extra code.

case MotionEvent.ACTION_POINTER_UP:
   if (buttons.get(HUD.UP).contains(x, y)
         || 
         buttons.get(HUD.DOWN).contains(x, y)) {

         // Player has released either up or down
         mTransform.stopVertical();
   }
   break;

case MotionEvent.ACTION_POINTER_DOWN:
   if (buttons.get(HUD.UP).contains(x, y)) {
         // Player has pressed up
         mTransform.headUp();
   } else if (buttons.get(HUD.DOWN).contains(x, y)) {
         // Player has pressed down
         mTransform.headDown();
   } else if (buttons.get(HUD.FLIP).contains(x, y)) {
         // Player has released the flip button
         mTransform.flip();
   } else if (buttons.get(HUD.SHOOT).contains(x, y)) {
         mPLS.spawnPlayerLaser(mTransform);
   }
   break;

It won't matter in our game whether it is a POINTER or not providing we respond to all the presses and releases. The player could be using crossed arms to play the game it doesn't make any difference to the Scrolling Shooter.

Note

However, if you were detecting more complicated gestures like pinches, zooms or some custom touch then the order, even the timing and movement while touched could be important. The MotionEvent class can handle all these situations but we don't need to do so in this book.

Let's turn our attention to the lasers.

Completing the LaserMovementComponent

We have already coded the PlayerLaserSpawner interface, implemented it through the GameEngine class, coded the PlayerInputComponent to receive an PlayerLaserSpawner instance as well as call the spawnPlayerLaser method (on GameEngine) when the player presses the on-screen shoot button. In addition, we have also coded a helper method in the Transform class (getFiringLocation) which calculates an aesthetically pleasing location to spawn a laser based upon the position and orientation of the player's ship.

For all of this to work we need to code the component classes of the laser itself. Add the following highlighted code to the move method of the LaserMovementComponent class.

@Override
public boolean move(long fps, 
                    Transform t, 
                    Transform playerTransform) {

   // Laser can only travel two screen widths
   float range = t.getmScreenSize().x * 2;

   // Where is the laser
   PointF location = t.getLocation();

   // How fast is it going
   float speed = t.getSpeed();

   if(t.headingRight()){
           location.x += speed / fps;
   }
   else if(t.headingLeft()){
           location.x -= speed / fps;
   }

   // Has the laser gone out of range
   if(location.x < - range || location.x > range){
          // disable the laser
          return false;
   }

   t.updateCollider();
   
   return true;
}

The new code in the move method initializes three local variables, range, location and speed. They are initialized using the laser's Transform reference. Their names are quite self-explanatory except perhaps range. The range variable is initialized by the width of the screen (t.getmScreensize.x) multiplied by two. We will use this value to monitor when it is time to deactivate the laser.

Next in the move method we see some code very similar to the PlayerMovementComponent. There is an if and an else-if block which detects which way the laser is heading (t.headingRight or t.headingLeft). Inside the if and else-if blocks the laser is moved horizontally in the appropriate direction using speed / fps.

The next if block checks if it is time to deactivate the laser using the formula repeated next:

if(location.x < - range || location.x > range){

The if statement detects whether double the width of the screen has been exceeded in either the left or right-hand directions. If it has then the move method returns false. Think back to when we called the move method in the GameObject class's update method- when the move method returned false the mIsActive member of GameObject is set to false. The move method will no longer be called on this GameObject.

The final line of code in the move method updates the laser's collider to its new position using the updateCollider method.

Completing the LaserSpawnComponent

The last bit of code for the laser is the spawn method of the LaserSpawnComponent. If you are wondering how the laser will draw itself refer to the PlayerLaserSpec class and you will see it uses a StdGraphicsComponent that we have already coded.

Add the new highlighted code into the spawn method as shown next.

@Override
public void spawn(Transform playerTransform, 
                            Transform t) {

   PointF startPosition = 
               playerTransform.getFiringLocation(t.getSize().x);

   t.setLocation((int)startPosition.x, (int)startPosition.y);

   if(playerTransform.getFacingRight()){
      t.headRight();
   }
   else{
          t.headLeft();
   }
}

The first thing the new code does is initialize a PointF called startPosition by calling the getFiringLocation method on the player's Transform reference. Notice also that the size of the laser is passed to getFiringLocation as is required for the method to do its calculations.

Next the setLocation method is called on the laser's Transform reference and the horizontal and vertical values now held by startPosition are used as arguments.

Finally, the player's heading is used in an if-else statement to decide which way to set the laser's heading. If the player was facing right it makes sense that the laser will also head right (or vice versa).

At this point the laser is ready to be drawn and moved.

Coding a scrolling background

The first frame of the game shows the background image like this. This is unmanipulated in any way.

Coding a scrolling background

The way the next frame is shown is to move the image off-screen to the left. So, what do we show on the last pixel-wide vertical column on the right-hand side of the screen? We will make a reversed copy of the same image and show it to the right of the original(unreversed) image. The next image shows a gap between the two images to make it plain that there is a join and where the two images are, but in reality, we will put the images right next to each other so the join is invisible.

Coding a scrolling background

As the original image and the reversed image are steadily scrolled to the left, eventually, half of each image will be shown and so on.

Coding a scrolling background

Eventually, we will be reaching the end of the original image and the last pixel on the right-hand-side of the reversed image will eventually be on-screen.

At the point when the reversed image is shown in full on the screen, just like the original image was at the start, we will move the original image over to the right-hand side. The two backgrounds will continuously scroll and as the right-hand image (either original or reversed) becomes the entire view that the player sees, the left-hand image (either original or reversed) will be moved to the right-hand-side ready to be scrolled into view.

Note

Note that this whole process is reversed when scrolling in the opposite direction!

In the next project we will also be drawing the platforms and other scenery in front of a scrolling background thereby creating a neat parallax effect.

Now we know what we need to achieve to scroll the background we can code the three background related component classes.

Completing the BackgroundGraphicsComponent

Let's start with the BackgroundGraphicsComponent. The first method we must code is the initialize method. Add the highlighted code to the initialize method.

@Override
public void initialize(Context c, 
                       ObjectSpec s, 
                       PointF objectSize) {

   // Make a resource id out of the string of the file name
   int resID = c.getResources()
          .getIdentifier(s.getBitmapName(),
          "drawable", c.getPackageName());

   // Load the bitmap using the id
   mBitmap = BitmapFactory
         .decodeResource(c.getResources(), resID);

   // Resize the bitmap
   mBitmap = Bitmap
         .createScaledBitmap(mBitmap,
                (int)objectSize.x,
                (int)objectSize.y,
                false);

   // Create a mirror image of the bitmap
   Matrix matrix = new Matrix();
   matrix.setScale(-1, 1);
   mBitmapReversed = Bitmap
                .createBitmap(mBitmap,
                0, 0,
                mBitmap.getWidth(),
                mBitmap.getHeight(),
                matrix, true);
}

The code is nothing we haven't seen before. The resource is selected using the name of the bitmap, it is loaded using decodeResource, it is scaled using createScaledBitmap then the Matrix class is used in conjunction with createBitmap to create the reversed version of the image. We now have two Bitmap objects (mBitmap and mBitmapReversed) ready to do our drawing.

Now we can code the draw method that will be called each frame of the game to draw the background. Add the new highlighted code to the draw method and then we can discuss it.

@Override
public void draw(Canvas canvas, 
            Paint paint, 
            Transform t) {

   int xClip = t.getXClip();
   int width = mBitmap.getWidth();
   int height = mBitmap.getHeight();
   int startY = 0;
   int endY = (int)t.getmScreenSize().y +20;

   // For the regular bitmap
   Rect fromRect1 = new Rect(0, 0, width - xClip, height);
   Rect toRect1 = new Rect(xClip, startY, width, endY);

   // For the reversed background
   Rect fromRect2 = new Rect(width - xClip, 0, width, height);
   Rect toRect2 = new Rect(0, startY, xClip, endY);

   //draw the two background bitmaps
   if (!t.getReversedFirst()) {
      canvas.drawBitmap(mBitmap, 
                        fromRect1, toRect1, paint);
                     
      canvas.drawBitmap(mBitmapReversed, 
                        fromRect2, toRect2, paint);

   } else {
      canvas.drawBitmap(mBitmap, fromRect2, 
                        toRect2, paint);
                     
      canvas.drawBitmap(mBitmapReversed, 
                        fromRect1, toRect1, paint);
   }
}

The first thing we need to do is declare some local variables to help us draw the two images in the correct place.

  • The xClip variable is initialized by calling getXclip using the Transform reference. The value of xClip is the key to deciding where the join in the image is. The Transform holds this value and it is manipulated in the BackgroundMovementComponent that we will code next.
  • The width variable is initialized from the width that the Bitmap was scaled to.
  • The height variable is initialized from the height that the Bitmap was scaled to.
  • The startY variable is the vertical point where we want to start drawing the images. This is simple- the top of the screen- zero. It is initialized accordingly.
  • The endY variable is the bottom of the screen and is initialized to the height of the screen + twenty pixels to make sure there are no glitches.

Next, we initialize four Rect objects. When we draw the bitmaps to the screen we will need two Rect objects for each Bitmap. One to determine the portion of the Bitmap to be drawn from. And one to determine the area of the screen to draw to. Therefore, we have named the Rect objects fromRect1, toRect1, fromRect2 and toRect2. Look at these four lines of code again.

// For the regular bitmap
Rect fromRect1 = new Rect(0, 0, width - xClip, height);
Rect toRect1 = new Rect(xClip, startY, width, endY);

// For the reversed background
Rect fromRect2 = new Rect(width - xClip, 0, width, height);
Rect toRect2 = new Rect(0, startY, xClip, endY);

First of all, note that for the purpose of explanation we can ignore all the vertical values of the four Rect objects. The vertical values are the second and fourth arguments and they are always startY and endY.

You can see that fromRect1 always starts at zero horizontally. And extends to the full width less whatever the value of xClip is.

Jumping to fromRect2 we see it always starts from the full width less xClip and extends to the full width.

Try and picture in your mind that as xClip increases in value the first image will shrink horizontally and the second image will grow. As xClip decreases in value the opposite happens.

Now turn your attention to toRect1. We can see that the image is drawn to the screen from xClip to whatever width is. Now look at toRect2 and see that it is drawn from the width less xClip to whatever width is. These values have the effect of positioning the images exactly side by side based on whatever their current width is as well as making sure that these widths cover exactly the whole width of the screen.

Tip

The author considered different ways of explaining how these Rect values are calculated and suggests that for absolute clarity of how this works the reader should use pencil and paper to calculate and draw the rectangles for different values of xClip. This exercise will be most useful once you have finished coding the background related components to see how xClip is manipulated.

The final part of the code uses the Transform reference to determine which image should be drawn on the left (first) and then draws the two images using drawBitimap and the four previously calculated Rect objects.

Tip

This is an overloaded version of drawBitmap which takes the Bitmap to be drawn then a portion of the image to be drawn (fromRect1 and fromRect2) and a screen destination coordinates (toRect1 and toRect2)

Completing the BackgroundMovementComponent

Next, we will get the background moving. This is achieved mainly by incrementing and decrementing mXClip in the Transform class but also by switching the order in which the images are drawn. Add the highlighted code to the move method of the BackgroundMovementComponent class.

@Override
public boolean move(long fps, 
   Transform t, 
   Transform playerTransform) {

   int currentXClip = t.getXClip();

   if(playerTransform.getFacingRight()) {
          currentXClip -= t.getSpeed() / fps;
          t.setXClip(currentXClip);
   }
   else {
          currentXClip += t.getSpeed() / fps;
          t.setXClip(currentXClip);
   }

   if (currentXClip >= t.getSize().x) {
          t.setXClip(0);
          t.flipReversedFirst();
   } 
   else if (currentXClip <= 0) {
          t.setXClip((int)t.getSize().x);
          t.flipReversedFirst();
   }

   return true;
}

The first thing the code does is get the current value of the clipping/joining position from the Transform reference and store it in the currentXClip local variable.

The first if-else block tests whether the player is facing left or right. If the player is facing right currentXclip is reduced by the background's speed divided by the current frame rate. If the player is facing left currentXClip is increased by the background's speed divided by the current frame rate. In both cases the setXClip method is used to update mXClip in the Transform.

Next in the code there is an if-else-if block. These test whether currentXClip is either greater than the width of the background or less than zero. If currentXClip is greater than the width of the background setXclip is used to set it back to zero and the order of the images is flipped with flipReversedFirst. If currentXClip is less than or equal to zero setXClip is used to set it to the width of the background and the order of the images is flipped with flipReversedFirst.

The method always returns true because we never want to deactivate the background.

We just need to spawn the background and then we can start work on the factory that will compose and construct all these components into GameObject instances.

Completing the BackgroundSpawnComponent

Add the highlighted code to the spawn method of the BackgroundSpawnComponent.

@Override
public void spawn(Transform playerLTransform, Transform t) {
   // Place the background in the top left corner
   t.setLocation(0f,0f);
}

A single line of code sets the backgrounds position to the top left corner of the screen.

GameObject/Component reality check

But hang on a minute! We haven't instantiated a single game object. For that we need two more classes to complete our game object production line. The first is the factory itself and the next is the Level class which you can edit to determine what the game looks like (how many and which type of enemy), you can even extend it to make a game with multiple different levels.

Note

We will see how to create a game with multiple different levels for the final project of the book starting in Chapter 22, Exploring More Patterns and Planning the Platformer Project.

Once these two classes are complete it will be very easy to add lots of different aliens to the game. It would also be trivial to design and add your own …Spec classes, code their component classes and add them to the game.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.46.60