01219245/cocos2d/Maze

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
This is part of 01219245.

In previous tutorials, we developed games with objects moving freely in the screen. In this tutorial, we will create a game where objects moves in the scene defined by game data. We will re-create a simpler version of Pac-Man. The game screen is shown below.

Pacman-maze.png

Steps

Maze & maze data

Notes: Cocos2d-html5 supports TMX tilemap (read more), however in this program, we shall do it manually.

Our maze consists of a set of smaller 40x40 sprites. Let's create an image for the wall try to show that on the screen.

Create a 40x40 block image and save it in res/images/wall.png. Note that in this program, we will place all resources in res directory. You should preload this image to make the game run smoothly. Follow the steps described in Flappy dot to preload this image as a resource.

Let's create a Maze class that keeps the maze information and also displays the maze as a set of sprites.

The key question here is how to keep the maze information. In a simple game with one maze like this, we can simply store the maze as a constant in our code. However, if you have many maze levels, you might want to load the data from files. (We shall discuss that later, hopefully.)

The code below shows Maze class in src/Maze.js. Note that since our screen is of size 800 x 600 and our wall image is of size 40 x 40, we can have 20 x 15 units. In our case, we leave the top row and the bottom row out, so the height of the maze is only 13 units.

var Maze = cc.Node.extend({
    ctor: function() {
        this._super();
        this.WIDTH = 20;
        this.HEIGHT = 13;
        this.MAP = [
            '####################',
            '#..................#',
            '#.###.###..###.###.#',
            '#.#...#......#...#.#',
            '#.#.###.####.###.#.#',
            '#.#.#..........#.#.#',
            '#.....###. ###.....#',
            '#.#.#..........#.#.#',
            '#.#.###.####.###.#.#',
            '#.#...#......#...#.#',
            '#.###.###..###.###.#',
            '#..................#',
            '####################'
        ];

        // ...  code for drawing the maze has be left out

    }
});

Notes: You might wonder why we put the constants here in the object. (We usually place these constants outside as class variables.) Can you guess why?

We will put all sprites (including the pacman and the dots) as the maze's children. The co-ordinate system that we use will be relative to the anchor point of the maze (which will be lower-left corner). The figure below shows the co-ordinate system.

Maze-co-ordinates.png

To draw the maze, we create appropriate sprites on the screen.

	for ( var r = 0; r < this.HEIGHT; r++ ) {
	    for ( var c = 0; c < this.WIDTH; c++ ) {
		if ( this.MAP[ r ][ c ] == '#' ) {
		    var s = cc.Sprite.create( 'res/images/wall.png' );
		    s.setAnchorPoint( cc.p( 0, 0 ) );
		    s.setPosition( cc.p( c * 40, (this.HEIGHT - r - 1) * 40 ) );
		    this.addChild( s );
		}
	    }
	}

Note that we use function cc.p(...) as a short cut for new cc.Point(...). Also, note how we put anchor points at the lower-left corners of all wall sprites and place all sprites in appropriate locations.

NOTES (IMPORTANT): We also use (this.HEIGHT - r - 1) to calculate the y-co-ordinate of the sprite. This is because the y-co-ordinates run in an opposite direction of the map data. (If we do not do this, our maze will flip vertically. You can try that)

We also put the anchor point for the maze at its lower-left corner as well. (Put this code also in method Maze.ctor.)

	this.setAnchorPoint( cc.p( 0, 0 ) );

To use this Maze in GameLayer we simply create it in GameLayer.init:

	this.maze = new Maze();
	this.maze.setPosition( cc.p( 0, 40 ) );
        this.addChild( this.maze );

We are ready to see our map. Let's load the game.

Gitmark.png Don't forget to commit this nice scene.

Moving the pac-man (blocky movement)

This is the important part of this tutorial. We want our PacMan to move freely in the maze as the user plays the game. The hard part is to make sure the PacMan does not get into the walls while ensuring the player feels that the controlling is smooth.

In our first attempt, we will make the PacMan moves not so freely, but all movements will stop the PacMan at the center of some block. For example, if the user just hits the left arrow key and quickly pulls off, the PacMan still move the whole step. It will not jump, though; it will move smoothly from one block to another. This approach is much easier to write and is not that bad. If you actually play it, the game feels OK, but the movement control clearly can later be improved.

Showing the character

Create a 40x40 pixels image for the PacMan. Save the file as res/images/pacman.png. Don't forget to add this file to the preloaded resource list.

It is easy to show the PacMan. This is the code for Pacman class in Pacman.js. Note that we keeps the current position of the Pacman in the object as well. This initial position is passed to the constructor ctor when the object is created.

var Pacman = cc.Sprite.extend({
    ctor: function( x, y ) {
        this._super();
        this.initWithFile( 'res/images/pacman.png' );
        
        this.x = x;
        this.y = y;
        this.updatePosition();
    },

    updatePosition: function() {
        this.setPosition( cc.p( this.x, this.y ) );
    }
});

Then, you can create it in GameLayer.init:

        this.pacman = new Pacman( 10*40 + 20, 6*40 + 20 );
        this.maze.addChild( this.pacman );

Notes: The pacman is added, as a child, to the maze, so that its position is relative to the maze. We do this because it will be easy to move the whole maze around.

Gitmark.png Refresh and see if the Pacman appears. Commit your work.

Movements: idea

The idea can be easily described in the next figure.

Maze-movement-states.png

The Pacman moves according the its currentDirection state. It keeps moving until it hits the center of the block. (We have to make sure that our movement step length match the block size, i.e., it should divides 40.) It would change the direction based on the nextDirection state. The nextDirection state is modified when the user presses a keyboard.

To implement this idea, we shall separate the tasks into smaller steps.

Movements: code (run through the walls freely, never stop moving)

In this step, we will try to make sure that our Pacman can move and change direction without thinking about the maze at all.

Let's start by adding a few constants that we will use. Place this code after the cc.Sprite.extend({}) block.

Pacman.MOVE_STEP = 5;
Pacman.DIR = {
    LEFT: 1,
    RIGHT: 2,
    UP: 3,
    DOWN: 4,
    STILL: 0
};

Note that we have Pacman.DIR.STILL state so that our pacman can rest a bit (stop) in the future steps.

We initialize the Pacman state in Pacman.ctor:

        this.direction = Pacman.DIR.STILL;

Now, add update method to Pacman class:

    update: function( dt ) {
        switch ( this.direction ) {
        case Pacman.DIR.UP:
            this.y += Pacman.MOVE_STEP;
            break;
        case Pacman.DIR.DOWN:
            this.y -= Pacman.MOVE_STEP;
            break;
        case Pacman.DIR.LEFT:
            this.x -= Pacman.MOVE_STEP;
            break;
        case Pacman.DIR.RIGHT:
            this.x += Pacman.MOVE_STEP;
            break;
        }
        this.updatePosition();
    },

Our Pacman class should be ready to run. Don't forget to call

        this.pacman.scheduleUpdate();

in GameLayer.init so that our update method is called.

Let's try to test it by changing the initialization code of the direction state from Pacman.DIR.STILL to Pacman.DIR.UP or Pacman.DIR.LEFT:

        this.direction = Pacman.DIR.UP;

Then, refresh the game to see if the Pacman moves.

Gitmark.png If the Pacman moves, let's change the state initialization code back and commit our current work.

Let's connect this direction controlling code to the keyboard events. Note that the GameLayer should be able to set the Pacman's direction (or the next direction, in our full version). Let's add a method to do so in Pacman class.

    setDirection: function( dir ) {
        this.direction = dir;
    },

Later on in this part, we shall work mainly in GameLayer.

To not forget, let's enable the keyboard inputs first in GameLayer.init.

        this.setKeyboardEnabled( true );

We then watch for the onKeyDown event and set the Pacman direction accordingly.

    onKeyDown: function( e ) {
        switch( e ) {
        case cc.KEY.left:
            this.pacman.setDirection( Pacman.DIR.LEFT );
            break;
        case cc.KEY.right:
            this.pacman.setDirection( Pacman.DIR.RIGHT );
            break;
        case cc.KEY.up:
            this.pacman.setDirection( Pacman.DIR.UP );
            break;
        case cc.KEY.down:
            this.pacman.setDirection( Pacman.DIR.DOWN );
            break;
        }
    },

This should enable our Pacman to move freely. It will go any where on the screen and even when you stop pressing any keys, Pacman still moves crazily.

Gitmark.png Let's commit our work for now.

Movements: code (run through the walls but stay in the blocks, never stop moving)

Let's implement the full state ideas.

We initialize an additional state: nextDirection:

        this.nextDirection = Pacman.DIR.STILL;
        this.direction = Pacman.DIR.STILL;

Now, the keyboard event should not directly and immediately change the Pacman direction; otherwise, it will move freely outside the moving lane.

In fact, the keyboard event should only change the nextDirection state. Let's change the method Pacman.setDirection to Pacman.setNextDirection:

    setNextDirection: function( dir ) {
        this.nextDirection = dir;
    },

and change GameLayer.onKeyDown accordingly (note that we essentially change from setDirection to setNextDirection):

    onKeyDown: function( e ) {
        switch( e ) {
        case cc.KEY.left:
            this.pacman.setNextDirection( Pacman.DIR.LEFT );
            break;
        case cc.KEY.right:
            this.pacman.setNextDirection( Pacman.DIR.RIGHT );
            break;
        case cc.KEY.up:
            this.pacman.setNextDirection( Pacman.DIR.UP );
            break;
        case cc.KEY.down:
            this.pacman.setNextDirection( Pacman.DIR.DOWN );
            break;
        }
    },

Finally, we need a way for the Pacman to change its direction to nextDirection when it is a the center of the block.

Our plan for the new Pacman.update method will be:

    update: function( dt ) {
        if ( ... ) {   // ... we need a condition for checking if we are at the center
            this.direction = this.nextDirection;
        }
        switch ( this.direction ) {
            // ... old position modification code
        }
        this.updatePosition();
    },

We can hard code the condition in the if statement above, but it will be more readable if we define a new method Pacman.isAtCenter for testing that.

EXERCISE: add the condition for testing if the Pacman is at the center of the block. Hint: you can use this.x and this.y. See the co-ordinate system figure above.

    isAtCenter: function() {
        return XXXXXX; // ... put your conditions here ....
    },

Using this method the if statement above becomes:

        if ( this.isAtCenter() ) {
            this.direction = this.nextDirection;
        }
Gitmark.png Try the game and commit your work.

Movements: code (run through the walls but stay in the blocks, stop)

We need to set Pacman's nextDirection to Pacman.DIR.STILL when we want the pacman to stop.

To do this, we need to think carefully about our state change scheme. For now, let's do something that mostly works. I.e., when the user releases a key, just tell the Pacman to stop.

    onKeyUp: function( e ) {
        this.pacman.setNextDirection( Pacman.DIR.STILL );
    },

This is buggy. But at this point, it's a good-enough solution for us (or, for me).

Question: Can you think of a situation when this implementation might cause the Pacman to stop when it should keep moving?

Movement: code (respect the walls)

We will have to do a little more work to make sure our Pacman runs nicely inside the maze.

Note that to do so, we need to be able to check if the next position will be in the wall. The first question is who will be responsible for this. Options are:

  • We let the Pacman deals with the walls itself. It should avoid running through the wall.
  • We can also let the GameLayer that knows both the Pacman and the Maze to co-ordinate this.

The first option looks better (at least for me), so we will follow that approach. This requires our Pacman to have a knowledge of the maze or have an ability to talk with the maze. Let's choose the second choice.

We then add the maze property to Pacman. Add this method to class Pacman:

    setMaze: function( maze ) {
        this.maze = maze;
    },

And assign the maze property in GameLayer.init:

        this.pacman.setMaze( this.maze );

We should provide a method in class Maze so that Pacman can ask if the next block is a wall. Add this method to class Maze:

    isWall: function( blockX, blockY ) {
        var r = this.HEIGHT - blockY - 1;
        var c = blockX;
        return this.MAP[ r ][ c ] == '#';
    },

NOTES (IMPORTANT): Note that our internal representation of the maze is an array of strings. The natural way to refer to blocks in MAP is by its row and its column. However, other parts of our game, co-ordinates are in (x,y) which switch the axises and also directions. To make the object interacting with other objects nicely we decide to have an interface that follows the (x,y) system. However, it is in block co-ordinates, i.e., the bottom-left is (0,0), the next one to the right is (1,0), and the block on top of the bottom-left is at (0,1).

Let's now work on the Pacman. The update method is changed so that method isPossibleToMove is called to check for walls:

    update: function( dt ) {
        if ( this.isAtCenter() ) {
            if ( ! this.isPossibleToMove( this.nextDirection ) ) {
                this.nextDirection = Pacman.DIR.STILL;
            }
            this.direction = this.nextDirection;
        }
        switch ( this.direction ) {
            // ... old code
        }
    },

Finally, let's implement isPossibleToMove. Oh, it's your job.

    isPossibleToMove: function( dir ) {
        if ( dir == Pacman.DIR.STILL ) {
            return true;
        }

        // ... do something to get nextBlockX and nextBlockY
        
        return ! this.maze.isWall( nextBlockX, nextBlockY );
    },

EXERCISE: implement method isPossibleToMove in Pacman. You may want to calculate the current blockX and blockY from this.x and this.y first. The use the direction to calculate the next location and talk with this.maze to figure out the possibility.

Gitmark.png Now our Pacman can run in the maze!! Don't forget to commit your work.

Eating dots

It's time to make our Pacman not to feel hungry any more.

Showing the dots

Create a 40 x 40 image for the dot and save it in res/images/dot.png. Add this to the resource list to preload the image.

A dot is simply a sprite:

var Dot = cc.Sprite.extend({
    ctor: function() {
        this._super();
        this.initWithFile( 'res/images/dot.png' );
    }
});

We can let Maze handles the dot creation while creating the walls.

        for ( var r = 0; r < this.HEIGHT; r++ ) {
            for ( var c = 0; c < this.WIDTH; c++ ) {
                if ( this.MAP[ r ][ c ] == '#' ) {
                    // ... old code
                } else if ( this.MAP[ r ][ c ] == '.' ) {
                    var d = new Dot();
                    d.setPosition( cc.p( c * 40 + 20, (this.HEIGHT -r - 1) * 40 + 20 ) );
                    this.addChild( d );
                }
            }
        }

Try to reload to see the dots.

Gitmark.png Add the dot image file, and commit.

Exercise: Eating the dots

We need a way to check, when we are at the center of a block, if that block contains a good dot. (A good dot is the one which is not eaten.) If that's the case, we should remove that dot from the screen.

Again, there are many options for do this. We can have the GameLayer responsible for that or let the Pacman talks with the Maze to get the work donw. The decision so far is to push the work to the Pacman and the Maze, so let's stick with that for now.

Let's imagine how the scenario should be.

  • When the Pacman reaches the center of the block,
    • it asks the Maze for a good dot at that location.
    • If there is a good dot,
      • the Pacman eats it, and
      • tell the Maze to remove that good dot from the maze.

Currently, when the Pacman eats the dots, it might do nothing. But later on, it should update the player's score.

Within this frame, we need to add the following method to Maze:

    getDot: function( blockX, blockY ) {
        // ... return the dot or null if there is no good dot at that location
    },
    removeDot: function( blockX, blockY, dot ) {
        // ... remove the dot from that location.
    },

Notes: You don't have to use the exact same method described above. Think of these as hints.

To retrieve a good dot at a particular position, you can use various JavaScript data structures to do so. To remove the dot, you can use method removeChild.

With these two method, we can implement method checkDot:

    checkDot: function() {
        // ... from this.x and this.y do something to get blockX and blockY

        var dot = this.maze.getDot( blockX, blockY );
        if ( dot ) {
            // ... do something..  currently we might do nothing, but our scoring code would appear here.
            this.maze.removeDot( blockX, blockY, dot );           
        }
    },

You can call this in Pacman.update:

        if ( this.isAtCenter() ) {
            this.checkDot();

            // ... old codes ...
        }
Gitmark.png After you make the Pacman eats the dots, don't forget to commit your work.

Showing scores: callbacks

We want to update the score. Note that when the Pacman eats a dot, the Pacman itself knows about it. To update the score, we can just let the Pacman does it as well. However, to do so, we must let the Pacman knows about the score label object on the screen so that it can update the score. This creates strong dependency between the Pacman implementation and how the game computes and shows its score.

In this section, we shall use a nice technique to remove this tight dependency.

A callback

Recall that functions in JavaScript are first-class objects. We will provide a way for the Pacman to signal the GameLayer when it eats a dot.

Let's create a new property for Pacman. Add this line to Pacman.ctor:

        this.eatCallback = null;

To change this property, we add function setEatCallback to Pacman:

    setEatCallback: function( callback ) {
        this.eatCallback = callback;
    },

This property keeps a function that Pacman should call. We not only call the function but also pass the dot object to the function as well, so that the function can, for example, calculate the score based on the type of dot that the Pacman eats.

We can update method checkDot as follows:

    checkDot: function() {
        // ... from this.x and this.y do something to get blockX and blockY
 
        var dot = this.maze.getDot( blockX, blockY );
        if ( dot ) {
            if ( this.eatCallback ) {
                this.eatCallback( dot );
            }
            this.maze.removeDot( blockX, blockY, dot );           
        }
    },

The score label and score update

Let's create property score in GameLayer. Place this code in method GameLayer.init.

        this.score = 0;

Also, create a label scoreLabel:

        this.scoreLabel = cc.LabelTTF.create( '0', 'Arial', 32 );
        this.scoreLabel.setPosition( cc.p( 15 * 40, 14 * 40 + 15 ) );
        this.addChild( this.scoreLabel );

Add method updateScoreLabel to GameLayer:

    updateScoreLabel: function() {        
        this.scoreLabel.setString( this.score );
    }

Let's create method registerEatCallback in class GameLayer to register a call back to the Pacman. Note that this callback refer to game layer using a variable gameLayer, not by this which would refer to Pacman when the function is called.

    registerEatCallback: function() {        
        var gameLayer = this;
        this.pacman.setEatCallback(function( dot ) {
            gameLayer.score++;
            gameLayer.updateScoreLabel();
        });
    }

Finally, we call registerEatCallback in GameLayer.init:

        this.registerEatCallback();

Adding challenges and other game improvements

The current game that we have is not quite a game, as it lacks a crucial component of games, i.e., challenges. A typical next step would be to add ghosts to the game, however there are many easier options that you can do to add challenges to the game. Here we list a few:

  • Add timing constraints:
    • Your dots may disappear as the time passes. This forces the player to try to get dots as quickly as possible.
    • You may add special dots with higher points. This dots may move very quickly (faster than the player's speed) so the player has to plan the move.
    • You may add special dots with random positions that disappear.
  • Add distance constraints:
    • The player may have limited movement distance. (Think of a car with gas.)
    • You may add special dots that add the movements to the player.

After the challenges, there are many things you can add to the game to make it more engaging. You may add sound effects. You may animate the Pacman. This is mainly discussed in Tutorial 102.

Better movements