Flappy Bird clone in Ruby


如果2010年的时候你有手机,你应该记得飞翔的小鸟。
 If you had your mobile phone in the early 2010s you certainly remember Flappy Bird.
A tiny, super addictive game designed by a bright Vietnamese student Nguyễn Hà Đông in just one night which rapidly became a widespread phenomenon and a vivid symbol of that era.
一个小的超级上瘾的游戏被一个聪明的越南学生设计的,只是一个晚上就变成了传播开了,变成了一个时代符号。
In this tutorial I will show you how to create your own clone of Flappy Bird in just 150 lines of code. To accomplish it we will use Ruby language and ruby2d gem (library).
这个指南告诉你用150行代码复制flappy bird.

Repository for this project can be found here


First let’s create a new directory for our project with two files, Gemfile and flappybird.rb. In Gemfile we will specify which gems we want to use and from which source. In our case, it would be only rub2d:

source 'https://rubygems.org'

gem 'ruby2d'

After typing bundle in our terminal Ruby should fetch the required gem.

Now switch to the flappyfird.rb and set up our initial scaffold:
初始化的架构

require 'ruby2d'

HEIGHT = 640 
WIDTH = 420

set width: WIDTH
set height: HEIGHT
set title: 'flappy bird'


update do
  
end


show 
In HEIGHT and WIDTH constants we will store the dimensions of the screen in pixels. set method allows us to specify the initial parameters of our game while show method displays our screen once the program get started. update do block is a game loop provided by ruby2d. It keeps on redrawing the screen (by default 30 times per second) and allows objects to move.
在高和宽常量用来存储屏幕的像素。
set方法让我们去设定我们游戏里的初始化参数,程序启动时通过show方法显示在屏幕上。
update 这个区块是游戏的循环,ruby2d提供的。
它保证了重画屏幕,默认是一画秒钟30次,让objects移动起来。

Now we need to add a background. Create a new directory called assets and inside it add another one called images. Within images directory copy and paste image of flappy bird image you can find under this link and call it simply flappybirdbg.png. Back to our flappybird.rb file and add net method draw_background:
现在我们需要加个背景,创建一个路径assets,里面包含文件夹images.在images里面粘贴flappybirdbg.png图片。
返回flappybird.rb文件,加一个方法 draw_background


def draw_background
  Image.new('./assets/images/flappybirdbg.png', x: 0, y: 0, width: WIDTH, height: HEIGHT)
end 

update do
  clear 
  draw_background
end
Image is another class provided by ruby2d gem. It renders images from a specified path. In our case we want the background image to cover the entire screen, so the width and height of the image are equal to the dimensions we specified in constans above. ruby2d uses two-dimensional x,y coordinates to place every object on the screen. But unlike in most common math examples point (0,0) is placed on the top left side of the screen. We call our newly created method within the game loop and add another method called clear. It’s necessary to refresh our screen after each iteration. Now if we start our program a new window with a background image should pop up:
Image 是ruby2d提供的另外一个class,
他通过一个指定的路径来render图片。
在我们案例中,我们想让背景覆盖整个背景,因此图片的高和宽等于上面指定的常量HEIGHT和WIDTH,
ruby2d用了2个维度dimensional, x和y坐标去放置object到屏幕上。
但不是数学中常见的那样,是用(0,0)表示左上角,
我们称在游戏循环中增加的新的方法 clear.
它的作用是每次迭代后刷新我们的屏幕。
现在我们开始我们的程序,背景图片就应该会pop up;


It’s time to create a bird. We must write a new class callled Bird with the constructor method and method to draw it on the screen (the same way as our background image):
是时间去创建一只鸟了。我们必须些一个新的class叫Bird,带有构造函数方法和画在屏幕上的方法。和背景图一样。

class Bird
  def initialize
    @x = 30 
    @y = HEIGHT / 2
    @width = 36
    @height = 33
  end 

  def draw
    Image.new('./assets/images/flappybird.png', x: @x, y: @y, width: @width, height: @height, z: 10)
  end
end
Copy flapppybird.png image from here and paste in assets/images directory. z param in the Image class indicates that the bird image should be displayed over background image. Now we need to add new instance of our Bird class and call method to draw in the game loop:

bird = Bird.new 

update do
  clear 
  draw_background
  bird.draw
end
with the following output:


Now it’s time to set our bird in motion. If you want to move an object in 2d game you need to rewrite it with slightly different coordinates and remove the older picture. Iterations in game loop are quick enough to deceive the player’s eyes and give an illusion of motion.
现在到时间去设置我们的鸟的动作了。
如果你想移动一个object,在2维的游戏里,你需要在一个区别很小的的坐标位置上重画,并且擦除老图。
迭代在游戏循环中是非常快的,这样玩家的眼睛才看不出来,给他们一个移动的画面。

In our game bird only moves up and down, but the passing pipes (which we will take care of soon) give the player illusion of moving forward. Bird is always pulling down to the ground by the force of gravity, but every time the player press space key it rises up a bit. That’s why we need to add new variables velocity and gravity to our Bird class and new method move which will change the bird’s position with each passed frame. Furthermore, we need to add jump method (invoked by player pressing a key):
在我们的游戏中,小鸟只是上下移动。但是通过的管道pipes,给玩家移动的画面。
鸟总是因为重力会向下掉,但是每次玩家按空白键,它就会上升一点。
那就是我们为什么要加入新变量velocity 和gravity 速度和重力,到our的Bird class。
Bird class 和新的move方法将改变bird的位置通过每个passed frame.
更多的情况是,我们需要增加jump方法(通过玩家按键来调用invoked)

class Bird
  def initialize
    @x = 30 
    @y = HEIGHT / 2
    @width = 36
    @height = 33
    @gravity = 0.7
    @velocity = 0
  end 

  def draw
    Image.new('./assets/images/flappybird.png', x: @x, y: @y, width: @width, height: @height, z: 10)
  end

  def move
    @velocity += @gravity
    @y = [@y + @velocity, 0].max
  end

  def jump 
    @velocity = -8
  end 
end
If we put bird.move method within game loop and restart the program our bird will suddenly fall below the screen. To fix it we need to add another method provided by ruby2d to listen player’s input. Paste the following code below update do block:
如果我们把bird.move方法放到game循环里面,然后重启程序,我们的小鸟会忽然fall below屏幕。
为了修复这个问题,我们需要加另外一个方法,ruby2d提供的监听玩家输入的方法。粘贴下面的代码到update区块里面。

on :key_up do |event|
  bird.jump if event.key == 'space' 
end

Pressing space key will raise the bird up and prevent it from falling! Please note that both gravity and velocity variables are strictly arbitrary. You can experiment bird’s speed of jumping and falling by changing those two at your will.
按space键,会让小鸟上升,让它不掉下来。速度和重力的变量值是随意的,你可以改变来体验下小鸟的跳起和降落的体验。

Alright, we are halfway to go! Now let’s add pipes. Those will be the main obstacles our bird needs to pass. Pipes always occur in pairs. One on the top and one on the bottom. Between them, there is always a certain gap that will allow our bird to fly through. The heights of the pipes must be random to give the player a challenge. But they also need to have some common sense to not make them impossible to pass. In our program top pipe’s height will be generated randomly. The bottom one’s height will be adjusted with the remaining space minus the constant space gap we defined. Here we must add a new class Pipe which will be generating coordinates for each of the pipes and draw them on the screen. For the sake of simplicity, each Pipe object will contain both upper and lower pipes together:
现在已经完成一半了。
开始增加管道,这是小鸟要越过的主要障碍。管道的高度是随机的,这给了玩家一个challenge。但是也需要让人看上去能够通过。
在我们的程序里面顶部的管道高度是随机生成的。底部的管道需要根据顶部的高度来调整,剩下的空间高度减去预留的常量空间就是底部管道高度。
那需要增加一个新class Pipe,生成坐标画在屏幕上。
简单期间for the sake of simplicity, 每个管道包含上下两部分。


class Pipe
  def initialize
    @width = 55 
    @height = 512/4 + rand(512/2)
    @x = WIDTH + @width
    @y = 0
    @open_gap = HEIGHT / 4
  end

  def draw
    Image.new('./assets/images/toppipe.png',
    x: @x,
    y: @y,
    width: @width,
    height: @height,
    z: 10)

    Image.new('./assets/images/bottompipe.png',
    x: @x,
    y: @height + @open_gap,
    width: @width,
    height: HEIGHT - @height,
    z: 10)
  end
end
Finding optimal values to generate pipes might be a tough task. In my case, formula 512/4 + rand(512/2) works well. But as with the bird’s speed, you can experiment with those values that suit you better. Or change a gap between pipes to increase of decrease difficulty. Pipes are ‘moving’ (changing their x coordinates) from right to left. Please note that they are generated a bit off the screen in order to give player the illusion that bird is actually moving forward (not the other way around). This is why we set initial @x variables to WIDTH + @width. Now let’s add a move method to the Pipe class with a similar manner as with the bird. With each frame, our pipes will be moving 5 pixels to the left。
优化管道的值去生成管道是一个tough难任务。
我的案例中,formula 512/4+rand(512/2)还不错。
但是配上鸟的速度,你能能够体验这些值不错。
或者改变管道之间的距离增加或减低难度。
管道通改变x坐标来移动,从右到左。
注意他们生成超出了屏幕一点,为了给玩家移动的画面感。
所以让他的x坐标是屏幕宽度加上管道宽度。
现在我们增加一个move的方法给pipe class,和小鸟的一样,5pixels像素/each frame. 
1秒30frames,那就是30*5=150frame,屏幕宽440,半个屏幕宽220,管道宽55.

  def move
    @x -= @moving_distance
  end
and update initialize method with @moving_distance variable equal to 5.

All pipes are stored within an array. This will help us to control them. With every 40 frames passed (around 1.3s) new pipes are added to the array. Also when pipes reach the end of the screen they will be removed from the array as we no longer need them:
所有的管道存储在一个数组里面。
这将帮助我们控制这些管道。
40frames(1.3s)就会有新的管道过来。
移动到屏幕顶端的管道就要从数组里面里删除,已经不需要了。

bird = Bird.new 
pipes = []
pipes << Pipe.new

update do
  clear 
  draw_background
  bird.draw
  bird.move

  pipes.each do |pipe|
    pipe.draw
    pipe.move
  end 

  pipes << Pipe.new if Window.frames % 40 == 0 
  pipes.shift if pipes.first.out_of_scope?
end
and add out_of_scope? method to the pipe class, to check if it is outside of the screen:
Pipe class增加 out_of_scope?方法,检查它是否跑到屏幕外面了。

  def out_of_scope?
    @x + @width <= 0 
  end

At this point all over core mechanics are done. Now we need to focus on the integration between bird and pipes. Somewhere between draw_background method add a new one that will display the player’s score:
到这所有的核心动作都做完了,现在需要集成鸟和管道之间integration,增加一个分数显示。

def draw_score(score)
  Text.new(score, x: WIDTH / 2 - 30, y: 120, size: 60, color: 'white', z: 11)
end
The new point is added every time bird passes a pipe. However, in our case bird’s position is always fixed and we need to check if pipe pass through the bird. In other words: if the current x coordinate of the certain pipe is smaller or equal to the x coordinate (30) of our bird. Moreover, we need to mark that pipe as scored in order to not add new points for it with each iteration when is passed a bird and is still on the screen. Let’s update our Pipe class with this new functionality:
每次小鸟通过管道,分数就会增加。我们还要检查小鸟是否通过了管道。
也就是管道的x坐标小于或等于小鸟的x坐标30,表示通过了管道。
还要标注这个管道已经加过分数了,别给加重复了。
更新一下程序功能。

class Pipe
  attr_writer :scored

  def initialize
    @width = 55 
    @height = 512/4 + rand(512/2)
    @x = WIDTH + @width
    @y = 0
    @open_gap = HEIGHT / 4
    @moving_distance = 5
  end

  def draw
    Image.new('./assets/images/toppipe.png',
    x: @x,
    y: @y,
    width: @width,
    height: @height,
    z: 10)

    Image.new('./assets/images/bottompipe.png',
    x: @x,
    y: @height + @open_gap,
    width: @width,
    height: HEIGHT - @height,
    z: 10)
  end

  def move
    @x -= @moving_distance
  end

  def out_of_scope?
    @x + @width <= 0 
  end

  def score? 
    passed? && !@scored
  end

  private
  def passed?
    @x <= 30
  end 
end
Images for bottom and top pipe can be downloaded from here

Then add a new variable score in our global context and update a game loop:

bird = Bird.new 
pipes = []
pipes << Pipe.new
score = 0

update do
  clear 
  draw_background
  draw_score(score)
  bird.draw
  bird.move

  pipes.each do |pipe|
    pipe.draw
    pipe.move
  end 

  pipes << Pipe.new if Window.frames % 40 == 0 

  if pipes.first.score? 
    pipes.first.scored = true 
    score += 1
  end 

  pipes.shift if pipes.first.out_of_scope?
end
That should increase the score by 1 every time a bird ‘passes’ a new pipe.

The last remaining issue is to set losing conditions. Our player can lose in two ways: if the bird falls down or hits one of the pipes. If one of those conditions is fulfilled, the game should get frozen with a huge “Game Over” text popping out in the middle of the screen. Just like in other cases add a new method that would render a game over text:
最后的问题设置失败的条件。2种情况下会失败,小鸟掉下来和撞到管道。
任何一种情况出现,游戏会被冻结,并出现一个巨大的“Game Over”文字在屏幕中央。
就像其它的一样,增加一个新方法来显示game over文本。

def draw_game_over
  Text.new("GAME OVER", x: WIDTH/2 - 100, y: HEIGHT/2, size: 30, color: 'red', z: 11)
end

Checking if the bird has fallen is pretty easy: all we need to do is write a new method in Bird class and measure if @y coordinate is equal to or greater than the HEIGHT value. If it is, that means bird is below the screen:
小鸟掉下来的判断比较简单,小鸟的y坐标大于屏幕高的像素就掉下来了。

  def felt?
    @y >= HEIGHT
  end
We also need to one more time update our game loop. First, add a game_over variable flag set to false right above it. Then invoke a method bird.felt? to check if our bird is still above the ground. And finally, check the current state of our newly created game_over variable:
我们也需要再一次更新游戏循环。
加个game_over 变量,先设置为false。
然后调用bird.felt?去检查鸟是否在地上。
最后检查game_over的值。

bird = Bird.new 
pipes = []
pipes << Pipe.new
game_over = false # new game_over flag variable 
score = 0

update do
  clear 
  draw_background
  draw_score(score)
  bird.draw
  bird.move

  pipes.each do |pipe|
    pipe.draw
    pipe.move
  end 

  if game_over # 1 
    draw_game_over
    next 
  end

  pipes << Pipe.new if Window.frames % 40 == 0 

  if pipes.first.score? 
    pipes.first.scored = true 
    score += 1
  end 

  pipes.shift if pipes.first.out_of_scope?

  if bird.felt? # 2 
    game_over = true
    pipes.each { |pipe| pipe.moving_distance = 0}
    bird.gravity = 0
    bird.velocity = 0
  end 
end
We also added few new things here:

#1 Now within the game loop we check whether game_over variable is true. If so we draw a game over screen and skip the rest of the loop (using next keyword). Please note that order in the game loop is crucial. This game over condition needs to be placed before logic responsible for moving pipes to freeze the entire screen properly if the player loses game.
现在,在游戏循环中,我们检查game_over变量是否为true。如果是这样,我们在屏幕上画一个游戏,并跳过循环的其余部分(使用next关键字)。请注意,游戏循环中的顺序至关重要。如果玩家输掉游戏,这个游戏结束条件需要放在负责移动管道的逻辑之前,以正确冻结整个屏幕。

#2 bird.felt? change game_over flag variable to true and stop movement for both bird and pipes by changing their velocity to 0. At this point we need to make those variables (located in Bird and Pipe class) accessible outside of their scope, by adding attr_writer method right below the classes definitions:

class Pipe
  attr_writer :scored, :moving_distance
  # (rest of the class body)
end 

class Bird
  attr_writer :gravity, :velocity
  # (rest of the class body)
end 
When user lost we don’t want to listen for input from his keyboard anymore. That’s why we need to add game_over flag to on :key_up block:
如果失败了,那用户按键也失效了,所有也要加上判断。
on :key_up do |event|
  bird.jump if event.key == 'space' && !game_over
end
Checking bird’s collision with pipes might be a bit more tricky. We need to check if its x,y coordinates match exactly with x,y coordinates of any of the pipes. We know that pipes are moving forward toward bird position, so only the first pipe in our array can crush with bird. Both upper and lower pipe have the same x coordinate, only the y coordinate (height) is different. Knowing all that we can update Pipe class with the new method hit?:
检查检查鸟与管道的碰撞可能有点棘手。小鸟的坐标在管道里面就表示碰撞了。变量是管道,管道在移动,给管道增加一个新方法,hit?(x,y)

  def hit?(x,y)
    (@x..@x + @width).include?(x) && ((0..@height).include?(y) || (@height+@open_gap..HEIGHT).include?(y))
  end 
This method takes coordinates from the current position of the bird and matches them against the coordinates of the pipes. To return true two conditions need to be fulfilled: birds x position is exactly the same as for pipes and its y position is the as as either top pipe or bottom pipe. To make the entire condition a bit shorter I use ranges. Notation:(@x..@x + @width).include?(x)

is more less the same as:

if x > @x && x < @x + @width 
When we have our logic for checking possible collisions we also need to allow Bird class to inform about its current x,y coordinates:
当我们有自己检查碰撞的逻辑时,我们需要允许Bird Class知道他当前的坐标。
class Bird
  attr_reader :x, :y
  attr_writer :gravity, :velocity
  # rest of the class body...
end 
and add this new condition in our game loop:

  if bird.felt? || pipes.first.hit?(bird.x, bird.y)
    game_over = true
    pipes.each { |pipe| pipe.moving_distance = 0}
    bird.gravity = 0
    bird.velocity = 0
  end 
And that’s it! Now we have a fully functional flappy bird clone!

Entire code below:

require 'ruby2d'

HEIGHT = 640 
WIDTH = 420

set width: WIDTH
set height: HEIGHT
set fps_cap: 30

def draw_background
  Image.new('./assets/images/flappybirdbg.png', x: 0, y: 0, width: WIDTH, height: HEIGHT)
end 

def draw_score(score)
  Text.new(score, x: WIDTH / 2 - 30, y: 120, size: 60, color: 'white', z: 11)
end

def draw_game_over
  Text.new("GAME OVER", x: WIDTH/2 - 100, y: HEIGHT/2, size: 30, color: 'red', z: 11)
end

class Bird
  attr_reader :x, :y
  attr_writer :gravity, :velocity

  def initialize
    @x = 30 
    @y = HEIGHT / 2
    @width = 36
    @height = 33
    @gravity = 0.7
    @velocity = 0
  end 

  def draw
    Image.new('./assets/images/flappybird.png', x: @x, y: @y, width: @width, height: @height, z: 10)
  end
  
  def jump 
    @velocity = -8
  end 

  def move
    @velocity += @gravity
    @y = [@y + @velocity, 0].max
  end

  def felt?
    @y >= HEIGHT
  end
end

class Pipe
  attr_writer :scored, :moving_distance

  def initialize
    @width = 55 
    @height = 512/4 + rand(512/2)
    @x = WIDTH + @width
    @y = 0
    @open_gap = HEIGHT / 4
    @scored = false 
    @moving_distance = 5
  end

  def draw
    Image.new('./assets/images/toppipe.png',x: @x, y: @y, width: @width, height: @height, z: 10)

    Image.new('./assets/images/bottompipe.png', x: @x, y: @height + @open_gap, width: @width, height: HEIGHT - @height, z: 10)
  end

  def move
    @x -= @moving_distance
  end

  def hit?(x,y)
    (@x..@x + @width).include?(x) && ((0..@height).include?(y) || (@height+@open_gap..HEIGHT).include?(y))
  end 

  def out_of_scope?
    @x + @width <= 0 
  end

  def score? 
    passed? && !@scored
  end

  private
  def passed?
    @x <= 30
  end 
end

bird = Bird.new
pipes = []
pipes << Pipe.new
score = 0
game_over = false

update do
  clear 

  draw_background
  draw_score(score)
  bird.draw
  bird.move

  pipes.each do |pipe|
    pipe.draw
    pipe.move
  end 

  if game_over
    draw_game_over
    next
  end

  pipes << Pipe.new if Window.frames % 40 == 0 
  
  if pipes.first.score? 
    pipes.first.scored = true 
    score += 1
  end 

  pipes.shift if pipes.first.out_of_scope?

  if pipes.first.hit?(bird.x,bird.y) || bird.felt?
    game_over = true
    pipes.each { |pipe| pipe.moving_distance = 0}
    bird.gravity = 0
    bird.velocity = 0
  end 
end

on :key_up do |event|
  bird.jump if event.key == 'space' && !game_over
end

show

阅读量: 806
发布于:
修改于: