ผลต่างระหว่างรุ่นของ "Prg2/arcade4 flappy dot"

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
แถว 368: แถว 368:
 
So let's create two images <tt>pillar-top.png</tt> and <tt>pillar-bottom.png</tt>, each of width 80 pixels and height 600 pixels.  Save them in directory <tt>images</tt>.
 
So let's create two images <tt>pillar-top.png</tt> and <tt>pillar-bottom.png</tt>, each of width 80 pixels and height 600 pixels.  Save them in directory <tt>images</tt>.
  
=== Model class and its sprite ===
+
=== Model class ===
 
Let's create class <tt>PillarPair</tt> in <tt>models.py</tt>.  Put the following code before class <tt>World</tt>.
 
Let's create class <tt>PillarPair</tt> in <tt>models.py</tt>.  Put the following code before class <tt>World</tt>.
  
แถว 383: แถว 383:
 
</syntaxhighlight>
 
</syntaxhighlight>
  
We add the <tt>PillarPairSprite</tt> to draw <tt>PillarPair</tt>  In its constructor, we create two sprites from the images we have just created. 
+
Make sure that we call PillarPair's update in world's update:
  
{{synfile|flappy.py}}
+
{{synfile|models.py}}
 
<syntaxhighlight lang="python">
 
<syntaxhighlight lang="python">
 +
class World:
 +
    # ...
 +
 +
    def update(self, delta):
 +
        # ...
 +
 +
        self.pillar_pair.update(delta)
 
</syntaxhighlight>
 
</syntaxhighlight>
  
To put both sprites in the correct position relative to the position of the <tt>PillarPair</tt>, we set two properties of each sprite: the AnchorPoint and the PositionTo understand this, consider the top pillar. We call
+
=== Pillar pair's sprite ===
 +
 
 +
We add the <tt>PillarPairSprite</tt> to draw <tt>PillarPair</tt>.  While we call it a sprite, for simplicity, it is actually not a subclass of <tt>arcade.Sprite</tt>.  This class basically deals with pillar pair drawing based on its <tt>model</tt> position (similar to ModelSprite)Inside the pillar pair sprite object, we create two sprites for two pillars.
  
this.topPillar.setAnchorPoint( new cc.Point( 0.5, 0 ) );
+
{{synfile|flappy.py}}
 +
<syntaxhighlight lang="python">
 +
class PillarPairSprite():
 +
    def __init__(self, model):
 +
        self.model = model
 +
       
 +
        self.top_pillar_sprite = arcade.Sprite('images/pillar-top.png')
 +
        self.bottom_pillar_sprite = arcade.Sprite('images/pillar-bottom.png')
 +
</syntaxhighlight>
  
to say that when we talk about the position of the top pillar, that position is the middle point (0.5) of the bottom (0) of the image (see Figure below, blue co-ordinates). For anchor points, the co-ordinate of the bottom-left corner of the sprite is (0,0) and the top-right corner is (1,1). Position (0.5,0) is the middle point of the bottom of the sprite.
+
To draw the pillar pair, we add the following method.
 +
 +
{{synfile|flappy.py}}
 +
<syntaxhighlight lang="python">
 +
class PillarPairSprite():
 +
    # ...
  
We then call
+
    def draw(self):
 +
        self.top_pillar_sprite.set_position(self.model.x, self.model.y + 400)
 +
        self.top_pillar_sprite.draw()
 +
       
 +
        self.bottom_pillar_sprite.set_position(self.model.x, self.model.y - 400)
 +
        self.bottom_pillar_sprite.draw()
 +
</syntaxhighlight>
  
  this.topPillar.setPosition( new cc.Point( 0, 100 ) );
+
Please pay attention to how we draw the top pillar and the bottom pillar. Their positions are calculated in relative to the model's x and y.
  
to place the top pillar at position (0, 100) relative to the position of the pillar: this means the top pillar is 100 pixels above the Node position (see Figure below, red co-ordinates).
 
  
[[Image:Top-pillar-relative.png]]
 
  
 
Let's see if the pillars appear.  Add file <tt>src/PillarPair.js</tt> to '''jsList''' in <tt>project.json</tt>.  Then in <tt>GameLayer</tt>, let's add the code that create a <tt>PillarPair</tt> in <tt>GameLayer.init</tt>.
 
Let's see if the pillars appear.  Add file <tt>src/PillarPair.js</tt> to '''jsList''' in <tt>project.json</tt>.  Then in <tt>GameLayer</tt>, let's add the code that create a <tt>PillarPair</tt> in <tt>GameLayer.init</tt>.

รุ่นแก้ไขเมื่อ 20:45, 13 กุมภาพันธ์ 2562

This is part of the course Programming 2, the material is originally from 01219245/cocos2d-js/Sprites2 from 01219245, 2nd semester 2557.

In this tutorial, we will recreate a clone of a wonderful Flappy Bird. Let's call it Flappy Dot (as our player would look like a dot). We will develop basic game mechanics in this tutorial. We will try to add special effects to the game in the next tutorial.

Task breakdown

Before we start, make sure you know how this game works. You may want to try it for a bit. I guess many of your friends have it on their phones. This is how our game would look like:

219245-dotscr.png

As usual, let's start by thinking about the possible list of increments we would add to an empty project to get this game.

When you get your list, please see the steps that we plan to take here.

  • Show the player on the screen.
  • The player can jump and fall. (Implement player physics)
  • Show a single pillar pair.
  • Move the pillar pair across the screen.
  • Let the pillar pair reappear.
  • Check for player-pillar collision.
  • Make the game with one pillar pair.
  • Show more than one pillar pairs.

The player and its movement

Create a new project and set up a Git repository

We will start with an empty game template. Put the following code in our main program flappy.py.

File: flappy.py
import arcade
 
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
 
class FlappyDotWindow(arcade.Window):
    def __init__(self, width, height):
        super().__init__(width, height)
 
        arcade.set_background_color(arcade.color.WHITE)

    def on_draw(self):
        arcade.start_render()
        
 
def main():
    window = FlappyDotWindow(SCREEN_WIDTH, SCREEN_HEIGHT)
    arcade.set_window(window)
    arcade.run()
 
if __name__ == '__main__':
    main()

Try to run the game to see if an empty white window appears. Then, create a git repository at the project directory and commit the code.

Gitmark.png Create your git repository.

Creating the dot model and the sprite

In this step, we shall create a sprite for the player, and show it in the middle of the screen.

Use a graphic editor to create an image for our player. The image should be of size 40 pixels x 40 pixels. Save the image as images/dot.png and try to make it look cute.

We will continue our model/window code structure. So let's create a dot model (called Player) and World in models.py as in our previous projects. Note that currently the Player do nothing in update

File: models.py
class Player:
    def __init__(self, world, x, y):
        self.world = world
        self.x = x
        self.y = y

    def update(self, delta):
        pass

class World:
    def __init__(self, width, height):
        self.width = width
        self.height = height
 
        self.player = Player(self, width // 2, height // 2)
 
     def update(self, delta):
        self.player.update(delta)

As in the previous projects, we then use ModelSprite to display the sprite. Add the class in flappy.py and update FlappyDotWindow accordingly.

File: flappy.py
class ModelSprite(arcade.Sprite):
    def __init__(self, *args, **kwargs):
        self.model = kwargs.pop('model', None)
 
        super().__init__(*args, **kwargs)
 
    def sync_with_model(self):
        if self.model:
            self.set_position(self.model.x, self.model.y)
 
    def draw(self):
        self.sync_with_model()
        super().draw()


class FlappyDotWindow(arcade.Window):
    def __init__(self, width, height):
        super().__init__(width, height)

        arcade.set_background_color(arcade.color.WHITE)

        self.world = World(SCREEN_WIDTH, SCREEN_HEIGHT)
        
        self.dot_sprite = ModelSprite('images/dot.png', model=self.world.player)

    def update(self, delta):
        self.world.update(delta)

    def on_draw(self):
        arcade.start_render()

        self.dot_sprite.draw()

Try to run the game. You should see your sprite in the middle of the screen.

Gitmark.png Commit your work.

Review of physics

You might forget all these, but if you want objects in your game to look and act a bit like real objects, you might have to recall stuffs you learned from mechanics.

Let's look at the basics. An object has a position, its position changes if it has non-zero velocity.

How can you change the player's position? We can set its x and y attribute on the model.

If you want to apply the velocity, you can change the player position based on the velocity.

If there is an acceleration, the object's velocity also changes. The Player currently does not have velocity as its attribute, so we will add it. Now, you can update the velocity based on the acceleration.

These attributes (the position, the velocity, and the acceleration) all have directions. Sometimes, you see negative velocity; this means the object is moving in an opposite direction as the positive direction. We shall follow the standard co-ordinate system for arcade, i.e., for the y-axis, we think of the direction as going upwards.

While in Physics, everything is continuous, but when writing games, we don't really need exact physics, so we can move objects in discrete steps. (In fact, method update is also called with parameter delta, the time period between this call and the last call, and you can use this to make your simulation more smooth.)

So the usual pseudo code for physics is as follows.

pos = pos + velocity;
velocity = velocity + acceleration

Falling dot

To simulate the player falls, we should maintain the player's current velocity, so that we can make it falls as close as the real object.

Let's add this line that initialize property vy in Player's initialization code:

File: models.py
class Player:
    def __init__(self, world, x, y):
        # ... [old code hidden]

        self.vy = 15

You may wonder why we put 15 here. It is just pure guess at this point. However, when you write games, you might want to try various possible values and pick the best one (i.e., the one that make the game fun).

The update method changes the player's position

File: models.py
class Player:
    # ...
    def update(self, delta):
        self.y += self.vy
        self.vy -= 1

Note that we update self.vy at the end of update. The constant -1 is the acceleration. The parameter delta represents delta time; we do not use it for now.

Try to run the program. You should see the player falling.

While our program works, don't just rush to commit right away. Let's try to get rid of the magic numbers first, by defining them explicitly.

Add these contants at the beginning of Player in models.py. These are class variables.

File: models.py
class Player:
    GRAVITY = 1
    STARTING_VELOCITY = 15

    # ...

Then replace 15 and 1 in the code with the appropriate constants as follows. Note that we can refer to these variables as self.GRAVITY or Player.GRAVITY.

File: models.py
class Player:
    # ...
    def __init__(self, world, x, y):
        # ... 

        self.vy = Player.STARTING_VELOCITY

    def update(self, delta):
        # ...
        self.vy -= Player.GRAVITY
Gitmark.png When your program looks good, commit it

Jumping dot

Now, let's make the dot jumps. Let's add method jump that set the velocity to some positive amount. Let's create a constant JUMPING_VELOCITY to represent this magic number as well.

File: models.py
class Player:
    # ...
    JUMPING_VELOCITY = 15

    # ...

    def jump(self):
        self.vy = Player.JUMPING_VELOCITY

To jump, we have to call player.jump() in an appropriate time. We will response to keyboard inputs. We shall follow the style we did in the last tutorial.

First, add method on_key_press in FlappyDotWindow to forward the call to the world.

File: flappy.py
class FlappyDotWindow(arcade.Window):
    # ...

    def on_key_press(self, key, key_modifiers):
         self.world.on_key_press(key, key_modifiers)

Then, in World for any key pressed, call jump.

File: models.py
class World:
    # ...

    def on_key_press(self, key, key_modifiers):
        self.player.jump()

To test this increment, you will have to click on the game canvas, and then quickly hit on any key to get the dot jumping. Try a few times to see how the dot moves. You can adjust the jumping velocity to make the movement nice.

Gitmark.png After a few trials to make sure your code works, please commit.

Game states

It won't be nice to have the user start clicking right away after the game loads. Therefore we shouldn't let the player move before the game actually starts. To do so, we will add a state to the game. How various objects interact depends on the game state. In this tutorial, we shall simply add a property state to World, and perform various events and update methods according to this state. We will learn a cleaner way later on.

For now, the game has 2 states: FROZEN and STARTED. After the game loads, its state is FROZEN. In this state, nothing moves, and after the user hit any keyboard, it changes its state to STARTED with the dot start to jump. The dot falls and jumps as usual in the STARTED state.

Let's add the constants for states in class World:

File: models.py
class World:
    STATE_FROZEN = 1
    STATE_STARTED = 2
    # ...

With this, we can refer to states as World.STATE_FRONT and World.STATE_STARTED.

Initial state

We let attribute state of World keeps the current game state. We initialize the state in __init__:

File: models.py
class World:
    # ...
    
    def __init__(self, width, height):
        # ...

        self.state = World.STATE_FROZEN

State transition

We add methods start and freeze to World so that FlappyDotWindow can tell the world to start working or to get frozen. We also include method is_started for querying the world's state.

File: models.py
class World:
    # ...
    def start(self):
        self.state = World.STATE_STARTED

    def freeze(self):
        self.state = World.STATE_FROZEN     
   
    def is_started(self):
        return self.state == World.STATE_STARTED

Our game changes its state when the user hits the keyboard. We let FlappyDotWindow inform the world to get started.

Rewrite method on_key_press in FlappyDotWindow to do that.

File: flappy.py
    def on_key_press(self, key, key_modifiers):
        if not self.world.is_started():
            self.world.start()
            
        self.world.on_key_press(key, key_modifiers)

The world's state

We have to change how the world behave according to its state. Basically, it should not update anything when it is frozen. Add the following if at the beginning of World.update

class World:
    # ...

    def update(self, delta):
        if self.state == World.STATE_FROZEN:
            return
        
        # ...
Gitmark.png Try to see if the game works as expected. Commit your work after you are done.

The pillar pair

In this section, we will implement a moving pair of opposing pillars. Let's call them a pillar pair. Clearly, we will use one or more sprites to represent it. Before we think about how to implement it, let's think about what we want from this thing: (1) we want to move the pillar pair and (2) we want to check if the dot hits the pillars.

There are basically two approaches for this.

1. Use 1 sprite with transparent background in the middle. 2. Use 2 sprites (one for the top pillar, another for the bottom pillar).

Both approaches are shown below.

Sprites-pillarpair1.png

Question: Can you think of the advantages and disadvantages for using each approach? Expand to see some of the possible advantages and disadvantages.

Using 1 sprite is easier to deal with. However, the space between two opposing pillars are fixed. Using 2 sprites gives flexibility but it might be hard to to deal with two objects. It might be even worse if we have to deal with many pillar pairs.

Here, we will try to get the best out of both approaches. We will use 2 sprites, but we shall combine them into one object.

Sprites-pillarpair2.png

The blue object in the figure above contains two red sprite objects. Specifically, we shall create a PillarPairSprite object that contains two sprites.

Sprite images

Draw two images for the top and bottom pillars. Since we can place each pillar at different height, we should make the images large enough so that the other end of the pillar is still outside the screen. The figure below shows the sprites and the screen; the red border shows the screen boundary.

Clipped-pillars.png

So let's create two images pillar-top.png and pillar-bottom.png, each of width 80 pixels and height 600 pixels. Save them in directory images.

Model class

Let's create class PillarPair in models.py. Put the following code before class World.

File: models.py
class PillarPair:
    def __init__(self, world, x, y):
        self.world = world
        self.x = x
        self.y = y

    def update(self, delta):
        pass

Make sure that we call PillarPair's update in world's update:

File: models.py
class World:
    # ...

    def update(self, delta):
        # ...

        self.pillar_pair.update(delta)

Pillar pair's sprite

We add the PillarPairSprite to draw PillarPair. While we call it a sprite, for simplicity, it is actually not a subclass of arcade.Sprite. This class basically deals with pillar pair drawing based on its model position (similar to ModelSprite). Inside the pillar pair sprite object, we create two sprites for two pillars.

File: flappy.py
class PillarPairSprite():
    def __init__(self, model):
        self.model = model
        
        self.top_pillar_sprite = arcade.Sprite('images/pillar-top.png')
        self.bottom_pillar_sprite = arcade.Sprite('images/pillar-bottom.png')

To draw the pillar pair, we add the following method.

File: flappy.py
class PillarPairSprite():
    # ...

    def draw(self):
        self.top_pillar_sprite.set_position(self.model.x, self.model.y + 400)
        self.top_pillar_sprite.draw()
        
        self.bottom_pillar_sprite.set_position(self.model.x, self.model.y - 400)
        self.bottom_pillar_sprite.draw()

Please pay attention to how we draw the top pillar and the bottom pillar. Their positions are calculated in relative to the model's x and y.


Let's see if the pillars appear. Add file src/PillarPair.js to jsList in project.json. Then in GameLayer, let's add the code that create a PillarPair in GameLayer.init.

File: src/GameLayer.js
        this.pillarPair = new PillarPair();
        this.pillarPair.setPosition( new cc.Point( 700, 300 ) );
        this.addChild( this.pillarPair );
Gitmark.png If the pillar pair appears, commit your change.

Question: What is the width of the space between the top pillar and the bottom pillar?

200 pixels.

Moving the pillars

Let's add the code that move the pillars, PillarPair.update.

File: src/PillarPair.js
    update: function( dt ) {
        this.setPositionX( this.getPositionX() - 5 );
    }

Then in GameLayer.init, add a line

File: GameLayer.js
this.pillarPair.scheduleUpdate();

after the PillarPair is created.

Gitmark.png Don't forget to commit.

Technicalities: preloading of resources

If you runs the game a few times, you might notice that sometimes some pillar does not appear. This is because when we create the sprites, the images we want to use are not completely loaded. To avoid this problem, we will tell the Cocos2d-JS library to load all our resources before starting our Scene. This is done by specifying resources in src/resource.js

File: src/resource.js
// Add files in res
var res = {
    dot_png: 'res/images/dot.png',
    pillar_top_png: 'res/images/pillar-top.png',
    pillar_bottom_png: 'res/images/pillar-bottom.png'
};

// Leave the original code for assigning g_resources untouched.
var g_resources = [];
for (var i in res) {
    g_resources.push(res[i]);
}

Note that this file lists all images and probably other resources (e.g., sound files) that we want to use.

Now, when you start our game, you'll see the Cocos2d-html5 preloading start screen.

Gitmark.png If your code works, commit the changes.

Moving the pillars after the game starts

Currently, we create the pillars right after the scene starts. We should synchronize this with the game state.

We will create the pillar pair often, so let's take the code for pillar creation out of GameLayer.init and place it in method createPillarPair:

File: src/GameLayer.js
    createPillarPair: function() {
        this.pillarPair = new PillarPair();
        this.pillarPair.setPosition( new cc.Point( 900, 300 ) );
        this.addChild( this.pillarPair );
        this.pillarPair.scheduleUpdate();
    }

In GameLayer.init, let's put this null initialization in place of the old pillar pair creation.

File: src/GameLayer.js
this.pillarPair = null;

Finally, let's call createPillarPair after the game state changes to GameLayer.STATES.STARTED in onKeyDown:

File: src/GameLayer.js
	if ( this.state == GameLayer.STATES.FRONT ) {
	    this.state = GameLayer.STATES.STARTED;
	    this.createPillarPair();
	    this.player.start();
            this.player.jump();
        } else if ( ... ) {
            // ...
        }

Try the game to see if the pillar pair starts moving after we hit a keyboard.

Gitmark.png Commit your change.

The code for starting a new game currently lies in an event handler onKeyDown. This method will get longer and longer as we work on our game. So let's clean the method a bit. We shall extract the game initialization code into method startGame.

File: src/GameLayer.js
    onKeyDown: function( keyCode, event ) {
	if ( this.state == GameLayer.STATES.FRONT ) {
            this.startGame();
	    this.state = GameLayer.STATES.STARTED;
        } else if ( ... ) {
            // ...
        }
    },

    // ...

    startGame: function() {
        this.createPillarPair();
        this.player.start();
        this.player.jump();
    },

Test the code, and commit.

Gitmark.png Commit your change.

z-axis: Showing the player in front of the pillars

If you move the player through the pillars, you should notice that the pillars are drawn over the player. It would be better to have the player in front of the pillars. To do so, we assign the higher z co-ordinate to the player when we add the player to the GameLayer with method addChild.

File: src/GameLayer.js
        this.player = new Player();
        this.player.setPosition( new cc.Point( screenWidth / 2, screenHeight / 2 ) );
        this.addChild( this.player, 1 );

Note that the default z co-ordinate is 0; so the z-co-ordinates of the pillars is lower than the player's.

Gitmark.png Test and commit the changes.

Exercise: Reusing the pillars

In this game, our player will have to fly passing a lot of pillar pairs. However, we will not always create a new pillar pair. Instead, we shall reuse the old pillar pair that recently disappear on the left side of the screen.

EXERCISE: modify method PillarPair.update so that right after the pillar pair move outside the screen, it re-enter at the right side of the screen.

Gitmark.png Test and commit the changes.

Exercise: Collision detection

Exercise: Random the pillar heights

Exercise: The series of pillar pairs

Additional Exercises