官术网_书友最值得收藏!

Chapter 8. The Snake Game

In this chapter, we will get straight down to designing and implementing a clone of the highly addictive Snake game. We will look at the design of the game and learn how to animate some bitmaps. Then we will look at a few aspects of the code that are new, such as our coordinate system. After that, we will whiz through the implementation of the game. Finally, we will look at how we could enhance our game.

In this chapter, we will cover the following topics:

  • Examine the design of our game
  • Look at the coordinate system of our Snake game
  • Examine the code structure so that when we come to implement the game, it will be more straightforward
  • Learn about animation with sprite sheets at the same time as implementing the home screen of our game
  • Break the code for the Snake game into manageable chunks and run through its full implementation
  • Enhance the game a little

Game design

If you haven't played the excellent Snake game before, here is an explanation of how it works. You control a very small snake. In our version, there is just a head, one body segment, and a tail. Here is a screenshot of our snake, made out of three segments:

The following screenshot shows the three segments individually:

Now, here is the thing; our snake is very hungry and also a very quick grower. Every time he eats an apple, he grows a body segment. This is a screenshot of the apple:

Life is great! Our snake just eats and grows! The problem that the player of our game needs to solve is that the snake is a little hyperactive. It never stops moving! What exacerbates this problem is that if the snake touches the side of the screen, it dies.

At first, this doesn't seem like too much of a problem, but as he grows longer and longer, he can't just keep going around in circles because he will bump inevitably into himself. This would again result in his demise:

For each apple eaten, we add an increasingly large amount to the score. Here is a sneak peek at what the game will look like after the basic implementation and before the enhancements:

The player controls the snake by tapping on the left or the right side of the screen. The snake will respond by turning left or right. The turn directions are relative to the direction the snake is traveling, which adds to the challenge because the player needs to think like a snake—kind of!

At the end of the chapter, we will also take a brief look at enhancing the game, use that enhanced version in the next chapter to publish it to the Google Play Store, and add leaderboards and achievements.

The coordinate system

In the previous chapter, we drew all our game objects directly to points on the screen, and we used real screen coordinates to detect collisions, bounces, and so on. This time, we will be doing things slightly differently. This is partly out of necessity, but as we will see, collision detection and keeping track of our game objects will also get simpler. This might be surprising when we think about the potential of our snake to be many blocks long.

Keeping track of the snake segments

To keep track of all the snake segments, we will first define a block size to define a portion of a grid for the entire game area. Every game object will reside at an (x,y) coordinate, based not on the pixel resolution of the screen but on a position within our virtual grid. In the game, we define a grid that is 40 blocks wide, like this:

//Determine the size of each block/place on the game board
 blockSize = screenWidth/40;

So we know that:

numBlocksWide = 40;

The height of the game screen in blocks will then simply be calculated by dividing the height of the screen in pixels by the previously determined value of blockSize minus a bit of space at the top for the score:

numBlocksHigh = ((screenHeight - topGap ))/blockSize;

This then allows us to keep track of our snake using two arrays for x and y coordinates, where element zero is the head and the last used element is the tail, a bit like this:

//An array for our snake
snakeX = new int[200];
snakeY = new int[200];

As long as we have a system for moving the head, perhaps something similar to the squash ball but based on our new game grid, we can do the following to make the body follow the head:

//move the body starting at the back
for(int i = snakeLength; i >0 ; i--){
  snakeX[i] = snakeX[i-1];
  snakeY[i] = snakeY[i-1];
}

The previous code simply starts at the back section of the snake and creates its location in the grid irrespective of what the section in front of it was. It proceeds up the body doing the same until everything has been moved to the location of the section that used to be just ahead of it.

This also makes collision detection (even for a very long snake) nice and easy.

Detecting collisions

Using our grid based on blockSize, we can detect a collision, for example, with the right side of the screen, like this:

if(snakeX[0] >= numBlocksWide)dead=true;

The previous code simply checks whether the first element of our array, which holds the x coordinate of the snake, is equal to or greater than the width of our game grid in blocks. Try to work out the code for collision with the left, top, and bottom before we see it during the implementation.

Detecting the event of the snake bumping into itself is quick too. We just need to check whether the first element of our array (the head) is in exactly the same position as any of the other sections, like this:

//Have we eaten ourselves?
for (int i = snakeLength-1; i > 0; i--) {
  if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) {
    dead = true;
    }
}

Drawing the snake

We simply draw every section of the snake relative to its grid location multiplied by the size in pixels of a block. The blockSize variable handles the entire challenge of making the game work on different screen sizes, like this:

//loop through every section of the snake and draw it
//a block at a time.
canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint);

Admittedly, there are probably more questions about how our implementation will work, but they are probably best answered by actually building the game.

Thus, we can easily follow along by either writing the code or just reading from the completed project. Let's take a look at the overall structure of our code.

The code structure

We will have two activities, one for the menu screen and one for the game screen. The menu screen activity will be called MainActivity, and the game screen activity will be called GameActivity. You can find all the completed code files as well as all the assets such as images, sprite sheets, and sound files in the Chapter8/Snake folder in the download bundle.

MainActivity

In contrast to our other projects, the menu screen will not have a UI designed in the Android Studio UI designer. It will consist of an animated snake head, a title, and a high score. The player will proceed to GameActivity by tapping anywhere on the screen. As we need to accomplish animations and user interactions, even the home screen will have a thread, a view object, and methods normally associated with our game screens, like this:

MainActivity.java file
    Imports
    MainActivity class
        Declare some variables and objects
        onCreate
        SnakeAnimView class
            Constructor
            Run method
            Update method
            Draw method
            controlFPS method
            pause method
            resume method
            onTouchEvent method
        onStop method
        onResume method
        onPause method
        onKeyDown method

We will not go deeper into the menu screen for now because at the end of this section, we will implement it line by line.

GameActivity

The game screen structure has many similarities to our Squash game and to the structure of the menu screen, although the internals of this structure vary a lot (as we have discussed and as we will see). There are some differences towards the end of the structure, most notably, a loadSound method and a configureDisplay method. Here is the structure (we will see afterwards why the two extra methods are there):

MainActivity.java file
    Imports
    GameActivity class
        Declare some variables and objects
        onCreate
        SnakeView class
            Constructor
            getSnake method
            getApple method
            Run method
            updateGame method
            drawGame method
            controlFPS method
            pause method
            resume method
            onTouchEvent method
        onStop method
        onResume method
        onPause method
        onKeyDown method
        loadSOund method
        configureDisplay method

Tidying up onCreate

One of the first things you might notice when you examine the code from the GameActivity class we will soon implement is just how short the onCreate method is:

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        loadSound();
        configureDisplay();
        snakeView = new SnakeView(this);
        setContentView(snakeView);

    }

We have written two methods, loadSound and configureDisplay. They do most of the initialization and setup present in our squash game. This makes our code less cluttered. All that is left in onCreate is the initialization of our SnakeView object and a call to setContentView.

We will look in detail at our loadSound and configureDisplay methods when we implement them.

As we have had advanced sight of the structure as well as previous experience of this type of implementation, we will just go through all of the implementation of our game activity in one phase.

Let's quickly implement the menu screen.

Animation, sprite sheets, and the Snake home screen

In the previous chapter, we used a bitmap to draw text, a circle, a line, and a single pixel on the blank bitmap we created in Java code. We then displayed the bitmap with all of its doodling using the Canvas class. Now we will look at a technique to draw two dimensional images, sometimes referred to as sprites. These are made from predrawn images. The images can be as simple as a plain pong ball or as complex as a glorious two-dimensional character with muscle definition, elaborate clothing, weapons, and hair.

So far, we have animated with unchanging objects, that is, we have moved a static unchanging image around the screen. In this section, we will see how to not only display a predrawn bitmap image on the screen but also continually alter it to create the illusion of on-the-spot animation.

Of course, the ultimate combination would be to animate the bitmap both by changing its image and moving it around at the same time. We will see that briefly when we look at an enhanced version of this chapter's Snake game, but will not be analyzing the code.

To do this on-the-spot bitmap animation, we need some bitmaps, as you might expect. For example, to draw a snake's tail swishing back and forth, we would need at least two frames of animation, showing the tail in different positions. In the following screenshot, the flower's head is towards the left:

In this screenshot, the flower has been flipped:

If the two bitmaps were shown one after the other, repeatedly, they would create the basic effect of a flower blowing in the wind. Of course, two frames of animation aren't going to contest for any animation awards, and there is another problem with these images as well, as we will learn, so we should add in more frames to make the animation as life-like as is practical.

We have just one more thing to discuss before we make an animated snake head for our game's home screen. How do we get Android to switch between these bitmaps?

Animating with sprite sheets

Firstly, we need to present the frames in a manner that is easy to manipulate in code. This is where sprite sheets come in. The following image shows some frames from a basic snake head animation that we will use on our game home screen. This time, they are presented in a strip of frames. All of them are parts of the same image, a bit like a series of images in a film. Also, notice in the following image that the frames are centered relative to each other and are exactly equal in size:

If we were to actually show the two previous flower images consecutively, they would not only would they sway but also jump around from one side to another on their stems, which is probably not the effect we were looking for.

Thus, with regard to the snake sprite sheet, as long as we show one frame after another, we will create a basic animation.

So how do we make our code jump from one part of the sprite sheet to the next? Each frame is exactly the same size, 64 x 64 pixels in this case, so we just need a way to display pixels from 0 to 63, then 64 to 127, then 128 to 192, and so on. As each frame of the sprite sheet image is subtly different, it allows us to use one image file with multiple frames to create our animation. Fortunately, we have a class to handle this, which is nothing quite as luxurious as a specific sprite sheet class but almost.

Tip

Regarding sprite sheet classes, such a thing does exist, although not in the regular Android classes. An API specifically designed for two-dimensional games will usually contain classes for sprite sheets. We will look at examples of this in the next chapter.

The Rect class holds the coordinates of a rectangle. Here, we create a new object of the Rect type, and initialize it to start at 0, 0 and end at 63, 63:

Rect rectToBeDrawn = new Rect(0, 0, 63, 63);

The Canvas class can then actually use our Rect object to define a portion of a previously loaded bitmap:

canvas.drawBitmap(headAnimBitmap, rectToBeDrawn, destRect, paint);

The preceding code is much simpler than it looks. First, we see canvas.drawBitmap. We are using the drawBitmap method of the Canvas class just as we have before. Then we pass headAnimBitmap, which is our sprite sheet containing all the frames we want to animate, as an argument. Rect rectToBeDrawn represents the coordinates of the currently relevant frame within headAnimationBitmap. destRect simply represents the screen coordinates at which we want to draw the current frame, and of course, paint is our object of the Paint class.

All we have to do now is change the coordinates of rectToBeDrawn and control the frame rate with a thread and we are done! Let's do that and create an animated home screen for our Snake game.

Implementing the Snake home screen

With the background information we just covered and our detailed look at the structure of the code we are about to write, there shouldn't be any surprises in this code. We will break things up into chunks just to make sure we follow exactly what is going on:

  1. Create a new project of API level 13. Call it Snake.
  2. Make the activity full screen as we have done before, and put your graphics into the drawable/mdpi folder. Of course, you can use my graphics as usual. They are supplied in the code download in the graphics folder of the Snake project.
  3. Here, you will find our MainActivity class declaration and member variables. Notice the variables for our Canvas and Bitmap class as well, we are declaring variables to hold frame size (width and height) as well as the number of frames. We also have a Rect object to hold the coordinates of the current frame of the sprite sheet. We will see these variables in action soon. Type the following code:
    public class MainActivity extends Activity {
    
        Canvas canvas;
        SnakeAnimView snakeAnimView;
    
        //The snake head sprite sheet
        Bitmap headAnimBitmap;
        //The portion of the bitmap to be drawn in the current frame
        Rect rectToBeDrawn;
        //The dimensions of a single frame
        int frameHeight = 64;
        int frameWidth = 64;
        int numFrames = 6;
        int frameNumber;
    
        int screenWidth;
        int screenHeight;
    
        //stats
        long lastFrameTime;
        int fps;
        int hi;
    
        //To start the game from onTouchEvent
        Intent i;
  4. The following is the implementation of the overridden onCreate method. We get the screen dimensions in the usual way. We load our sprite sheet into the headAnimBitmap Bitmap. Finally, we create a new SnakeAnimView and set it as the content view. Type the following code after the code from the previous step:
    @Override
        protected void onCreate(Bundle savedInstanceState) {
          super.onCreate(savedInstanceState);
    
            //find out the width and height of the screen
            Display display = getWindowManager().getDefaultDisplay();
            Point size = new Point();
            display.getSize(size);
            screenWidth = size.x;
            screenHeight = size.y;
    
            headAnimBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head_sprite_sheet);
    
            
            snakeAnimView = new SnakeAnimView(this);
            setContentView(snakeAnimView);
    
            i = new Intent(this, GameActivity.class);
    
        }
  5. Here is the declaration of our SurfaceView class, called SnakeAnimView, along with its member variables. Notice that it extends SurfaceView and implements Runnable. All its methods follow in the next steps. Type this code after the code from the preceding step:
    class SnakeAnimView extends SurfaceView implements Runnable {
            Thread ourThread = null;
            SurfaceHolder ourHolder;
            volatile boolean playingSnake;
            Paint paint;
  6. Here is the constructor that gets the frameWidth value by dividing the bitmap width by the number of frames, and the frameHeight value using the getHeight method. Type this code after the code from the previous step:
    public SnakeAnimView(Context context) {
        super(context);
        ourHolder = getHolder();
        paint = new Paint();
        frameWidth = headAnimBitmap.getWidth()/numFrames;
        frameHeight = headAnimBitmap.getHeight();
    }
  7. Now we implement the short but crucial run method. It calls each of the key methods of this class one after the other. These three methods are implemented in the following three steps after this step. Type the following code after the code from the preceding step:
    @Override
            public void run() {
                while (playingSnake) {
                    update();
                    draw();
                    controlFPS();
    
                }
    
            }
  8. Here is the update method. It tracks and chooses the frame number that needs to be displayed. Each time through the update method, we calculate the coordinates of the sprite sheet to be drawn using frameWidth, frameHeight, and frameNumber. If you are wondering why we subtract 1 from each horizontal coordinate, it is because like the screen coordinates, bitmaps start their coordinates at 0, 0:
    public void update() {
    
      //which frame should we draw
      rectToBeDrawn = new Rect((frameNumber * frameWidth)-1, 0,(frameNumber * frameWidth +frameWidth)-1, frameHeight);
    
      //now the next frame
      frameNumber++;
    
      //don't try and draw frames that don't exist
      if(frameNumber == numFrames){
        frameNumber = 0;//back to the first frame
      }
    }
  9. Next is the draw method, which does nothing new until the end, when it calculates the place on the screen to draw the bitmap by dividing the screenHeight and screenWidth variables by 2. These coordinates are then saved in destRect. Both destRect and rectToDraw are then passed to the drawBitmap method, which draws the frame required at the location required. Type this code after the code from the previous step:
    public void draw() {
    
                if (ourHolder.getSurface().isValid()) {
                    canvas = ourHolder.lockCanvas();
                    //Paint paint = new Paint();
                    canvas.drawColor(Color.BLACK);//the background
                    paint.setColor(Color.argb(255, 255, 255, 255));
                    paint.setTextSize(150);
                    canvas.drawText("Snake", 10, 150, paint);
                    paint.setTextSize(25);
                    canvas.drawText("  Hi Score:" + hi, 10, screenHeight-50, paint);
    
                    //Draw the snake head
                    //make this Rect whatever size and location you like
                    //(startX, startY, endX, endY)
                    Rect destRect = new Rect(screenWidth/2-100, screenHeight/2-100, screenWidth/2+100, screenHeight/2+100);
    
                    canvas.drawBitmap(headAnimBitmap, rectToBeDrawn, destRect, paint);
    
                    ourHolder.unlockCanvasAndPost(canvas);
                }
    
            }
  10. Our trusty old controlFPS method ensures that our animation appears at a sensible rate. The only change in this code is that the initialization of timeTosleep is changed to create a 500-millisecond pause between each frame. Type the following code after the code from the preceding step:
    public void controlFPS() {
                long timeThisFrame = (System.currentTimeMillis() - lastFrameTime);
                long timeToSleep = 500 - timeThisFrame;
                if (timeThisFrame > 0) {
                    fps = (int) (1000 / timeThisFrame);
                }
                if (timeToSleep > 0) {
    
                    try {
                        ourThread.sleep(timeToSleep);
                    } catch (InterruptedException e) {
                    }
    
                }
    
                lastFrameTime = System.currentTimeMillis();
            }
  11. Next are our pause and resume methods, which work with the Android lifecycle methods to start and stop our thread. Type this code after the code from the previous step:
    public void pause() {
                playingSnake = false;
                try {
                    ourThread.join();
                } catch (InterruptedException e) {
                }
    
            }
    
            public void resume() {
                playingSnake = true;
                ourThread = new Thread(this);
                ourThread.start();
            }
  12. For our SnakeAnimView class and our onTouchEvent method, which simply starts the game when the screen is touched anywhere, we enter the following code. Obviously, we don't have a GameActivity yet:
    @Override
            public boolean onTouchEvent(MotionEvent motionEvent) {
    
    
                startActivity(i);
                return true;
            }
    }
  13. Finally, back in the MainActivity class, we handle some Android lifecycle methods. We also handle what happens when the player presses the back button:
    @Override
        protected void onStop() {
            super.onStop();
    
            while (true) {
                snakeAnimView.pause();
                break;
            }
    
            finish();
        }
    
        @Override
        protected void onResume() {
            super.onResume();
            snakeAnimView.resume();
        }
    
        @Override
        protected void onPause() {
            super.onPause();
            snakeAnimView.pause();
        }
    
        public boolean onKeyDown(int keyCode, KeyEvent event) {
            if (keyCode == KeyEvent.KEYCODE_BACK) {
                snakeAnimView.pause();
                finish();
                return true;
            }
            return false;
        }
  14. Now you must temporarily comment out this line from step 4 to test the animation. The reason for this is that it causes an error until we implement the GameActivity class:
    //i = new Intent(this, GameActivity.class);
  15. Test the app.
  16. Uncomment the line from step 14 when we have implemented the GameActivity class. Here is our completed home screen:

In this exercise, we set up a class that extended SurfaceView, just like we did for our squash game. We had a run method, which controlled the thread, as well as an update method, which calculated the coordinates of the current animation within our sprite sheet. The draw method simply drew to the screen using the coordinates calculated by the update method.

As in the squash game, we had an onTouchUpdate method, but the code this time was very simple. As a touch of any type in any location was all we needed to detect, we added just one line of code to the method.

Implementing the Snake game activity

Not all of this code is new. In fact, we have either used most of it before or discussed it earlier in the chapter. However, I wanted to present every line to you in order and in context with at least a brief explanation, even when we have seen it before. Having said that, I haven't included the long list of imports as we will either be prompted to add them automatically or we can just press Alt + Enter when needed.

This way, we can remind ourselves how the whole thing comes together without any blanks in our understanding. As usual, I will summarize as we proceed through the implementation, and go into a few bits of extra depth at the end:

  1. Add an activity called GameActivity. Select a blank activity when asked.
  2. Make the activity full screen as we have done before.
  3. As usual, create some sound effects or use mine. Create an assets directory in the main directory in the usual way. Copy and paste the sound files (sample1.ogg, sample2.ogg, sample3.ogg, and sample4.ogg) into it.
  4. Create individual non-sprite-sheet versions of graphics or use mine. Copy and paste them in the res/drawable-mdpi folder.
  5. Here is the GameActivity class declaration with the member variables. There is nothing new here until we declare our arrays for our snake (snakeX and snakeY). Also, notice our variables used to control our game grid (blockSize, numBlocksHigh, and numBlocksWide). Now type this code:
    public class GameActivity extends Activity {
    
        Canvas canvas;
        SnakeView snakeView;
    
        Bitmap headBitmap;
        Bitmap bodyBitmap;
        Bitmap tailBitmap;
        Bitmap appleBitmap;
    
        //Sound
        //initialize sound variables
        private SoundPool soundPool;
        int sample1 = -1;
        int sample2 = -1;
        int sample3 = -1;
        int sample4 = -1;
    
        //for snake movement
        int directionOfTravel=0;
        //0 = up, 1 = right, 2 = down, 3= left
    
    
        int screenWidth;
        int screenHeight;
        int topGap;
    
        //stats
        long lastFrameTime;
        int fps;
        int score;
        int hi;
    
        //Game objects
        int [] snakeX;
        int [] snakeY;
        int snakeLength;
        int appleX;
        int appleY;
    
        //The size in pixels of a place on the game board
        int blockSize;
        int numBlocksWide;
        int numBlocksHigh;
  6. As explained previously, our new, small onCreate method has very little to do because much of the work is done in the loadSound and configureDisplay methods. Type this code after the code from the previous step:
    @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            loadSound();
            configureDisplay();
            snakeView = new SnakeView(this);
            setContentView(snakeView);
    
        }
  7. Here is the class declaration, member variables, and constructor for our SnakeView class. We allocate 200 int variables to the snakeX and snakeY arrays, and call the getSnake and getApple methods, which will place an apple and our snake on the screen. This is just what we want when the class is constructed:
      class SnakeView extends SurfaceView implements Runnable {
          Thread ourThread = null;
          SurfaceHolder ourHolder;
          volatile boolean playingSnake;
          Paint paint;
    
          public SnakeView(Context context) {
              super(context);
              ourHolder = getHolder();
              paint = new Paint();
    
                //Even my 9 year old play tester couldn't
                //get a snake this long
                snakeX = new int[200];
                snakeY = new int[200];
    
                //our starting snake
                getSnake();
                //get an apple to munch
                getApple();
            }
  8. Here is how we spawn a snake and an apple in our coordinate system. In the getSnake method, we place the snake's head in the approximate center of the screen by initializing snakeX[0] and snakeY[0] to the number of blocks high and wide divided by 2. We then place a body segment and the tail segment immediately behind. Notice that we don't need to make any special arrangement for the different types of segments. As long as the drawing code knows that the first segment is a head, the last segment is a tail, and everything in between is a body, then that will do. In the getApple method, the integer variables appleX and appleY are initialized to random locations within our game grid. This method is called from the constructor, as we saw in the previous step. It will also be called to place a new apple every time our snake manages to eat an apple, as we will see. Type this code after the code from the previous step:
    public void getSnake(){
                snakeLength = 3;
                //start snake head in the middle of screen
                snakeX[0] = numBlocksWide / 2;
                snakeY[0] = numBlocksHigh / 2;
    
                //Then the body
                snakeX[1] = snakeX[0]-1;
                snakeY[1] = snakeY[0];
    
                //And the tail
                snakeX[1] = snakeX[1]-1;
                snakeY[1] = snakeY[0];
            }
    
            public void getApple(){
                Random random = new Random();
                appleX = random.nextInt(numBlocksWide-1)+1;
                appleY = random.nextInt(numBlocksHigh-1)+1;
            }
  9. Next comes the run method, which controls the flow of the game. Type the following code after the code from the previous step:
    @Override
            public void run() {
                while (playingSnake) {
                    updateGame();
                    drawGame();
                    controlFPS();
    
                }
    
            }
  10. Now we will look at updateGame, the most complex method of the entire app. Having said that, it is probably slightly less complex than the same method in our squash game. This is because of our coordinate system, which leads to simpler collision detection. Here is the code for updateGame. Study it carefully, and we will dissect it line by line at the end:
    public void updateGame() {
    
        //Did the player get the apple
        if(snakeX[0] == appleX && snakeY[0] == appleY){
            //grow the snake
            snakeLength++;
            //replace the apple
            getApple();
            //add to the score
            score = score + snakeLength;
            soundPool.play(sample1, 1, 1, 0, 0, 1);
        }
    
        //move the body - starting at the back
        for(int i=snakeLength; i >0 ; i--){
            snakeX[i] = snakeX[i-1];
            snakeY[i] = snakeY[i-1];
        }
    
        //Move the head in the appropriate direction
        switch (directionOfTravel){
            case 0://up
            snakeY[0]  --;
            break;
    
            case 1://right
            snakeX[0] ++;
            break;
    
            case 2://down
            snakeY[0] ++;
            break;
    
            case 3://left
            snakeX[0] --;
            break;
            }
    
            //Have we had an accident
            boolean dead = false;
            //with a wall
            if(snakeX[0] == -1)dead=true;
            if(snakeX[0] >= numBlocksWide) dead = true;
            if(snakeY[0] == -1)dead=true;
            if(snakeY[0] == numBlocksHigh) dead = true;
            //or eaten ourselves?
            for (int i = snakeLength-1; i > 0; i--) {
                if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) {
                dead = true;
            }
        }
    
    
            if(dead){
            //start again
            soundPool.play(sample4, 1, 1, 0, 0, 1);
            score = 0;
            getSnake();
    
            }
    
            }
  11. We have worked out where our game objects are on the screen, so now we can draw them. This code is easy to understand as we have seen most of it before:
    public void drawGame() {
    
        if (ourHolder.getSurface().isValid()) {
            canvas = ourHolder.lockCanvas();
            //Paint paint = new Paint();
            canvas.drawColor(Color.BLACK);//the background
            paint.setColor(Color.argb(255, 255, 255, 255));
            paint.setTextSize(topGap/2);
            canvas.drawText("Score:" + score + "  Hi:" + hi, 10, topGap-6, paint);
    
            //draw a border - 4 lines, top right, bottom , left
            paint.setStrokeWidth(3);//3 pixel border
            canvas.drawLine(1,topGap,screenWidth-1,topGap,paint);
            canvas.drawLine(screenWidth-1,topGap,screenWidth-1,topGap+(numBlocksHigh*blockSize),paint);
            canvas.drawLine(screenWidth-1,topGap+(numBlocksHigh*blockSize),1,topGap+(numBlocksHigh*blockSize),paint);
            canvas.drawLine(1,topGap, 1,topGap+(numBlocksHigh*blockSize), paint);
    
            //Draw the snake
            canvas.drawBitmap(headBitmap, snakeX[0]*blockSize, (snakeY[0]*blockSize)+topGap, paint);
            //Draw the body
            for(int i = 1; i < snakeLength-1;i++){
                canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint);
            }
            //draw the tail
            canvas.drawBitmap(tailBitmap, snakeX[snakeLength-1]*blockSize, (snakeY[snakeLength-1]*blockSize)+topGap, paint);
    
            //draw the apple
            canvas.drawBitmap(appleBitmap, appleX*blockSize, (appleY*blockSize)+topGap, paint);
    
            ourHolder.unlockCanvasAndPost(canvas);
        }
    
            }
  12. Here is the controlFPS method, unchanged from our squash game's controlFPS method, except that we have a different target frame rate. Type this code after the code from the preceding step:
    public void controlFPS() {
        long timeThisFrame = (System.currentTimeMillis() - lastFrameTime);
        long timeToSleep = 100 - timeThisFrame;
        if (timeThisFrame > 0) {
            fps = (int) (1000 / timeThisFrame);
        }
        if (timeToSleep > 0) {
    
            try {
                ourThread.sleep(timeToSleep);
            } catch (InterruptedException e) {
            }
    
            }
    
            lastFrameTime = System.currentTimeMillis();
            }
  13. Here are our unchanged pause and resume methods. Type the following code after the code from the previous step:
    public void pause() {
                playingSnake = false;
                try {
                    ourThread.join();
                } catch (InterruptedException e) {
                }
    
            }
    
            public void resume() {
                playingSnake = true;
                ourThread = new Thread(this);
                ourThread.start();
            }
  14. Then we have the onTouchEvent method, similar to that of our squash game. There are no new concepts here, but the way it works in this game is as follows. We switch on the ACTION_UP event. This is broadly the same as detecting a click. We then check whether the press was on the left or the right. If it was on the right, we increment directionOfTravel. If it was on the left, we decrement directionOfTravel. If you looked carefully at the updateGame method, you would have seen that directionOfTravel indicates the direction in which to move the snake. Remember, the snake never stops. This is why we did it differently from our squash game. Type this code after the code from the previous step:
    @Override
        public boolean onTouchEvent(MotionEvent motionEvent) {
    
            switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_UP:
                if (motionEvent.getX() >= screenWidth / 2) {
                    //turn right
                    directionOfTravel ++;
                    //no such direction
    
                    if(directionOfTravel == 4)
                    //loop back to 0(up)
                    directionOfTravel = 0;
                }
            else {
                //turn left
                directionOfTravel--;
                if(directionOfTravel == -1) {//no such direction
                //loop back to 0(up)
                directionOfTravel = 3;
                            }
                        }
                }
                return true;
            }
  15. Back in the GameActivity class, we now handle the Android lifecycle methods and the "back" button functionality. Type this code after the code from the preceding step:
    @Override
        protected void onStop() {
            super.onStop();
    
            while (true) {
                snakeView.pause();
                break;
            }
    
            finish();
        }
    
    
        @Override
        protected void onResume() {
            super.onResume();
            snakeView.resume();
        }
    
        @Override
        protected void onPause() {
            super.onPause();
            snakeView.pause();
        }
    
        public boolean onKeyDown(int keyCode, KeyEvent event) {
            if (keyCode == KeyEvent.KEYCODE_BACK) {
    
               snakeView.pause();
    
    
    
                Intent i = new Intent(this, MainActivity.class);
                startActivity(i);
                finish();
                return true;
            }
            return false;
        }
  16. Here is our loadSound method, which simply tidies up the onCreate method by moving all of the sound initialization to here. Type this code after the code from the previous step:
    public void loadSound(){
        soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC, 0);
        try {
            //Create objects of the 2 required classes
            AssetManager assetManager = getAssets();
            AssetFileDescriptor descriptor;
    
            //create our three fx in memory ready for use
            descriptor = assetManager.openFd("sample1.ogg");
            sample1 = soundPool.load(descriptor, 0);
    
            descriptor = assetManager.openFd("sample2.ogg");
            sample2 = soundPool.load(descriptor, 0);
    
    
            descriptor = assetManager.openFd("sample3.ogg");
            sample3 = soundPool.load(descriptor, 0);
    
            descriptor = assetManager.openFd("sample4.ogg");
            sample4 = soundPool.load(descriptor, 0);
    
    
            } catch (IOException e) {
            //Print an error message to the console
            Log.e("error", "failed to load sound files);
            }
        }
  17. Then we have the configureDisplay method, which is called from onCreate and does the entire setup of bitmaps and screen size calculations. We will look at this in more detail later. Type the following code after the code from the previous step:
    public void configureDisplay(){
            //find out the width and height of the screen
            Display display = getWindowManager().getDefaultDisplay();
            Point size = new Point();
            display.getSize(size);
            screenWidth = size.x;
            screenHeight = size.y;
            topGap = screenHeight/14;
            //Determine the size of each block/place on the game board
            blockSize = screenWidth/40;
    
            //Determine how many game blocks will fit into the 
            //height and width
            //Leave one block for the score at the top
            numBlocksWide = 40;
            numBlocksHigh = ((screenHeight - topGap ))/blockSize;
    
            //Load and scale bitmaps
            headBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head);
            bodyBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.body);
            tailBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.tail);
            appleBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.apple);
    
            //scale the bitmaps to match the block size
            headBitmap = Bitmap.createScaledBitmap(headBitmap, blockSize, blockSize, false);
            bodyBitmap = Bitmap.createScaledBitmap(bodyBitmap, blockSize, blockSize, false);
            tailBitmap = Bitmap.createScaledBitmap(tailBitmap, blockSize, blockSize, false);
            appleBitmap = Bitmap.createScaledBitmap(appleBitmap, blockSize, blockSize, false);
    
        }
  18. Now run the app. The game is much more playable on an actual device than it is on the emulator.

We covered the code as we progressed, but as usual, here is a piece-by-piece dissection of a few of the more complicated methods, starting with the updateGame method.

First, we check whether the player has eaten an apple. More specifically, is the snake's head in the same grid location as the apple? The if statement checks whether this has occurred, and then does the following:

  • Increases the length of the snake
  • Puts another apple on the screen by calling getApple
  • Adds a value to the player's score, relative to the length of the snake, making each apple worth more than the previous one
  • Plays a beep

Here is the code for the actions that we have just described:

public void updateGame() {

            //Did the player get the apple
            if(snakeX[0] == appleX && snakeY[0] == appleY){
                //grow the snake
                snakeLength++;
                //replace the apple
                getApple();
                //add to the score
                score = score + snakeLength;
                soundPool.play(sample1, 1, 1, 0, 0, 1);
            }

Now we simply move each segment of the snake, starting from the back, to the position of the segment in front of it. We do this with a for loop:

            //move the body - starting at the back
            for(int i = snakeLength; i >0 ; i--){
                snakeX[i] = snakeX[i-1];
                snakeY[i] = snakeY[i-1];
            }

Of course, we better move the head too! We move the head last because the leading section of the body would move to the wrong place if we move the head earlier. As long as the entire move is made before any drawing is done, all will be well. Our run method ensures that this is always the case. Here is the code to move the head in the direction determined by directionOfTravel. As we saw, directionOfTravel is manipulated by the player in the onTouchEvent method:

            //Move the head in the appropriate direction
            switch (directionOfTravel){
                case 0://up
                    snakeY[0]  --;
                    break;

                case 1://right
                    snakeX[0] ++;
                    break;

                case 2://down
                    snakeY[0] ++;
                    break;

                case 3://left
                    snakeX[0] --;
                    break;
            }

Next, we check for a collision with a wall. We saw this code when we looked at collision detection earlier. Here is the complete solution, starting with the left wall, then right, then top, and then bottom:

            //Have we had an accident
            boolean dead = false;
            //with a wall
            if(snakeX[0] == -1)dead=true;
            if(snakeX[0] >= numBlocksWide)dead=true;
            if(snakeY[0] == -1)dead=true;
            if(snakeY[0] == numBlocksHigh)dead=true;

Then we check whether the snake has collided with itself. Initially, this seemed awkward, but as we previously saw, we just loop through our snake array to check whether any of the segments are in the same place as the head, in both x and y coordinates:

           //or eaten ourselves?
            for (int i = snakeLength-1; i > 0; i--) {
                if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) {
                    dead = true;
                }
            }

If any part of our collision detection code sets dead to true, we simply play a sound, set the score to 0, and get a new baby snake:

            if(dead){
                //start again
                soundPool.play(sample4, 1, 1, 0, 0, 1);
                score = 0;
                getSnake();

            }

        }

Now we take a closer look at the drawGame method. First, we get ready to draw by clearing the screen:

public void drawGame() {

            if (ourHolder.getSurface().isValid()) {
                canvas = ourHolder.lockCanvas();
                //Paint paint = new Paint();
                canvas.drawColor(Color.BLACK);//the background
                paint.setColor(Color.argb(255, 255, 255, 255));
                paint.setTextSize(topGap/2);

Now we draw the text for the player's score, just above topGap that we define in configureDisplay:

    canvas.drawText("Score:" + score + "  Hi:" + hi, 10, topGap-6, paint);

Now, using drawLine, we draw a visible border around our game grid:

 //draw a border - 4 lines, top right, bottom, left
                paint.setStrokeWidth(3);//4 pixel border
                canvas.drawLine(1,topGap,screenWidth-1,topGap,paint);
                canvas.drawLine(screenWidth-1,topGap,screenWidth-1,topGap+(numBlocksHigh*blockSize),paint);
                canvas.drawLine(screenWidth-1,topGap+(numBlocksHigh*blockSize),1,topGap+(numBlocksHigh*blockSize),paint);
                canvas.drawLine(1,topGap, 1,topGap+(numBlocksHigh*blockSize), paint);

Next, we draw the snake's head:

//Draw the snake
canvas.drawBitmap(headBitmap, snakeX[0]*blockSize, (snakeY[0]*blockSize)+topGap, paint);

The snake's head will be followed by all the body segments. Look at the condition of the for loop. This starts at 1, which means it is not redrawing the head position, and ends at snakeLength - 1, which means it is not drawing the tail segment. Here is the code used to draw the body section:

//Draw the body
for(int i = 1; i < snakeLength-1; i++){
    canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint);
}

Here, we draw the tail of the snake:

//draw the tail
canvas.drawBitmap(tailBitmap, snakeX[snakeLength-
    1]*blockSize, (snakeY[snakeLength-1]*blockSize)+topGap, paint);

Finally, we draw the apple as follows:

                //draw the apple
                canvas.drawBitmap(appleBitmap, appleX*blockSize, 
                    (appleY*blockSize)+topGap, paint);

                ourHolder.unlockCanvasAndPost(canvas);
            }

        }

Next, we will go through the configureDisplay method.

First, we get the screen resolution and store the results in screenWidth and screenHeight as normal:

public void configureDisplay(){
        //find out the width and height of the screen
        Display display = getWindowManager().getDefaultDisplay();
        Point size = new Point();
        display.getSize(size);
        screenWidth = size.x;
        screenHeight = size.y;

Here, we define a gap called topGap. It will be a space at the top of the screen and will not be a part of the game area. This gap is used for the score. We saw topGap used fairly extensively in the drawGame method. After this, we calculate the width and height of the remaining area in blocks:

        topGap = screenHeight/14;
        //Determine the size of each block/place on the game board
        blockSize = screenWidth/40;

        //Determine how many game blocks will fit into the height and width
        //Leave one block for the score at the top
        numBlocksWide = 40;
        numBlocksHigh = (screenHeight - topGap )/blockSize;

In the following part of the code, we load all our image files into Bitmap objects:

//Load and scale bitmaps
        headBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head);
        bodyBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.body);
        tailBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.tail);
        appleBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.apple);

Finally, we scale each bitmap to be the same width and height as blockSize:

        //scale the bitmaps to match the block size
        headBitmap = Bitmap.createScaledBitmap(headBitmap, blockSize, 
            blockSize, false);
        bodyBitmap = Bitmap.createScaledBitmap(bodyBitmap, blockSize, 
            blockSize, false);
tailBitmap = Bitmap.createScaledBitmap(tailBitmap, blockSize, 
    blockSize, false);
appleBitmap = Bitmap.createScaledBitmap(appleBitmap, 
    blockSize, blockSize, false);

    }

Now we can take a quick look at a few different ways we can improve the game.

Enhancing the game

Here is a series of questions and answers to lead us to an improved version of our Snake game. It doesn't matter if you can't answer some (or even all) of the questions. Just take a look at the questions and answers, after which you can take a look at the new game and the code.

Self-test questions

Q1) What can be used to provide a visual improvement for our game screen? Can we use a nice light green, grassy background instead of just black?

Q2) How about some nice flowers?

Q3) If you're feeling brave, make the flowers sway. Think about what we have learned about sprite sheets. The theory is exactly the same as that of the animated snake head. We just need a few lines of code to control the frame rate separately from the game frame rate.

Q4) We could set up another counter and use our snake head animation in GameActivity, but it wouldn't be that useful because the subtle tongue movements would be barely visible at the smaller size. But could we swish the tail segment?

Q5) Here is a slightly trickier enhancement. You can't help notice that when the snake sprites are headed in three out of the four possible directions, they don't look right. Can you fix this?

Summary

This is the end of yet another successful game project. You now know how to create and animate sprite sheets to add more realism to our games. Now we have an enhanced Snake game.

In the next chapter, we will see how simple it is to add leaderboards and achievements. This will make our game social and compelling by letting the player see the high scores and achievements of their friends and compare them with their own.

主站蜘蛛池模板: 百色市| 拜泉县| 贵州省| 田东县| 沁阳市| 邳州市| 五河县| 盖州市| 娱乐| 瑞丽市| 临桂县| 白玉县| 鄂州市| 贵溪市| 建宁县| 嫩江县| 阿鲁科尔沁旗| 南京市| 丹寨县| 宝应县| 澄迈县| 武邑县| 九龙坡区| 萝北县| 安化县| 阳城县| 登封市| 洛川县| 哈尔滨市| 新干县| 越西县| 东辽县| 通城县| 吴旗县| 和静县| 新绛县| 河源市| 西乌| 闸北区| 永新县| 阜阳市|